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
+372 -22
View File
@@ -26,7 +26,11 @@ if [ -n "${DEPLOY_GEOINTEL_INSTALL_AI:-}" ]; then
GEOINTEL_INSTALL_AI="$DEPLOY_GEOINTEL_INSTALL_AI"
fi
GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}"
GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-true}"
if [ "$GEOINTEL_INSTALL_AI" != "true" ]; then
echo "Production release deployment requires the gated AI image (GEOINTEL_INSTALL_AI=true)." >&2
exit 2
fi
GEOINTEL_APP_VERSION="$(tr -d '[:space:]' < VERSION)"
if ! [[ "$GEOINTEL_APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid semantic version in VERSION: ${GEOINTEL_APP_VERSION}" >&2
@@ -57,7 +61,52 @@ source_tree_hash() {
}
resolve_build_sha() {
local head="" content=""
local head="" content="" controller_sha="" controller_source=""
if [ -n "${GITEA_COMMIT_SHA:-}" ]; then
controller_sha="$GITEA_COMMIT_SHA"
controller_source="GITEA_COMMIT_SHA"
fi
if [ -n "${GITHUB_SHA:-}" ]; then
if ! [[ "$GITHUB_SHA" =~ ^[0-9A-Fa-f]{40}$ ]]; then
echo "GITHUB_SHA must contain one full 40-character Git commit SHA." >&2
return 2
fi
if [ -n "$controller_sha" ] && [ "${controller_sha,,}" != "${GITHUB_SHA,,}" ]; then
echo "Controller commit variables disagree." >&2
return 2
fi
controller_sha="$GITHUB_SHA"
controller_source="${controller_source:-GITHUB_SHA}"
fi
if [ -n "$controller_sha" ]; then
if ! [[ "$controller_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
echo "${controller_source} must contain one full 40-character Git commit SHA." >&2
return 2
fi
controller_sha="${controller_sha,,}"
if [ -n "${GEOINTEL_BUILD_SHA:-}" ] && [ "${GEOINTEL_BUILD_SHA,,}" != "$controller_sha" ]; then
echo "Explicit build revision differs from the controller revision." >&2
return 2
fi
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
head="$(git rev-parse HEAD 2>/dev/null || true)"
if [ "${head,,}" != "$controller_sha" ]; then
echo "Prepared Git checkout does not match the controller revision." >&2
return 2
fi
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "Prepared Git checkout contains changes outside the controller revision." >&2
return 2
fi
fi
printf '%s' "$controller_sha"
return 0
fi
if [ -n "${GITEA_REPOSITORY:-}" ] || [ -n "${GITHUB_REPOSITORY:-}" ]; then
echo "Automated deployment context is missing GITEA_COMMIT_SHA/GITHUB_SHA." >&2
return 2
fi
# 1. Explicit override wins.
if [ -n "${GEOINTEL_BUILD_SHA:-}" ]; then
@@ -107,7 +156,13 @@ if [ -z "$GEOINTEL_BUILD_SHA" ]; then
echo "Could not determine a build revision for this deployment." >&2
exit 2
fi
export GEOINTEL_BUILD_SHA
echo "Build revision: ${GEOINTEL_BUILD_SHA}"
GEOINTEL_RELEASE_TOKEN="$(printf '%s' "$GEOINTEL_BUILD_SHA" | tr -c 'A-Za-z0-9._-' '_' | cut -c1-48)"
if [ -z "$GEOINTEL_RELEASE_TOKEN" ]; then
echo "Could not derive a safe release evidence identifier." >&2
exit 2
fi
GEOINTEL_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
GEOINTEL_IMAGE_REPOSITORY="${GEOINTEL_IMAGE_REPOSITORY:-geointel-all-in-one}"
if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then
@@ -116,8 +171,23 @@ else
GEOINTEL_RELEASE_VARIANT="gis"
fi
GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}-${GEOINTEL_RELEASE_VARIANT}"
GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous"
FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:${GEOINTEL_FRONTEND_PORT:-1202}}"
GEOINTEL_BACKUPS_PATH="${GEOINTEL_BACKUPS_PATH:-/mnt/user/appdata/geointel/backups}"
GEOINTEL_STORAGE_PATH="${GEOINTEL_STORAGE_PATH:-/mnt/user/appdata/geointel/storage}"
GEOINTEL_MODELS_PATH="${GEOINTEL_MODELS_PATH:-/mnt/user/appdata/geointel/models}"
GEOINTEL_POSTGIS_DATA_PATH="${GEOINTEL_POSTGIS_DATA_PATH:-/mnt/user/appdata/geointel/postgres-data}"
GEOINTEL_DEPLOY_EVIDENCE_DIR="${GEOINTEL_DEPLOY_EVIDENCE_DIR:-artifacts/release-evidence/deploy/${GEOINTEL_RELEASE_TOKEN}-ai}"
GEOINTEL_PREDEPLOY_BACKUP_DIR=""
GEOINTEL_RELEASE_IMAGE_ID=""
GEOINTEL_BACKUP_LINK_DEST=""
GEOINTEL_PREDEPLOY_ROLLBACK_TAG=""
case "$GEOINTEL_DEPLOY_EVIDENCE_DIR" in
/*|*..*)
echo "Deployment evidence directory must be repository-relative and must not contain '..'." >&2
exit 2
;;
esac
wait_for_geointel_health() {
local status=""
@@ -141,35 +211,312 @@ wait_for_geointel_health() {
start_image() {
local image="$1"
local running_image_id=""
local running_revision=""
local running_ai=""
GEOINTEL_IMAGE="$image" bash deploy/unraid/run-dockerman-container.sh
wait_for_geointel_health
running_image_id="$(docker inspect --format '{{.Image}}' geointel)"
if [ "$running_image_id" != "$image" ]; then
echo "Running container image ${running_image_id} differs from attested image ${image}." >&2
return 1
fi
running_revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' geointel)"
running_ai="$(docker inspect --format '{{index .Config.Labels "io.geointel.ai.enabled"}}' geointel)"
if [ "$running_revision" != "$GEOINTEL_BUILD_SHA" ] || [ "$running_ai" != "true" ]; then
echo "Running container labels do not match the attested AI revision." >&2
return 1
fi
echo "Running container matches attested image: ${running_image_id}"
}
scan_release_image() {
local scanned_image_id=""
local current_image_id=""
local inspect_output="${GEOINTEL_DEPLOY_EVIDENCE_DIR}/image-inspect.json"
local sbom_output="${GEOINTEL_DEPLOY_EVIDENCE_DIR}/geointel-sbom.spdx.json"
local vulnerability_output="${GEOINTEL_DEPLOY_EVIDENCE_DIR}/geointel-container-vulnerabilities.json"
local attestation_output="${GEOINTEL_DEPLOY_EVIDENCE_DIR}/deployment-attestation.json"
scanned_image_id="$(docker image inspect --format '{{.Id}}' "$GEOINTEL_RELEASE_IMAGE")"
test -n "$scanned_image_id"
mkdir -p "$ROOT/$GEOINTEL_DEPLOY_EVIDENCE_DIR"
docker image inspect "$GEOINTEL_RELEASE_IMAGE" > "$ROOT/$inspect_output"
bash scripts/generate_container_sbom.sh "$GEOINTEL_RELEASE_IMAGE" "$sbom_output"
bash scripts/scan_container_image.sh "$GEOINTEL_RELEASE_IMAGE" "$vulnerability_output"
current_image_id="$(docker image inspect --format '{{.Id}}' "$GEOINTEL_RELEASE_IMAGE")"
if [ "$current_image_id" != "$scanned_image_id" ]; then
echo "Release image tag changed while SBOM/scan evidence was being generated." >&2
return 1
fi
test -s "$ROOT/$inspect_output"
test -s "$ROOT/$sbom_output"
test -s "$ROOT/$vulnerability_output"
GEOINTEL_RELEASE_IMAGE_ID="$scanned_image_id"
python3 - \
"$ROOT/$attestation_output" \
"$GEOINTEL_RELEASE_IMAGE" \
"$GEOINTEL_RELEASE_IMAGE_ID" \
"$GEOINTEL_BUILD_SHA" \
"$inspect_output" \
"$sbom_output" \
"$vulnerability_output" <<'PY'
import datetime
import json
import pathlib
import sys
output, image_tag, image_id, revision, inspect_path, sbom_path, vulnerability_path = sys.argv[1:]
payload = {
"schema_version": 1,
"attested_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"image_tag": image_tag,
"image_id": image_id,
"image_config_digest": image_id,
"revision": revision,
"variant": "ai",
"evidence": {
"image_inspect": inspect_path,
"sbom": sbom_path,
"vulnerabilities": vulnerability_path,
},
}
path = pathlib.Path(output)
temporary = path.with_suffix(".json.partial")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
PY
test -s "$ROOT/$attestation_output"
echo "Exact deployment image scanned: ${GEOINTEL_RELEASE_IMAGE_ID}"
}
preflight_backup_capacity() {
local database_name=""
local database_user=""
local database_size_bytes=""
database_name="$(docker exec geointel sh -c 'printf %s "${POSTGRES_DB:-${GEOINTEL_POSTGRES_DB:-geointel}}"')"
database_user="$(docker exec geointel sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"')"
database_size_bytes="$(docker exec geointel psql -X -v ON_ERROR_STOP=1 \
-U "$database_user" -d "$database_name" -Atqc \
'SELECT pg_database_size(current_database());')"
mkdir -p "$GEOINTEL_BACKUPS_PATH"
python3 - \
"$GEOINTEL_BACKUPS_PATH" \
"$GEOINTEL_STORAGE_PATH" \
"$GEOINTEL_MODELS_PATH" \
"$database_size_bytes" <<'PY'
import os
import pathlib
import shutil
import stat
import sys
backup_root = pathlib.Path(sys.argv[1]).expanduser().resolve()
sources = [pathlib.Path(value).expanduser().resolve() for value in sys.argv[2:4]]
database_bytes = int(sys.argv[4])
def retained_bytes(root: pathlib.Path) -> int:
if not root.is_dir():
raise SystemExit(f"Mandatory snapshot source is not a directory: {root}")
total = 0
for current, directories, files in os.walk(root, topdown=True, followlinks=False):
current_path = pathlib.Path(current)
for name in [*directories, *files]:
path = current_path / name
details = path.lstat()
if stat.S_ISLNK(details.st_mode):
raise SystemExit(f"Mandatory snapshot refuses symlinked content: {path}")
if name in directories and not stat.S_ISDIR(details.st_mode):
raise SystemExit(f"Snapshot directory changed during capacity preflight: {path}")
if name in files:
if not stat.S_ISREG(details.st_mode):
raise SystemExit(f"Mandatory snapshot refuses non-regular content: {path}")
total += details.st_size
return total
source_bytes = sum(retained_bytes(source) for source in sources)
# Reflink clones are used when the backing filesystem supports them. Budget for
# a complete copy plus two uncompressed database sizes (dump and isolated
# restore/cutover recovery) so fallback still fails before the live backend is
# quiesced rather than midway through the snapshot.
required = source_bytes + (2 * database_bytes)
headroom = max(5 * 1024**3, required // 10)
free = shutil.disk_usage(backup_root).free
if free < required + headroom:
raise SystemExit(
"Insufficient free space for a fail-safe predeploy snapshot: "
f"required={required + headroom} free={free} source={source_bytes} database={database_bytes}"
)
print(
"Predeploy snapshot capacity: "
f"source_bytes={source_bytes} database_bytes={database_bytes} free_bytes={free}"
)
PY
}
select_verified_link_dest() {
local candidate=""
GEOINTEL_BACKUP_LINK_DEST=""
while IFS= read -r candidate; do
if (
cd "$candidate" \
&& sha256sum -c CHECKSUMS.sha256 >/dev/null \
&& python3 "$ROOT/scripts/release_backup_snapshot.py" verify-backup --backup-dir "$candidate"
); then
GEOINTEL_BACKUP_LINK_DEST="$candidate"
echo "Using verified prior byte snapshot as link-dest: ${candidate}"
return 0
fi
echo "Skipping unusable prior backup link-dest: ${candidate}" >&2
done < <(
python3 - "$GEOINTEL_BACKUPS_PATH" <<'PY'
import pathlib
import sys
root = pathlib.Path(sys.argv[1]).expanduser().resolve()
candidates = sorted(
(
path
for path in root.iterdir()
if path.is_dir() and not path.name.startswith(".") and (path / "manifest.json").is_file()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
for candidate in candidates:
print(candidate)
PY
)
echo "No verified prior byte snapshot found; this deployment will create a full first snapshot."
}
create_predeploy_backup() {
local container_exists="false"
local container_running="false"
local release_id=""
local backup_link_args=()
local current_image_id=""
local existing_rollback_id=""
if docker ps -a --format '{{.Names}}' | grep -Fxq geointel; then
container_exists="true"
fi
if [ "$(docker inspect -f '{{.State.Running}}' geointel 2>/dev/null || true)" = "true" ]; then
container_running="true"
fi
if [ "$container_exists" = "false" ]; then
if [ -f "$GEOINTEL_POSTGIS_DATA_PATH/PG_VERSION" ]; then
echo "PostGIS data exists without a running GeoIntel container; refusing an unbacked migration." >&2
return 1
fi
echo "No existing GeoIntel state found; pre-deploy backup is not required for this fresh install."
return 0
fi
if [ "$container_running" != "true" ]; then
echo "Existing GeoIntel container is not running; refusing deployment because a consistent backup cannot be created." >&2
return 1
fi
current_image_id="$(docker inspect --format '{{.Image}}' geointel)"
if ! [[ "$current_image_id" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "Running release does not expose one immutable Docker image ID." >&2
return 1
fi
release_id="predeploy-${GEOINTEL_RELEASE_TOKEN:0:24}-$(date -u +%Y%m%dT%H%M%SZ)-$$"
GEOINTEL_PREDEPLOY_ROLLBACK_TAG="${GEOINTEL_IMAGE_REPOSITORY}:rollback-${release_id}"
existing_rollback_id="$(docker image inspect --format '{{.Id}}' "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG" 2>/dev/null || true)"
if [ -n "$existing_rollback_id" ] && [ "$existing_rollback_id" != "$current_image_id" ]; then
echo "Backup-specific rollback tag already identifies different image bytes." >&2
return 1
fi
docker tag "$current_image_id" "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG"
# This conservative full-copy fallback estimate runs while the existing
# release is still healthy. Verified backup-to-backup hardlinks normally
# avoid recopying unchanged bytes, but are never assumed for this fail-closed
# capacity decision.
preflight_backup_capacity
select_verified_link_dest
if [ -n "$GEOINTEL_BACKUP_LINK_DEST" ]; then
backup_link_args=(--link-dest-backup "$GEOINTEL_BACKUP_LINK_DEST")
fi
echo "Quiescing the current backend so the rollback point cannot miss concurrent writes..."
if ! docker exec -i geointel python - <<'PY'
import os
import pathlib
import signal
import time
matches = []
for item in pathlib.Path("/proc").iterdir():
if not item.name.isdigit() or int(item.name) in {os.getpid(), os.getppid()}:
continue
try:
command = (item / "cmdline").read_bytes().replace(b"\0", b" ")
except (OSError, PermissionError):
continue
if b"uvicorn" in command and b"app.main:app" in command:
matches.append(int(item.name))
if not matches:
raise SystemExit("Could not identify the running GeoIntel backend")
for process_id in matches:
os.kill(process_id, signal.SIGTERM)
deadline = time.monotonic() + 60
remaining = matches
while remaining and time.monotonic() < deadline:
time.sleep(0.25)
remaining = [process_id for process_id in remaining if pathlib.Path(f"/proc/{process_id}").exists()]
if remaining:
raise SystemExit(f"Backend did not stop cleanly: {remaining}")
print(f"Stopped {len(matches)} backend process(es)")
PY
then
echo "Could not quiesce the current backend; refusing a potentially inconsistent backup." >&2
docker restart geointel >/dev/null || true
wait_for_geointel_health || true
return 1
fi
GEOINTEL_PREDEPLOY_BACKUP_DIR="${GEOINTEL_BACKUPS_PATH%/}/${release_id}"
echo "Creating mandatory pre-deploy backup ${release_id}..."
if ! bash scripts/backup_release_state.sh \
--container geointel \
--output-root "$GEOINTEL_BACKUPS_PATH" \
--release-id "$release_id" \
--storage-path "$GEOINTEL_STORAGE_PATH" \
--models-path "$GEOINTEL_MODELS_PATH" \
--inventory-mode sha256 \
--rollback-image-tag "$GEOINTEL_PREDEPLOY_ROLLBACK_TAG" \
"${backup_link_args[@]}" \
|| ! bash scripts/verify_release_backup.sh \
--container geointel \
--backup-dir "$GEOINTEL_PREDEPLOY_BACKUP_DIR"; then
echo "Pre-deploy backup failed; restarting the unchanged current release." >&2
docker restart geointel >/dev/null || true
wait_for_geointel_health || true
GEOINTEL_PREDEPLOY_BACKUP_DIR=""
return 1
fi
echo "Pre-deploy backup verified: ${GEOINTEL_PREDEPLOY_BACKUP_DIR}"
}
rollback_previous() {
if ! docker image inspect "$GEOINTEL_PREVIOUS_IMAGE" >/dev/null 2>&1; then
echo "Automatic rollback unavailable: ${GEOINTEL_PREVIOUS_IMAGE} does not exist." >&2
if [ -z "$GEOINTEL_PREDEPLOY_BACKUP_DIR" ]; then
echo "Automatic rollback unavailable: no verified pre-deploy database backup was created." >&2
return 1
fi
echo "Rolling back to ${GEOINTEL_PREVIOUS_IMAGE}..."
start_image "$GEOINTEL_PREVIOUS_IMAGE"
echo "Rolling back database and image to the verified pre-deploy state..."
GEOINTEL_DEPLOY_LOCK_HELD=true \
bash deploy/unraid/rollback-dockerman-container.sh \
--backup-dir "$GEOINTEL_PREDEPLOY_BACKUP_DIR" \
--confirm-production-database-restore
}
docker compose -f docker-compose.unraid.yml config >/dev/null
current_image_id="$(docker inspect --format '{{.Image}}' geointel 2>/dev/null || true)"
release_image_id="$(
docker image inspect --format '{{.Id}}' "$GEOINTEL_RELEASE_IMAGE" 2>/dev/null || true
)"
if (
[ -n "$current_image_id" ] &&
[ "$current_image_id" != "$release_image_id" ] &&
docker image inspect "$current_image_id" >/dev/null 2>&1
); then
docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE"
elif [ -n "$current_image_id" ] && [ "$current_image_id" = "$release_image_id" ]; then
echo "Current container already uses ${GEOINTEL_RELEASE_IMAGE}; preserving the existing previous image."
fi
if docker image inspect "$GEOINTEL_RELEASE_IMAGE" >/dev/null 2>&1; then
stored_revision="$(
docker image inspect \
@@ -208,7 +555,10 @@ else
.
fi
if ! start_image "$GEOINTEL_RELEASE_IMAGE"; then
scan_release_image
create_predeploy_backup
if ! start_image "$GEOINTEL_RELEASE_IMAGE_ID"; then
rollback_previous || true
exit 1
fi