diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 00000000..85ee733c --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,98 @@ +name: Deployment release gate + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deployment-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + deterministic: + name: LF, Compose, docs, and TypeScript + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.16.0" + package-manager-cache: false + - name: Verify shell syntax and LF policy + run: | + git ls-files -z '*.sh' | xargs -0 -n1 bash -n + bash scripts/verify-line-endings.sh + - name: Verify Compose and installation contracts + run: | + bash scripts/test-unified-compose.sh + bash scripts/test-compose-secret-policy.sh + bash scripts/test-no-deployment-coupling.sh + bash scripts/test-verify-workspace-install-docs.sh + bash scripts/unified-deployment-smoke.sh --self-test + git diff --check + - name: Install backend dependencies + working-directory: backend + run: npm ci + - name: Test and type-check backend + working-directory: backend + run: | + npx vitest run + npx tsc --noEmit -p . + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Test and type-check frontend + working-directory: frontend + run: | + npx vitest run + npx tsc -b + + linux-docker: + name: Linux Docker deployment and rollback + runs-on: ubuntu-24.04 + timeout-minutes: 70 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run unified deployment smoke + run: timeout --signal=TERM --kill-after=45s 30m bash scripts/unified-deployment-smoke.sh + - name: Run thothctl update smoke + run: timeout --signal=TERM --kill-after=45s 30m bash scripts/thothctl-update-smoke.sh + + windows-clone: + name: Windows clone and Compose contract + runs-on: windows-2025 + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.5" + cache-dependency-path: tools/thothctl/go.sum + - name: Build native Windows thothctl + working-directory: tools/thothctl + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path ../../dist/thothctl | Out-Null + go build -trimpath -o ../../dist/thothctl/thothctl-windows-amd64.exe ./cmd/thothctl + - name: Verify Windows clone contract + shell: pwsh + run: >- + ./scripts/test-windows-clone-contract.ps1 + -ThothctlPath "$PWD/dist/thothctl/thothctl-windows-amd64.exe" diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index f6e608ff..d1987914 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -3,6 +3,35 @@ > Starting-point snapshot for new sessions. Last updated: 2026-08-05 (portable deployment decoupled). > Point a fresh session here ("read PROJECT_STATE.md") before substantial work. +## Unified deployment release gate — Task 13 (2026-08-05) + +- **Release coverage.** `scripts/unified-deployment-smoke.sh` gates the two-service render/build, + frontend-to-core routing, embedded pinned Pi, Git registry bootstrap, offline recreation, valid + update, invalid-update retention, and the four persistent stores. `scripts/thothctl-update-smoke.sh` + independently exercises the bad-Pi update and automatic rollback path. +- **Isolation and disclosure boundary.** Every run generates a unique temporary root, Compose + project, container/image names, transaction image tags, and run label. The rollback fixture uses + an immutable public digest as a deliberately dead core rather than a host-local image registry. + Cleanup checks ownership before removing exact containers, Compose resources, image references, + control state, and temporary files. There is no global prune. Failure diagnostics are bounded + and sanitized, and all credentials/endpoints used by the smokes are disposable fixtures rather + than operator or repository secrets. +- **Cross-platform CI contract.** `.github/workflows/deployment.yml` uses immutable action commits, + pinned supported Node and Go versions, runs LF/Compose/secret/coupling/docs/TypeScript gates on + Linux, runs each Docker smoke once under its own outer timeout, and builds/invokes native Windows + `thothctl` after the PowerShell clone/LF/Compose contract. Native Windows execution remains an + explicit manual release gate in addition to CI; no Windows Docker container startup is claimed + by the static clone job. +- **Validation status.** Deterministic Phase A gates, backend **434/434** plus TypeScript, + frontend **386/386** plus TypeScript, and harness **862 passed / 5 L2 deselected** are green. + The unified one-shot Docker run passed frontend/core/internal Pi, registry bootstrap, offline + recreation, valid update, invalid-update retention, and persistence before Docker Desktop + refused the daemon-to-host local-registry push; the update-only run reached the same boundary. + Both exact run/project resource sets were independently proved absent. The local-registry + fixture was then removed in favor of the immutable dead-core digest, but the requested no-retry + rule leaves automatic rollback/preservation pending in CI or a fresh manual release run. Native + Windows PowerShell execution is also still a manual release gate. + ## Portable deployment decoupling — LIVE 2026-08-05 - **Mandatory stack.** The supported Compose stack is exactly `frontend` plus `core`; use the diff --git a/README.md b/README.md index c33557bb..74c02553 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,45 @@ volume afterward. It never targets the fixed `thothii` operator project or its v `KEEP_SMOKE_RESOURCES=1` to retain that smoke project's resources for inspection; remove them later with `docker compose --project-name "$SMOKE_PROJECT" down --volumes`. +## Unified deployment release gates + +Task 13 adds a no-secret release gate around the canonical base plus local Compose profile. Its +deterministic safety check does not contact the Docker daemon: + +```sh +bash scripts/unified-deployment-smoke.sh --self-test +``` + +The two Docker smokes are separate release jobs. Each creates a unique Compose project, temporary +Git workspace remote, fixture provider, image names, and run label. Its exit trap removes only +resources carrying that exact run identity and never performs a global Docker prune. + +```sh +bash scripts/unified-deployment-smoke.sh +bash scripts/thothctl-update-smoke.sh +``` + +The unified smoke builds and starts `frontend` and `core`, verifies the embedded Pi and internal +registry, recreates with the Git remote offline, activates a valid Git update, rejects invalid Git +content while retaining the valid snapshot, and checks the four persistence volumes. Both smokes +inject a digest-pinned non-core candidate under a deliberately mismatched Pi version and require +`thothctl pi update` to roll back while preserving settings, sessions, Pi state, registry revision, +and mount identity. Fixture credentials are generated locally; neither command needs a real +provider key or a repository secret. CI gives each smoke one 30-minute outer timeout and does not +retry it. + +On a native Windows clone, the release contract is: + +```powershell +.\scripts\test-windows-clone-contract.ps1 ` + -ThothctlPath "$PWD\dist\thothctl\thothctl-windows-amd64.exe" +``` + +It checks Git's CRLF/LF attributes and bytes, renders exactly `core` plus `frontend` with Docker +Compose without starting containers, and invokes the native Windows `thothctl`. The GitHub Actions +deployment workflow runs the deterministic Linux gates, both bounded Docker smokes, and this +Windows clone contract with immutable action pins and supported pinned Node/Go toolchains. + ## Optional local pgvector and recovery The local-vector overlay reads `THT_VECTOR_BOOTSTRAP_PASSWORD`, diff --git a/scripts/test-windows-clone-contract.ps1 b/scripts/test-windows-clone-contract.ps1 new file mode 100644 index 00000000..41354b0c --- /dev/null +++ b/scripts/test-windows-clone-contract.ps1 @@ -0,0 +1,108 @@ +param( + [string]$RepositoryRoot = "", + [string]$ThothctlPath = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = (& git rev-parse --show-toplevel).Trim() + if ($LASTEXITCODE -ne 0) { + throw "git could not resolve the repository root" + } +} +$RepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot) + +$tracked = @(& git -C $RepositoryRoot ls-files) +if ($LASTEXITCODE -ne 0) { + throw "git ls-files failed" +} + +$scriptRelativePath = "scripts/test-windows-clone-contract.ps1" +$eolAttribute = (& git -C $RepositoryRoot check-attr eol -- $scriptRelativePath).Trim() +if ($LASTEXITCODE -ne 0 -or -not $eolAttribute.EndsWith("eol: crlf", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "the Windows contract script must have the repository eol=crlf attribute" +} +$scriptBytes = [System.IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $scriptRelativePath)) +if (-not ($scriptBytes -contains [byte]0x0D)) { + throw "the Windows contract script was not checked out with CRLF bytes" +} + +$offenders = [System.Collections.Generic.List[string]]::new() +foreach ($relativePath in $tracked) { + $name = [System.IO.Path]::GetFileName($relativePath) + $mustBeLf = $relativePath.EndsWith(".sh", [System.StringComparison]::OrdinalIgnoreCase) -or + $relativePath.EndsWith(".yml", [System.StringComparison]::OrdinalIgnoreCase) -or + $relativePath.EndsWith(".yaml", [System.StringComparison]::OrdinalIgnoreCase) -or + $name.Equals("Dockerfile", [System.StringComparison]::OrdinalIgnoreCase) -or + $name.StartsWith("Dockerfile.", [System.StringComparison]::OrdinalIgnoreCase) -or + $name.EndsWith(".Dockerfile", [System.StringComparison]::OrdinalIgnoreCase) + if (-not $mustBeLf) { + continue + } + $absolutePath = Join-Path $RepositoryRoot $relativePath + $bytes = [System.IO.File]::ReadAllBytes($absolutePath) + if ($bytes -contains [byte]0x0D) { + $offenders.Add($relativePath) + } +} +if ($offenders.Count -ne 0) { + throw "CR byte 0x0D found in tracked LF contract files: $($offenders -join ', ')" +} + +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("thothii-windows-contract-" + [guid]::NewGuid().ToString("N")) +$savedEnvironment = @{ + THT_WORKSPACE_GIT_REMOTE = $env:THT_WORKSPACE_GIT_REMOTE + PI_AUTH_FILE = $env:PI_AUTH_FILE + THT_SECRETS_FILE = $env:THT_SECRETS_FILE +} +try { + [System.IO.Directory]::CreateDirectory($temporaryRoot) | Out-Null + $piAuth = Join-Path $temporaryRoot "pi-auth.json" + $secrets = Join-Path $temporaryRoot "thothii.secrets" + [System.IO.File]::WriteAllText($piAuth, "{}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($secrets, "THT_MODEL_API_KEY=windows-contract`n", [System.Text.UTF8Encoding]::new($false)) + + $env:THT_WORKSPACE_GIT_REMOTE = "https://git.example.invalid/platform/thoth-workspaces.git" + $env:PI_AUTH_FILE = $piAuth + $env:THT_SECRETS_FILE = $secrets + $composeFiles = @( + "--project-directory", $RepositoryRoot, + "-f", (Join-Path $RepositoryRoot "compose.yaml"), + "-f", (Join-Path $RepositoryRoot "deploy/compose.local.yaml") + ) + $services = @(& docker compose @composeFiles config --services) + if ($LASTEXITCODE -ne 0) { + throw "Docker Compose could not render the Windows clone" + } + if ((($services | Sort-Object) -join ",") -ne "core,frontend") { + throw "rendered Windows stack must contain exactly core and frontend" + } + & docker compose @composeFiles config --quiet + if ($LASTEXITCODE -ne 0) { + throw "Docker Compose rejected the Windows clone" + } + + if ([string]::IsNullOrWhiteSpace($ThothctlPath)) { + $ThothctlPath = Join-Path $RepositoryRoot "dist/thothctl/thothctl-windows-amd64.exe" + } + $ThothctlPath = [System.IO.Path]::GetFullPath($ThothctlPath) + if (-not [System.IO.File]::Exists($ThothctlPath)) { + throw "Windows thothctl binary is missing: $ThothctlPath" + } + & $ThothctlPath --help | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Windows thothctl invocation failed" + } +} +finally { + foreach ($name in $savedEnvironment.Keys) { + [System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process") + } + if ([System.IO.Directory]::Exists($temporaryRoot)) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} + +Write-Output "Windows clone, LF-byte, Compose render, and thothctl invocation contracts passed." diff --git a/scripts/thothctl-update-smoke.sh b/scripts/thothctl-update-smoke.sh new file mode 100755 index 00000000..5059306b --- /dev/null +++ b/scripts/thothctl-update-smoke.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd -P)" +# shellcheck source=./unified-deployment-smoke.sh +source "$root/scripts/unified-deployment-smoke.sh" + +task13_smoke_main update diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh new file mode 100755 index 00000000..62e95a65 --- /dev/null +++ b/scripts/unified-deployment-smoke.sh @@ -0,0 +1,1031 @@ +#!/usr/bin/env bash +# End-to-end release gate for the canonical two-service Compose distribution. +# This file is also sourced by thothctl-update-smoke.sh so both entry points use the same +# isolated fixture, exact cleanup, and sanitized failure reporting. +set -euo pipefail + +TASK13_BAD_CANDIDATE_IMAGE="registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373" +TASK13_BAD_PI_VERSION="0.80.4-task13" +TASK13_CURL_CONNECT_TIMEOUT=3 +TASK13_CURL_MAX_TIME=10 +TASK13_CLEANUP_TIMEOUT=20 + +task13_fail() { + printf 'Task 13 smoke failed: %s\n' "$*" >&2 + return 1 +} + +task13_sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print $1}' + else + task13_fail "sha256sum or shasum is required" + fi +} + +task13_sanitize() { + local line + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -n "${TASK13_SECRET_VALUE:-}" ]]; then + line="${line//"$TASK13_SECRET_VALUE"/[REDACTED]}" + fi + printf '%s\n' "$line" + done | sed -E \ + -e 's#([[:alpha:]][[:alnum:]+.-]*://[^:/@[:space:]]+:)[^@/[:space:]]+@#\1[REDACTED]@#g' \ + -e 's/(([Pp]roxy-)?[Aa]uthorization:[[:space:]]*([Bb]earer|[Bb]asic)[[:space:]]+)[^[:space:]]+/\1[REDACTED]/g' \ + -e "s/(([\"']?[[:alnum:]_.-]*(password|token|api[_-]?key|secret|key)[[:alnum:]_.-]*[\"']?[[:space:]]*[:=][[:space:]]*)([\"'][^\"']*[\"']|[^[:space:],;]+))/\2[REDACTED]/Ig" +} + +task13_log_failure() { + local label="$1" + TASK13_FAILURE_LOGGED=1 + printf 'Task 13 command failed: %s\n' "$label" >&2 + tail -n 200 "$TASK13_LOG" | task13_sanitize >&2 + return 1 +} + +task13_run_logged() { + local label="$1" + shift + if ! "$@" >>"$TASK13_LOG" 2>&1; then + task13_log_failure "$label" + fi +} + +task13_bounded() { + local seconds="$1" label="$2" command_pid watchdog_pid rc + shift 2 + "$@" & + command_pid=$! + ( + local sleep_pid + sleep "$seconds" & + sleep_pid=$! + trap 'kill "$sleep_pid" 2>/dev/null || true' EXIT INT TERM + wait "$sleep_pid" 2>/dev/null || exit 0 + trap - EXIT INT TERM + if kill -0 "$command_pid" 2>/dev/null; then + printf 'Task 13 command timed out after %ss: %s\n' "$seconds" "$label" >&2 + kill -TERM "$command_pid" 2>/dev/null || true + sleep 5 + kill -KILL "$command_pid" 2>/dev/null || true + fi + ) & + watchdog_pid=$! + if wait "$command_pid"; then + rc=0 + else + rc=$? + fi + kill "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + return "$rc" +} + +task13_compose_files() { + TASK13_COMPOSE=( + docker compose + --project-name "$TASK13_PROJECT" + --project-directory "$TASK13_ROOT" + --env-file "$TASK13_ENV_FILE" + -f "$TASK13_ROOT/compose.yaml" + -f "$TASK13_ROOT/deploy/compose.local.yaml" + -f "$TASK13_OVERRIDE" + ) + if [[ -f "$TASK13_CURRENT_IMAGE_OVERRIDE" ]]; then + TASK13_COMPOSE+=(-f "$TASK13_CURRENT_IMAGE_OVERRIDE") + fi +} + +task13_compose() { + task13_compose_files + "${TASK13_COMPOSE[@]}" "$@" +} + +task13_compose_logged() { + local label="$1" + shift + task13_compose_files + task13_run_logged "$label" "${TASK13_COMPOSE[@]}" "$@" +} + +task13_write_environment() { + local remote="$1" + { + printf 'THOTH_HTTP_PORT=0\n' + printf 'THOTH_CORE_HTTP_PORT=0\n' + printf 'MAX_PI_PROCESSES=2\n' + printf 'PI_AUTH_FILE=%s\n' "$TASK13_PI_AUTH" + printf 'THT_SECRETS_FILE=%s\n' "$TASK13_SECRETS" + printf 'THT_WORKSPACE_GIT_REMOTE=%s\n' "$remote" + printf 'THT_WORKSPACE_GIT_BRANCH=%s\n' "$TASK13_BRANCH" + printf 'THT_WORKSPACE_GIT_AUTHOR_NAME=Task 13 Smoke\n' + printf 'THT_WORKSPACE_GIT_AUTHOR_EMAIL=task13-smoke@example.invalid\n' + printf 'THT_LLM_URL=http://%s:9000/v1\n' "$TASK13_LLM_CONTAINER" + } >"$TASK13_ENV_FILE" + chmod 0600 "$TASK13_ENV_FILE" +} + +task13_write_fixture_files() { + printf '{}\n' >"$TASK13_PI_AUTH" + printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" + chmod 0644 "$TASK13_PI_AUTH" + chmod 0600 "$TASK13_SECRETS" + + cat >"$TASK13_PI_MODELS" <"$TASK13_PI_SETTINGS" <<'EOF' +{ + "defaultProjectTrust": "always", + "enabledModels": ["local-qwen/task13-smoke"] +} +EOF + chmod 0644 "$TASK13_PI_MODELS" "$TASK13_PI_SETTINGS" + + cat >"$TASK13_LLM_SERVER" <<'EOF' +import http from "node:http"; + +const server = http.createServer((request, response) => { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"status":"ok"}'); + return; + } + if (request.method === "GET" && request.url === "/v1/models") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"object":"list","data":[{"id":"task13-smoke","object":"model"}]}'); + return; + } + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404, { "content-type": "application/json" }); + response.end('{"error":{"message":"not found"}}'); + return; + } + + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + let stream = true; + try { stream = JSON.parse(body).stream !== false; } catch { /* return the safe fixture */ } + if (!stream) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + id: "task13", object: "chat.completion", created: 1, model: "task13-smoke", + choices: [{ index: 0, message: { role: "assistant", content: "OK" }, finish_reason: "stop" }], + })); + return; + } + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + response.write('data: {"id":"task13","object":"chat.completion.chunk","created":1,"model":"task13-smoke","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}\n\n'); + response.write('data: {"id":"task13","object":"chat.completion.chunk","created":1,"model":"task13-smoke","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'); + response.end("data: [DONE]\n\n"); + }); +}); + +server.listen(9000, "0.0.0.0"); +EOF + chmod 0644 "$TASK13_LLM_SERVER" + + cat >"$TASK13_OVERRIDE" <"$TASK13_INSTALLATION" <"$TASK13_SEED/workspaces/task13-smoke.yaml" <<'EOF' +workspace: + schema_version: 2 + id: task13-smoke + name: Task 13 Smoke + language: en +dwh: + engine: postgres + database: warehouse + schema: analytics + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: pgvector + database: vectors + schema: public + collection: task13_documents + dimensions: 8 + distance: cosine + supported_transports: [pgvector_direct] + embedding: + provider: ollama_compatible + model: task13-embedding + dimensions: 8 +llm_policy: + default: local-qwen/task13-smoke + allowed: [local-qwen/task13-smoke] +EOF + task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" add workspaces/task13-smoke.yaml + task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" \ + -c user.name='Task 13 Smoke' -c user.email='task13-smoke@example.invalid' \ + commit -m 'Seed Task 13 workspace' + task13_run_logged "push initial workspace" git -C "$TASK13_SEED" \ + push "$TASK13_REMOTE" "HEAD:$TASK13_BRANCH" + chmod -R a+rX "$TASK13_REMOTE" +} + +task13_build_thothctl() { + local os arch + mkdir -p "$TASK13_THOTHCTL_DIR" + task13_run_logged "build thothctl cross-platform binaries" env \ + THT_THOTHCTL_OUTPUT_DIRECTORY="$TASK13_THOTHCTL_DIR" \ + bash "$TASK13_ROOT/scripts/build-thothctl.sh" + os="$(uname -s)" + arch="$(uname -m)" + case "$os/$arch" in + Darwin/x86_64) TASK13_THOTHCTL="$TASK13_THOTHCTL_DIR/thothctl-darwin-amd64" ;; + Darwin/arm64) TASK13_THOTHCTL="$TASK13_THOTHCTL_DIR/thothctl-darwin-arm64" ;; + Linux/x86_64|Linux/amd64) TASK13_THOTHCTL="$TASK13_THOTHCTL_DIR/thothctl-linux-amd64" ;; + Linux/aarch64|Linux/arm64) TASK13_THOTHCTL="$TASK13_THOTHCTL_DIR/thothctl-linux-arm64" ;; + *) task13_fail "unsupported smoke host: $os/$arch" ;; + esac + chmod 0700 "$TASK13_THOTHCTL" + task13_run_logged "invoke host thothctl" "$TASK13_THOTHCTL" --help +} + +task13_assert_rendered_contract() { + local services rendered + services="$(task13_compose config --services | sort)" + [[ "$services" == $'core\nfrontend' ]] || task13_fail "rendered stack is not exactly core and frontend" + rendered="$TASK13_TMP/rendered-compose.yaml" + task13_compose config >"$rendered" + if grep -Eqi 'docker\.sock|/var/run/docker' "$rendered"; then + task13_fail "rendered Compose exposes a Docker daemon endpoint" + fi + if grep -Fq "$TASK13_SECRET_VALUE" "$rendered"; then + task13_fail "rendered Compose exposed the fixture secret" + fi + for endpoint in THT_DWH_REST_URL THT_VEC_REST_URL THT_OLLAMA_URL THT_LLM_URL; do + grep -Fq "$endpoint" "$rendered" || task13_fail "rendered Compose lacks $endpoint" + done +} + +task13_start_stack() { + printf '== Build and start isolated local Compose distribution ==\n' + task13_assert_rendered_contract + task13_compose_logged "build local Compose images" build --pull + task13_compose_logged "start local Compose distribution" up --detach --wait --wait-timeout 120 + TASK13_NETWORK="$(docker network ls \ + --filter "label=com.docker.compose.project=$TASK13_PROJECT" \ + --filter 'label=com.docker.compose.network=thothii' --format '{{.Name}}')" + [[ -n "$TASK13_NETWORK" && "$TASK13_NETWORK" != *$'\n'* ]] || task13_fail "isolated Compose network was not resolved" + task13_run_logged "start deterministic local LLM fixture" docker run --detach \ + --name "$TASK13_LLM_CONTAINER" \ + --label "io.thothii.task13.run=$TASK13_RUN_ID" \ + --network "$TASK13_NETWORK" \ + --entrypoint node \ + --volume "$TASK13_LLM_SERVER:/fixtures/fake-llm.mjs:ro" \ + "$TASK13_CORE_IMAGE" /fixtures/fake-llm.mjs + for _attempt in $(seq 1 30); do + if docker exec "$TASK13_LLM_CONTAINER" node -e \ + "fetch('http://127.0.0.1:9000/health',{signal:AbortSignal.timeout(3000)}).then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" \ + >>"$TASK13_LOG" 2>&1; then + return 0 + fi + sleep 1 + done + task13_log_failure "deterministic local LLM fixture readiness" +} + +task13_frontend_address() { + task13_compose port frontend 8080 | awk 'NR == 1 {print $0}' +} + +task13_core_id() { + task13_compose ps -q core +} + +task13_assert_runtime() { + local frontend expected_pi actual_pi core_id + frontend="$(task13_frontend_address)" + expected_pi="$(sed -n 's/^ARG PI_VERSION=//p' "$TASK13_ROOT/docker/core.Dockerfile" | head -n 1)" + actual_pi="$(task13_compose exec -T core pi --version | tr -d '\r\n')" + [[ -n "$expected_pi" && "$actual_pi" == "$expected_pi" ]] || task13_fail "embedded Pi version mismatch" + task13_run_logged "frontend health" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error "http://$frontend/" + task13_run_logged "same-origin core health" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error "http://$frontend/api/health" + task13_compose_logged "core non-root identity" exec -T core sh -ceu \ + 'test "$(id -u)" = 10001' + task13_compose_logged "embedded Pi executable" exec -T core sh -ceu \ + 'command -v pi >/dev/null' + task13_compose_logged "core Docker socket isolation" exec -T core sh -ceu \ + 'test ! -e /var/run/docker.sock' + task13_compose_logged "workspace registry bootstrap" exec -T core \ + curl --connect-timeout 3 --max-time 10 -fsS http://127.0.0.1:8787/workspace-registry/status + task13_compose_logged "active workspace registry state" exec -T core sh -ceu \ + 'test -f /data/workspace-registry/state/active.json' + task13_compose_logged "mounted Pi auth readability" exec -T core sh -ceu \ + 'test -r /home/thoth/.pi/agent/auth.json' + task13_compose_logged "mounted application secret readability" exec -T core sh -ceu \ + 'test -r /run/secrets/thothii.secrets' + core_id="$(task13_core_id)" + [[ "$(docker inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$core_id")" == "$TASK13_RUN_ID" ]] \ + || task13_fail "core lacks the explicit Task 13 resource label" + task13_compose_logged "internal Pi provider smoke" exec -T core \ + curl --connect-timeout 3 --max-time 45 -fsS -X POST \ + -H 'x-thoth-principal-issuer: thothctl' \ + -H 'x-thoth-principal-subject: thothctl-maintenance' \ + -H 'x-thoth-principal-display-name: Thothctl maintenance' \ + -H 'x-thoth-is-admin: 1' \ + http://127.0.0.1:8787/pi-management/test + task13_run_logged "thothctl Pi doctor" "$TASK13_THOTHCTL" --installation "$TASK13_INSTALLATION" pi doctor +} + +task13_registry_status() { + task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \ + http://127.0.0.1:8787/workspace-registry/status +} + +task13_registry_head() { + sed -n 's/.*"head":"\([0-9a-f][0-9a-f]*\)".*/\1/p' +} + +task13_active_registry_head() { + task13_compose exec -T core sed -n \ + 's/.*"head":"\([0-9a-f][0-9a-f]*\)".*/\1/p' \ + /data/workspace-registry/state/active.json +} + +task13_assert_sentinels() { + task13_compose exec -T core sh -ceu ' + test "$(cat /data/settings/task13-settings)" = settings-preserved + test "$(cat /data/sessions/task13-session)" = sessions-preserved + test "$(cat /home/thoth/.pi/task13-pi-state)" = pi-state-preserved + test -f /data/workspace-registry/state/active.json + ' +} + +task13_mount_fingerprint() { + docker inspect --format '{{range .Mounts}}{{println .Destination "=" .Type ":" .Name}}{{end}}' "$(task13_core_id)" \ + | LC_ALL=C sort +} + +task13_prepare_persistence() { + task13_compose exec -T core sh -ceu ' + printf %s settings-preserved > /data/settings/task13-settings + printf %s sessions-preserved > /data/sessions/task13-session + printf %s pi-state-preserved > /home/thoth/.pi/task13-pi-state + ' + TASK13_INITIAL_MOUNTS="$(task13_mount_fingerprint)" + TASK13_INITIAL_HEAD="$(task13_active_registry_head)" + [[ "$TASK13_INITIAL_HEAD" =~ ^[0-9a-f]{40}$ ]] || task13_fail "initial registry head is invalid" + task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \ + http://127.0.0.1:8787/workspaces \ + | grep -Fq 'Task 13 Smoke' || task13_fail "initial workspace is unavailable" +} + +task13_registry_lifecycle() { + local offline_status offline_head valid_head + printf '== Recreate offline and retain the validated registry snapshot ==\n' + task13_write_environment /fixtures/offline.git + task13_compose_logged "offline Compose recreation" up --detach --force-recreate --wait --wait-timeout 120 + offline_status="$(task13_registry_status)" + offline_head="$(printf '%s' "$offline_status" | task13_registry_head)" + [[ "$offline_head" == "$TASK13_INITIAL_HEAD" ]] || task13_fail "offline recreation changed registry head" + grep -Fq '"degraded":true' <<<"$offline_status" || task13_fail "offline recreation did not report degraded mode" + task13_assert_sentinels + [[ "$(task13_mount_fingerprint)" == "$TASK13_INITIAL_MOUNTS" ]] || task13_fail "offline recreation changed volume identity" + + printf '== Pull a valid Git workspace update ==\n' + sed -i.bak 's/name: Task 13 Smoke/name: Task 13 Smoke Updated/' \ + "$TASK13_SEED/workspaces/task13-smoke.yaml" + rm "$TASK13_SEED/workspaces/task13-smoke.yaml.bak" + task13_run_logged "commit valid workspace update" git -C "$TASK13_SEED" add workspaces/task13-smoke.yaml + task13_run_logged "commit valid workspace update" git -C "$TASK13_SEED" \ + -c user.name='Task 13 Smoke' -c user.email='task13-smoke@example.invalid' \ + commit -m 'Update Task 13 workspace' + task13_run_logged "push valid workspace update" git -C "$TASK13_SEED" \ + push "$TASK13_REMOTE" "HEAD:$TASK13_BRANCH" + chmod -R a+rX "$TASK13_REMOTE" + task13_write_environment /fixtures/remote.git + task13_compose_logged "online Compose recreation" up --detach --force-recreate --wait --wait-timeout 120 + task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST \ + http://127.0.0.1:8787/workspace-registry/pull >/dev/null + task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \ + http://127.0.0.1:8787/workspaces \ + | grep -Fq 'Task 13 Smoke Updated' || task13_fail "valid Git update was not activated" + valid_head="$(task13_active_registry_head)" + [[ "$valid_head" =~ ^[0-9a-f]{40}$ && "$valid_head" != "$TASK13_INITIAL_HEAD" ]] \ + || task13_fail "valid Git update did not advance the registry head" + TASK13_INITIAL_HEAD="$valid_head" + task13_assert_sentinels + + printf '== Reject invalid Git content and retain the valid snapshot ==\n' + printf 'workspace: invalid\n' >"$TASK13_SEED/workspaces/task13-smoke.yaml" + task13_run_logged "commit invalid workspace update" git -C "$TASK13_SEED" add workspaces/task13-smoke.yaml + task13_run_logged "commit invalid workspace update" git -C "$TASK13_SEED" \ + -c user.name='Task 13 Smoke' -c user.email='task13-smoke@example.invalid' \ + commit -m 'Invalid Task 13 workspace fixture' + task13_run_logged "push invalid workspace update" git -C "$TASK13_SEED" \ + push "$TASK13_REMOTE" "HEAD:$TASK13_BRANCH" + chmod -R a+rX "$TASK13_REMOTE" + if task13_compose exec -T core curl --connect-timeout 3 --max-time 15 -fsS -X POST \ + http://127.0.0.1:8787/workspace-registry/pull >>"$TASK13_LOG" 2>&1; then + task13_fail "registry accepted invalid Git content" + fi + [[ "$(task13_active_registry_head)" == "$TASK13_INITIAL_HEAD" ]] \ + || task13_fail "invalid Git content replaced the valid registry head" + task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \ + http://127.0.0.1:8787/workspaces \ + | grep -Fq 'Task 13 Smoke Updated' || task13_fail "invalid Git content displaced the valid workspace" + task13_assert_sentinels +} + +task13_prepare_bad_candidate() { + task13_run_logged "pull pinned dead-core candidate" docker image pull "$TASK13_BAD_CANDIDATE_IMAGE" + TASK13_BAD_CANDIDATE_ID="$(docker image inspect --format '{{.Id}}' "$TASK13_BAD_CANDIDATE_IMAGE")" + [[ "$TASK13_BAD_CANDIDATE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || task13_fail "bad candidate image identity was not resolved" +} + +task13_update_rollback() { + local before_image before_mounts before_head output rc phase after_image after_mounts after_head + printf '== Inject a bad pinned Pi candidate and prove automatic rollback ==\n' + task13_prepare_bad_candidate + before_image="$(docker inspect --format '{{.Image}}' "$(task13_core_id)")" + TASK13_PREVIOUS_IMAGE_ID="$before_image" + before_mounts="$(task13_mount_fingerprint)" + before_head="$(task13_active_registry_head)" + output="$TASK13_TMP/thothctl-update.out" + set +e + "$TASK13_THOTHCTL" --installation "$TASK13_INSTALLATION" pi update \ + --version "$TASK13_BAD_PI_VERSION" --source pull --image "$TASK13_BAD_CANDIDATE_IMAGE" --yes \ + >"$output" 2>&1 + rc=$? + set -e + [[ "$rc" -ne 0 ]] || task13_fail "bad Pi candidate unexpectedly passed update verification" + if grep -Fq "$TASK13_SECRET_VALUE" "$output"; then + task13_fail "thothctl update output exposed the fixture secret" + fi + grep -Fq 'previous core image was restored' "$output" \ + || { task13_sanitize <"$output" >&2; task13_fail "thothctl did not report automatic rollback"; } + [[ -f "$TASK13_UPDATE_STATE" ]] || task13_fail "thothctl update state was not persisted" + phase="$(sed -n 's/.*"phase": "\([^"]*\)".*/\1/p' "$TASK13_UPDATE_STATE" | head -n 1)" + [[ "$phase" == rolled_back ]] || task13_fail "update state phase is not rolled_back" + if grep -Fq "$TASK13_SECRET_VALUE" "$TASK13_UPDATE_STATE"; then + task13_fail "update state exposed the fixture secret" + fi + task13_compose_files + after_image="$(docker inspect --format '{{.Image}}' "$(task13_core_id)")" + after_mounts="$(task13_mount_fingerprint)" + after_head="$(task13_active_registry_head)" + [[ "$after_image" == "$before_image" ]] || task13_fail "rollback did not restore the previous core image" + [[ "$after_mounts" == "$before_mounts" ]] || task13_fail "rollback changed persistence volume identity" + [[ "$after_head" == "$before_head" ]] || task13_fail "rollback changed the active registry revision" + task13_assert_sentinels + task13_run_logged "post-rollback thothctl doctor" \ + "$TASK13_THOTHCTL" --installation "$TASK13_INSTALLATION" pi doctor + task13_compose exec -T core curl --connect-timeout 3 --max-time 10 -fsS \ + http://127.0.0.1:8787/workspaces \ + | grep -Fq 'Task 13 Smoke' || task13_fail "rollback lost the active workspace" +} + +task13_remove_labeled_container() { + local name="$1" label + [[ -n "$name" ]] || return 0 + if ! docker container inspect "$name" >/dev/null 2>&1; then + return 0 + fi + label="$(docker container inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$name")" + [[ "$label" == "$TASK13_RUN_ID" ]] || { + printf 'refusing to remove foreign container %s\n' "$name" >&2 + return 1 + } + task13_bounded "$TASK13_CLEANUP_TIMEOUT" "remove owned container" \ + docker container rm --force "$name" >/dev/null +} + +task13_remove_labeled_image() { + local reference="$1" label + [[ -n "$reference" ]] || return 0 + if ! docker image inspect "$reference" >/dev/null 2>&1; then + return 0 + fi + label="$(docker image inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$reference")" + [[ "$label" == "$TASK13_RUN_ID" ]] || { + printf 'refusing to remove foreign image %s\n' "$reference" >&2 + return 1 + } + task13_bounded "$TASK13_CLEANUP_TIMEOUT" "remove owned image" \ + docker image rm "$reference" >/dev/null +} + +task13_remove_transaction_image() { + local reference="$1" expected_id="$2" actual_id + [[ -n "$reference" ]] || return 0 + if ! docker image inspect "$reference" >/dev/null 2>&1; then + return 0 + fi + if [[ ! "$reference" =~ ^thothii-core:thothctl-[0-9a-f]{16}-(candidate|previous)$ \ + || ! "$expected_id" =~ ^sha256:([0-9a-f]{64}|owned)$ ]]; then + printf 'refusing to remove invalid transaction image reference %s\n' "$reference" >&2 + return 1 + fi + actual_id="$(docker image inspect --format '{{.Id}}' "$reference")" + [[ "$actual_id" == "$expected_id" ]] || { + printf 'refusing to remove foreign transaction image %s\n' "$reference" >&2 + return 1 + } + task13_bounded "$TASK13_CLEANUP_TIMEOUT" "remove owned transaction image" \ + docker image rm "$reference" >/dev/null +} + +task13_assert_project_ownership() { + local kind id ids label + for kind in container volume network; do + if ! ids="$(docker "$kind" ls -q --filter "label=com.docker.compose.project=$TASK13_PROJECT")"; then + task13_fail "could not enumerate Compose project $kind resources" + return 1 + fi + while IFS= read -r id; do + [[ -n "$id" ]] || continue + case "$kind" in + container) + if ! label="$(docker container inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$id")"; then + task13_fail "could not inspect Compose project container resource" + return 1 + fi + ;; + volume) + if ! label="$(docker volume inspect --format '{{ index .Labels "io.thothii.task13.run" }}' "$id")"; then + task13_fail "could not inspect Compose project volume resource" + return 1 + fi + ;; + network) + if ! label="$(docker network inspect --format '{{ index .Labels "io.thothii.task13.run" }}' "$id")"; then + task13_fail "could not inspect Compose project network resource" + return 1 + fi + ;; + esac + if [[ "$label" != "$TASK13_RUN_ID" ]]; then + task13_fail "Compose project contains a foreign $kind resource" + return 1 + fi + done <<<"$ids" + done +} + +task13_assert_built_image_ownership() { + local image label + for image in "$TASK13_CORE_IMAGE" "$TASK13_FRONTEND_IMAGE"; do + label="$(docker image inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$image")" + [[ "$label" == "$TASK13_RUN_ID" ]] || task13_fail "built image lacks the Task 13 run label" + done +} + +task13_cleanup() { + local original_rc="$1" cleanup_rc=0 transaction="" leftovers="" image_id="" + set +e + if [[ "$original_rc" -ne 0 && "${TASK13_FAILURE_LOGGED:-0}" -eq 0 ]] \ + && [[ -n "${TASK13_LOG:-}" && -f "$TASK13_LOG" ]]; then + printf '%s\n' '--- sanitized Task 13 diagnostic log ---' >&2 + tail -n 200 "$TASK13_LOG" | task13_sanitize >&2 + fi + task13_remove_labeled_container "${TASK13_LLM_CONTAINER:-}" || cleanup_rc=1 + if [[ -n "${TASK13_PROJECT:-}" && -n "${TASK13_ROOT:-}" && -f "${TASK13_OVERRIDE:-}" ]]; then + if task13_assert_project_ownership >>"${TASK13_LOG:-/dev/null}" 2>&1; then + task13_compose_files + task13_bounded "$TASK13_CLEANUP_TIMEOUT" "stop owned Compose project" \ + "${TASK13_COMPOSE[@]}" down --volumes --remove-orphans --timeout 10 \ + >>"${TASK13_LOG:-/dev/null}" 2>&1 || cleanup_rc=1 + else + cleanup_rc=1 + fi + fi + if [[ -f "${TASK13_UPDATE_STATE:-}" ]]; then + transaction="$(sed -n 's/.*"transaction": "\([^"]*\)".*/\1/p' "$TASK13_UPDATE_STATE" | head -n 1)" + fi + if [[ -n "$transaction" ]]; then + task13_remove_transaction_image "thothii-core:thothctl-$transaction-candidate" \ + "${TASK13_BAD_CANDIDATE_ID:-}" >>"${TASK13_LOG:-/dev/null}" 2>&1 || cleanup_rc=1 + task13_remove_transaction_image "thothii-core:thothctl-$transaction-previous" \ + "${TASK13_PREVIOUS_IMAGE_ID:-}" >>"${TASK13_LOG:-/dev/null}" 2>&1 || cleanup_rc=1 + fi + for image in \ + "${TASK13_FRONTEND_IMAGE:-}" \ + "${TASK13_CORE_IMAGE:-}"; do + [[ -n "$image" ]] || continue + task13_remove_labeled_image "$image" >>"${TASK13_LOG:-/dev/null}" 2>&1 || cleanup_rc=1 + done + if [[ -n "${TASK13_RUN_ID:-}" ]]; then + while IFS= read -r image_id; do + [[ -n "$image_id" ]] || continue + task13_remove_labeled_image "$image_id" >>"${TASK13_LOG:-/dev/null}" 2>&1 || cleanup_rc=1 + done < <(docker image ls -q --filter "label=io.thothii.task13.run=$TASK13_RUN_ID" | sort -u) + fi + if [[ -n "${TASK13_CONTROL_DIR:-}" ]]; then + if [[ "$TASK13_CONTROL_DIR" == "$TASK13_ROOT/.thothctl/$TASK13_PROJECT" \ + && "$TASK13_PROJECT" =~ ^thothii-[0-9a-f]{12}$ ]]; then + rm -rf "$TASK13_CONTROL_DIR" + else + cleanup_rc=1 + fi + fi + if [[ -n "${TASK13_RUN_ID:-}" ]]; then + leftovers="$(docker container ls -aq --filter "label=io.thothii.task13.run=$TASK13_RUN_ID")" + leftovers+="$(docker volume ls -q --filter "label=io.thothii.task13.run=$TASK13_RUN_ID")" + leftovers+="$(docker network ls -q --filter "label=io.thothii.task13.run=$TASK13_RUN_ID")" + leftovers+="$(docker image ls -q --filter "label=io.thothii.task13.run=$TASK13_RUN_ID")" + [[ -z "$leftovers" ]] || cleanup_rc=1 + fi + if [[ -n "${TASK13_TMP:-}" && -d "$TASK13_TMP" ]]; then + if [[ "${TASK13_TMP%/*}" == "${TASK13_TMP_PARENT:-}" \ + && "${TASK13_TMP##*/}" == thothii-task13.* ]]; then + rm -rf "$TASK13_TMP" + else + cleanup_rc=1 + fi + fi + if [[ "$cleanup_rc" -eq 0 ]]; then + printf 'Task 13 cleanup proof: no labeled containers, volumes, networks, or images remain for %s.\n' \ + "${TASK13_RUN_ID:-unknown}" + else + printf 'Task 13 cleanup proof failed for %s.\n' "${TASK13_RUN_ID:-unknown}" >&2 + fi + trap - EXIT + if [[ "$original_rc" -ne 0 ]]; then + exit "$original_rc" + fi + exit "$cleanup_rc" +} + +task13_self_test_sanitizer() { + local input output leaked + TASK13_SECRET_VALUE="fixture-known-secret" + input="$(printf '%s\n' \ + 'fixture-known-secret' \ + 'password=plain-secret' \ + '{"api_key":"json-secret"}' \ + 'Authorization: Bearer bearer-secret' \ + 'https://alice:url-secret@example.invalid/repo.git')" + output="$(printf '%s\n' "$input" | task13_sanitize)" + for leaked in fixture-known-secret plain-secret json-secret bearer-secret url-secret; do + if grep -Fq "$leaked" <<<"$output"; then + task13_fail "sanitizer leaked $leaked" + fi + done + [[ "$(grep -Fc '[REDACTED]' <<<"$output")" -eq 5 ]] \ + || task13_fail "sanitizer did not redact every credential form" +} + +task13_self_test_cleanup_ownership() { + local calls foreign_error owned_name foreign_name + calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-cleanup-contract.XXXXXX")" + foreign_error="$calls.foreign-error" + owned_name="task13-owned-contract" + foreign_name="task13-foreign-contract" + TASK13_RUN_ID="task13-contract-run" + + docker() { + printf '%s\n' "$*" >>"$calls" + if [[ "$1 $2" == "container inspect" ]]; then + if [[ "$3" == "--format" ]]; then + if [[ "${*: -1}" == "$owned_name" ]]; then + printf '%s\n' "$TASK13_RUN_ID" + else + printf '%s\n' 'some-other-run' + fi + fi + return 0 + fi + [[ "$1 $2" == "container rm" ]] + } + + if task13_remove_labeled_container "$foreign_name" 2>"$foreign_error"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup accepted a foreign-labeled container" + fi + if grep -Fq "container rm --force $foreign_name" "$calls"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup attempted to remove a foreign-labeled container" + fi + grep -Fq "refusing to remove foreign container $foreign_name" "$foreign_error" \ + || task13_fail "cleanup refusal was not explicit" + + task13_remove_labeled_container "$owned_name" + [[ "$(grep -Fc "container rm --force $owned_name" "$calls")" -eq 1 ]] \ + || task13_fail "cleanup did not remove exactly the owned container" + unset -f docker + rm -f "$calls" "$foreign_error" +} + +task13_self_test_image_cleanup_ownership() { + local calls foreign_error owned_ref foreign_ref + calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-image-cleanup-contract.XXXXXX")" + foreign_error="$calls.foreign-error" + owned_ref="task13-owned-contract:local" + foreign_ref="task13-foreign-contract:local" + TASK13_RUN_ID="task13-contract-run" + + if ! declare -F task13_remove_labeled_image >/dev/null; then + rm -f "$calls" "$foreign_error" + task13_fail "image cleanup ownership guard is missing" + fi + + docker() { + printf '%s\n' "$*" >>"$calls" + if [[ "$1 $2" == "image inspect" ]]; then + if [[ "$3" == "--format" ]]; then + if [[ "${*: -1}" == "$owned_ref" ]]; then + printf '%s\n' "$TASK13_RUN_ID" + else + printf '%s\n' 'some-other-run' + fi + fi + return 0 + fi + [[ "$1 $2" == "image rm" ]] + } + + if task13_remove_labeled_image "$foreign_ref" 2>"$foreign_error"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup accepted a foreign-labeled image" + fi + if grep -Fq "image rm $foreign_ref" "$calls"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup attempted to remove a foreign-labeled image" + fi + grep -Fq "refusing to remove foreign image $foreign_ref" "$foreign_error" \ + || task13_fail "image cleanup refusal was not explicit" + + task13_remove_labeled_image "$owned_ref" + [[ "$(grep -Fc "image rm $owned_ref" "$calls")" -eq 1 ]] \ + || task13_fail "cleanup did not remove exactly the owned image reference" + unset -f docker + rm -f "$calls" "$foreign_error" +} + +task13_self_test_transaction_image_cleanup() { + local calls foreign_error owned_ref foreign_ref + calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-transaction-cleanup-contract.XXXXXX")" + foreign_error="$calls.foreign-error" + owned_ref="thothii-core:thothctl-0123456789abcdef-candidate" + foreign_ref="thothii-core:thothctl-fedcba9876543210-candidate" + + if ! declare -F task13_remove_transaction_image >/dev/null; then + rm -f "$calls" "$foreign_error" + task13_fail "transaction image cleanup guard is missing" + fi + + docker() { + printf '%s\n' "$*" >>"$calls" + if [[ "$1 $2" == "image inspect" ]]; then + if [[ "$3" == "--format" ]]; then + if [[ "${*: -1}" == "$owned_ref" ]]; then + printf '%s\n' 'sha256:owned' + else + printf '%s\n' 'sha256:foreign' + fi + fi + return 0 + fi + [[ "$1 $2" == "image rm" ]] + } + + if task13_remove_transaction_image "$foreign_ref" 'sha256:owned' 2>"$foreign_error"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup accepted a transaction image with a foreign identity" + fi + if grep -Fq "image rm $foreign_ref" "$calls"; then + unset -f docker + rm -f "$calls" "$foreign_error" + task13_fail "cleanup attempted to remove a foreign transaction image" + fi + grep -Fq "refusing to remove foreign transaction image $foreign_ref" "$foreign_error" \ + || task13_fail "transaction image cleanup refusal was not explicit" + + task13_remove_transaction_image "$owned_ref" 'sha256:owned' + [[ "$(grep -Fc "image rm $owned_ref" "$calls")" -eq 1 ]] \ + || task13_fail "cleanup did not remove exactly the owned transaction image reference" + unset -f docker + rm -f "$calls" "$foreign_error" +} + +task13_self_test_source_contract() { + local root host_network push_command registry_function workflow uses_count pinned_uses_count + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" + workflow="$root/.github/workflows/deployment.yml" + host_network='--network'' host' + push_command='docker image ''push' + registry_function='task13_start_''registry' + if rg -n 'docker[[:space:]]+(system[[:space:]]+)?prune' \ + "$root/scripts/unified-deployment-smoke.sh" \ + "$root/scripts/thothctl-update-smoke.sh" >/dev/null; then + task13_fail "Task 13 smoke scripts must never prune global Docker state" + fi + ! grep -Fq -- "$host_network" "$root/scripts/unified-deployment-smoke.sh" \ + || task13_fail "the image registry must not depend on host networking" + if grep -Fq -- "$push_command" "$root/scripts/unified-deployment-smoke.sh" \ + || grep -Fq -- "$registry_function" "$root/scripts/unified-deployment-smoke.sh"; then + task13_fail "the rollback fixture must not depend on a daemon-to-host local image registry" + fi + grep -Eq '^TASK13_BAD_CANDIDATE_IMAGE="[^"[:space:]]+@sha256:[0-9a-f]{64}"$' \ + "$root/scripts/unified-deployment-smoke.sh" \ + || task13_fail "the bad rollback candidate must be an immutable digest reference" + grep -Eq 'timeout .*scripts/unified-deployment-smoke\.sh' "$workflow" \ + || task13_fail "CI lacks an outer timeout for the unified deployment smoke" + grep -Eq 'timeout .*scripts/thothctl-update-smoke\.sh' "$workflow" \ + || task13_fail "CI lacks an outer timeout for the thothctl update smoke" + uses_count="$(grep -Ec '^[[:space:]]+uses:' "$workflow")" + pinned_uses_count="$(grep -Ec '^[[:space:]]+uses: [^[:space:]]+@[0-9a-f]{40}([[:space:]]|$)' "$workflow")" + [[ "$uses_count" -gt 0 && "$uses_count" -eq "$pinned_uses_count" ]] \ + || task13_fail "every deployment workflow action must use a full immutable commit pin" + if grep -Fq '${{ secrets.' "$workflow"; then + task13_fail "the deployment release gate must not require repository secrets" + fi + for command in \ + 'bash scripts/verify-line-endings.sh' \ + 'bash scripts/test-unified-compose.sh' \ + 'bash scripts/test-compose-secret-policy.sh' \ + 'bash scripts/test-no-deployment-coupling.sh' \ + 'bash scripts/test-verify-workspace-install-docs.sh' \ + 'npx vitest run' \ + 'npx tsc --noEmit -p .' \ + 'npx tsc -b' \ + './scripts/test-windows-clone-contract.ps1'; do + grep -Fq "$command" "$workflow" || task13_fail "CI coverage is missing: $command" + done +} + +task13_self_test() { + task13_self_test_sanitizer + task13_self_test_cleanup_ownership + task13_self_test_image_cleanup_ownership + task13_self_test_transaction_image_cleanup + task13_self_test_source_contract + printf 'Task 13 smoke safety contracts passed.\n' +} + +task13_initialize() { + umask 077 + TASK13_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" + TASK13_TMP_PARENT="$(cd "${TMPDIR:-/tmp}" && pwd -P)" + TASK13_TMP="$(mktemp -d "$TASK13_TMP_PARENT/thothii-task13.XXXXXX")" + TASK13_TMP="$(cd "$TASK13_TMP" && pwd -P)" + TASK13_LOG="$TASK13_TMP/task13.log" + TASK13_FAILURE_LOGGED=0 + : >"$TASK13_LOG" + trap 'task13_cleanup $?' EXIT + trap 'exit 130' INT TERM HUP + TASK13_RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$-${RANDOM:-0}" + TASK13_INSTALLATION="$TASK13_TMP/thothii-installation.yaml" + TASK13_PROJECT="thothii-$(task13_sha256_text "$TASK13_INSTALLATION" | cut -c1-12)" + TASK13_CONTROL_DIR="$TASK13_ROOT/.thothctl/$TASK13_PROJECT" + [[ ! -e "$TASK13_CONTROL_DIR" ]] || task13_fail "unique thothctl control directory already exists" + TASK13_CURRENT_IMAGE_OVERRIDE="$TASK13_CONTROL_DIR/current-image.yaml" + TASK13_UPDATE_STATE="$TASK13_CONTROL_DIR/update-state.json" + TASK13_REMOTE="$TASK13_TMP/remote.git" + TASK13_SEED="$TASK13_TMP/seed" + TASK13_BRANCH="task13-smoke" + TASK13_ENV_FILE="$TASK13_TMP/local.env" + TASK13_OVERRIDE="$TASK13_TMP/compose.task13.yaml" + TASK13_PI_AUTH="$TASK13_TMP/pi-auth.json" + TASK13_SECRETS="$TASK13_TMP/thothii.secrets" + TASK13_PI_MODELS="$TASK13_TMP/models.json" + TASK13_PI_SETTINGS="$TASK13_TMP/pi-settings.json" + TASK13_LLM_SERVER="$TASK13_TMP/fake-llm.mjs" + TASK13_THOTHCTL_DIR="$TASK13_TMP/thothctl" + TASK13_LLM_CONTAINER="$TASK13_PROJECT-llm" + TASK13_CORE_IMAGE="task13-core-$TASK13_RUN_ID:local" + TASK13_FRONTEND_IMAGE="task13-frontend-$TASK13_RUN_ID:local" + TASK13_SECRET_VALUE="task13-secret-$TASK13_RUN_ID" + TASK13_NETWORK="" + TASK13_BAD_CANDIDATE_ID="" + TASK13_PREVIOUS_IMAGE_ID="" +} + +task13_require_tools() { + for command in bash git docker curl sed awk grep rg sort; do + command -v "$command" >/dev/null 2>&1 || task13_fail "$command is required" + done + task13_run_logged "Docker daemon readiness" docker info + task13_run_logged "Docker Compose readiness" docker compose version + task13_self_test_source_contract +} + +task13_smoke_main() { + local mode="${1:-full}" + [[ "$mode" == full || "$mode" == update ]] || task13_fail "unknown Task 13 smoke mode: $mode" + task13_initialize + task13_require_tools + task13_write_fixture_files + task13_write_environment /fixtures/remote.git + task13_seed_registry + task13_build_thothctl + task13_start_stack + task13_assert_project_ownership + task13_assert_built_image_ownership + task13_assert_runtime + task13_prepare_persistence + if [[ "$mode" == full ]]; then + task13_registry_lifecycle + fi + task13_update_rollback + printf 'Task 13 %s deployment smoke passed.\n' "$mode" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + if [[ "${1:-}" == "--self-test" ]]; then + task13_self_test + else + task13_smoke_main full + fi +fi