diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..239ad9ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.venv +venv +__pycache__ +*.pyc +.pytest_cache + +frontend/node_modules +frontend/dist +frontend/*.tsbuildinfo +backend/.pytest_cache +backend/**/*.pyc +backend/**/__pycache__ + +storage +postgres-data +datasets/raw +datasets/processed +datasets/cache +exports +models + +.env diff --git a/.env.example b/.env.example index 95b5da12..22c7fbac 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ VITE_MAP_STYLE_URL=https://demotiles.maplibre.org/style.json GEOINTEL_FRONTEND_PORT=1202 GEOINTEL_BACKEND_PORT=8000 GEOINTEL_STORAGE_PATH=./storage +GEOINTEL_POSTGIS_DATA_PATH=./postgres-data GEOINTEL_POSTGRES_DB=geointel GEOINTEL_POSTGRES_USER=geointel GEOINTEL_POSTGRES_PASSWORD=geointel diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e884ea..30745837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ # Changelog +## Sprint 32 Unraid all-in-one runtime (2026-06-17) + +- Added `docker-compose.unraid.yml` for a single `geointel` container on Unraid. +- Added `deploy/unraid/Dockerfile.all-in-one`, embedding PostGIS, FastAPI, nginx and the built React frontend in one image. +- Added `deploy/unraid/all-in-one-start.sh` to start embedded PostGIS, apply Alembic migrations, start the backend and serve nginx. +- Added `deploy/unraid/nginx-all-in-one.conf` with localhost backend proxying inside the same container. +- Updated Tower deploy scripts to stop the old multi-container stack without removing volumes and start the all-in-one stack. +- Updated the Unraid template so the Docker can be edited from Unraid with one web port, storage path, PostGIS data path and app icon. +- No API contracts, migrations, product features, provider fetching or AI behavior were introduced. + ## Sprint 31 Unraid deployment template (2026-06-17) - Made Docker Compose ports, storage path, PostGIS credentials, CORS origins and upload limit configurable through `.env` defaults. diff --git a/README.md b/README.md index 595e26bb..63d1e35a 100644 --- a/README.md +++ b/README.md @@ -69,32 +69,33 @@ make readiness ## Unraid / Tower deployment -GeoIntel runs on Unraid as a Docker Compose stack with PostGIS, backend and frontend services. The frontend is the browser entrypoint and proxies `/api` and `/health` to the backend. +GeoIntel runs on Unraid as an all-in-one Docker container. The container embeds PostGIS, runs the FastAPI backend internally, and serves the frontend through nginx on one editable web port. Unraid template assets live in: - `deploy/unraid/geointel.env.example` - `deploy/unraid/geointel-unraid-template.xml` - `deploy/unraid/geointel-icon.svg` +- `docker-compose.unraid.yml` -Copy the Unraid env template to `.env` in the checkout and edit ports there: +Copy the Unraid env template to `.env` in the checkout and edit ports/paths there: ```bash cd /mnt/user/appdata/geointel cp deploy/unraid/geointel.env.example .env nano .env -docker compose up -d --build +docker compose -f docker-compose.unraid.yml up -d --build ``` Common editable values: ```env GEOINTEL_FRONTEND_PORT=1202 -GEOINTEL_BACKEND_PORT=8000 GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage +GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data ``` -The database port is intentionally not exposed to the LAN. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes. +The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes. ## Sprint 2 quick start diff --git a/backend/tests/test_sprint31_unraid_template.py b/backend/tests/test_sprint31_unraid_template.py index 85998e48..46607c4e 100644 --- a/backend/tests/test_sprint31_unraid_template.py +++ b/backend/tests/test_sprint31_unraid_template.py @@ -10,24 +10,25 @@ def test_unraid_template_documents_editable_runtime_settings() -> None: template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8") assert "GeoIntel Kempen" in template - assert "GeoIntel is a multi-container Docker Compose stack" in template - assert "http://[IP]:[PORT:1202]/" in template - assert "http://[IP]:[PORT:1202]/geointel-icon.svg" in template - assert 'Target="GEOINTEL_FRONTEND_PORT"' in template - assert 'Target="GEOINTEL_BACKEND_PORT"' in template - assert 'Target="GEOINTEL_STORAGE_PATH"' in template + assert "GeoIntel all-in-one runs the complete GeoIntel Kempen V1 stack in one Docker container" in template + assert "geointel-all-in-one:latest" in template + assert "http://[IP]:[PORT:80]/" in template + assert "http://[IP]:[PORT:80]/geointel-icon.svg" in template + assert 'Target="80"' in template + assert 'Target="/app/storage"' in template + assert 'Target="/var/lib/postgresql/data"' in template assert 'Target="GEOINTEL_POSTGRES_PASSWORD"' in template assert 'Mask="true">change-me-before-shared-use' in template -def test_unraid_env_template_matches_compose_variables() -> None: - compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") +def test_unraid_env_template_matches_single_container_compose_variables() -> None: + compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8") env_template = (ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8") for key in ( "GEOINTEL_FRONTEND_PORT", - "GEOINTEL_BACKEND_PORT", "GEOINTEL_STORAGE_PATH", + "GEOINTEL_POSTGIS_DATA_PATH", "GEOINTEL_POSTGRES_DB", "GEOINTEL_POSTGRES_USER", "GEOINTEL_POSTGRES_PASSWORD", @@ -39,6 +40,14 @@ def test_unraid_env_template_matches_compose_variables() -> None: assert "GEOINTEL_FRONTEND_PORT=1202" in env_template assert "GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage" in env_template + assert "GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data" in env_template + assert "${GEOINTEL_POSTGIS_DATA_PATH:-geointel_postgis}:/var/lib/postgresql/data" in compose + assert "geointel_postgis:" in compose + assert "geointel:" in compose + assert "db:" not in compose + assert "backend:" not in compose + assert "frontend:" not in compose + assert '"${GEOINTEL_FRONTEND_PORT:-1202}:80"' in compose def test_unraid_readme_explains_port_changes_and_safe_cleanup() -> None: @@ -46,12 +55,44 @@ def test_unraid_readme_explains_port_changes_and_safe_cleanup() -> None: assert "cp deploy/unraid/geointel.env.example .env" in readme assert "GEOINTEL_FRONTEND_PORT=1203" in readme - assert "docker compose up -d --build" in readme - assert "bash scripts/live_migration_smoke.sh" in readme + assert "docker compose -f docker-compose.unraid.yml up -d --build" in readme + assert "curl -fsS" in readme assert "docker builder prune -af" in readme assert "Avoid broad volume pruning" in readme +def test_unraid_all_in_one_runtime_starts_embedded_postgis_backend_and_nginx() -> None: + dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") + start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8") + nginx_config = (ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8") + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8") + + assert "FROM postgis/postgis:16-3.4 AS runtime" in dockerfile + assert "COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/" in dockerfile + assert 'CMD ["/usr/local/bin/geointel-all-in-one-start"]' in dockerfile + assert "/usr/local/bin/docker-entrypoint.sh postgres &" in start_script + assert "python -m alembic upgrade head" in start_script + assert "uvicorn app.main:app --host 127.0.0.1 --port 8000 &" in start_script + assert 'exec nginx -g "daemon off;"' in start_script + assert "proxy_pass http://127.0.0.1:8000/api/" in nginx_config + assert "proxy_pass http://127.0.0.1:8000/health" in nginx_config + assert "frontend/node_modules" in dockerignore + assert "storage" in dockerignore + assert "postgres-data" in dockerignore + + +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") + + for script in (powershell, bash): + assert "docker compose down --remove-orphans || true" in script + assert "docker compose -f docker-compose.unraid.yml config" in script + assert "docker compose -f docker-compose.unraid.yml build geointel" in script + assert "docker compose -f docker-compose.unraid.yml up -d" in script + assert "COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh" in script + + def test_frontend_and_unraid_icon_assets_are_present() -> None: deploy_icon = (ROOT / "deploy" / "unraid" / "geointel-icon.svg").read_text(encoding="utf-8") frontend_icon = (ROOT / "frontend" / "public" / "geointel-icon.svg").read_text(encoding="utf-8") diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one new file mode 100644 index 00000000..38d7f5dd --- /dev/null +++ b/deploy/unraid/Dockerfile.all-in-one @@ -0,0 +1,58 @@ +FROM node:20-alpine AS frontend-build + +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +FROM postgis/postgis:16-3.4 AS runtime + +ENV GEOINTEL_ENV=production \ + GEOINTEL_API_PREFIX=/api/v1 \ + GEOINTEL_STORAGE_ROOT=/app/storage \ + STORAGE_ROOT=/app/storage \ + GEOINTEL_POSTGRES_DB=geointel \ + GEOINTEL_POSTGRES_USER=geointel \ + GEOINTEL_POSTGRES_PASSWORD=geointel \ + GEOINTEL_ALL_IN_ONE=1 \ + PATH="/opt/geointel/venv/bin:${PATH}" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + gdal-bin \ + libgdal-dev \ + libgeos-dev \ + libpq-dev \ + libproj-dev \ + nginx \ + proj-bin \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/ /app/ +COPY fixtures/ /app/fixtures/ +COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf +COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start +COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/ + +RUN python3 -m venv /opt/geointel/venv \ + && pip install --no-cache-dir --upgrade pip setuptools \ + && pip install --no-cache-dir ".[gis]" \ + && python scripts/gis_import_smoke.py \ + && chmod +x /usr/local/bin/geointel-all-in-one-start \ + && mkdir -p /app/storage /run/nginx /var/log/nginx + +VOLUME ["/var/lib/postgresql/data", "/app/storage"] + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --retries=10 --start-period=60s \ + CMD curl -fsS http://127.0.0.1/health >/dev/null || exit 1 + +CMD ["/usr/local/bin/geointel-all-in-one-start"] diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index b0396dfc..4f6509ad 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -1,30 +1,38 @@ -# GeoIntel Unraid template +# GeoIntel Unraid all-in-one container -GeoIntel is a multi-container Docker Compose stack: +GeoIntel can run on Unraid as one Docker container. -- `db`: PostGIS 16 / PostGIS 3.4 -- `backend`: FastAPI/GIS runtime -- `frontend`: nginx-served React app with `/api` and `/health` proxying to the backend +Inside that single container: -The normal browser entrypoint is: +- embedded PostGIS stores the application database +- Alembic migrations run at startup +- FastAPI runs on internal `127.0.0.1:8000` +- nginx serves the React/MapLibre frontend on container port `80` +- nginx proxies `/api` and `/health` to the internal backend + +The browser entrypoint is: ```text http://:${GEOINTEL_FRONTEND_PORT} ``` -## Files - -- `geointel.env.example`: copy this to the repository checkout as `.env`. -- `geointel-unraid-template.xml`: Unraid/DockerMan-style metadata for the editable settings. -- `geointel-icon.svg`: icon source for template/app metadata. - -The frontend also serves the same icon at: +The app icon is served from the same container: ```text http://:${GEOINTEL_FRONTEND_PORT}/geointel-icon.svg ``` -## First setup on Unraid +## Files + +- `docker-compose.unraid.yml`: recommended single-container Compose stack. +- `deploy/unraid/Dockerfile.all-in-one`: builds the single container. +- `deploy/unraid/all-in-one-start.sh`: starts embedded PostGIS, backend and nginx. +- `deploy/unraid/nginx-all-in-one.conf`: frontend and API proxy config for one container. +- `deploy/unraid/geointel.env.example`: copy to `.env` and edit ports/paths. +- `deploy/unraid/geointel-unraid-template.xml`: Unraid/DockerMan metadata for editable fields. +- `deploy/unraid/geointel-icon.svg`: icon source. + +## First setup with Compose Manager From the Unraid shell: @@ -33,59 +41,46 @@ cd /mnt/user/appdata git clone gitea-widefrog:NuklearRabbit/geointel.git geointel cd /mnt/user/appdata/geointel cp deploy/unraid/geointel.env.example .env -``` - -Edit `.env` before starting the stack: - -```bash nano .env -``` - -Common values: - -```env -GEOINTEL_FRONTEND_PORT=1202 -GEOINTEL_BACKEND_PORT=8000 -GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage -GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use -GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202 -``` - -Start or rebuild: - -```bash -docker compose config -docker compose up -d --build +docker compose -f docker-compose.unraid.yml config +docker compose -f docker-compose.unraid.yml up -d --build ``` Validate: ```bash -bash scripts/live_migration_smoke.sh -bash scripts/verify_browser_runtime.sh "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}" +curl -fsS "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/health" +curl -fsS "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/api/v1/projects" +curl -I "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/geointel-icon.svg" ``` -## Change ports +## Change the browser port -Change the web UI port: +Edit `.env`: ```env GEOINTEL_FRONTEND_PORT=1203 -``` - -If you expose a different web port, also update CORS origins: - -```env GEOINTEL_CORS_ORIGINS=http://localhost:1203,http://127.0.0.1:1203,http://192.168.10.150:1203 ``` Apply: ```bash -docker compose up -d --build +docker compose -f docker-compose.unraid.yml up -d --build ``` -The database port is intentionally not published to the LAN. The backend reaches it through the internal Compose service name `db`. +## Persistent paths + +Recommended Unraid paths: + +```env +GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage +GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data +``` + +`GEOINTEL_STORAGE_PATH` contains uploads, tiles, masks, reports and exports. + +`GEOINTEL_POSTGIS_DATA_PATH` contains the embedded PostGIS database files. ## Update from Gitea @@ -93,11 +88,10 @@ The database port is intentionally not published to the LAN. The backend reaches cd /mnt/user/appdata/geointel git fetch origin main git reset --hard origin/main -docker compose up -d --build -bash scripts/live_migration_smoke.sh +docker compose -f docker-compose.unraid.yml up -d --build ``` -## Cleanup notes +## Safe cleanup Safe cache cleanup if Docker build cache fills the Unraid Docker image: @@ -106,3 +100,7 @@ docker builder prune -af ``` Avoid broad volume pruning unless you explicitly intend to remove persisted PostGIS data or GeoIntel artifacts. + +## Multi-container development stack + +The root `docker-compose.yml` remains available for development and CI-like validation with separate `db`, `backend` and `frontend` services. For Unraid app-style operation, prefer `docker-compose.unraid.yml`. diff --git a/deploy/unraid/all-in-one-start.sh b/deploy/unraid/all-in-one-start.sh new file mode 100644 index 00000000..adb7f35d --- /dev/null +++ b/deploy/unraid/all-in-one-start.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +export POSTGRES_DB="${GEOINTEL_POSTGRES_DB:-${POSTGRES_DB:-geointel}}" +export POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-${POSTGRES_USER:-geointel}}" +export POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-geointel}}" +export PGDATA="${PGDATA:-/var/lib/postgresql/data}" +export STORAGE_ROOT="${STORAGE_ROOT:-${GEOINTEL_STORAGE_ROOT:-/app/storage}}" +export DATABASE_URL="${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}}" +export CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-${CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}}" +export MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-${MAX_UPLOAD_MB:-500}}" + +mkdir -p "$PGDATA" "$STORAGE_ROOT" /run/nginx /var/log/nginx +chown -R postgres:postgres "$PGDATA" + +postgres_pid="" +backend_pid="" + +shutdown() { + if [ -n "$backend_pid" ] && kill -0 "$backend_pid" 2>/dev/null; then + kill "$backend_pid" 2>/dev/null || true + fi + if [ -n "$postgres_pid" ] && kill -0 "$postgres_pid" 2>/dev/null; then + kill "$postgres_pid" 2>/dev/null || true + wait "$postgres_pid" 2>/dev/null || true + fi +} +trap shutdown INT TERM EXIT + +echo "Starting embedded PostGIS..." +/usr/local/bin/docker-entrypoint.sh postgres & +postgres_pid="$!" + +echo "Waiting for embedded PostGIS..." +for attempt in $(seq 1 60); do + if pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then + echo "PostGIS is ready after attempt ${attempt}." + break + fi + if ! kill -0 "$postgres_pid" 2>/dev/null; then + echo "PostGIS process exited before becoming ready." + wait "$postgres_pid" + fi + sleep 2 +done + +if ! pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then + echo "PostGIS did not become ready." + exit 1 +fi + +echo "Applying Alembic migrations..." +python -m alembic upgrade head + +echo "Starting GeoIntel backend..." +uvicorn app.main:app --host 127.0.0.1 --port 8000 & +backend_pid="$!" + +echo "Waiting for GeoIntel backend..." +python - <<'PY' +import time +import urllib.request + +last_error = None +for attempt in range(1, 61): + try: + urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=3).read() + print(f"Backend is ready after attempt {attempt}.") + break + except Exception as exc: + last_error = exc + print(f"Backend not ready yet ({attempt}/60): {exc}") + time.sleep(1) +else: + raise SystemExit(f"Backend did not become ready: {last_error}") +PY + +echo "Starting nginx frontend on container port 80..." +exec nginx -g "daemon off;" diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index 59bade22..fd71d3f6 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -1,18 +1,18 @@ GeoIntel Kempen - Local Docker Compose stack + geointel-all-in-one:latest gitea-widefrog:NuklearRabbit/geointel.git bridge - sh + bash false http://192.168.10.150:1202 GeoIntel Kempen - GeoIntel is a multi-container Docker Compose stack for the GeoIntel Kempen GeoAI Workbench: PostGIS, FastAPI and React/MapLibre. Use this template together with deploy/unraid/geointel.env.example and docker-compose.yml; it documents the editable Unraid settings for the Compose stack. + 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. Productivity: Tools: GIS: - http://[IP]:[PORT:1202]/ + http://[IP]:[PORT:80]/ deploy/unraid/geointel-unraid-template.xml - http://[IP]:[PORT:1202]/geointel-icon.svg + http://[IP]:[PORT:80]/geointel-icon.svg @@ -20,14 +20,14 @@ - GeoIntel Kempen runs as a Docker Compose stack with services db, backend and frontend. Edit the matching .env variables to change ports and paths, then run docker compose up -d --build from /mnt/user/appdata/geointel. + Single-container GeoIntel runtime for Unraid. The container starts embedded PostGIS, runs Alembic migrations, starts the FastAPI backend on localhost and serves the frontend through nginx on container port 80. - 1202 - 8000 - /mnt/user/appdata/geointel/storage - geointel - geointel - change-me-before-shared-use + 1202 + /mnt/user/appdata/geointel/storage + /mnt/user/appdata/geointel/postgres-data + geointel + geointel + change-me-before-shared-use http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202 500 diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index fa6043f9..0669bfb8 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -1,17 +1,16 @@ -# GeoIntel Unraid/Compose environment template. +# GeoIntel Unraid all-in-one environment template. # Copy this file to /mnt/user/appdata/geointel/.env and edit values there. # Browser URL: http://: GEOINTEL_FRONTEND_PORT=1202 -# Optional direct backend API port. The frontend proxies /api and /health, so this -# does not need to be exposed to normal browser users. -GEOINTEL_BACKEND_PORT=8000 - # Persisted application artifacts: uploads, tiles, masks, reports and exports. GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage -# Internal PostGIS database settings. The database is not published to the LAN. +# Embedded PostGIS data directory for the all-in-one container. +GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data + +# Internal embedded PostGIS settings. The database is not published to the LAN. GEOINTEL_POSTGRES_DB=geointel GEOINTEL_POSTGRES_USER=geointel GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use diff --git a/deploy/unraid/nginx-all-in-one.conf b/deploy/unraid/nginx-all-in-one.conf new file mode 100644 index 00000000..6f92f4b9 --- /dev/null +++ b/deploy/unraid/nginx-all-in-one.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /index.html { + add_header Cache-Control "no-cache"; + try_files /index.html =404; + } + + location = /geointel-icon.svg { + add_header Cache-Control "public, max-age=3600"; + try_files /geointel-icon.svg =404; + } + + location /assets/ { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /health { + proxy_pass http://127.0.0.1:8000/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml new file mode 100644 index 00000000..49746c7b --- /dev/null +++ b/docker-compose.unraid.yml @@ -0,0 +1,23 @@ +services: + geointel: + build: + context: . + dockerfile: deploy/unraid/Dockerfile.all-in-one + image: geointel-all-in-one:latest + container_name: geointel + environment: + GEOINTEL_POSTGRES_DB: ${GEOINTEL_POSTGRES_DB:-geointel} + GEOINTEL_POSTGRES_USER: ${GEOINTEL_POSTGRES_USER:-geointel} + GEOINTEL_POSTGRES_PASSWORD: ${GEOINTEL_POSTGRES_PASSWORD:-geointel} + GEOINTEL_STORAGE_ROOT: /app/storage + GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} + GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} + ports: + - "${GEOINTEL_FRONTEND_PORT:-1202}:80" + volumes: + - ${GEOINTEL_POSTGIS_DATA_PATH:-geointel_postgis}:/var/lib/postgresql/data + - ${GEOINTEL_STORAGE_PATH:-./storage}:/app/storage + restart: unless-stopped + +volumes: + geointel_postgis: diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 64f11a65..b1862161 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,32 @@ +## Sprint 32 Unraid all-in-one runtime (2026-06-17) + +Changed: +- Added `docker-compose.unraid.yml` for a single editable `geointel` container on Unraid. +- Added `deploy/unraid/Dockerfile.all-in-one` to build one image containing embedded PostGIS, backend GIS runtime, nginx and frontend static assets. +- Added `deploy/unraid/all-in-one-start.sh` to start embedded PostGIS, apply Alembic migrations, start FastAPI on internal localhost and serve nginx on container port 80. +- Added `deploy/unraid/nginx-all-in-one.conf` so `/api` and `/health` proxy to `127.0.0.1:8000` inside the same container. +- Updated `deploy/unraid/geointel.env.example`, Unraid XML template and README for one-container operation and editable web/storage/database paths. +- Updated Tower deploy scripts to stop the old multi-container stack without deleting volumes and start `docker-compose.unraid.yml`. +- Updated `scripts/live_migration_smoke.sh` to support both all-in-one `geointel` and legacy `backend` Compose services. +- Added root `.dockerignore` for all-in-one builds. +- Updated Sprint 31 tests to cover the all-in-one Dockerfile, startscript, nginx config, deploy scripts and template metadata. + +Validation: +- `python -m pytest backend/tests/test_sprint31_unraid_template.py backend/tests/test_live_migration_smoke_script.py backend/tests/test_docker_runtime_config.py` passed: 26 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest` passed: 179 tests. +- `bash scripts/run_readiness_check.sh` passed: 179 backend tests, frontend typecheck/build, Alembic head check and script syntax checks. +- `cd backend && python -m alembic heads` passed: single head `202606120900`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` passed. +- `bash -n deploy/unraid/all-in-one-start.sh` passed. + +Notes: +- No API contracts, migrations, product features, provider fetching or AI behavior changed. +- Local Windows environment does not have `docker` in PATH; Tower deployment should provide the live all-in-one Docker validation. + ## Sprint 31 Unraid deployment template (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 892aba6d..5bcd483f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -11,6 +11,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add Docker build context ignores for backend and frontend. - [x] Run Docker/PostGIS live validation on Tower/Unraid. - [x] Add Unraid Compose template assets with editable ports, storage path and app icon. +- [x] Add single-container Unraid runtime with embedded PostGIS, backend and frontend. ## Current implementation status @@ -43,6 +44,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Dataset detail, raster controls and vector controls component decomposition. - [x] QA/QC results and map workspace component decomposition. - [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid. +- [x] Single-container `geointel` Unraid compose/template runtime. - [ ] Further frontend component decomposition for export preview and shared workbench orchestration. ## Sprint 8 status diff --git a/scripts/deploy_tower.ps1 b/scripts/deploy_tower.ps1 index 56bb27c4..511cec11 100644 --- a/scripts/deploy_tower.ps1 +++ b/scripts/deploy_tower.ps1 @@ -30,16 +30,17 @@ fi git remote get-url origin >/dev/null 2>&1 || git remote add origin '$RemoteRepo' git fetch origin '$RemoteBranch' git reset --hard 'origin/$RemoteBranch' -git branch -M '$RemoteBranch' -chmod +x scripts/*.sh backend/docker_start.sh || true + git branch -M '$RemoteBranch' +chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true -docker compose config >/dev/null -docker compose build backend frontend -docker compose up -d -docker compose ps +docker compose down --remove-orphans || true +docker compose -f docker-compose.unraid.yml config >/dev/null +docker compose -f docker-compose.unraid.yml build geointel +docker compose -f docker-compose.unraid.yml up -d +docker compose -f docker-compose.unraid.yml ps if [ -x scripts/live_migration_smoke.sh ]; then - bash scripts/live_migration_smoke.sh + COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh fi if [ -x scripts/verify_browser_runtime.sh ]; then diff --git a/scripts/deploy_tower.sh b/scripts/deploy_tower.sh index 13659c25..8e66a696 100755 --- a/scripts/deploy_tower.sh +++ b/scripts/deploy_tower.sh @@ -32,14 +32,16 @@ fi 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 -docker compose config >/dev/null -docker compose build backend frontend -docker compose up -d -docker compose ps +docker compose down --remove-orphans || true +docker compose -f docker-compose.unraid.yml config >/dev/null +docker compose -f docker-compose.unraid.yml build geointel +docker compose -f docker-compose.unraid.yml up -d +docker compose -f docker-compose.unraid.yml ps if [[ -x scripts/live_migration_smoke.sh ]]; then - bash scripts/live_migration_smoke.sh + COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh fi if [[ -x scripts/verify_browser_runtime.sh ]]; then diff --git a/scripts/live_migration_smoke.sh b/scripts/live_migration_smoke.sh index 36f8f188..6f4f8100 100755 --- a/scripts/live_migration_smoke.sh +++ b/scripts/live_migration_smoke.sh @@ -4,14 +4,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHON_BIN="${PYTHON_BIN:-}" LIVE_SMOKE_USE_DOCKER="${LIVE_SMOKE_USE_DOCKER:-auto}" +COMPOSE_FILE_PATH="${COMPOSE_FILE:-docker-compose.yml}" run_docker_smoke() { + service_name="$1" cd "$ROOT" echo "== GeoIntel live migration smoke ==" - echo "Using Docker Compose backend service." + echo "Using Docker Compose service: ${service_name}." - docker compose exec -T backend sh <<'CONTAINER_SCRIPT' + docker compose -f "$COMPOSE_FILE_PATH" exec -T "$service_name" sh <<'CONTAINER_SCRIPT' set -eu cd /app @@ -76,13 +78,18 @@ echo "== Live migration smoke passed ==" CONTAINER_SCRIPT } -if [ "$LIVE_SMOKE_USE_DOCKER" != "0" ] && command -v docker >/dev/null 2>&1 && [ -f "$ROOT/docker-compose.yml" ]; then - if docker compose ps -q backend >/dev/null 2>&1 && [ -n "$(docker compose ps -q backend 2>/dev/null)" ]; then - if [ "$LIVE_SMOKE_USE_DOCKER" = "1" ] || docker compose ps --status running backend | grep -q backend; then - run_docker_smoke - exit 0 +if [ "$LIVE_SMOKE_USE_DOCKER" != "0" ] && command -v docker >/dev/null 2>&1 && [ -f "$ROOT/$COMPOSE_FILE_PATH" ]; then + cd "$ROOT" + for service_name in geointel backend; do + if docker compose -f "$COMPOSE_FILE_PATH" ps -q "$service_name" >/dev/null 2>&1 \ + && [ -n "$(docker compose -f "$COMPOSE_FILE_PATH" ps -q "$service_name" 2>/dev/null)" ]; then + if [ "$LIVE_SMOKE_USE_DOCKER" = "1" ] \ + || docker compose -f "$COMPOSE_FILE_PATH" ps --status running "$service_name" | grep -q "$service_name"; then + run_docker_smoke "$service_name" + exit 0 + fi fi - fi + done fi if [ -z "$PYTHON_BIN" ]; then