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
+19 -9
View File
@@ -2142,11 +2142,17 @@ bash scripts/backup_release_state.sh \
```
The backup is written atomically and contains a PostgreSQL custom-format dump,
archive listing, Alembic/PostGIS metadata, critical table counts, optional
storage/model inventories and SHA-256 checksums. An empty or known-default
database password leaves the release gate failed. For an emergency backup
before rotating that password, add `--allow-insecure-password`; the manifest
still records the insecure state.
archive listing, Alembic/PostGIS metadata, critical table counts and
byte-complete SHA-256-verified storage/model snapshots. The first snapshot is a
full copy; a later deployment may hard-link only checksum-identical files from
another completed, fully verified backup with `--link-dest-backup`. It never
hard-links a live source file and never deletes an older backup. An empty or
known-default database password leaves the release gate failed. For an
emergency backup before rotating that password, add
`--allow-insecure-password`; the manifest still records the insecure state.
`backup_tool_revision` identifies the candidate source that executed the
backup; `running_image_revision` identifies the currently running old image.
Rollback is always bound to the retained immutable Docker `image_id`.
Verify without changing any database:
@@ -2190,7 +2196,7 @@ docker exec geointel python /app/scripts/audit_data_operations.py \
--output /app/storage/release-evidence/rc-current/data-operations.json
```
Preview old unreferenced derived/cache/export candidates without deletion:
Preview old unreferenced derived/cache/export candidates without mutation:
```bash
docker exec geointel python /app/scripts/cleanup_storage_artifacts.py \
@@ -2199,10 +2205,14 @@ docker exec geointel python /app/scripts/cleanup_storage_artifacts.py \
```
Apply requires a reviewed candidate count, the exact
`DELETE_STORAGE_ARTIFACTS` token and a backup no older than 24 hours with a
checksum-verified database dump and SHA-256 storage inventory. The host backup
`QUARANTINE_STORAGE_ARTIFACTS` token and a backup no older than 24 hours with a
checksum-verified database dump and byte-complete storage snapshot. The host backup
root is mounted read-only at `/app/backups`. See
`docs/DATA_OPERATIONS_RUNBOOK.md`. No cleanup is scheduled by GeoIntel.
`docs/DATA_OPERATIONS_RUNBOOK.md`. Candidates enter protected
`operator-evidence/cleanup-quarantine` storage through an interruption-safe
hard-link/unlink state machine. `restore_storage_quarantine.py` reverses that
move with the exact `RESTORE_QUARANTINED_ARTIFACTS` token and refuses to
overwrite an existing original path. No cleanup is scheduled by GeoIntel.
## RC-8 Belgium/North Sea release journeys
+14 -5
View File
@@ -14,17 +14,26 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from sqlalchemy import func
ROOT = Path(__file__).resolve().parents[1]
BACKEND_ROOT = ROOT / "backend" if (ROOT / "backend" / "app").is_dir() else ROOT
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from app.core.config import get_settings
from app.db.session import SessionLocal
from sqlalchemy import func
from app.models import AnalysisRun, Dataset, DatasetVersion, Detection, Export, Job, Project, Segmentation
from app.core.config import get_settings # noqa: E402 - imported after backend path bootstrap
from app.db.session import SessionLocal # noqa: E402 - imported after backend path bootstrap
from app.models import ( # noqa: E402 - imported after backend path bootstrap
AnalysisRun,
Dataset,
DatasetVersion,
Detection,
Export,
Job,
Project,
Segmentation,
)
NATIONAL_PROJECT_NAME = "Belgium and North Sea Workbench"
+163 -48
View File
@@ -3,12 +3,14 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTAINER="geointel"
OUTPUT_ROOT="backups"
OUTPUT_ROOT="${GEOINTEL_BACKUPS_PATH:-/mnt/user/appdata/geointel/backups}"
RELEASE_ID="rc-$(date -u +%Y%m%dT%H%M%SZ)"
STORAGE_PATH=""
MODELS_PATH=""
INVENTORY_MODE="metadata"
ALLOW_INSECURE_PASSWORD="false"
LINK_DEST_BACKUP=""
ROLLBACK_IMAGE_TAG=""
usage() {
cat <<'EOF'
@@ -19,11 +21,16 @@ GeoIntel container. It never deletes or restores application data.
Options:
--container NAME Docker container (default: geointel)
--output-root PATH Host backup root (default: backups)
--output-root PATH Host backup root (default:
/mnt/user/appdata/geointel/backups)
--release-id ID Safe backup directory name
--storage-path PATH Optional host storage path to inventory
--models-path PATH Optional host model path to inventory
--inventory-mode metadata|sha256 Hash all inventoried files only with sha256
--storage-path PATH Host storage path to snapshot byte-for-byte
--models-path PATH Host model path to snapshot byte-for-byte
--inventory-mode metadata|sha256 Retained manifest compatibility setting
--link-dest-backup PATH Verified older backup used only to hard-link
checksum-identical backup-to-backup files
--rollback-image-tag TAG Immutable backup-specific tag bound to the
running image ID
--allow-insecure-password Complete emergency backup despite an
empty/default production DB password
EOF
@@ -37,6 +44,8 @@ while [ "$#" -gt 0 ]; do
--storage-path) STORAGE_PATH="$2"; shift 2 ;;
--models-path) MODELS_PATH="$2"; shift 2 ;;
--inventory-mode) INVENTORY_MODE="$2"; shift 2 ;;
--link-dest-backup) LINK_DEST_BACKUP="$2"; shift 2 ;;
--rollback-image-tag) ROLLBACK_IMAGE_TAG="$2"; shift 2 ;;
--allow-insecure-password) ALLOW_INSECURE_PASSWORD="true"; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
@@ -51,12 +60,93 @@ if [ "$INVENTORY_MODE" != "metadata" ] && [ "$INVENTORY_MODE" != "sha256" ]; the
echo "--inventory-mode must be metadata or sha256" >&2
exit 2
fi
for required in docker python3 sha256sum git; do
for required in docker python3 sha256sum; do
if ! command -v "$required" >/dev/null 2>&1; then
echo "Missing required command: $required" >&2
exit 2
fi
done
resolve_source_revision() {
local controller_sha="" explicit_sha="${GEOINTEL_BUILD_SHA:-}"
local gitea_sha="${GITEA_COMMIT_SHA:-}" github_sha="${GITHUB_SHA:-}"
local git_head="" git_dirty="false" source=""
if [ -n "$gitea_sha" ]; then
if ! [[ "$gitea_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
echo "GITEA_COMMIT_SHA must contain one full 40-character Git commit SHA." >&2
return 2
fi
controller_sha="${gitea_sha,,}"
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
github_sha="${github_sha,,}"
if [ -n "$controller_sha" ] && [ "$controller_sha" != "$github_sha" ]; then
echo "Controller commit variables disagree." >&2
return 2
fi
controller_sha="$github_sha"
source="${source:-GITHUB_SHA}"
fi
if [ -n "$explicit_sha" ]; then
if ! [[ "$explicit_sha" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
echo "GEOINTEL_BUILD_SHA contains an unsafe release revision." >&2
return 2
fi
explicit_sha="${explicit_sha,,}"
fi
if [ -n "$controller_sha" ]; then
if [ -n "$explicit_sha" ] && [ "$explicit_sha" != "$controller_sha" ]; then
echo "GEOINTEL_BUILD_SHA differs from the controller revision." >&2
return 2
fi
explicit_sha="$controller_sha"
elif [ -n "${GITEA_REPOSITORY:-}${GITHUB_REPOSITORY:-}" ]; then
if ! [[ "$explicit_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Automated backup requires a full controller or GEOINTEL_BUILD_SHA revision." >&2
return 2
fi
source="GEOINTEL_BUILD_SHA"
fi
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
git_head="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
git_head="${git_head,,}"
if ! [[ "$git_head" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve a full Git revision from the source checkout." >&2
return 2
fi
if [ -n "$explicit_sha" ] && [[ "$explicit_sha" =~ ^[0-9a-f]{40}$ ]] && [ "$git_head" != "$explicit_sha" ]; then
echo "Source checkout does not match the supplied release revision." >&2
return 2
fi
if [ -n "$(git -C "$ROOT" status --porcelain=v1 2>/dev/null)" ]; then
git_dirty="true"
fi
if [ -z "$explicit_sha" ]; then
explicit_sha="$git_head"
source="git"
fi
fi
if [ -z "$explicit_sha" ]; then
echo "Cannot bind backup to a source revision; provide GEOINTEL_BUILD_SHA or a controller SHA." >&2
return 2
fi
SOURCE_REVISION="$explicit_sha"
SOURCE_REVISION_SOURCE="${source:-GEOINTEL_BUILD_SHA}"
SOURCE_GIT_DIRTY="$git_dirty"
}
SOURCE_REVISION=""
SOURCE_REVISION_SOURCE=""
SOURCE_GIT_DIRTY="false"
resolve_source_revision
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != "true" ]; then
echo "Container '$CONTAINER' is not running." >&2
exit 3
@@ -72,6 +162,27 @@ if [ -e "$PARTIAL" ] || [ -e "$FINAL" ]; then
fi
mkdir -p "$PARTIAL"
if [ -n "$LINK_DEST_BACKUP" ]; then
LINK_DEST_BACKUP="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve(strict=True))' "$LINK_DEST_BACKUP")"
python3 - "$OUTPUT_ROOT" "$LINK_DEST_BACKUP" <<'PY'
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
candidate = pathlib.Path(sys.argv[2])
try:
candidate.relative_to(root)
except ValueError as exc:
raise SystemExit(f"Link-dest backup must remain below {root}") from exc
if candidate == root or candidate.name.startswith("."):
raise SystemExit("Link-dest backup must identify one completed immutable backup")
PY
(
cd "$LINK_DEST_BACKUP"
sha256sum -c CHECKSUMS.sha256 >/dev/null
)
fi
cleanup_partial() {
if [ -d "$PARTIAL" ]; then
rm -rf -- "$PARTIAL"
@@ -113,10 +224,21 @@ test -s "$PARTIAL/database.list"
IMAGE_ID="$(docker inspect -f '{{.Image}}' "$CONTAINER")"
IMAGE_NAME="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER")"
GIT_COMMIT="$(git -C "$ROOT" rev-parse HEAD)"
GIT_DIRTY="false"
if [ -n "$(git -C "$ROOT" status --porcelain=v1)" ]; then
GIT_DIRTY="true"
RUNNING_IMAGE_REVISION="$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CONTAINER")"
RUNNING_IMAGE_REVISION_IS_FULL_SHA="false"
if [[ "$RUNNING_IMAGE_REVISION" =~ ^[0-9A-Fa-f]{40}$ ]]; then
RUNNING_IMAGE_REVISION="${RUNNING_IMAGE_REVISION,,}"
RUNNING_IMAGE_REVISION_IS_FULL_SHA="true"
elif ! [[ "$RUNNING_IMAGE_REVISION" =~ ^[A-Za-z0-9._-]{1,128}$ ]]; then
echo "Running image has an unsafe or missing OCI revision label." >&2
exit 3
fi
if [ -n "$ROLLBACK_IMAGE_TAG" ]; then
TAGGED_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$ROLLBACK_IMAGE_TAG" 2>/dev/null || true)"
if [ "$TAGGED_IMAGE_ID" != "$IMAGE_ID" ]; then
echo "Backup-specific rollback tag does not resolve to the running image ID." >&2
exit 3
fi
fi
docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$DB_NAME" -AtF $'\t' \
@@ -132,48 +254,32 @@ for table in projects areas datasets dataset_versions vector_features jobs analy
printf '%s\t%s\n' "$table" "$count" >> "$PARTIAL/table-counts.tsv"
done
inventory_path() {
snapshot_path() {
local source_path="$1"
local output_path="$2"
local label="$2"
local manifest_path="$PARTIAL/${label}-manifest.tsv"
local snapshot_path="$PARTIAL/${label}-snapshot"
local link_args=()
if [ -z "$source_path" ]; then
printf 'not_requested\n' > "$output_path"
printf 'not_requested\n' > "$manifest_path"
return
fi
python3 - "$source_path" "$output_path" "$INVENTORY_MODE" <<'PY'
import hashlib
import os
import pathlib
import sys
root = pathlib.Path(sys.argv[1]).expanduser().resolve()
output = pathlib.Path(sys.argv[2])
mode = sys.argv[3]
if not root.is_dir():
raise SystemExit(f"Inventory root is not a directory: {root}")
def digest(path: pathlib.Path) -> str:
value = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
with output.open("w", encoding="utf-8", newline="\n") as handle:
handle.write("relative_path\tsize_bytes\tmtime_ns\tsha256\n")
for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()):
if path.is_symlink() or not path.is_file():
continue
stat = path.stat()
checksum = digest(path) if mode == "sha256" else ""
relative = path.relative_to(root).as_posix()
if "\t" in relative or "\n" in relative:
raise SystemExit(f"Unsupported inventory path: {relative!r}")
handle.write(f"{relative}\t{stat.st_size}\t{stat.st_mtime_ns}\t{checksum}\n")
PY
if [ -n "$LINK_DEST_BACKUP" ]; then
link_args=(
--link-dest-snapshot "$LINK_DEST_BACKUP/${label}-snapshot"
--link-dest-manifest "$LINK_DEST_BACKUP/${label}-manifest.tsv"
)
fi
python3 "$ROOT/scripts/release_backup_snapshot.py" create \
--source "$source_path" \
--snapshot "$snapshot_path" \
--manifest "$manifest_path" \
--label "$label" \
"${link_args[@]}"
}
inventory_path "$STORAGE_PATH" "$PARTIAL/storage-manifest.tsv"
inventory_path "$MODELS_PATH" "$PARTIAL/models-manifest.tsv"
snapshot_path "$STORAGE_PATH" storage
snapshot_path "$MODELS_PATH" models
python3 - "$PARTIAL/manifest.json" <<PY
import json
@@ -190,16 +296,25 @@ payload = {
"database_password_secure": ${PASSWORD_SECURE@Q} == "true",
"image_id": ${IMAGE_ID@Q},
"image_name": ${IMAGE_NAME@Q},
"git_commit": ${GIT_COMMIT@Q},
"git_dirty": ${GIT_DIRTY@Q} == "true",
"rollback_image_tag": ${ROLLBACK_IMAGE_TAG@Q} or None,
"backup_tool_revision": ${SOURCE_REVISION@Q},
"backup_tool_revision_source": ${SOURCE_REVISION_SOURCE@Q},
"backup_tool_git_dirty": ${SOURCE_GIT_DIRTY@Q} == "true",
"running_image_revision": ${RUNNING_IMAGE_REVISION@Q},
"running_image_revision_is_full_sha": ${RUNNING_IMAGE_REVISION_IS_FULL_SHA@Q} == "true",
"inventory_mode": ${INVENTORY_MODE@Q},
"storage_inventory_requested": bool(${STORAGE_PATH@Q}),
"models_inventory_requested": bool(${MODELS_PATH@Q}),
"storage_snapshot_requested": bool(${STORAGE_PATH@Q}),
"models_snapshot_requested": bool(${MODELS_PATH@Q}),
"link_dest_backup": ${LINK_DEST_BACKUP@Q} or None,
}
path = pathlib.Path(__import__("sys").argv[1])
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
python3 "$ROOT/scripts/release_backup_snapshot.py" verify-backup --backup-dir "$PARTIAL"
(
cd "$PARTIAL"
find . -maxdepth 1 -type f ! -name CHECKSUMS.sha256 -printf '%f\n' \
+105 -7
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env python3
"""Dry-run-first cleanup for old unreferenced derived/cache artifacts."""
"""Dry-run-first quarantine for old unreferenced derived/cache artifacts."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from audit_data_operations import build_report
from release_backup_guard import require_confirmation, verify_current_backup
@@ -14,7 +18,21 @@ from app.core.config import get_settings
from app.db.session import SessionLocal
CONFIRMATION = "DELETE_STORAGE_ARTIFACTS"
CONFIRMATION = "QUARANTINE_STORAGE_ARTIFACTS"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_manifest(path: Path, payload: dict[str, object]) -> None:
temporary = path.with_suffix(".json.partial")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def parse_args() -> argparse.Namespace:
@@ -26,6 +44,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--confirm")
parser.add_argument("--backup-dir", type=Path)
parser.add_argument("--backup-max-age-hours", type=float, default=24.0)
parser.add_argument(
"--quarantine-root",
type=Path,
help="Protected destination below the storage root (default: operator-evidence/cleanup-quarantine)",
)
return parser.parse_args()
@@ -44,7 +67,8 @@ def main() -> int:
blocked_reason = None
backup = None
deleted: list[str] = []
quarantined: list[dict[str, object]] = []
quarantine_manifest: Path | None = None
if args.apply:
require_confirmation(args.confirm, CONFIRMATION)
if args.backup_dir is None:
@@ -59,9 +83,80 @@ def main() -> int:
"review the dry run and raise the explicit limit"
)
else:
quarantine_root = (
args.quarantine_root
or storage_root / "operator-evidence" / "cleanup-quarantine"
).resolve()
protected_quarantine_root = (
storage_root / "operator-evidence" / "cleanup-quarantine"
).resolve()
try:
quarantine_root.relative_to(protected_quarantine_root)
except ValueError as exc:
raise RuntimeError(
"--quarantine-root must remain below "
"operator-evidence/cleanup-quarantine in --storage-root"
) from exc
operation_id = f"cleanup-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid4().hex[:12]}"
operation_root = quarantine_root / operation_id
operation_root.mkdir(parents=True, exist_ok=False)
quarantine_manifest = operation_root / "manifest.json"
entries: list[dict[str, object]] = []
for candidate in candidates:
if candidate.path.is_symlink():
raise RuntimeError(f"Cleanup candidate became a symlink: {candidate.relative_path}")
try:
candidate.path.resolve().relative_to(storage_root)
except ValueError as exc:
raise RuntimeError(
f"Cleanup candidate escaped storage: {candidate.relative_path}"
) from exc
destination = operation_root / "files" / candidate.relative_path
current_size = candidate.path.stat().st_size
if current_size != candidate.size_bytes:
raise RuntimeError(f"Cleanup candidate changed size: {candidate.relative_path}")
entries.append(
{
"relative_path": candidate.relative_path,
"size_bytes": current_size,
"sha256": sha256(candidate.path),
"status": "planned",
"quarantine_relative_path": destination.relative_to(storage_root).as_posix(),
}
)
manifest: dict[str, object] = {
"schema_version": 1,
"operation_id": operation_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"state": "in_progress",
"storage_root": str(storage_root),
"backup_release_id": backup.release_id,
"entries": entries,
}
write_manifest(quarantine_manifest, manifest)
for candidate, entry in zip(candidates, entries, strict=True):
destination = storage_root / str(entry["quarantine_relative_path"])
destination.parent.mkdir(parents=True, exist_ok=True)
try:
os.link(candidate.path, destination, follow_symlinks=False)
except FileExistsError as exc:
raise RuntimeError(f"Quarantine destination already exists: {destination}") from exc
if (
not destination.is_file()
or destination.stat().st_size != entry["size_bytes"]
or sha256(destination) != entry["sha256"]
):
destination.unlink(missing_ok=True)
raise RuntimeError(f"Quarantine link verification failed: {candidate.relative_path}")
entry["status"] = "linked"
write_manifest(quarantine_manifest, manifest)
candidate.path.unlink()
deleted.append(candidate.relative_path)
entry["status"] = "quarantined"
quarantined.append(dict(entry))
write_manifest(quarantine_manifest, manifest)
manifest["state"] = "complete"
manifest["completed_at"] = datetime.now(timezone.utc).isoformat()
write_manifest(quarantine_manifest, manifest)
payload = {
"schema_version": 1,
@@ -72,8 +167,11 @@ def main() -> int:
"candidate_count": len(candidates),
"candidate_bytes": sum(item.size_bytes for item in candidates),
"candidates": [item.relative_path for item in candidates],
"deleted_count": len(deleted),
"deleted": deleted,
"deleted_count": 0,
"deleted": [],
"quarantined_count": len(quarantined),
"quarantined": quarantined,
"quarantine_manifest": str(quarantine_manifest) if quarantine_manifest else None,
"blocked_reason": blocked_reason,
"protected_prefixes": report["cleanup"]["protected_prefixes"],
"backup": (
@@ -81,7 +179,7 @@ def main() -> int:
"release_id": backup.release_id,
"created_at": backup.created_at.isoformat(),
"age_hours": round(backup.age_hours, 3),
"git_commit": backup.git_commit,
"backup_tool_revision": backup.backup_tool_revision,
}
if backup
else None
+12 -5
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from release_backup_snapshot import verify_backup as verify_byte_snapshots
@dataclass(frozen=True)
class VerifiedBackup:
@@ -16,7 +18,7 @@ class VerifiedBackup:
release_id: str
created_at: datetime
age_hours: float
git_commit: str
backup_tool_revision: str
def _sha256(path: Path) -> str:
@@ -62,6 +64,8 @@ def verify_current_backup(
missing = sorted(name for name in required if not (root / name).is_file())
if missing:
raise RuntimeError(f"Backup is incomplete; missing: {', '.join(missing)}")
if not (root / "storage-snapshot").is_dir():
raise RuntimeError("Backup is incomplete; missing: storage-snapshot")
checksum_lines = (root / "CHECKSUMS.sha256").read_text(encoding="utf-8").splitlines()
checked: set[str] = set()
@@ -93,6 +97,9 @@ def verify_current_backup(
raise RuntimeError("Backup was made from an insecure database configuration")
if manifest.get("inventory_mode") != "sha256" or manifest.get("storage_inventory_requested") is not True:
raise RuntimeError("Destructive maintenance requires a SHA-256 storage inventory backup")
if manifest.get("storage_snapshot_requested") is not True:
raise RuntimeError("Destructive maintenance requires a byte-complete storage snapshot")
verify_byte_snapshots(root)
created = _created_at(manifest.get("created_at"))
current = now or datetime.now(timezone.utc)
@@ -107,17 +114,17 @@ def verify_current_backup(
)
release_id = manifest.get("release_id")
git_commit = manifest.get("git_commit")
backup_tool_revision = manifest.get("backup_tool_revision", manifest.get("git_commit"))
if not isinstance(release_id, str) or not release_id:
raise RuntimeError("Backup release id is missing")
if not isinstance(git_commit, str) or len(git_commit) < 7:
raise RuntimeError("Backup Git commit is missing")
if not isinstance(backup_tool_revision, str) or len(backup_tool_revision) < 7:
raise RuntimeError("Backup tool revision is missing")
return VerifiedBackup(
backup_dir=root,
release_id=release_id,
created_at=created,
age_hours=age_hours,
git_commit=git_commit,
backup_tool_revision=backup_tool_revision,
)
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Create and verify byte-complete, symlink-safe release backup snapshots."""
from __future__ import annotations
import argparse
import errno
import hashlib
import json
import os
import re
import stat
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
MANIFEST_HEADER = "relative_path\tsize_bytes\tmtime_ns\tsha256"
SAFE_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
FICLONE = 0x40049409
FICLONE_FALLBACK_ERRORS = {
errno.EXDEV,
errno.EOPNOTSUPP,
errno.ENOTTY,
errno.EINVAL,
errno.ENOSYS,
}
@dataclass(frozen=True)
class SourceEntry:
path: Path
relative_path: str
stat_result: os.stat_result
is_directory: bool
@dataclass(frozen=True)
class ManifestEntry:
relative_path: str
size_bytes: int
mtime_ns: int
sha256: str
def _safe_relative(value: str) -> str:
if not value or "\t" in value or "\n" in value or "\r" in value:
raise RuntimeError(f"Unsupported snapshot path: {value!r}")
candidate = PurePosixPath(value)
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
raise RuntimeError(f"Unsafe snapshot path: {value!r}")
return candidate.as_posix()
def _collect(root: Path) -> list[SourceEntry]:
entries: list[SourceEntry] = []
for current, directory_names, file_names in os.walk(root, topdown=True, followlinks=False):
directory_names.sort()
file_names.sort()
current_path = Path(current)
for name, is_directory in [
*((name, True) for name in directory_names),
*((name, False) for name in file_names),
]:
path = current_path / name
details = path.lstat()
relative = _safe_relative(path.relative_to(root).as_posix())
if stat.S_ISLNK(details.st_mode):
raise RuntimeError(f"Release snapshot refuses symlinked content: {relative}")
if is_directory and not stat.S_ISDIR(details.st_mode):
raise RuntimeError(f"Snapshot directory changed during inventory: {relative}")
if not is_directory and not stat.S_ISREG(details.st_mode):
raise RuntimeError(f"Release snapshot refuses non-regular content: {relative}")
entries.append(SourceEntry(path, relative, details, is_directory))
return entries
def _same_file_state(before: os.stat_result, after: os.stat_result) -> bool:
return (
before.st_dev,
before.st_ino,
before.st_size,
before.st_mtime_ns,
) == (
after.st_dev,
after.st_ino,
after.st_size,
after.st_mtime_ns,
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _copy_all(source_descriptor: int, destination_descriptor: int) -> None:
while True:
value = os.read(source_descriptor, 1024 * 1024)
if not value:
return
view = memoryview(value)
while view:
written = os.write(destination_descriptor, view)
if written <= 0:
raise RuntimeError("Snapshot copy stopped before writing all bytes")
view = view[written:]
def _clone_or_copy(entry: SourceEntry, destination: Path) -> ManifestEntry:
source_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
destination_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
source_descriptor = os.open(entry.path, source_flags)
destination_descriptor = -1
try:
opened = os.fstat(source_descriptor)
if not stat.S_ISREG(opened.st_mode) or not _same_file_state(entry.stat_result, opened):
raise RuntimeError(f"Snapshot file changed before copying: {entry.relative_path}")
destination.parent.mkdir(parents=True, exist_ok=True)
destination_descriptor = os.open(destination, destination_flags, stat.S_IMODE(opened.st_mode))
cloned = False
if os.name == "posix":
try:
import fcntl
fcntl.ioctl(destination_descriptor, FICLONE, source_descriptor)
cloned = True
except OSError as exc:
if exc.errno not in FICLONE_FALLBACK_ERRORS:
raise
if not cloned:
os.lseek(source_descriptor, 0, os.SEEK_SET)
os.ftruncate(destination_descriptor, 0)
_copy_all(source_descriptor, destination_descriptor)
os.fsync(destination_descriptor)
after = os.fstat(source_descriptor)
if not _same_file_state(opened, after):
raise RuntimeError(f"Snapshot file changed while copying: {entry.relative_path}")
except BaseException:
if destination_descriptor >= 0:
os.close(destination_descriptor)
destination_descriptor = -1
destination.unlink(missing_ok=True)
raise
finally:
if destination_descriptor >= 0:
os.close(destination_descriptor)
os.close(source_descriptor)
os.chmod(destination, stat.S_IMODE(entry.stat_result.st_mode) & ~0o222, follow_symlinks=False)
retained_times = (entry.stat_result.st_atime_ns, entry.stat_result.st_mtime_ns)
try:
os.utime(destination, ns=retained_times, follow_symlinks=False)
except NotImplementedError:
# Windows does not expose no-follow utime. The destination was created
# exclusively above; recheck it before using the portable call.
if destination.is_symlink():
destination.unlink(missing_ok=True)
raise RuntimeError(f"Snapshot destination became a symlink: {entry.relative_path}")
os.utime(destination, ns=retained_times)
source_checksum = _sha256(entry.path)
snapshot_checksum = _sha256(destination)
final_source = entry.path.lstat()
if not _same_file_state(entry.stat_result, final_source):
raise RuntimeError(f"Snapshot file changed during checksum verification: {entry.relative_path}")
if source_checksum != snapshot_checksum:
raise RuntimeError(f"Snapshot checksum differs from source: {entry.relative_path}")
return ManifestEntry(
relative_path=entry.relative_path,
size_bytes=entry.stat_result.st_size,
mtime_ns=entry.stat_result.st_mtime_ns,
sha256=snapshot_checksum,
)
def _link_verified_prior(
entry: SourceEntry,
destination: Path,
prior_root: Path,
prior_manifest: dict[str, ManifestEntry],
) -> ManifestEntry | None:
retained = prior_manifest.get(entry.relative_path)
if retained is None or retained.size_bytes != entry.stat_result.st_size:
return None
source_checksum = _sha256(entry.path)
final_source = entry.path.lstat()
if not _same_file_state(entry.stat_result, final_source):
raise RuntimeError(f"Snapshot file changed during prior comparison: {entry.relative_path}")
if source_checksum != retained.sha256:
return None
prior_path = prior_root / entry.relative_path
try:
prior_details = prior_path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISREG(prior_details.st_mode) or prior_details.st_size != retained.size_bytes:
raise RuntimeError(f"Prior snapshot file is not reusable: {entry.relative_path}")
if _sha256(prior_path) != retained.sha256:
raise RuntimeError(f"Prior snapshot checksum changed: {entry.relative_path}")
destination.parent.mkdir(parents=True, exist_ok=True)
os.link(prior_path, destination, follow_symlinks=False)
if destination.stat().st_size != retained.size_bytes or _sha256(destination) != retained.sha256:
destination.unlink(missing_ok=True)
raise RuntimeError(f"Hard-linked snapshot verification failed: {entry.relative_path}")
return ManifestEntry(
relative_path=entry.relative_path,
size_bytes=entry.stat_result.st_size,
mtime_ns=entry.stat_result.st_mtime_ns,
sha256=source_checksum,
)
def read_manifest(path: Path) -> dict[str, ManifestEntry]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0] != MANIFEST_HEADER:
raise RuntimeError(f"Snapshot inventory has an invalid header: {path}")
entries: dict[str, ManifestEntry] = {}
for line in lines[1:]:
fields = line.split("\t")
if len(fields) != 4:
raise RuntimeError(f"Snapshot inventory has an invalid row: {line!r}")
relative_path, size_text, mtime_text, checksum = fields
relative_path = _safe_relative(relative_path)
if relative_path in entries:
raise RuntimeError(f"Snapshot inventory repeats a path: {relative_path}")
try:
size_bytes = int(size_text)
mtime_ns = int(mtime_text)
except ValueError as exc:
raise RuntimeError(f"Snapshot inventory has invalid metadata: {relative_path}") from exc
if size_bytes < 0 or mtime_ns < 0 or not re.fullmatch(r"[0-9a-f]{64}", checksum):
raise RuntimeError(f"Snapshot inventory has invalid retained state: {relative_path}")
entries[relative_path] = ManifestEntry(relative_path, size_bytes, mtime_ns, checksum)
return entries
def verify_snapshot(snapshot_path: Path, manifest_path: Path) -> None:
root = snapshot_path.expanduser().resolve(strict=True)
if not root.is_dir():
raise RuntimeError(f"Snapshot path is not a directory: {root}")
expected = read_manifest(manifest_path)
observed_entries = _collect(root)
observed_files = {item.relative_path: item for item in observed_entries if not item.is_directory}
extra = sorted(set(observed_files) - set(expected))
missing = sorted(set(expected) - set(observed_files))
if extra:
raise RuntimeError(f"Snapshot contains unmanifested files: {', '.join(extra[:10])}")
if missing:
raise RuntimeError(f"Snapshot omits manifested files: {', '.join(missing[:10])}")
for relative, retained in expected.items():
current = observed_files[relative]
if current.stat_result.st_size != retained.size_bytes:
raise RuntimeError(f"Snapshot size differs for: {relative}")
if _sha256(current.path) != retained.sha256:
raise RuntimeError(f"Snapshot checksum differs for: {relative}")
def create_snapshot(
source: Path,
snapshot_path: Path,
manifest_path: Path,
*,
label: str,
link_dest_snapshot: Path | None = None,
link_dest_manifest: Path | None = None,
) -> None:
if not SAFE_LABEL.fullmatch(label):
raise RuntimeError(f"Unsafe snapshot label: {label!r}")
root = source.expanduser()
if root.is_symlink():
raise RuntimeError(f"Release snapshot refuses a symlinked root: {root}")
root = root.resolve(strict=True)
if not root.is_dir():
raise RuntimeError(f"Snapshot source is not a directory: {root}")
snapshot_path = snapshot_path.expanduser().resolve()
manifest_path = manifest_path.expanduser().resolve()
for output in (snapshot_path, manifest_path):
try:
output.relative_to(root)
except ValueError:
pass
else:
raise RuntimeError("Release snapshot output must not be inside its source tree")
if snapshot_path.exists():
raise RuntimeError(f"Snapshot destination already exists: {snapshot_path}")
snapshot_path.mkdir(parents=True, exist_ok=False)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
prior_root: Path | None = None
prior_manifest: dict[str, ManifestEntry] = {}
if (link_dest_snapshot is None) != (link_dest_manifest is None):
raise RuntimeError("Prior snapshot and manifest must be supplied together")
if link_dest_snapshot is not None and link_dest_manifest is not None:
prior_root = link_dest_snapshot.expanduser().resolve(strict=True)
prior_manifest = read_manifest(link_dest_manifest.expanduser().resolve(strict=True))
initial = _collect(root)
retained: list[ManifestEntry] = []
for entry in initial:
destination = snapshot_path / entry.relative_path
if entry.is_directory:
destination.mkdir(parents=True, exist_ok=False)
os.chmod(destination, stat.S_IMODE(entry.stat_result.st_mode), follow_symlinks=False)
continue
linked = (
_link_verified_prior(entry, destination, prior_root, prior_manifest)
if prior_root is not None
else None
)
retained.append(linked or _clone_or_copy(entry, destination))
final = _collect(root)
if [(item.relative_path, item.is_directory) for item in initial] != [
(item.relative_path, item.is_directory) for item in final
]:
raise RuntimeError(f"Snapshot source contents changed while backup was running: {root}")
with manifest_path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(f"{MANIFEST_HEADER}\n")
for entry in retained:
handle.write(
f"{entry.relative_path}\t{entry.size_bytes}\t{entry.mtime_ns}\t{entry.sha256}\n"
)
verify_snapshot(snapshot_path, manifest_path)
def verify_backup(backup_dir: Path) -> None:
root = backup_dir.expanduser().resolve(strict=True)
payload = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
for label in ("storage", "models"):
requested = payload.get(f"{label}_inventory_requested") is True
snapshotted = payload.get(f"{label}_snapshot_requested") is True
if requested != snapshotted:
raise RuntimeError(f"Backup manifest does not bind the {label} inventory to a snapshot")
if requested:
verify_snapshot(root / f"{label}-snapshot", root / f"{label}-manifest.tsv")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
create = subparsers.add_parser("create")
create.add_argument("--source", type=Path, required=True)
create.add_argument("--snapshot", type=Path, required=True)
create.add_argument("--manifest", type=Path, required=True)
create.add_argument("--label", required=True)
create.add_argument("--link-dest-snapshot", type=Path)
create.add_argument("--link-dest-manifest", type=Path)
verify = subparsers.add_parser("verify")
verify.add_argument("--snapshot", type=Path, required=True)
verify.add_argument("--manifest", type=Path, required=True)
verify_backup_parser = subparsers.add_parser("verify-backup")
verify_backup_parser.add_argument("--backup-dir", type=Path, required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.command == "create":
create_snapshot(
args.source,
args.snapshot,
args.manifest,
label=args.label,
link_dest_snapshot=args.link_dest_snapshot,
link_dest_manifest=args.link_dest_manifest,
)
elif args.command == "verify":
verify_snapshot(args.snapshot, args.manifest)
else:
verify_backup(args.backup_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Restore a traceable GeoIntel cleanup quarantine without overwriting data."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
CONFIRMATION = "RESTORE_QUARANTINED_ARTIFACTS"
CLEANUP_PREFIXES = ("exports", "previews", "tiles", "masks", "derived", "rasters/derived")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_manifest(path: Path, payload: dict[str, object]) -> None:
temporary = path.with_suffix(".json.partial")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--storage-root", type=Path, required=True)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--confirm", required=True)
return parser.parse_args()
def _within(path: Path, root: Path, *, label: str) -> Path:
resolved = path.resolve()
try:
resolved.relative_to(root)
except ValueError as exc:
raise RuntimeError(f"{label} escapes the storage root") from exc
return resolved
def main() -> int:
args = parse_args()
if args.confirm != CONFIRMATION:
raise RuntimeError(f"Refusing restore; pass --confirm {CONFIRMATION}")
storage_root = args.storage_root.expanduser().resolve()
manifest_path = _within(args.manifest.expanduser(), storage_root, label="Manifest")
protected_quarantine_root = storage_root / "operator-evidence" / "cleanup-quarantine"
try:
manifest_path.relative_to(protected_quarantine_root.resolve())
except ValueError as exc:
raise RuntimeError("Manifest is outside the protected cleanup quarantine") from exc
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
if payload.get("schema_version") != 1:
raise RuntimeError("Unsupported quarantine manifest")
if payload.get("state") not in {"complete", "in_progress", "restore_in_progress", "restored"}:
raise RuntimeError("Quarantine manifest is not in a restorable state")
raw_entries = payload.get("entries")
if not isinstance(raw_entries, list):
raise RuntimeError("Quarantine manifest entries are invalid")
plans: list[tuple[str, dict[str, object], Path, Path]] = []
for raw_entry in raw_entries:
if not isinstance(raw_entry, dict):
raise RuntimeError("Quarantine manifest entry is invalid")
status = raw_entry.get("status")
if status not in {"planned", "linked", "quarantined", "restore_linked", "restored"}:
raise RuntimeError(f"Quarantine manifest entry has an invalid status: {status!r}")
relative_path = raw_entry.get("relative_path")
quarantine_relative_path = raw_entry.get("quarantine_relative_path")
expected_hash = raw_entry.get("sha256")
expected_size = raw_entry.get("size_bytes")
if (
not isinstance(relative_path, str)
or not isinstance(expected_hash, str)
or not isinstance(expected_size, int)
):
raise RuntimeError("Quarantine manifest entry lacks recovery metadata")
if not any(
relative_path == prefix or relative_path.startswith(f"{prefix}/")
for prefix in CLEANUP_PREFIXES
):
raise RuntimeError(f"Original path is outside the cleanup allowlist: {relative_path}")
original = _within(storage_root / relative_path, storage_root, label="Original path")
if not isinstance(quarantine_relative_path, str):
if status != "planned":
raise RuntimeError("Quarantine manifest entry lacks its retained path")
quarantine_relative_path = (
manifest_path.parent / "files" / relative_path
).relative_to(storage_root).as_posix()
raw_entry["quarantine_relative_path"] = quarantine_relative_path
quarantined = _within(
storage_root / quarantine_relative_path,
storage_root,
label="Quarantine path",
)
try:
quarantined.relative_to(manifest_path.parent.resolve())
except ValueError as exc:
raise RuntimeError("Quarantine entry escapes its operation directory") from exc
original_exists = original.exists()
quarantined_exists = quarantined.exists()
if original_exists:
if not original.is_file() or original.stat().st_size != expected_size or sha256(original) != expected_hash:
raise RuntimeError(f"Restore destination already exists with different bytes: {relative_path}")
if quarantined_exists:
if (
not quarantined.is_file()
or quarantined.stat().st_size != expected_size
or sha256(quarantined) != expected_hash
):
raise RuntimeError(f"Quarantined artifact checksum mismatch: {quarantine_relative_path}")
if original_exists and quarantined_exists:
if not os.path.samefile(original, quarantined):
raise RuntimeError(f"Restore destination already exists: {relative_path}")
plans.append(("remove_duplicate_link", raw_entry, quarantined, original))
elif original_exists:
plans.append(("mark_restored", raw_entry, quarantined, original))
elif quarantined_exists:
plans.append(("restore", raw_entry, quarantined, original))
else:
raise RuntimeError(f"Both original and quarantined artifacts are missing: {relative_path}")
payload["state"] = "restore_in_progress"
write_manifest(manifest_path, payload)
for action, entry, quarantined, original in plans:
if action == "restore":
original.parent.mkdir(parents=True, exist_ok=True)
_within(original, storage_root, label="Original path")
try:
os.link(quarantined, original, follow_symlinks=False)
except FileExistsError as exc:
raise RuntimeError(f"Restore destination was created concurrently: {original}") from exc
if not os.path.samefile(quarantined, original):
original.unlink(missing_ok=True)
raise RuntimeError(f"Restore link verification failed: {original}")
entry["status"] = "restore_linked"
write_manifest(manifest_path, payload)
quarantined.unlink()
elif action == "remove_duplicate_link":
quarantined.unlink()
entry["status"] = "restored"
entry["restored_at"] = datetime.now(timezone.utc).isoformat()
write_manifest(manifest_path, payload)
payload["state"] = "restored"
payload["restored_at"] = datetime.now(timezone.utc).isoformat()
write_manifest(manifest_path, payload)
print(json.dumps({"state": "restored", "restored_count": len(plans)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -43,6 +43,8 @@ echo "== GeoIntel run readiness check =="
"$PYTHON_BIN" -m py_compile scripts/build_release_package.py
"$PYTHON_BIN" -m py_compile scripts/verify_python_lock.py
"$PYTHON_BIN" scripts/verify_python_lock.py
"$PYTHON_BIN" -m py_compile scripts/verify_repository_layout.py
"$PYTHON_BIN" scripts/verify_repository_layout.py
"$PYTHON_BIN" -m py_compile scripts/verify_security_exceptions.py
"$PYTHON_BIN" scripts/verify_security_exceptions.py
bash -n scripts/backup_release_state.sh
@@ -55,6 +57,7 @@ bash -n scripts/scan_container_image.sh
bash -n scripts/audit_python_dependencies.sh
echo "Using Python: ${PYTHON_BIN}"
bash scripts/check_repo_structure.sh
"$PYTHON_BIN" -m ruff check backend scripts tests
${PYTHON_BIN} scripts/smoke_docs.py
${PYTHON_BIN} scripts/validate_fixtures.py
${PYTHON_BIN} scripts/smoke_contracts.py
@@ -129,8 +132,10 @@ ${PYTHON_BIN} -m py_compile scripts/migrate_runtime_model_provenance.py
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m py_compile scripts/archive_technical_projects.py
${PYTHON_BIN} -m py_compile scripts/release_backup_guard.py
${PYTHON_BIN} -m py_compile scripts/release_backup_snapshot.py
${PYTHON_BIN} -m py_compile scripts/audit_data_operations.py
${PYTHON_BIN} -m py_compile scripts/cleanup_storage_artifacts.py
${PYTHON_BIN} -m py_compile scripts/restore_storage_quarantine.py
${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m compileall backend/app
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)
@@ -149,6 +154,7 @@ bash -n deploy/unraid/gosu-setpriv
bash -n deploy/unraid/run-dockerman-container.sh
bash -n deploy/unraid/deploy-release.sh
bash -n deploy/unraid/rollback-dockerman-container.sh
bash -n deploy/unraid/restore-predeploy-database.sh
bash -n scripts/verify_browser_runtime.sh
bash -n scripts/verify_demo_export_workflow.sh
bash -n scripts/verify_demo_raster_workflow.sh
+13 -4
View File
@@ -21,8 +21,17 @@ esac
docker image inspect "$TARGET_IMAGE" >/dev/null
mkdir -p "$ROOT/$(dirname "$OUTPUT")" "$CACHE_DIR"
"$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py"
"$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" \
--print-container-ids > "$IGNORE_FILE"
mapfile -t ignored_container_ids < <(
"$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" \
--print-container-ids | tr -d '\r'
)
ignore_args=()
trivy_ignore_args=()
if [ "${#ignored_container_ids[@]}" -gt 0 ]; then
printf '%s\n' "${ignored_container_ids[@]}" > "$IGNORE_FILE"
ignore_args=(-v "$IGNORE_FILE:$CONTAINER_IGNORE_FILE:ro")
trivy_ignore_args=(--ignorefile "$CONTAINER_IGNORE_FILE")
fi
# Keep the complete report, including vulnerabilities without an available fix.
docker run --rm \
@@ -45,14 +54,14 @@ docker run --rm \
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$CACHE_DIR:/root/.cache/trivy" \
-v "$IGNORE_FILE:$CONTAINER_IGNORE_FILE:ro" \
"${ignore_args[@]}" \
"$TRIVY_IMAGE" \
image \
--scanners vuln \
--timeout 20m \
--skip-version-check \
--ignore-unfixed \
--ignorefile "$CONTAINER_IGNORE_FILE" \
"${trivy_ignore_args[@]}" \
--skip-files /usr/local/bin/gosu \
--severity HIGH,CRITICAL \
--exit-code 1 \
+9 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTAINER="geointel"
BACKUP_DIR=""
@@ -59,15 +60,22 @@ required = {
"database_name",
"database_user",
"image_id",
"git_commit",
}
missing = sorted(required - payload.keys())
if missing:
raise SystemExit(f"Backup manifest misses: {', '.join(missing)}")
if payload["schema_version"] != 1 or payload["read_only_source"] is not True:
raise SystemExit("Unsupported or unsafe backup manifest")
tool_revision = payload.get("backup_tool_revision", payload.get("git_commit"))
if not isinstance(tool_revision, str) or len(tool_revision) < 7:
raise SystemExit("Backup manifest lacks its backup-tool revision")
running_revision = payload.get("running_image_revision")
if running_revision is not None and not isinstance(running_revision, str):
raise SystemExit("Backup manifest has an invalid running-image revision")
PY
python3 "$ROOT/scripts/release_backup_snapshot.py" verify-backup --backup-dir "$BACKUP_DIR"
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != "true" ]; then
echo "Container '$CONTAINER' is required to run pg_restore --list." >&2
exit 3
+27 -18
View File
@@ -17,29 +17,39 @@ EXCEPTIONS_PATH = ROOT / "security" / "pip-audit-exceptions.json"
def load_and_validate() -> tuple[dict[str, object], list[str]]:
payload = json.loads(EXCEPTIONS_PATH.read_text(encoding="utf-8"))
errors: list[str] = []
try:
review_by = dt.date.fromisoformat(str(payload["review_by"]))
except (KeyError, ValueError):
errors.append("review_by must be an ISO date")
review_by = dt.date.min
if review_by < dt.date.today():
errors.append(f"dependency exception review expired on {review_by.isoformat()}")
if payload.get("package") != "starlette":
errors.append("only the documented Starlette compatibility exception is allowed")
controls = payload.get("compensating_controls")
if not isinstance(controls, list) or len(controls) < 3:
errors.append("at least three compensating controls are required")
if set(payload) != {"schema_version", "advisories"}:
errors.append("exception policy must contain only schema_version and advisories")
if payload.get("schema_version") != 1:
errors.append("schema_version must be 1")
advisories = payload.get("advisories")
if not isinstance(advisories, list) or not advisories:
errors.append("at least one advisory exception is required")
if not isinstance(advisories, list):
errors.append("advisories must be a list")
else:
ids = [str(item.get("id", "")) for item in advisories if isinstance(item, dict)]
if len(ids) != len(set(ids)) or any(not item.startswith("PYSEC-") for item in ids):
errors.append("advisory IDs must be unique PYSEC identifiers")
for item in advisories:
if not isinstance(item, dict) or len(str(item.get("reason", ""))) < 30:
if not isinstance(item, dict):
errors.append("every advisory must be an object")
continue
required = {"id", "package", "review_by", "reason"}
allowed = required | {"aliases"}
if not required.issubset(item) or not set(item).issubset(allowed):
errors.append("every advisory must match the documented exception schema")
if not str(item.get("package", "")).strip():
errors.append("every advisory requires a package")
if len(str(item.get("reason", ""))) < 30:
errors.append("every advisory requires a specific reason")
break
try:
review_by = dt.date.fromisoformat(str(item["review_by"]))
except (KeyError, ValueError):
errors.append("every advisory review_by must be an ISO date")
else:
if review_by < dt.date.today():
errors.append(
f"dependency exception {item.get('id', '')} expired on "
f"{review_by.isoformat()}"
)
aliases = [
str(alias)
for item in advisories
@@ -72,8 +82,7 @@ def main() -> int:
print(alias)
else:
print(
"Dependency exceptions valid through "
f"{payload['review_by']} with documented compensating controls."
f"Dependency exception policy valid; {len(payload['advisories'])} active exception(s)."
)
return 0