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}."