fix(release): make deployment backup and rollback immutable

This commit is contained in:
Jens
2026-08-30 06:00:43 +02:00
parent a0884d64c9
commit c272220277
47 changed files with 3035 additions and 430 deletions
+36 -20
View File
@@ -5,8 +5,10 @@ GeoIntel uses the same release gates in Gitea Actions and GitHub Actions:
- `.gitea/workflows/release-gates.yml`
- `.github/workflows/release-gates.yml`
Gitea is the operational source-control platform. The GitHub workflow is kept
equivalent so a mirror or external review does not receive a weaker gate.
Gitea is the operational source-control platform. Its release workflow builds
the production AI variant and is the only workflow that can automatically
deploy. The GitHub mirror builds and scans the same AI variant for external
review but is not a production deployment authority.
## Runner requirements
@@ -16,11 +18,11 @@ The `ubuntu-latest` runner must provide:
- Python 3.11 and Node 20 through the official setup actions;
- Bash and Docker with Compose v2;
- permission to build images and mount `/var/run/docker.sock`;
- sufficient disk for the all-in-one GIS image and scanner databases.
- sufficient disk for the all-in-one AI image and scanner databases.
The container job builds the GIS release variant only. PyTorch and
Ultralytics remain in the optional `ai` extra and in the explicit AI image
variant; CI does not silently make them base dependencies.
Both container jobs build the explicit AI image, including the
PyTorch/Ultralytics layers used on Tower. AI packages remain outside the
standard backend lock and are pinned by Docker build arguments.
## Quality gate
@@ -33,11 +35,25 @@ cd frontend && npm ci
```
It then validates the lock policy and runs the complete readiness script. The
readiness script covers backend compile/tests, contract audits, Alembic
single-head, frontend typecheck/build and release-script syntax. CI also
readiness script covers Ruff, repository-layout validation, backend
compile/tests, contract audits, Alembic single-head, frontend typecheck/build
and release-script syntax. CI also
renders offline migration SQL and resolved Compose configuration as retained
evidence.
Pull requests run managed validation against the real `backend/` and
`frontend/` projects plus the complete release gates. On a `main` push, the
Unraid deploy job has explicit `needs` dependencies on quality, dependency and
AI-container jobs. There is no separate deployment workflow: manual validation
uses `workflow_dispatch` on this same release-gates workflow and cannot skip
quality, dependency or AI-container jobs.
The deploy host requires a full controller commit SHA, builds the AI variant,
records its Docker image/config digest, generates SBOM and Trivy evidence for
that exact local image, and starts the immutable image ID. Deployment fails if
the running container ID, revision label or AI label differs from the retained
attestation.
## Reproducible Python lock
`backend/requirements-runtime.lock` and `backend/requirements-ci.lock` are
@@ -73,12 +89,12 @@ The dependency job:
- publishes both unfiltered and policy-filtered Python JSON reports plus the
npm JSON report, including on failure.
The only current Python/container exceptions are the Starlette 2026 advisories recorded
in `security/pip-audit-exceptions.json`. FastAPI 0.139.2 still constrains
Starlette below 0.53 while patched releases begin at 1.x. GeoIntel applies
request-target, form-content, route-class and Linux-runtime compensating
controls. The exception file has a mandatory review date; readiness and CI
fail automatically after it expires. New advisories are never auto-ignored.
There are currently no Python or container vulnerability exceptions.
`security/pip-audit-exceptions.json` remains as a strict, machine-readable
registry: every future exception must identify one advisory, package, specific
reason and expiry date. Readiness and CI fail on malformed or expired entries;
new advisories are never auto-ignored. GeoIntel requires Starlette 1.3.1 or
newer and therefore no longer suppresses the five 2026 Starlette advisories.
The all-in-one image replaces the Go-based base-image `gosu` helper with a
small `setpriv` exec wrapper and upgrades packaged setuptools/wheel metadata;
the final runtime filesystem no longer exposes the vulnerable Go executable.
@@ -89,8 +105,8 @@ verified to contain the audited shell wrapper. This is not a vulnerability
exception: the raw evidence remains published and the runtime wrapper is
exercised during live release validation.
The container job builds a non-AI all-in-one image and uses digest-pinned
scanner images:
The operational container job builds the production AI all-in-one image and
uses digest-pinned scanner images:
- Syft 1.44.0 generates an SPDX JSON SBOM;
- Trivy 0.70.0 generates a complete JSON vulnerability report;
@@ -103,12 +119,12 @@ Run these controls on a Docker-enabled workstation:
```bash
docker build \
-f deploy/unraid/Dockerfile.all-in-one \
--build-arg GEOINTEL_INSTALL_AI=false \
--build-arg GEOINTEL_INSTALL_AI=true \
--build-arg GEOINTEL_BUILD_SHA=local \
--build-arg GEOINTEL_BUILD_TIME=local \
-t geointel-ci:local .
bash scripts/generate_container_sbom.sh geointel-ci:local
bash scripts/scan_container_image.sh geointel-ci:local
-t geointel-ci:local-ai .
bash scripts/generate_container_sbom.sh geointel-ci:local-ai
bash scripts/scan_container_image.sh geointel-ci:local-ai
```
Outputs are written below ignored `artifacts/`; scanner cache is written below
+28 -7
View File
@@ -89,9 +89,9 @@ The normal project lifecycle archive path does not need destructive
confirmation because it changes only `status=archived` and preserves all
data.
## Destructive apply gate
## Recoverable quarantine gate
First create a fresh backup with a SHA-256 storage inventory on the host:
First create a fresh backup with a byte-complete SHA-256 storage snapshot on the host:
```bash
bash scripts/backup_release_state.sh \
@@ -112,7 +112,8 @@ bash scripts/verify_release_backup.sh \
```
The Unraid runtime mounts `GEOINTEL_BACKUPS_PATH` read-only at `/app/backups`.
Only after reviewing the dry run may an operator execute:
Only after reviewing the dry run may an operator move the exact candidates to
a protected quarantine:
```bash
docker exec geointel python /app/scripts/cleanup_storage_artifacts.py \
@@ -120,14 +121,34 @@ docker exec geointel python /app/scripts/cleanup_storage_artifacts.py \
--max-delete <reviewed-candidate-count> \
--backup-dir /app/backups/<release-id> \
--backup-max-age-hours 24 \
--confirm DELETE_STORAGE_ARTIFACTS \
--confirm QUARANTINE_STORAGE_ARTIFACTS \
--apply
```
The command re-runs the audit immediately before deletion. It refuses the
The command re-runs the audit immediately before quarantine. It refuses the
operation when the exact token is absent, the candidate count exceeds the
operator limit, the backup is stale/incomplete, checksums differ, the storage
inventory is not SHA-256, or the path is outside the cleanup allowlist.
operator limit, the backup is stale/incomplete, snapshot checksums differ, or
the path is outside the cleanup allowlist.
Every moved byte is hashed and retained below
`operator-evidence/cleanup-quarantine/<operation-id>/files/`; an atomic
manifest records its original path, quarantine path, size, checksum and
backup identity. Cleanup uses a backup-to-quarantine hard-link state machine:
the manifest records `planned`, `linked` and `quarantined` transitions so each
crash window can be reconciled without losing the retained inode.
Restore a reviewed quarantine without overwriting any path:
```bash
docker exec geointel python /app/scripts/restore_storage_quarantine.py \
--storage-root /app/storage \
--manifest /app/storage/operator-evidence/cleanup-quarantine/<operation-id>/manifest.json \
--confirm RESTORE_QUARANTINED_ARTIFACTS
```
Restore validates every retained checksum and never replaces an existing
destination. It uses an exclusive hard link and reconciles both-file and
one-file interruption states before updating the manifest, so an interrupted
restore can be resumed and verified.
The older demo-export cleanup has the same gate and uses confirmation token
`DELETE_DEMO_EXPORTS`.
+46 -7
View File
@@ -15,7 +15,7 @@ The repository version is stored in `VERSION`. The current release is
- clean `main` worktree at the commit being released;
- secure non-default PostGIS password in the Tower `.env`;
- existing local AI model only when the AI image is enabled;
- recent checksum-verified backup with SHA-256 storage inventory;
- recent checksum-verified database dump and byte-complete storage/model snapshot;
- Docker, `ssh-keygen`, Python 3.11, Node 20 and Bash available;
- one Alembic head and no unsupported metric represented as successful.
@@ -39,6 +39,24 @@ docker compose config
## Immutable deployment
`deploy-release.sh` first builds/reuses the candidate while the current release
stays available. Immediately before replacement it quiesces backend writes and
creates and checksum-verifies a database dump plus byte-complete SHA-256
storage/model snapshots under `/mnt/user/appdata/geointel/backups`. The first
snapshot copies every byte (using CoW reflinks when supported); later snapshots
hard-link only checksum-identical bytes from a verified older backup, never
from live storage. A conservative full-copy/free-space preflight runs before
backend quiescence. Only then may the
candidate start or run Alembic. If backup fails, the unchanged release is
restarted; deployment stops if existing PostGIS state cannot be backed up
consistently.
The backup manifest separates provenance intentionally:
`backup_tool_revision` is the new candidate source running the backup tool,
while `running_image_revision` is the OCI label of the old release whose data
is being captured. The retained Docker `image_id`, not either descriptive
revision field, is authoritative for rollback.
On the Codex workstation:
```powershell
@@ -58,8 +76,9 @@ bash scripts/live_migration_smoke.sh
## Backup and recovery proof
Create an immutable backup. The SHA-256 inventory can take several minutes on
large storage:
Create an immutable byte-complete backup. Initial storage/model copy and
verification can be I/O-heavy; subsequent backups deduplicate unchanged bytes
against the newest verified prior snapshot:
```bash
bash scripts/backup_release_state.sh \
@@ -124,17 +143,37 @@ gate fails on reachable fixed HIGH/CRITICAL findings.
## Rollback proof
The rollback command reuses persistent paths and never downgrades Alembic:
Use the exact backup printed by the deployment. Rollback first stops the
candidate, restores and verifies the pre-deploy PostgreSQL dump in a temporary
proof database, then swaps database names while retaining the pre-restore
database as a recovery point. Only then does it start the exact image ID bound
into that backup; it never relies on a mutable global `previous` tag. It never runs an
Alembic downgrade or starts an old image against an unknown newer schema:
```bash
bash deploy/unraid/rollback-dockerman-container.sh
bash deploy/unraid/rollback-dockerman-container.sh \
--backup-dir /mnt/user/appdata/geointel/backups/<predeploy-release-id> \
--confirm-production-database-restore
curl -fsS http://127.0.0.1:1202/health/ready
bash deploy/unraid/deploy-release.sh
curl -fsS http://127.0.0.1:1202/health/ready
```
For a future backward-incompatible migration, restore the verified pre-release
backup instead of running an older image against a newer schema.
Database rollback restores persisted rows and schema. Files newly written by a
failed candidate remain in storage as unreferenced evidence; the protected,
recoverable quarantine flow in `DATA_OPERATIONS_RUNBOOK.md` handles those
files without deleting source data.
After the rollback has remained healthy and its retained evidence has been
reviewed, list the recovery database printed by the script. Remove it only by
an explicit, separately approved `dropdb` maintenance command; deployment and
rollback never auto-delete recovery databases or backup directories. Retain at
least the current successful predeploy backup and its predecessor. Before
removing an older backup, run `verify_release_backup.sh` on the backups that
remain and confirm no newer snapshot hard-links depend on operator policy for
retention (hard-linked bytes remain allocated while any retained backup names
them). Remove its `rollback-predeploy-*` image tag only in the same explicitly
reviewed retention operation.
## Tag and signed package
+5 -4
View File
@@ -28,10 +28,11 @@ The restore smoke may only create databases whose name starts with
use `pg_restore --clean` and drops the temporary database unless an operator
explicitly asks to retain it.
Storage and model files are inventoried rather than copied into the database
dump. Release backups must therefore be paired with the persistent storage
volume backup policy. Use `--inventory-mode sha256` for final release
evidence.
Storage and model files are retained as byte-complete, SHA-256 verified
snapshots alongside the database dump. Snapshot creation rejects symlinks and
special files. The first snapshot uses CoW reflinks when supported and falls
back to full copies; later snapshots hard-link checksum-identical files only
from a verified older backup, never from live storage.
An old persistent volume can also retain glibc collation metadata for the
empty `postgres` and `template1` system databases. If `createdb` fails for that