From 5699006a5e57d53e7da3e0a80e75a4c4f545da0f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 18 Jul 2026 02:31:28 +0200 Subject: [PATCH] Harden immutable release deployment --- CHANGELOG.md | 5 + backend/tests/test_docker_runtime_config.py | 8 +- backend/tests/test_rc5_release_deployment.py | 78 +++++++++++++ .../tests/test_rc_runtime_observability.py | 9 +- .../test_sprint187_temporal_map_foundation.py | 13 ++- .../tests/test_sprint31_unraid_template.py | 34 +++--- deploy/unraid/Dockerfile.all-in-one | 15 ++- deploy/unraid/README.md | 67 +++++++++-- deploy/unraid/all-in-one-start.sh | 21 ++++ deploy/unraid/deploy-release.sh | 107 ++++++++++++++++++ deploy/unraid/geointel-unraid-template.xml | 8 +- deploy/unraid/geointel.env.example | 4 +- deploy/unraid/nginx-all-in-one.conf | 2 +- deploy/unraid/rollback-dockerman-container.sh | 33 ++++++ deploy/unraid/run-dockerman-container.sh | 39 ++++++- docs/CODEX_EXECUTION_LOG.md | 5 + scripts/deploy_tower.ps1 | 56 +-------- scripts/deploy_tower.sh | 57 +--------- scripts/run_readiness_check.sh | 6 + scripts/verify_release_fresh_install.sh | 61 ++++++++++ 20 files changed, 477 insertions(+), 151 deletions(-) create mode 100644 backend/tests/test_rc5_release_deployment.py create mode 100644 deploy/unraid/deploy-release.sh create mode 100644 deploy/unraid/rollback-dockerman-container.sh create mode 100644 scripts/verify_release_fresh_install.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7efe4b4d..ec63fa04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,11 @@ - Kept map rectangle and full-area selection available for coverage inspection when a selected national theme has not yet been materialized, without querying an unrelated active dataset. +- Hardened the all-in-one release path with commit-SHA image tags, OCI build + labels, previous-image preservation, automatic/manual rollback, an isolated + fresh-install smoke and dependency-cache-safe build metadata. +- Made production startup reject known-default PostGIS passwords and apply the + configured upload limit consistently to nginx and FastAPI. ## Sprint 240 Operational forest, agriculture, nature and soil themes (2026-07-17) diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index e6b44e99..259ac7b5 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -167,9 +167,11 @@ def test_frontend_uses_same_origin_api_proxy_by_default() -> None: def test_nginx_runtime_allows_real_gis_upload_payloads() -> None: frontend_nginx = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8") all_in_one_nginx = (ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8") + start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8") assert "client_max_body_size 250m;" in frontend_nginx - assert "client_max_body_size 250m;" in all_in_one_nginx + assert "client_max_body_size __GEOINTEL_MAX_UPLOAD_MB__m;" in all_in_one_nginx + assert 'sed -i "s/__GEOINTEL_MAX_UPLOAD_MB__/${MAX_UPLOAD_MB}/g"' in start_script def test_nginx_runtime_allows_long_ai_and_qa_requests() -> None: @@ -314,11 +316,11 @@ def test_all_in_one_dockerfile_caches_dependencies_and_uses_cpu_torch_for_ai_run def test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env() -> None: deploy_ps1 = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") deploy_sh = (ROOT / "scripts" / "deploy_tower.sh").read_text(encoding="utf-8") + release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") assert 'DEPLOY_GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-}"' in deploy_sh - assert 'docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI"' in deploy_sh - assert 'docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI"' in deploy_ps1 + assert "--build-arg GEOINTEL_INSTALL_AI=" in release_script assert "DEPLOY_GEOINTEL_INSTALL_AI" in deploy_ps1 assert "[string]$InstallAi" in deploy_ps1 diff --git a/backend/tests/test_rc5_release_deployment.py b/backend/tests/test_rc5_release_deployment.py new file mode 100644 index 00000000..e9f555ff --- /dev/null +++ b/backend/tests/test_rc5_release_deployment.py @@ -0,0 +1,78 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_build_identity_does_not_invalidate_dependency_layers() -> None: + dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") + + dependency_install = dockerfile.index("/usr/bin/python3.11 -m venv /opt/geointel/venv") + source_copy = dockerfile.index("COPY backend/ /app/") + build_identity = dockerfile.index("ARG GEOINTEL_BUILD_SHA=unknown") + + assert build_identity > dependency_install + assert build_identity > source_copy + assert 'org.opencontainers.image.revision="${GEOINTEL_BUILD_SHA}"' in dockerfile + assert 'org.opencontainers.image.created="${GEOINTEL_BUILD_TIME}"' in dockerfile + + +def test_release_deploy_preserves_immutable_and_previous_images() -> None: + script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") + + assert 'GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}"' in script + assert 'GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous"' in script + assert 'docker tag "$current_image_id" "${GEOINTEL_IMAGE_REPOSITORY}:${current_revision}"' in script + assert 'docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE"' in script + assert "rollback_previous()" in script + assert "Deployed immutable image" in script + + +def test_runtime_configuration_is_validated_before_container_replacement() -> None: + run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") + + validation_index = run_script.index("validate_runtime_config") + replacement_index = run_script.index("docker compose down") + + assert validation_index < replacement_index + assert "known-default PostGIS password" in run_script + assert "GEOINTEL_MAX_UPLOAD_MB must be between 1 and 2048" in run_script + assert 'docker image inspect "$GEOINTEL_IMAGE"' in run_script + + +def test_fresh_install_smoke_is_isolated_and_cleans_only_its_temp_path() -> None: + script = (ROOT / "scripts" / "verify_release_fresh_install.sh").read_text(encoding="utf-8") + + assert "mktemp -d" in script + assert "geointel-fresh-smoke.*" in script + assert "-p 127.0.0.1::80" in script + assert "GEOINTEL_POSTGRES_PASSWORD=" in script + assert "/health/ready" in script + assert "/api/v1/system/capabilities" in script + assert "docker exec" in script + assert "python -m alembic heads" in script + + +def test_manual_rollback_reuses_persistent_paths_and_requires_existing_image() -> None: + rollback = (ROOT / "deploy" / "unraid" / "rollback-dockerman-container.sh").read_text(encoding="utf-8") + run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8") + + assert "geointel-all-in-one:previous" in rollback + assert 'docker image inspect "$GEOINTEL_ROLLBACK_IMAGE"' in rollback + assert 'GEOINTEL_IMAGE="$GEOINTEL_ROLLBACK_IMAGE"' in rollback + assert '-v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data"' in run_script + assert '-v "${GEOINTEL_STORAGE_PATH}:/app/storage"' in run_script + + +def test_readiness_checks_all_release_shell_entrypoints() -> None: + readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") + + for path in ( + "scripts/deploy_tower.sh", + "scripts/verify_release_fresh_install.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 diff --git a/backend/tests/test_rc_runtime_observability.py b/backend/tests/test_rc_runtime_observability.py index 8ad644f4..a209d9e2 100644 --- a/backend/tests/test_rc_runtime_observability.py +++ b/backend/tests/test_rc_runtime_observability.py @@ -36,13 +36,16 @@ def test_runtime_report_is_read_only_by_default_and_requires_confirmation() -> N def test_all_in_one_deploy_embeds_immutable_build_identity() -> None: dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") + release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") deploy_powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") deploy_shell = (ROOT / "scripts" / "deploy_tower.sh").read_text(encoding="utf-8") assert "ARG GEOINTEL_BUILD_SHA=unknown" in dockerfile assert 'GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}"' in dockerfile assert 'GEOINTEL_BUILD_TIME="${GEOINTEL_BUILD_TIME}"' in dockerfile + assert 'org.opencontainers.image.revision="${GEOINTEL_BUILD_SHA}"' in dockerfile + assert 'GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)"' in release_script + assert "--build-arg GEOINTEL_BUILD_SHA=" in release_script + assert "--build-arg GEOINTEL_BUILD_TIME=" in release_script for deploy_source in (deploy_powershell, deploy_shell): - assert 'GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)"' in deploy_source - assert "--build-arg GEOINTEL_BUILD_SHA=" in deploy_source - assert "--build-arg GEOINTEL_BUILD_TIME=" in deploy_source + assert "bash deploy/unraid/deploy-release.sh" in deploy_source diff --git a/backend/tests/test_sprint187_temporal_map_foundation.py b/backend/tests/test_sprint187_temporal_map_foundation.py index ab7a36f6..c19afd64 100644 --- a/backend/tests/test_sprint187_temporal_map_foundation.py +++ b/backend/tests/test_sprint187_temporal_map_foundation.py @@ -524,8 +524,11 @@ def test_temporal_frontend_and_official_operator_contracts_exist() -> None: def test_tower_deploy_waits_for_startup_migration_before_live_smoke() -> None: for relative_path in ("scripts/deploy_tower.ps1", "scripts/deploy_tower.sh"): script = (ROOT / relative_path).read_text(encoding="utf-8") - wait_position = script.index("wait_for_geointel_health") - invocation_position = script.index("\nwait_for_geointel_health", wait_position) - smoke_position = script.index("LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh") - assert "docker inspect --format" in script - assert invocation_position < smoke_position + assert "bash deploy/unraid/deploy-release.sh" in script + + release_script = (ROOT / "deploy/unraid/deploy-release.sh").read_text(encoding="utf-8") + wait_position = release_script.index("wait_for_geointel_health") + invocation_position = release_script.index("\n wait_for_geointel_health", wait_position) + smoke_position = release_script.index("LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh") + assert "docker inspect --format" in release_script + assert invocation_position < smoke_position diff --git a/backend/tests/test_sprint31_unraid_template.py b/backend/tests/test_sprint31_unraid_template.py index 02d9365b..b8b3102b 100644 --- a/backend/tests/test_sprint31_unraid_template.py +++ b/backend/tests/test_sprint31_unraid_template.py @@ -10,7 +10,7 @@ def test_unraid_template_documents_editable_runtime_settings() -> None: template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8") assert "geointel" in template - assert "GeoIntel all-in-one runs the complete GeoIntel Kempen V1 stack in one Docker container" in template + assert "Belgium and Belgian North Sea workbench" in template assert "geointel-all-in-one:latest" in template assert "http://[IP]:[PORT:80]/" in template assert "http://192.168.10.150:1202/geointel-icon.png" in template @@ -109,29 +109,35 @@ def test_unraid_all_in_one_runtime_starts_embedded_postgis_backend_and_nginx() - def test_tower_deploy_uses_single_container_unraid_compose() -> None: powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") bash = (ROOT / "scripts" / "deploy_tower.sh").read_text(encoding="utf-8") + release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") for script in (powershell, bash): - assert "docker compose -f docker-compose.unraid.yml config" in script - assert "--build-arg GEOINTEL_INSTALL_AI=" in script - assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in script - assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in script - assert "-f deploy/unraid/Dockerfile.all-in-one" in script - assert "-t geointel-all-in-one:latest" in script - assert "docker compose -f docker-compose.unraid.yml build geointel" not in script - assert "bash deploy/unraid/run-dockerman-container.sh" in script - assert "LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh" in script + assert "bash deploy/unraid/deploy-release.sh" in script + + assert "docker compose -f docker-compose.unraid.yml config" in release_script + assert "--build-arg GEOINTEL_INSTALL_AI=" in release_script + assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in release_script + assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in release_script + assert "-f deploy/unraid/Dockerfile.all-in-one" in release_script + assert '-t "$GEOINTEL_RELEASE_IMAGE"' in release_script + assert '-t "${GEOINTEL_IMAGE_REPOSITORY}:latest"' in release_script + assert 'GEOINTEL_IMAGE="$image" bash deploy/unraid/run-dockerman-container.sh' in release_script + assert "LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh" in release_script def test_tower_deploy_build_uses_remote_env_ai_setting_by_default() -> None: powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") bash = (ROOT / "scripts" / "deploy_tower.sh").read_text(encoding="utf-8") + release_script = (ROOT / "deploy" / "unraid" / "deploy-release.sh").read_text(encoding="utf-8") for script in (powershell, bash): - assert "if [ -f .env ]; then" in script - assert ". ./.env" in script assert "DEPLOY_GEOINTEL_INSTALL_AI" in script - assert 'GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}"' in script - assert 'docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI"' in script + assert "bash deploy/unraid/deploy-release.sh" in script + + assert "if [ -f .env ]; then" in release_script + assert ". ./.env" in release_script + assert 'GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}"' in release_script + assert "--build-arg GEOINTEL_INSTALL_AI=" in release_script def test_powershell_tower_deploy_streams_remote_script_to_bash() -> None: diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index a381a685..86e1dc81 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -12,13 +12,9 @@ ARG GEOINTEL_INSTALL_AI=false ARG GEOINTEL_TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu ARG GEOINTEL_TORCH_VERSION=2.13.0 ARG GEOINTEL_TORCHVISION_VERSION=0.28.0 -ARG GEOINTEL_BUILD_SHA=unknown -ARG GEOINTEL_BUILD_TIME=unknown ENV GEOINTEL_ENV=production \ GEOINTEL_API_PREFIX=/api/v1 \ - GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}" \ - GEOINTEL_BUILD_TIME="${GEOINTEL_BUILD_TIME}" \ GEOINTEL_STORAGE_ROOT=/app/storage \ STORAGE_ROOT=/app/storage \ GEOINTEL_ALL_IN_ONE=1 \ @@ -154,6 +150,17 @@ RUN chmod +x /usr/local/bin/geointel-all-in-one-start \ /app/scripts/run_background_corpus_split_matrix.sh \ /app/scripts/run_split_background_promotion_workflow.sh +ARG GEOINTEL_BUILD_SHA=unknown +ARG GEOINTEL_BUILD_TIME=unknown + +ENV GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}" \ + GEOINTEL_BUILD_TIME="${GEOINTEL_BUILD_TIME}" + +LABEL org.opencontainers.image.title="GeoIntel" \ + org.opencontainers.image.description="GeoIntel workbench for Belgium and the Belgian North Sea" \ + org.opencontainers.image.revision="${GEOINTEL_BUILD_SHA}" \ + org.opencontainers.image.created="${GEOINTEL_BUILD_TIME}" + VOLUME ["/var/lib/postgresql/data", "/app/storage"] EXPOSE 80 diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index 410e6f6f..a8f6a5d6 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -72,11 +72,19 @@ cd /mnt/user/appdata/geointel cp deploy/unraid/geointel.env.example .env nano .env docker compose -f docker-compose.unraid.yml config -docker build --build-arg GEOINTEL_INSTALL_AI=${GEOINTEL_INSTALL_AI:-false} -f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest . -bash deploy/unraid/run-dockerman-container.sh +bash deploy/unraid/deploy-release.sh ``` -The repository deploy scripts run the same flow automatically. They validate the Compose reference, build the image with the `GEOINTEL_INSTALL_AI` build arg, install the DockerMan template/icon, remove any old Compose-owned `geointel` container, preserve/migrate the PostGIS data path and start the final container with DockerMan labels. +Set `GEOINTEL_POSTGRES_PASSWORD` to a unique value before that first start. +Production startup fails before replacing the active container when the +password is empty or one of the documented defaults. + +The repository deploy scripts run the same flow automatically. They validate +the Compose reference, preserve the current image as +`geointel-all-in-one:previous`, build an immutable commit-SHA tag plus `latest`, +install the DockerMan metadata and start the SHA-tagged image. A failed start, +live migration smoke or browser/API smoke automatically attempts the previous +image without changing the configured PostGIS or storage paths. `scripts/deploy_tower.sh` and `scripts/deploy_tower.ps1` source the remote `.env` before building the image. That means `GEOINTEL_INSTALL_AI=true` in @@ -99,8 +107,9 @@ libraries needed for Ultralytics imports; it still never downloads model weights The documented CPU runtime installs pinned `torch==2.13.0` and `torchvision==0.28.0` from `https://download.pytorch.org/whl/cpu`, avoiding the unused CUDA runtime wheels included by the general Linux package index. The -Dockerfile copies dependency metadata before backend source, so normal code-only -redeploys can reuse the expensive dependency layer. +Dockerfile copies dependency metadata before backend source and applies +commit/build metadata only after the heavy file layers. Normal code-only +redeploys therefore reuse the apt, GIS and optional PyTorch dependency layers. `YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent path, so Ultralytics settings do not fall back to root user config directories. @@ -173,8 +182,7 @@ GEOINTEL_CORS_ORIGINS=http://localhost:1203,http://127.0.0.1:1203,http://192.168 Apply: ```bash -docker build --build-arg GEOINTEL_INSTALL_AI=${GEOINTEL_INSTALL_AI:-false} -f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest . -bash deploy/unraid/run-dockerman-container.sh +bash deploy/unraid/deploy-release.sh ``` ## Persistent paths @@ -226,10 +234,55 @@ curl http://127.0.0.1:1202/api/v1/assistant/models cd /mnt/user/appdata/geointel git fetch origin main git reset --hard origin/main +bash deploy/unraid/deploy-release.sh +``` + +The equivalent low-level build remains available for debugging: + +```bash docker build --build-arg GEOINTEL_INSTALL_AI=${GEOINTEL_INSTALL_AI:-false} -f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest . bash deploy/unraid/run-dockerman-container.sh ``` +## Release identity, fresh install and rollback + +Inspect the running immutable revision and retained images: + +```bash +docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' geointel +docker image ls geointel-all-in-one +``` + +Run a fresh install in isolated temporary PostGIS, storage and model paths. The +script binds only a random loopback port and removes its own container and +temporary directory: + +```bash +bash scripts/verify_release_fresh_install.sh geointel-all-in-one:latest +``` + +Return to the image that was active immediately before the latest deployment: + +```bash +bash deploy/unraid/rollback-dockerman-container.sh +``` + +For an older retained commit, select its immutable tag explicitly: + +```bash +GEOINTEL_ROLLBACK_IMAGE=geointel-all-in-one: \ + bash deploy/unraid/rollback-dockerman-container.sh +``` + +Rollback reuses the configured PostGIS and storage mounts and never runs an +Alembic downgrade. If a future release has a backward-incompatible migration, +restore its verified pre-release backup instead of forcing an older app +against a newer schema. + +The configured upload limit is shared by FastAPI and the generated nginx +runtime configuration. Values outside `1..2048` MiB are rejected before the +active application is replaced. + ## Safe cleanup Safe cache cleanup if Docker build cache fills the Unraid Docker image: diff --git a/deploy/unraid/all-in-one-start.sh b/deploy/unraid/all-in-one-start.sh index c067c2f9..413e3f4d 100644 --- a/deploy/unraid/all-in-one-start.sh +++ b/deploy/unraid/all-in-one-start.sh @@ -13,6 +13,27 @@ export YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}" export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}" export GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP="${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}" +case "$MAX_UPLOAD_MB" in + ''|*[!0-9]*) + echo "GEOINTEL_MAX_UPLOAD_MB must be a whole number between 1 and 2048." >&2 + exit 2 + ;; +esac +if [ "$MAX_UPLOAD_MB" -lt 1 ] || [ "$MAX_UPLOAD_MB" -gt 2048 ]; then + echo "GEOINTEL_MAX_UPLOAD_MB must be between 1 and 2048." >&2 + exit 2 +fi + +case "${GEOINTEL_ENV:-production}:${POSTGRES_PASSWORD}" in + production:|production:geointel|production:postgres|production:password|production:changeme|production:change-me-before-shared-use) + echo "Refusing to start production with an empty or known-default PostGIS password." >&2 + exit 2 + ;; +esac + +sed -i "s/__GEOINTEL_MAX_UPLOAD_MB__/${MAX_UPLOAD_MB}/g" /etc/nginx/conf.d/default.conf +nginx -t + mkdir -p "$PGDATA" "$STORAGE_ROOT" "$YOLO_CONFIG_DIR" /run/nginx /var/log/nginx chown -R postgres:postgres "$PGDATA" diff --git a/deploy/unraid/deploy-release.sh b/deploy/unraid/deploy-release.sh new file mode 100644 index 00000000..4c836a75 --- /dev/null +++ b/deploy/unraid/deploy-release.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +if [ -n "${DEPLOY_GEOINTEL_INSTALL_AI:-}" ]; then + GEOINTEL_INSTALL_AI="$DEPLOY_GEOINTEL_INSTALL_AI" +fi + +GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}" +GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)" +GEOINTEL_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +GEOINTEL_IMAGE_REPOSITORY="${GEOINTEL_IMAGE_REPOSITORY:-geointel-all-in-one}" +GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}" +GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous" +FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:${GEOINTEL_FRONTEND_PORT:-1202}}" + +wait_for_geointel_health() { + local status="" + for attempt in $(seq 1 90); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' geointel 2>/dev/null || true)" + if [ "$status" = "healthy" ]; then + echo "GeoIntel container is healthy after attempt ${attempt}." + return 0 + fi + if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + echo "GeoIntel container entered terminal state: ${status}" >&2 + docker logs --tail 120 geointel >&2 || true + return 1 + fi + sleep 2 + done + echo "GeoIntel container did not become healthy (last state: ${status:-missing})." >&2 + docker logs --tail 120 geointel >&2 || true + return 1 +} + +start_image() { + local image="$1" + GEOINTEL_IMAGE="$image" bash deploy/unraid/run-dockerman-container.sh + wait_for_geointel_health +} + +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 + return 1 + fi + echo "Rolling back to ${GEOINTEL_PREVIOUS_IMAGE}..." + start_image "$GEOINTEL_PREVIOUS_IMAGE" +} + +docker compose -f docker-compose.unraid.yml config >/dev/null + +current_image_id="$(docker inspect --format '{{.Image}}' geointel 2>/dev/null || true)" +if [ -n "$current_image_id" ] && docker image inspect "$current_image_id" >/dev/null 2>&1; then + current_revision="$( + docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + "$current_image_id" 2>/dev/null || true + )" + if [ -n "$current_revision" ] && [ "$current_revision" != "" ] && [ "$current_revision" != "unknown" ]; then + docker tag "$current_image_id" "${GEOINTEL_IMAGE_REPOSITORY}:${current_revision}" + fi + docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE" +fi + +docker build \ + --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI" \ + --build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA" \ + --build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME" \ + -f deploy/unraid/Dockerfile.all-in-one \ + -t "$GEOINTEL_RELEASE_IMAGE" \ + -t "${GEOINTEL_IMAGE_REPOSITORY}:latest" \ + . + +if ! start_image "$GEOINTEL_RELEASE_IMAGE"; then + rollback_previous || true + exit 1 +fi + +if [ -x scripts/live_migration_smoke.sh ]; then + if ! LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh; then + rollback_previous || true + exit 1 + fi +fi + +if [ -x scripts/verify_browser_runtime.sh ]; then + if ! bash scripts/verify_browser_runtime.sh "$FRONTEND_URL"; then + rollback_previous || true + exit 1 + fi +fi + +echo "Deployed immutable image ${GEOINTEL_RELEASE_IMAGE}." +docker image inspect \ + --format 'revision={{index .Config.Labels "org.opencontainers.image.revision"}} created={{index .Config.Labels "org.opencontainers.image.created"}}' \ + "$GEOINTEL_RELEASE_IMAGE" diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index fd49f609..ee85d344 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -7,8 +7,8 @@ bash false http://192.168.10.150:1202 - GeoIntel Kempen - GeoIntel all-in-one runs the complete GeoIntel Kempen V1 stack in one Docker container: embedded PostGIS, FastAPI backend, nginx frontend and MapLibre UI. Use docker-compose.unraid.yml or this template so the web port, storage path and database path can be edited from Unraid. + GeoIntel Belgium and North Sea + GeoIntel all-in-one runs the Belgium and Belgian North Sea workbench in one Docker container: embedded PostGIS, FastAPI backend, nginx frontend and MapLibre UI. Use docker-compose.unraid.yml or this template so the web port, storage path and database path can be edited from Unraid. Productivity: Tools: GIS: http://[IP]:[PORT:80]/ deploy/unraid/geointel-unraid-template.xml @@ -29,7 +29,7 @@ geointel change-me-before-shared-use http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202 - 500 + 500 true https://geo.api.vlaanderen.be/OMWRGBMRVL/wms 1.0 @@ -63,7 +63,7 @@ 20 true https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs - 60000 + 60000 30000000 true http://host.docker.internal:11434 diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index 004af607..a84cda1c 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -14,6 +14,8 @@ GEOINTEL_MODELS_PATH=/mnt/user/appdata/geointel/models GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data # Internal embedded PostGIS settings. The database is not published to the LAN. +# Replace the placeholder with a unique secret before the first start. Production +# startup rejects empty and known-default passwords. GEOINTEL_POSTGRES_DB=geointel GEOINTEL_POSTGRES_USER=geointel GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use @@ -21,7 +23,7 @@ GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use # Browser origins allowed when directly calling the backend API. GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202 -# Upload guard in MiB. +# Upload guard in MiB. The same 1-2048 limit is applied by nginx and FastAPI. GEOINTEL_MAX_UPLOAD_MB=500 # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. diff --git a/deploy/unraid/nginx-all-in-one.conf b/deploy/unraid/nginx-all-in-one.conf index 9675d651..14679393 100644 --- a/deploy/unraid/nginx-all-in-one.conf +++ b/deploy/unraid/nginx-all-in-one.conf @@ -1,7 +1,7 @@ server { listen 80; server_name _; - client_max_body_size 250m; + client_max_body_size __GEOINTEL_MAX_UPLOAD_MB__m; proxy_read_timeout 600s; proxy_send_timeout 600s; diff --git a/deploy/unraid/rollback-dockerman-container.sh b/deploy/unraid/rollback-dockerman-container.sh new file mode 100644 index 00000000..987fb0c4 --- /dev/null +++ b/deploy/unraid/rollback-dockerman-container.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +GEOINTEL_ROLLBACK_IMAGE="${GEOINTEL_ROLLBACK_IMAGE:-geointel-all-in-one:previous}" + +if ! docker image inspect "$GEOINTEL_ROLLBACK_IMAGE" >/dev/null 2>&1; then + echo "Rollback image does not exist: ${GEOINTEL_ROLLBACK_IMAGE}" >&2 + exit 2 +fi + +echo "Starting rollback image ${GEOINTEL_ROLLBACK_IMAGE} without changing persistent volumes..." +GEOINTEL_IMAGE="$GEOINTEL_ROLLBACK_IMAGE" bash deploy/unraid/run-dockerman-container.sh + +for attempt in $(seq 1 90); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' geointel 2>/dev/null || true)" + if [ "$status" = "healthy" ]; then + LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh + echo "Rollback completed with healthy image ${GEOINTEL_ROLLBACK_IMAGE}." + exit 0 + fi + if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + docker logs --tail 120 geointel >&2 || true + exit 1 + fi + sleep 2 +done + +echo "Rollback container did not become healthy." >&2 +docker logs --tail 120 geointel >&2 || true +exit 1 diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index 75386419..27dbcabf 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -12,12 +12,13 @@ if [ -f .env ]; then fi GEOINTEL_FRONTEND_PORT="${GEOINTEL_FRONTEND_PORT:-1202}" +GEOINTEL_IMAGE="${GEOINTEL_IMAGE:-geointel-all-in-one:latest}" 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_POSTGRES_DB="${GEOINTEL_POSTGRES_DB:-geointel}" GEOINTEL_POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-geointel}" -GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-geointel}" +GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-}" GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}" GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}" ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" @@ -101,6 +102,39 @@ OLLAMA_TIMEOUT_SECONDS="${OLLAMA_TIMEOUT_SECONDS:-120}" OLLAMA_MAX_OUTPUT_TOKENS="${OLLAMA_MAX_OUTPUT_TOKENS:-1200}" OLLAMA_CONTEXT_TOKENS="${OLLAMA_CONTEXT_TOKENS:-16384}" +validate_runtime_config() { + case "$GEOINTEL_FRONTEND_PORT" in + ''|*[!0-9]*) + echo "GEOINTEL_FRONTEND_PORT must be a whole number." >&2 + return 2 + ;; + esac + if [ "$GEOINTEL_FRONTEND_PORT" -lt 1 ] || [ "$GEOINTEL_FRONTEND_PORT" -gt 65535 ]; then + echo "GEOINTEL_FRONTEND_PORT must be between 1 and 65535." >&2 + return 2 + fi + + case "$GEOINTEL_MAX_UPLOAD_MB" in + ''|*[!0-9]*) + echo "GEOINTEL_MAX_UPLOAD_MB must be a whole number." >&2 + return 2 + ;; + esac + if [ "$GEOINTEL_MAX_UPLOAD_MB" -lt 1 ] || [ "$GEOINTEL_MAX_UPLOAD_MB" -gt 2048 ]; then + echo "GEOINTEL_MAX_UPLOAD_MB must be between 1 and 2048." >&2 + return 2 + fi + + case "$GEOINTEL_POSTGRES_PASSWORD" in + ''|geointel|postgres|password|changeme|change-me-before-shared-use) + echo "Refusing deployment with an empty or known-default PostGIS password." >&2 + return 2 + ;; + esac + + docker image inspect "$GEOINTEL_IMAGE" >/dev/null +} + install_dockerman_metadata() { if [ -d /boot/config/plugins/dockerMan ]; then mkdir -p /boot/config/plugins/dockerMan/templates-user /boot/config/plugins/dockerMan/images @@ -125,6 +159,7 @@ migrate_compose_volume_if_needed() { cp -a "${compose_volume_path}/." "$GEOINTEL_POSTGIS_DATA_PATH/" } +validate_runtime_config install_dockerman_metadata docker compose down --remove-orphans || true @@ -233,6 +268,6 @@ docker run -d \ -v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data" \ -v "${GEOINTEL_STORAGE_PATH}:/app/storage" \ -v "${GEOINTEL_MODELS_PATH}:/app/models" \ - geointel-all-in-one:latest + "$GEOINTEL_IMAGE" docker ps --filter name=geointel diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 64758a7c..3c40dbc1 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -40,6 +40,11 @@ run reused the same project and dataset identifiers. Browser acceptance then exposed and closed a map guard that had prevented coverage-only selection when the chosen theme was honestly unavailable. +- RC-5 deployment hardening started by centralizing Tower release execution, + moving build identity behind expensive dependency layers, retaining + immutable commit images plus a `previous` rollback target, adding automatic + rollback and an isolated fresh-install smoke, and failing closed on default + database secrets or inconsistent upload limits. - Froze the RC geography as all Belgian land plus the separately labelled territorial sea, EEZ and continental shelf. diff --git a/scripts/deploy_tower.ps1 b/scripts/deploy_tower.ps1 index b99736b4..1f3e5179 100644 --- a/scripts/deploy_tower.ps1 +++ b/scripts/deploy_tower.ps1 @@ -34,59 +34,9 @@ git reset --hard "origin/$REMOTE_BRANCH" git branch -M "$REMOTE_BRANCH" chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true -if [ -f .env ]; then - set -a - . ./.env - set +a -fi - -if [ -n "${DEPLOY_GEOINTEL_INSTALL_AI:-}" ]; then - GEOINTEL_INSTALL_AI="$DEPLOY_GEOINTEL_INSTALL_AI" -fi -GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}" -GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)" -GEOINTEL_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -docker compose -f docker-compose.unraid.yml config >/dev/null -docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI" \ - --build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA" \ - --build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME" \ - -f deploy/unraid/Dockerfile.all-in-one \ - -t geointel-all-in-one:latest \ - . -bash deploy/unraid/run-dockerman-container.sh - -wait_for_geointel_health() { - local status='' - for attempt in $(seq 1 90); do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' geointel 2>/dev/null || true)" - if [ "$status" = 'healthy' ]; then - echo "GeoIntel container is healthy after attempt $attempt." - return 0 - fi - if [ "$status" = 'unhealthy' ] || [ "$status" = 'exited' ] || [ "$status" = 'dead' ]; then - echo "GeoIntel container entered terminal state: $status" >&2 - docker logs --tail 120 geointel >&2 || true - return 1 - fi - sleep 2 - done - echo "GeoIntel container did not become healthy (last state: ${status:-missing})." >&2 - docker logs --tail 120 geointel >&2 || true - return 1 -} - -# Container startup owns the migration. Waiting here prevents the validation -# smoke from racing a concurrent `alembic upgrade head` against the same DB. -wait_for_geointel_health - -if [ -x scripts/live_migration_smoke.sh ]; then - LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh -fi - -if [ -x scripts/verify_browser_runtime.sh ]; then - bash scripts/verify_browser_runtime.sh "$FRONTEND_URL" -fi +FRONTEND_URL="$FRONTEND_URL" \ +DEPLOY_GEOINTEL_INSTALL_AI="${DEPLOY_GEOINTEL_INSTALL_AI:-}" \ +bash deploy/unraid/deploy-release.sh '@ $utf8NoBom = [System.Text.UTF8Encoding]::new($false) diff --git a/scripts/deploy_tower.sh b/scripts/deploy_tower.sh index 5afaaa66..e1eed741 100755 --- a/scripts/deploy_tower.sh +++ b/scripts/deploy_tower.sh @@ -35,58 +35,7 @@ git fetch origin "$REMOTE_BRANCH" git checkout -B "$REMOTE_BRANCH" "origin/$REMOTE_BRANCH" chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true -if [ -f .env ]; then - set -a - # shellcheck disable=SC1091 - . ./.env - set +a -fi - -if [ -n "${DEPLOY_GEOINTEL_INSTALL_AI:-}" ]; then - GEOINTEL_INSTALL_AI="$DEPLOY_GEOINTEL_INSTALL_AI" -fi -GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}" -GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)" -GEOINTEL_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -docker compose -f docker-compose.unraid.yml config >/dev/null -docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI" \ - --build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA" \ - --build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME" \ - -f deploy/unraid/Dockerfile.all-in-one \ - -t geointel-all-in-one:latest \ - . -bash deploy/unraid/run-dockerman-container.sh - -wait_for_geointel_health() { - local status='' - for attempt in $(seq 1 90); do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' geointel 2>/dev/null || true)" - if [[ "$status" == 'healthy' ]]; then - echo "GeoIntel container is healthy after attempt $attempt." - return 0 - fi - if [[ "$status" == 'unhealthy' || "$status" == 'exited' || "$status" == 'dead' ]]; then - echo "GeoIntel container entered terminal state: $status" >&2 - docker logs --tail 120 geointel >&2 || true - return 1 - fi - sleep 2 - done - echo "GeoIntel container did not become healthy (last state: ${status:-missing})." >&2 - docker logs --tail 120 geointel >&2 || true - return 1 -} - -# Container startup owns the migration. Waiting here prevents the validation -# smoke from racing a concurrent `alembic upgrade head` against the same DB. -wait_for_geointel_health - -if [[ -x scripts/live_migration_smoke.sh ]]; then - LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh -fi - -if [[ -x scripts/verify_browser_runtime.sh ]]; then - bash scripts/verify_browser_runtime.sh "$FRONTEND_URL" -fi +FRONTEND_URL="$FRONTEND_URL" \ +DEPLOY_GEOINTEL_INSTALL_AI="${DEPLOY_GEOINTEL_INSTALL_AI:-}" \ +bash deploy/unraid/deploy-release.sh REMOTE_SCRIPT diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 50f1fcc1..ff12f10e 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -106,6 +106,12 @@ ${PYTHON_BIN} -m compileall backend/app (cd frontend && npm run typecheck) (cd frontend && npm run build) bash -n scripts/live_migration_smoke.sh +bash -n scripts/deploy_tower.sh +bash -n scripts/verify_release_fresh_install.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 +bash -n deploy/unraid/rollback-dockerman-container.sh bash -n scripts/verify_browser_runtime.sh bash -n scripts/verify_demo_export_workflow.sh bash -n scripts/verify_demo_raster_workflow.sh diff --git a/scripts/verify_release_fresh_install.sh b/scripts/verify_release_fresh_install.sh new file mode 100644 index 00000000..e5bbd1ee --- /dev/null +++ b/scripts/verify_release_fresh_install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${1:-geointel-all-in-one:latest}" +NAME="geointel-fresh-smoke-$(date +%s)-$$" +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/geointel-fresh-smoke.XXXXXX")" +PASSWORD="fresh-smoke-$(cat /proc/sys/kernel/random/uuid)" + +cleanup() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + case "$ROOT" in + "${TMPDIR:-/tmp}"/geointel-fresh-smoke.*) + rm -rf -- "$ROOT" + ;; + *) + echo "Refusing to remove unexpected smoke path: ${ROOT}" >&2 + ;; + esac +} +trap cleanup EXIT + +docker image inspect "$IMAGE" >/dev/null +mkdir -p "$ROOT/postgres-data" "$ROOT/storage" "$ROOT/models" + +docker run -d \ + --name "$NAME" \ + -p 127.0.0.1::80 \ + -e GEOINTEL_POSTGRES_DB=geointel_fresh_smoke \ + -e GEOINTEL_POSTGRES_USER=geointel_fresh_smoke \ + -e GEOINTEL_POSTGRES_PASSWORD="$PASSWORD" \ + -e GEOINTEL_MAX_UPLOAD_MB=500 \ + -e OLLAMA_ENABLED=false \ + -v "$ROOT/postgres-data:/var/lib/postgresql/data" \ + -v "$ROOT/storage:/app/storage" \ + -v "$ROOT/models:/app/models" \ + "$IMAGE" >/dev/null + +for attempt in $(seq 1 90); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$NAME" 2>/dev/null || true)" + if [ "$status" = "healthy" ]; then + break + fi + if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + docker logs --tail 120 "$NAME" >&2 || true + exit 1 + fi + sleep 2 +done + +if [ "${status:-}" != "healthy" ]; then + echo "Fresh-install container did not become healthy." >&2 + docker logs --tail 120 "$NAME" >&2 || true + exit 1 +fi + +host_port="$(docker port "$NAME" 80/tcp | awk -F: 'NR == 1 {print $NF}')" +curl -fsS "http://127.0.0.1:${host_port}/health/ready" >/dev/null +curl -fsS "http://127.0.0.1:${host_port}/api/v1/system/capabilities" >/dev/null +docker exec "$NAME" python -m alembic heads + +echo "Fresh install passed for ${IMAGE} on isolated storage ${ROOT}."