From d5cd964e51e6e68bddac51d9490da8f21b9a17ff Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 16 Aug 2026 19:53:00 +0300 Subject: [PATCH] deploy: attest production artifact revision --- deploy/docker-compose.prod.yml | 13 +++-- deploy/images/postgres.Dockerfile | 3 + deploy/images/runtime.Dockerfile | 3 + .../Confirm-DomovoyProductionArtifact.ps1 | 56 +++++++++++++++++++ .../windows/Test-DomovoyProductionHealth.ps1 | 16 ++++++ dev/ACTIVE_TASK.json | 6 +- ...000045-production-artifact-provenance.json | 23 ++++++++ docs/DEPLOYMENT.md | 9 +++ docs/PROJECT_STATUS.md | 9 +-- 9 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 deploy/windows/Confirm-DomovoyProductionArtifact.ps1 create mode 100644 dev/tasks/DEV-000045-production-artifact-provenance.json diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 27eac29..a7e0753 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -4,6 +4,7 @@ services: build: context: .. dockerfile: deploy/images/postgres.Dockerfile + args: { SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-assistant} @@ -31,7 +32,7 @@ services: volumes: ["assistant_nats:/data"] notification-receiver: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: notification-receiver } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: notification-receiver, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: LISTEN_ADDR: ":8085" @@ -50,7 +51,7 @@ services: retries: 10 core-runtime: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: LISTEN_ADDR: ":8080" @@ -74,7 +75,7 @@ services: - assistant_core_secrets:/run/secrets:ro continuity-runtime: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: LISTEN_ADDR: ":8081" @@ -87,7 +88,7 @@ services: COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN} commit-runtime: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: LISTEN_ADDR: ":8082" @@ -109,12 +110,12 @@ services: - assistant_notification_writer_secret:/run/notification-writer:ro cognitive-worker: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: { LISTEN_ADDR: ":8083" } creator-web: - build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web } } + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped environment: LISTEN_ADDR: ":8084" diff --git a/deploy/images/postgres.Dockerfile b/deploy/images/postgres.Dockerfile index c27e2d6..648f34d 100644 --- a/deploy/images/postgres.Dockerfile +++ b/deploy/images/postgres.Dockerfile @@ -1,4 +1,7 @@ FROM postgres:18.6-alpine +ARG SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision=$SOURCE_REVISION \ + org.opencontainers.image.source="https://git.cin.su/m/Assistant.git" COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh diff --git a/deploy/images/runtime.Dockerfile b/deploy/images/runtime.Dockerfile index f98e085..7760945 100644 --- a/deploy/images/runtime.Dockerfile +++ b/deploy/images/runtime.Dockerfile @@ -7,6 +7,9 @@ ARG TARGET RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/runtime ./cmd/${TARGET} FROM alpine:3.23 +ARG SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision=$SOURCE_REVISION \ + org.opencontainers.image.source="https://git.cin.su/m/Assistant.git" RUN addgroup -S assistant && adduser -S -G assistant assistant USER assistant COPY --from=build /out/runtime /usr/local/bin/assistant-runtime diff --git a/deploy/windows/Confirm-DomovoyProductionArtifact.ps1 b/deploy/windows/Confirm-DomovoyProductionArtifact.ps1 new file mode 100644 index 0000000..f4116a4 --- /dev/null +++ b/deploy/windows/Confirm-DomovoyProductionArtifact.ps1 @@ -0,0 +1,56 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-f]{40}$')] + [string]$Revision, + [string]$DockerContext = 'docker-prod', + [string]$LocalManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json", + [string]$NasManifestPath = '\\nas\MST\codex\_monitoring\domovoy\deployment-manifest.json', + [string[]]$ExpectedContainers = @( + 'assistant-postgres-1', + 'assistant-notification-receiver-1', + 'assistant-core-runtime-1', + 'assistant-continuity-runtime-1', + 'assistant-commit-runtime-1', + 'assistant-cognitive-worker-1', + 'assistant-creator-web-1' + ) +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Write-AtomicJson([string]$Path, [string]$Json) { + $directory = Split-Path -Parent $Path + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $partial = Join-Path $directory ('.' + [IO.Path]::GetFileName($Path) + ".${PID}.partial") + try { + [IO.File]::WriteAllText($partial, $Json + "`n", [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $partial -Destination $Path -Force + } + finally { + if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force } + } +} + +$artifacts = foreach ($container in $ExpectedContainers) { + $raw = ((& docker.exe --context $DockerContext inspect $container --format '{{json .}}') -join '').Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "Cannot inspect $container" } + $inspect = $raw | ConvertFrom-Json + $actual = [string]$inspect.Config.Labels.'org.opencontainers.image.revision' + if ($actual -ne $Revision) { throw "$container revision mismatch: expected=$Revision actual=$actual" } + if ($inspect.State.Status -ne 'running') { throw "$container is not running" } + [ordered]@{ container = $container; image_id = [string]$inspect.Image; revision = $actual } +} + +$manifest = [ordered]@{ + version = 1 + project = 'domovoy' + revision = $Revision + promoted_at_utc = [DateTime]::UtcNow.ToString('o') + artifacts = @($artifacts) +} +$json = $manifest | ConvertTo-Json -Depth 5 +Write-AtomicJson $LocalManifestPath $json +Write-AtomicJson $NasManifestPath $json +Write-Output "Production artifact confirmed: revision=$Revision containers=$($artifacts.Count)" diff --git a/deploy/windows/Test-DomovoyProductionHealth.ps1 b/deploy/windows/Test-DomovoyProductionHealth.ps1 index 9cf9db2..7692fcb 100644 --- a/deploy/windows/Test-DomovoyProductionHealth.ps1 +++ b/deploy/windows/Test-DomovoyProductionHealth.ps1 @@ -7,6 +7,7 @@ param( [string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres', [string]$NasStatusPath = '\\nas\MST\codex\_monitoring\domovoy\status.json', [string]$LocalStatusPath = "$env:LOCALAPPDATA\Domovoy\monitoring\status.json", + [string]$DeploymentManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json", [double]$MaxProductionBackupAgeHours = 30, [double]$MaxNasBackupAgeHours = 36, [int]$MinimumCertificateDays = 21, @@ -60,6 +61,21 @@ foreach ($container in $ExpectedContainers) { catch { Add-Check "container:$container" $false $_.Exception.Message } } +try { + $manifest = Get-Content -LiteralPath $DeploymentManifestPath -Raw | ConvertFrom-Json + if ($manifest.version -ne 1 -or $manifest.revision -notmatch '^[0-9a-f]{40}$') { throw 'invalid deployment manifest' } + if (@($manifest.artifacts).Count -ne 7) { throw 'deployment manifest artifact count mismatch' } + foreach ($artifact in $manifest.artifacts) { + $raw = ((& docker.exe --context $DockerContext inspect $artifact.container --format '{{json .}}') -join '').Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "cannot inspect $($artifact.container)" } + $inspect = $raw | ConvertFrom-Json + $label = [string]$inspect.Config.Labels.'org.opencontainers.image.revision' + if ($label -ne $manifest.revision -or [string]$inspect.Image -ne [string]$artifact.image_id) { throw "artifact drift: $($artifact.container)" } + } + Add-Check 'deployment-artifact' $true "revision=$($manifest.revision) containers=$(@($manifest.artifacts).Count)" +} +catch { Add-Check 'deployment-artifact' $false $_.Exception.Message } + try { $response = Invoke-RestMethod -Uri $Endpoint -TimeoutSec 15 $ok = $response.status -eq 'ok' diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 8c5c633..cb05800 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000044-post-notification-production-audit.json", - "task_id": "DEV-000044", - "task_revision": 3 + "task_file": "dev/tasks/DEV-000045-production-artifact-provenance.json", + "task_id": "DEV-000045", + "task_revision": 1 } diff --git a/dev/tasks/DEV-000045-production-artifact-provenance.json b/dev/tasks/DEV-000045-production-artifact-provenance.json new file mode 100644 index 0000000..b9a7f2d --- /dev/null +++ b/dev/tasks/DEV-000045-production-artifact-provenance.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000045", + "task_revision": 1, + "architecture_revision": "ADR-0005", + "base_repo_revision": "959c89d47bf231dbcfb7a05e7b63b0e8579e3f0c", + "risk_tier": "E", + "intent": "Stamp project-built production images with the immutable Git revision, record promoted image digests in an external deployment manifest, and make production health reject provenance drift without using docker-test.", + "requirement_ids": ["INV-TOPO-001", "DEV-000044-FINDING-ARTIFACT-PROVENANCE", "USER-DOCKER-PROD-ONLY"], + "allowed_change_scope": {"modules": [], "paths": ["deploy/docker-compose.prod.yml", "deploy/images/runtime.Dockerfile", "deploy/images/postgres.Dockerfile", "deploy/windows/Confirm-DomovoyProductionArtifact.ps1", "deploy/windows/Test-DomovoyProductionHealth.ps1", "docs/DEPLOYMENT.md", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000045-production-artifact-provenance.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**", "tests/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0}, + "contracts": {"consume": ["existing production Compose topology", "Docker OCI image metadata"], "may_change": []}, + "semantic_owner": "none-operational-verification", + "canonical_state": [], + "state_transitions": [], + "side_effects_and_privileges": ["rebuild and replace project-owned production containers with exact merged source", "write non-secret deployment manifest to local profile and private NAS monitoring directory"], + "persistence_and_migrations": ["non-secret external deployment manifest only"], + "failure_retry_restart": ["manifest is published atomically only after all expected labels and digests verify", "failed verification leaves previous manifest intact", "health becomes degraded on revision or digest drift"], + "assumptions": ["docker context docker-prod is the production endpoint", "Git HEAD is merged origin/main before promotion", "docker-test remains prohibited"], + "acceptance_scenarios": ["every project-built image has the merged Git revision OCI label", "manifest records revision and exact image digests", "health verifies running labels and digests against the manifest", "production remains healthy after exact-artifact promotion"], + "required_tests": ["PowerShell parser", "task schema", "architecture preflight", "exact diff scope", "go test", "go vet", "Compose config", "production artifact confirmation", "production health", "CI", "docker-test absence"], + "terminal_gap_states": ["BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 303bcf2..88920b4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,6 +14,15 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/ The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed. +Project-built production images receive `org.opencontainers.image.revision` and `org.opencontainers.image.source` OCI labels. Promotion must set `ASSISTANT_SOURCE_REVISION` to the full merged `origin/main` SHA before `docker compose build/up`; the default `unknown` is intentionally rejected by artifact confirmation. After all project containers are running, execute: + +```powershell +$revision = git rev-parse origin/main +powershell -ExecutionPolicy Bypass -File .\deploy\windows\Confirm-DomovoyProductionArtifact.ps1 -Revision $revision -DockerContext docker-prod +``` + +The confirmation script accepts the promotion only when every project-built container has that revision label, then atomically records its exact Docker image ID in `%LOCALAPPDATA%\Domovoy\monitoring\deployment-manifest.json` and the private NAS monitoring directory. Production health compares every running label and image ID to this manifest and becomes degraded on drift. The manifest is non-secret operational evidence; it is not canonical Assistant state. + The first external operation adds `assistant-notification-receiver-1` on production host port `18081`. Caddy applies `deploy/caddy/assistent.caddy`, routing only `https://assistent.cin.su/delivery/*` to that port and all other paths to Creator Web on `18080`. The receiver has an isolated durable volume and no Assistant database access. Commit success means an immutable receiver receipt exists; Windows display and human reading are not Commit outcomes. Migrations `010_capability_notification_binding.sql` and `011_commit_notification_envelope.sql` must be applied before the new runtimes start. The initial Capability binding is owner-local for `domovoy-creator-principal`, revision 1, capability `prospective-notification-delivery`, origin `https://assistent.cin.su`, delivery path `/delivery/v1/deliveries`, receipt path `/delivery/v1/receipts`, protocol `IDEMPOTENT_RECEIPT_V1`, and secret reference `domovoy-notification-write-v1`. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 2d8c26e..390018c 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -20,9 +20,10 @@ Audit date: 2026-08-16. Active audit: DEV-000044 revision 3. ## Remaining work requiring a new task decision -1. **Artifact provenance:** running images expose Compose image digests but no OCI Git revision label, and Go build metadata reports the module as `(devel)`. Operational behavior is verified, but the source revision of an already-running image cannot be independently proven from that image alone. A deployment task should stamp the immutable Git revision and record the promoted image digests. -2. **Cognitive provider:** the cognitive worker intentionally remains the deterministic fake. Selecting a real model/provider, data policy and allowed proposal contract requires a separate capability/architecture decision; it must not gain canonical mutation authority. -3. **Product breadth:** notification delivery is the only accepted real external operation. Any additional operation needs its own proposal, authority model, idempotency/reconciliation semantics and Capability binding. -4. **Artifact and Epistemics product behavior:** their ownership boundaries exist, but no product task has yet specified durable user-facing workflows for them. +1. **Cognitive provider:** the cognitive worker intentionally remains the deterministic fake. Selecting a real model/provider, data policy and allowed proposal contract requires a separate capability/architecture decision; it must not gain canonical mutation authority. +2. **Product breadth:** notification delivery is the only accepted real external operation. Any additional operation needs its own proposal, authority model, idempotency/reconciliation semantics and Capability binding. +3. **Artifact and Epistemics product behavior:** their ownership boundaries exist, but no product task has yet specified durable user-facing workflows for them. + +DEV-000045 closes the DEV-000044 artifact-provenance finding for new promotions by stamping the merged Git revision into OCI labels, recording exact promoted image IDs outside the repository, and making production monitoring reject label or digest drift. Images that predate DEV-000045 remain historically unverifiable from the image alone. These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository. -- 2.54.0