diff --git a/CHANGELOG.md b/CHANGELOG.md index bbbec905..d3d20d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,12 @@ or overwriting an existing release tag. - Preserved the actual prior rollback image across repeated no-op deploys of the same immutable release. +- Exposed every operator-owned all-in-one runtime limit and local YOLO setting + through advanced Unraid edit fields while keeping deployment-only bridge + variables internal. +- Added a fail-safe isolated upgrade smoke that restores a verified backup to + a temporary database, runs the release image migration chain and removes the + temporary database without touching production. - Made production startup reject known-default PostGIS passwords and apply the configured upload limit consistently to nginx and FastAPI. diff --git a/backend/tests/test_rc5_release_deployment.py b/backend/tests/test_rc5_release_deployment.py index 1ab3b5b2..203399d4 100644 --- a/backend/tests/test_rc5_release_deployment.py +++ b/backend/tests/test_rc5_release_deployment.py @@ -78,9 +78,24 @@ def test_readiness_checks_all_release_shell_entrypoints() -> None: for path in ( "scripts/deploy_tower.sh", "scripts/verify_release_fresh_install.sh", + "scripts/verify_release_upgrade_smoke.sh", "deploy/unraid/all-in-one-start.sh", "deploy/unraid/run-dockerman-container.sh", "deploy/unraid/deploy-release.sh", "deploy/unraid/rollback-dockerman-container.sh", ): assert f"bash -n {path}" in readiness + + +def test_upgrade_smoke_restores_and_upgrades_only_an_isolated_database() -> None: + script = (ROOT / "scripts" / "verify_release_upgrade_smoke.sh").read_text(encoding="utf-8") + + assert "--confirm-isolated-upgrade" in script + assert "restore_release_backup_smoke.sh" in script + assert "--keep-database" in script + assert "geointel_restore_verify_" in script + assert 'make_url(os.environ["DATABASE_URL"])' in script + assert ".set(database=os.environ[\"TARGET_DB\"])" in script + assert "python -m alembic upgrade head" in script + assert "production_database_untouched" in script + assert 'dropdb --if-exists -U "$db_user" "$TARGET_DB"' in script diff --git a/backend/tests/test_sprint31_unraid_template.py b/backend/tests/test_sprint31_unraid_template.py index b8b3102b..727547ed 100644 --- a/backend/tests/test_sprint31_unraid_template.py +++ b/backend/tests/test_sprint31_unraid_template.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path @@ -54,6 +55,26 @@ def test_unraid_env_template_matches_single_container_compose_variables() -> Non assert '"${GEOINTEL_FRONTEND_PORT:-1202}:80"' in compose +def test_unraid_template_exposes_every_operator_owned_runtime_setting() -> None: + run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") + template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8") + + runtime_variables = set( + re.findall(r'^([A-Z][A-Z0-9_]+)="\$\{\1:-', run_script, flags=re.MULTILINE) + ) + template_variables = set(re.findall(r'Target="([A-Z][A-Z0-9_]+)"', template)) + bridged_or_internal = { + "GEOINTEL_FRONTEND_PORT", + "GEOINTEL_IMAGE", + "GEOINTEL_MODELS_PATH", + "GEOINTEL_POSTGIS_DATA_PATH", + "GEOINTEL_STORAGE_PATH", + } + + assert runtime_variables - template_variables == bridged_or_internal + assert 'Target="/app/models"' in template + + def test_unraid_readme_explains_port_changes_and_safe_cleanup() -> None: readme = (ROOT / "deploy" / "unraid" / "README.md").read_text(encoding="utf-8") diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index 99012814..ba4e443d 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -262,6 +262,15 @@ temporary directory: bash scripts/verify_release_fresh_install.sh geointel-all-in-one:latest ``` +Verify an upgrade against an isolated restore of a checksum-verified backup. +The generated temporary database is removed even when the check fails: + +```bash +bash scripts/verify_release_upgrade_smoke.sh \ + --backup-dir /mnt/user/appdata/geointel/backups/ \ + --confirm-isolated-upgrade +``` + Return to the image that was active immediately before the latest deployment: ```bash diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index ee85d344..d3db68db 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -24,6 +24,7 @@ 1202 /mnt/user/appdata/geointel/storage + /mnt/user/appdata/geointel/models /mnt/user/appdata/geointel/postgres-data geointel geointel @@ -32,8 +33,11 @@ 500 true https://geo.api.vlaanderen.be/OMWRGBMRVL/wms + Ortho 1.0 + 128 1024 + 24 true https://geo.api.vlaanderen.be/GRB/wfs https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl @@ -45,26 +49,62 @@ true https://geo.api.vlaanderen.be/BWK/wfs https://www.dov.vlaanderen.be/geoserver/wfs + 10 20000 + 1000 + 200 100000 + 180 + 20 + 256 + 24 true https://geo.api.vlaanderen.be/DHMV/wcs 5.0 + 10 20000 12000000 + 300 + 160 true https://geoservice.waterinfo.be/OGRK/wcs 5.0 + 10 + 20000 12000000 + 300 + 160 true + https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0 + https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1 + 1000 50000 + 120 + 32 true https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs 20 + 4 true https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs + 100 60000 30000000 + 300 + 160 + false + /app/models + + yolo-configured + Configured YOLO detector + + /app/storage/ultralytics + cpu + 640 + 100 + 1000 + 0.5 + 1 true http://host.docker.internal:11434 qwen3.5:9b diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index bd132286..053d1d52 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -49,6 +49,13 @@ a healthy rollback against the retained database/storage. The release tag contract was then tightened to immutable commit-plus-profile tags so repeated deploys reuse rather than overwrite the same build identity. +- A repeated no-op deploy reused the existing profile-tagged image and + preserved the actual previous image. The Unraid template now exposes every + operator-owned acquisition, upload, local-model and assistant setting as an + editable field, with only deployment bridge variables kept internal. +- Added an explicit release upgrade verifier that composes the checksum-backed + isolated restore drill with the deployed image's Alembic chain and destroys + only the generated verification database. - Froze the RC geography as all Belgian land plus the separately labelled territorial sea, EEZ and continental shelf. diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index ff12f10e..cd58da13 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -108,6 +108,7 @@ ${PYTHON_BIN} -m compileall backend/app bash -n scripts/live_migration_smoke.sh bash -n scripts/deploy_tower.sh bash -n scripts/verify_release_fresh_install.sh +bash -n scripts/verify_release_upgrade_smoke.sh bash -n deploy/unraid/all-in-one-start.sh bash -n deploy/unraid/run-dockerman-container.sh bash -n deploy/unraid/deploy-release.sh diff --git a/scripts/verify_release_upgrade_smoke.sh b/scripts/verify_release_upgrade_smoke.sh new file mode 100644 index 00000000..d36619aa --- /dev/null +++ b/scripts/verify_release_upgrade_smoke.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER="geointel" +BACKUP_DIR="" +OUTPUT="" +CONFIRM="false" +RESTORE_RESULT="" +TARGET_DB="" + +usage() { + cat <<'EOF' +Usage: bash scripts/verify_release_upgrade_smoke.sh \ + --backup-dir PATH --confirm-isolated-upgrade [options] + +Restores a verified release backup into a generated temporary database, runs +the currently deployed image's Alembic upgrade against that database, verifies +PostGIS and the single expected head, then removes the temporary database. + +Options: + --container NAME + --output PATH +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --backup-dir) BACKUP_DIR="$2"; shift 2 ;; + --container) CONTAINER="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + --confirm-isolated-upgrade) CONFIRM="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [ "$CONFIRM" != "true" ] || [ -z "$BACKUP_DIR" ]; then + echo "Explicit --confirm-isolated-upgrade and --backup-dir are required." >&2 + exit 2 +fi + +for required in docker python3; do + command -v "$required" >/dev/null 2>&1 || { + echo "Missing required command: $required" >&2 + exit 2 + } +done + +BACKUP_DIR="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$BACKUP_DIR")" +RESTORE_RESULT="$(mktemp "${TMPDIR:-/tmp}/geointel-upgrade-restore.XXXXXX.json")" + +cleanup() { + if [ -n "$TARGET_DB" ] && [[ "$TARGET_DB" =~ ^geointel_restore_verify_[0-9_]+$ ]]; then + db_user="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"' 2>/dev/null || true)" + if [ -n "$db_user" ]; then + docker exec "$CONTAINER" dropdb --if-exists -U "$db_user" "$TARGET_DB" >/dev/null 2>&1 || true + fi + fi + rm -f -- "$RESTORE_RESULT" +} +trap cleanup EXIT + +bash "$(dirname "$0")/restore_release_backup_smoke.sh" \ + --backup-dir "$BACKUP_DIR" \ + --container "$CONTAINER" \ + --confirm-isolated-restore \ + --keep-database \ + --output "$RESTORE_RESULT" + +TARGET_DB="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["temporary_database"])' "$RESTORE_RESULT")" +if ! [[ "$TARGET_DB" =~ ^geointel_restore_verify_[0-9_]+$ ]]; then + echo "Unsafe temporary database returned by restore smoke: ${TARGET_DB}" >&2 + exit 3 +fi + +docker exec -e TARGET_DB="$TARGET_DB" "$CONTAINER" sh -lc ' + target_url="$(python -c '"'"' +import os +from sqlalchemy.engine import make_url + +print( + make_url(os.environ["DATABASE_URL"]) + .set(database=os.environ["TARGET_DB"]) + .render_as_string(hide_password=False) +) +'"'"')" + export DATABASE_URL="$target_url" + python -m alembic upgrade head +' + +DB_USER="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"' )" +ALEMBIC_HEAD="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$TARGET_DB" -Atqc \ + "SELECT version_num FROM alembic_version;")" +EXPECTED_HEAD="$(docker exec "$CONTAINER" sh -lc 'python -m alembic heads | awk "{print \$1}"')" +POSTGIS_VERSION="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$TARGET_DB" -Atqc \ + "SELECT postgis_version();")" +RELEASE_REVISION="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CONTAINER")" + +if [ "$ALEMBIC_HEAD" != "$EXPECTED_HEAD" ]; then + echo "Upgraded temporary database head '${ALEMBIC_HEAD}' differs from '${EXPECTED_HEAD}'." >&2 + exit 4 +fi + +if [ -z "$OUTPUT" ]; then + OUTPUT="${BACKUP_DIR%/}-upgrade-smoke.json" +fi +python3 - "$OUTPUT" <