Harden immutable release deployment
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 02:31:28 +02:00
parent 611ad0cd57
commit 5699006a5e
20 changed files with 477 additions and 151 deletions
+5
View File
@@ -69,6 +69,11 @@
- Kept map rectangle and full-area selection available for coverage inspection - Kept map rectangle and full-area selection available for coverage inspection
when a selected national theme has not yet been materialized, without when a selected national theme has not yet been materialized, without
querying an unrelated active dataset. 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) ## Sprint 240 Operational forest, agriculture, nature and soil themes (2026-07-17)
+5 -3
View File
@@ -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: def test_nginx_runtime_allows_real_gis_upload_payloads() -> None:
frontend_nginx = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8") 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") 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 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: 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: 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_ps1 = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8")
deploy_sh = (ROOT / "scripts" / "deploy_tower.sh").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") 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 '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 "--build-arg GEOINTEL_INSTALL_AI=" in release_script
assert 'docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI"' in deploy_ps1
assert "DEPLOY_GEOINTEL_INSTALL_AI" in deploy_ps1 assert "DEPLOY_GEOINTEL_INSTALL_AI" in deploy_ps1
assert "[string]$InstallAi" in deploy_ps1 assert "[string]$InstallAi" in deploy_ps1
@@ -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
@@ -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: def test_all_in_one_deploy_embeds_immutable_build_identity() -> None:
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") 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_powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8")
deploy_shell = (ROOT / "scripts" / "deploy_tower.sh").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 "ARG GEOINTEL_BUILD_SHA=unknown" in dockerfile
assert 'GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}"' in dockerfile assert 'GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}"' in dockerfile
assert 'GEOINTEL_BUILD_TIME="${GEOINTEL_BUILD_TIME}"' 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): for deploy_source in (deploy_powershell, deploy_shell):
assert 'GEOINTEL_BUILD_SHA="$(git rev-parse HEAD)"' in deploy_source assert "bash deploy/unraid/deploy-release.sh" in deploy_source
assert "--build-arg GEOINTEL_BUILD_SHA=" in deploy_source
assert "--build-arg GEOINTEL_BUILD_TIME=" in deploy_source
@@ -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: def test_tower_deploy_waits_for_startup_migration_before_live_smoke() -> None:
for relative_path in ("scripts/deploy_tower.ps1", "scripts/deploy_tower.sh"): for relative_path in ("scripts/deploy_tower.ps1", "scripts/deploy_tower.sh"):
script = (ROOT / relative_path).read_text(encoding="utf-8") script = (ROOT / relative_path).read_text(encoding="utf-8")
wait_position = script.index("wait_for_geointel_health") assert "bash deploy/unraid/deploy-release.sh" in script
invocation_position = script.index("\nwait_for_geointel_health", wait_position)
smoke_position = script.index("LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh") release_script = (ROOT / "deploy/unraid/deploy-release.sh").read_text(encoding="utf-8")
assert "docker inspect --format" in script wait_position = release_script.index("wait_for_geointel_health")
assert invocation_position < smoke_position 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
+20 -14
View File
@@ -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") template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
assert "<Name>geointel</Name>" in template assert "<Name>geointel</Name>" 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 "<Repository>geointel-all-in-one:latest</Repository>" in template assert "<Repository>geointel-all-in-one:latest</Repository>" in template
assert "<WebUI>http://[IP]:[PORT:80]/</WebUI>" in template assert "<WebUI>http://[IP]:[PORT:80]/</WebUI>" in template
assert "<Icon>http://192.168.10.150:1202/geointel-icon.png</Icon>" in template assert "<Icon>http://192.168.10.150:1202/geointel-icon.png</Icon>" 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: def test_tower_deploy_uses_single_container_unraid_compose() -> None:
powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8")
bash = (ROOT / "scripts" / "deploy_tower.sh").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): for script in (powershell, bash):
assert "docker compose -f docker-compose.unraid.yml config" in script assert "bash deploy/unraid/deploy-release.sh" in script
assert "--build-arg GEOINTEL_INSTALL_AI=" in script
assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in script assert "docker compose -f docker-compose.unraid.yml config" in release_script
assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in script assert "--build-arg GEOINTEL_INSTALL_AI=" in release_script
assert "-f deploy/unraid/Dockerfile.all-in-one" in script assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in release_script
assert "-t geointel-all-in-one:latest" in script assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in release_script
assert "docker compose -f docker-compose.unraid.yml build geointel" not in script assert "-f deploy/unraid/Dockerfile.all-in-one" in release_script
assert "bash deploy/unraid/run-dockerman-container.sh" in script assert '-t "$GEOINTEL_RELEASE_IMAGE"' in release_script
assert "LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh" in 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: def test_tower_deploy_build_uses_remote_env_ai_setting_by_default() -> None:
powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8") powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8")
bash = (ROOT / "scripts" / "deploy_tower.sh").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): for script in (powershell, bash):
assert "if [ -f .env ]; then" in script
assert ". ./.env" in script
assert "DEPLOY_GEOINTEL_INSTALL_AI" in script assert "DEPLOY_GEOINTEL_INSTALL_AI" in script
assert 'GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}"' in script assert "bash deploy/unraid/deploy-release.sh" in script
assert 'docker build --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI"' 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: def test_powershell_tower_deploy_streams_remote_script_to_bash() -> None:
+11 -4
View File
@@ -12,13 +12,9 @@ ARG GEOINTEL_INSTALL_AI=false
ARG GEOINTEL_TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu ARG GEOINTEL_TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu
ARG GEOINTEL_TORCH_VERSION=2.13.0 ARG GEOINTEL_TORCH_VERSION=2.13.0
ARG GEOINTEL_TORCHVISION_VERSION=0.28.0 ARG GEOINTEL_TORCHVISION_VERSION=0.28.0
ARG GEOINTEL_BUILD_SHA=unknown
ARG GEOINTEL_BUILD_TIME=unknown
ENV GEOINTEL_ENV=production \ ENV GEOINTEL_ENV=production \
GEOINTEL_API_PREFIX=/api/v1 \ GEOINTEL_API_PREFIX=/api/v1 \
GEOINTEL_BUILD_SHA="${GEOINTEL_BUILD_SHA}" \
GEOINTEL_BUILD_TIME="${GEOINTEL_BUILD_TIME}" \
GEOINTEL_STORAGE_ROOT=/app/storage \ GEOINTEL_STORAGE_ROOT=/app/storage \
STORAGE_ROOT=/app/storage \ STORAGE_ROOT=/app/storage \
GEOINTEL_ALL_IN_ONE=1 \ 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_background_corpus_split_matrix.sh \
/app/scripts/run_split_background_promotion_workflow.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"] VOLUME ["/var/lib/postgresql/data", "/app/storage"]
EXPOSE 80 EXPOSE 80
+60 -7
View File
@@ -72,11 +72,19 @@ cd /mnt/user/appdata/geointel
cp deploy/unraid/geointel.env.example .env cp deploy/unraid/geointel.env.example .env
nano .env nano .env
docker compose -f docker-compose.unraid.yml config 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/deploy-release.sh
bash deploy/unraid/run-dockerman-container.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 `scripts/deploy_tower.sh` and `scripts/deploy_tower.ps1` source the remote
`.env` before building the image. That means `GEOINTEL_INSTALL_AI=true` in `.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 The documented CPU runtime installs pinned `torch==2.13.0` and
`torchvision==0.28.0` from `https://download.pytorch.org/whl/cpu`, avoiding the `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 unused CUDA runtime wheels included by the general Linux package index. The
Dockerfile copies dependency metadata before backend source, so normal code-only Dockerfile copies dependency metadata before backend source and applies
redeploys can reuse the expensive dependency layer. 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 `YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent
path, so Ultralytics settings do not fall back to root user config directories. 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: Apply:
```bash ```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/deploy-release.sh
bash deploy/unraid/run-dockerman-container.sh
``` ```
## Persistent paths ## Persistent paths
@@ -226,10 +234,55 @@ curl http://127.0.0.1:1202/api/v1/assistant/models
cd /mnt/user/appdata/geointel cd /mnt/user/appdata/geointel
git fetch origin main git fetch origin main
git reset --hard 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 . 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/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:<commit-sha> \
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 cleanup
Safe cache cleanup if Docker build cache fills the Unraid Docker image: Safe cache cleanup if Docker build cache fills the Unraid Docker image:
+21
View File
@@ -13,6 +13,27 @@ export YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}"
export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}" export YOLO_CONFIG_DIR="${YOLO_CONFIG_DIR:-$STORAGE_ROOT/ultralytics}"
export GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP="${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}" 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 mkdir -p "$PGDATA" "$STORAGE_ROOT" "$YOLO_CONFIG_DIR" /run/nginx /var/log/nginx
chown -R postgres:postgres "$PGDATA" chown -R postgres:postgres "$PGDATA"
+107
View File
@@ -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" != "<no value>" ] && [ "$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"
+4 -4
View File
@@ -7,8 +7,8 @@
<Shell>bash</Shell> <Shell>bash</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>http://192.168.10.150:1202</Support> <Support>http://192.168.10.150:1202</Support>
<Project>GeoIntel Kempen</Project> <Project>GeoIntel Belgium and North Sea</Project>
<Overview>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.</Overview> <Overview>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.</Overview>
<Category>Productivity: Tools: GIS:</Category> <Category>Productivity: Tools: GIS:</Category>
<WebUI>http://[IP]:[PORT:80]/</WebUI> <WebUI>http://[IP]:[PORT:80]/</WebUI>
<TemplateURL>deploy/unraid/geointel-unraid-template.xml</TemplateURL> <TemplateURL>deploy/unraid/geointel-unraid-template.xml</TemplateURL>
@@ -29,7 +29,7 @@
<Config Name="Postgres User" Target="GEOINTEL_POSTGRES_USER" Default="geointel" Mode="" Description="Embedded PostGIS database user." Type="Variable" Display="advanced" Required="true" Mask="false">geointel</Config> <Config Name="Postgres User" Target="GEOINTEL_POSTGRES_USER" Default="geointel" Mode="" Description="Embedded PostGIS database user." Type="Variable" Display="advanced" Required="true" Mask="false">geointel</Config>
<Config Name="Postgres Password" Target="GEOINTEL_POSTGRES_PASSWORD" Default="change-me-before-shared-use" Mode="" Description="Embedded PostGIS database password. Change before shared use." Type="Variable" Display="advanced" Required="true" Mask="true">change-me-before-shared-use</Config> <Config Name="Postgres Password" Target="GEOINTEL_POSTGRES_PASSWORD" Default="change-me-before-shared-use" Mode="" Description="Embedded PostGIS database password. Change before shared use." Type="Variable" Display="advanced" Required="true" Mask="true">change-me-before-shared-use</Config>
<Config Name="CORS Origins" Target="GEOINTEL_CORS_ORIGINS" Default="http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202" Mode="" Description="Comma-separated browser origins allowed to call the backend directly." Type="Variable" Display="advanced" Required="false" Mask="false">http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202</Config> <Config Name="CORS Origins" Target="GEOINTEL_CORS_ORIGINS" Default="http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202" Mode="" Description="Comma-separated browser origins allowed to call the backend directly." Type="Variable" Display="advanced" Required="false" Mask="false">http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202</Config>
<Config Name="Max Upload MB" Target="GEOINTEL_MAX_UPLOAD_MB" Default="500" Mode="" Description="Maximum upload size in MiB enforced by the backend settings." Type="Variable" Display="advanced" Required="true" Mask="false">500</Config> <Config Name="Max Upload MB" Target="GEOINTEL_MAX_UPLOAD_MB" Default="500" Mode="" Description="Maximum upload size in MiB enforced consistently by nginx and the backend (1-2048)." Type="Variable" Display="advanced" Required="true" Mask="false">500</Config>
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config> <Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config> <Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config>
<Config Name="Orthophoto Resolution (m)" Target="ORTHOPHOTO_RESOLUTION_M" Default="1.0" Mode="" Description="Requested analysis sampling in metres per pixel. Keep at 1.0 for the active building model profile." Type="Variable" Display="advanced" Required="true" Mask="false">1.0</Config> <Config Name="Orthophoto Resolution (m)" Target="ORTHOPHOTO_RESOLUTION_M" Default="1.0" Mode="" Description="Requested analysis sampling in metres per pixel. Keep at 1.0 for the active building model profile." Type="Variable" Display="advanced" Required="true" Mask="false">1.0</Config>
@@ -63,7 +63,7 @@
<Config Name="MDK Probe Timeout Seconds" Target="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" Default="20" Mode="" Description="Maximum wait for one read-only MDK GetCapabilities request." Type="Variable" Display="advanced" Required="true" Mask="false">20</Config> <Config Name="MDK Probe Timeout Seconds" Target="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS" Default="20" Mode="" Description="Maximum wait for one read-only MDK GetCapabilities request." Type="Variable" Display="advanced" Required="true" Mask="false">20</Config>
<Config Name="Official Thematic Raster Acquisition" Target="THEMATIC_RASTER_ENABLED" Default="true" Mode="" Description="Allow bounded official Departement Omgeving rasters for space, population, accessibility and services." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config> <Config Name="Official Thematic Raster Acquisition" Target="THEMATIC_RASTER_ENABLED" Default="true" Mode="" Description="Allow bounded official Departement Omgeving rasters for space, population, accessibility and services." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Thematic Raster WCS URL" Target="THEMATIC_RASTER_WCS_URL" Default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs" Mode="" Description="Official public MercatorNet WCS endpoint. Product identifiers remain server allowlisted." Type="Variable" Display="advanced" Required="true" Mask="false">https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs</Config> <Config Name="Thematic Raster WCS URL" Target="THEMATIC_RASTER_WCS_URL" Default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs" Mode="" Description="Official public MercatorNet WCS endpoint. Product identifiers remain server allowlisted." Type="Variable" Display="advanced" Required="true" Mask="false">https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs</Config>
<Config Name="Thematic Raster Maximum Side (m)" Target="THEMATIC_RASTER_MAX_SIDE_M" Default="60000" Mode="" Description="Maximum side length for one allowlisted thematic raster scope; supports the complete Kempen work area while WCS transfers remain tiled." Type="Variable" Display="advanced" Required="true" Mask="false">60000</Config> <Config Name="Thematic Raster Maximum Side (m)" Target="THEMATIC_RASTER_MAX_SIDE_M" Default="60000" Mode="" Description="Maximum side length for one bounded allowlisted thematic raster request; larger national selections must remain partitioned." Type="Variable" Display="advanced" Required="true" Mask="false">60000</Config>
<Config Name="Thematic Raster Maximum Cells" Target="THEMATIC_RASTER_MAX_PIXELS" Default="30000000" Mode="" Description="Maximum raster cells per allowlisted thematic acquisition or selection analysis." Type="Variable" Display="advanced" Required="true" Mask="false">30000000</Config> <Config Name="Thematic Raster Maximum Cells" Target="THEMATIC_RASTER_MAX_PIXELS" Default="30000000" Mode="" Description="Maximum raster cells per allowlisted thematic acquisition or selection analysis." Type="Variable" Display="advanced" Required="true" Mask="false">30000000</Config>
<Config Name="Local Ollama Assistant" Target="OLLAMA_ENABLED" Default="true" Mode="" Description="Enable the source-grounded GeoIntel assistant backed by Ollama on the Unraid host." Type="Variable" Display="always" Required="true" Mask="false">true</Config> <Config Name="Local Ollama Assistant" Target="OLLAMA_ENABLED" Default="true" Mode="" Description="Enable the source-grounded GeoIntel assistant backed by Ollama on the Unraid host." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
<Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config> <Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config>
+3 -1
View File
@@ -14,6 +14,8 @@ GEOINTEL_MODELS_PATH=/mnt/user/appdata/geointel/models
GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
# Internal embedded PostGIS settings. The database is not published to the LAN. # 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_DB=geointel
GEOINTEL_POSTGRES_USER=geointel GEOINTEL_POSTGRES_USER=geointel
GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use 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. # 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 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 GEOINTEL_MAX_UPLOAD_MB=500
# Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS.
+1 -1
View File
@@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 250m; client_max_body_size __GEOINTEL_MAX_UPLOAD_MB__m;
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_send_timeout 600s; proxy_send_timeout 600s;
@@ -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
+37 -2
View File
@@ -12,12 +12,13 @@ if [ -f .env ]; then
fi fi
GEOINTEL_FRONTEND_PORT="${GEOINTEL_FRONTEND_PORT:-1202}" 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_STORAGE_PATH="${GEOINTEL_STORAGE_PATH:-/mnt/user/appdata/geointel/storage}"
GEOINTEL_MODELS_PATH="${GEOINTEL_MODELS_PATH:-/mnt/user/appdata/geointel/models}" 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_POSTGIS_DATA_PATH="${GEOINTEL_POSTGIS_DATA_PATH:-/mnt/user/appdata/geointel/postgres-data}"
GEOINTEL_POSTGRES_DB="${GEOINTEL_POSTGRES_DB:-geointel}" GEOINTEL_POSTGRES_DB="${GEOINTEL_POSTGRES_DB:-geointel}"
GEOINTEL_POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-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_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}" GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}"
ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" 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_MAX_OUTPUT_TOKENS="${OLLAMA_MAX_OUTPUT_TOKENS:-1200}"
OLLAMA_CONTEXT_TOKENS="${OLLAMA_CONTEXT_TOKENS:-16384}" 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() { install_dockerman_metadata() {
if [ -d /boot/config/plugins/dockerMan ]; then if [ -d /boot/config/plugins/dockerMan ]; then
mkdir -p /boot/config/plugins/dockerMan/templates-user /boot/config/plugins/dockerMan/images 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/" cp -a "${compose_volume_path}/." "$GEOINTEL_POSTGIS_DATA_PATH/"
} }
validate_runtime_config
install_dockerman_metadata install_dockerman_metadata
docker compose down --remove-orphans || true docker compose down --remove-orphans || true
@@ -233,6 +268,6 @@ docker run -d \
-v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data" \ -v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data" \
-v "${GEOINTEL_STORAGE_PATH}:/app/storage" \ -v "${GEOINTEL_STORAGE_PATH}:/app/storage" \
-v "${GEOINTEL_MODELS_PATH}:/app/models" \ -v "${GEOINTEL_MODELS_PATH}:/app/models" \
geointel-all-in-one:latest "$GEOINTEL_IMAGE"
docker ps --filter name=geointel docker ps --filter name=geointel
+5
View File
@@ -40,6 +40,11 @@
run reused the same project and dataset identifiers. Browser acceptance then run reused the same project and dataset identifiers. Browser acceptance then
exposed and closed a map guard that had prevented coverage-only selection exposed and closed a map guard that had prevented coverage-only selection
when the chosen theme was honestly unavailable. 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 - Froze the RC geography as all Belgian land plus the separately labelled
territorial sea, EEZ and continental shelf. territorial sea, EEZ and continental shelf.
+3 -53
View File
@@ -34,59 +34,9 @@ git reset --hard "origin/$REMOTE_BRANCH"
git branch -M "$REMOTE_BRANCH" git branch -M "$REMOTE_BRANCH"
chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true
if [ -f .env ]; then FRONTEND_URL="$FRONTEND_URL" \
set -a DEPLOY_GEOINTEL_INSTALL_AI="${DEPLOY_GEOINTEL_INSTALL_AI:-}" \
. ./.env bash deploy/unraid/deploy-release.sh
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
'@ '@
$utf8NoBom = [System.Text.UTF8Encoding]::new($false) $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+3 -54
View File
@@ -35,58 +35,7 @@ git fetch origin "$REMOTE_BRANCH"
git checkout -B "$REMOTE_BRANCH" "origin/$REMOTE_BRANCH" git checkout -B "$REMOTE_BRANCH" "origin/$REMOTE_BRANCH"
chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true
if [ -f .env ]; then FRONTEND_URL="$FRONTEND_URL" \
set -a DEPLOY_GEOINTEL_INSTALL_AI="${DEPLOY_GEOINTEL_INSTALL_AI:-}" \
# shellcheck disable=SC1091 bash deploy/unraid/deploy-release.sh
. ./.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
REMOTE_SCRIPT REMOTE_SCRIPT
+6
View File
@@ -106,6 +106,12 @@ ${PYTHON_BIN} -m compileall backend/app
(cd frontend && npm run typecheck) (cd frontend && npm run typecheck)
(cd frontend && npm run build) (cd frontend && npm run build)
bash -n scripts/live_migration_smoke.sh 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_browser_runtime.sh
bash -n scripts/verify_demo_export_workflow.sh bash -n scripts/verify_demo_export_workflow.sh
bash -n scripts/verify_demo_raster_workflow.sh bash -n scripts/verify_demo_raster_workflow.sh
+61
View File
@@ -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}."