diff --git a/.env.example b/.env.example index 11d788b..8686790 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD MYSQL_PORT=3306 API_PORT=5001 +# Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag, +# which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by +# the connected build template (docker-compose.yml builds from source). +IMAGE_TAG=0.7.0 # ---- Zabbix integration (optional, for printer supply monitoring) ---- ZABBIX_URL= diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml new file mode 100644 index 0000000..d5c426d --- /dev/null +++ b/docker-compose.airgap.yml @@ -0,0 +1,90 @@ +# shopdb-flask AIR-GAPPED single-site stack. +# +# For a site with NO internet. Nothing is built or pulled here: the images are +# built on a connected box (scripts/build-offline-bundle.ps1), shipped as a +# tarball, and `docker load`ed at the site. This file only RUNS pre-loaded +# images. See docs/DEPLOY-AIRGAP.md for the full runbook. +# +# Differences from docker-compose.yml (the connected/build template): +# - api uses `image:` (a loaded image), never `build: .` (build needs the net). +# - NO ./plugins bind mount. The image already carries every plugin baked in; +# binding a host ./plugins (which does not exist at an image-only site) would +# mask the baked plugins with an empty dir and load ZERO plugins. +# - a one-shot `migrate` service runs db upgrade + plugin upgrade-all + seed +# BEFORE api starts, so `up -d` alone brings up a working site (no manual +# `docker compose exec ... flask db upgrade` to forget). +# +# Usage at the site: +# docker load < shopdb-stack-.tar.gz +# cp .env.example .env # then edit: secrets, CORS_ORIGINS, IMAGE_TAG +# docker compose -f docker-compose.airgap.yml up -d +# docker compose -f docker-compose.airgap.yml exec api flask seed admin + +# Shared application environment, reused by the migrate one-shot and the api +# service so the two never drift. A YAML anchor, not a container. +x-app-env: &app-env + FLASK_APP: wsgi.py + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ZABBIX_URL: ${ZABBIX_URL:-} + ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + +services: + db: + image: mysql:8.0 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + # One-shot schema + seed. Runs to completion and exits; api waits for it. + # Every step is idempotent, so it is safe to run on every `up`. + migrate: + image: shopdb-flask:${IMAGE_TAG:-0.7.0} + restart: "no" + depends_on: + db: + condition: service_healthy + environment: + <<: *app-env + command: + - sh + - -c + - > + flask db upgrade && + flask plugin upgrade-all && + flask seed permissions && + flask seed settings && + flask seed reference-data + + api: + image: shopdb-flask:${IMAGE_TAG:-0.7.0} + restart: unless-stopped + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + <<: *app-env + ports: + - "${API_PORT:-5001}:5001" + +volumes: + db_data: diff --git a/docs/DEPLOY-AIRGAP.md b/docs/DEPLOY-AIRGAP.md new file mode 100644 index 0000000..26cd17c --- /dev/null +++ b/docs/DEPLOY-AIRGAP.md @@ -0,0 +1,149 @@ +# Air-gapped Docker deploy + +For a site with **no internet**. The site cannot `pip install`, `npm ci`, or +`docker pull`, so nothing is built or pulled there. You build a fully +self-contained image on a **connected** box, ship it as one tarball, and +`docker load` + run it at the site. + +This is the counterpart to `docker-compose.yml` (the connected/build template). +The air-gapped stack uses `docker-compose.airgap.yml`, which differs in three +ways that matter: + +- **`image:` not `build: .`** - the site runs a loaded image; it never builds. +- **No `./plugins` bind mount** - the image already carries every plugin baked + in. Binding a host `./plugins` (which does not exist at an image-only site) + would mask the baked plugins with an empty dir and load **zero** plugins. +- **A one-shot `migrate` service** runs `db upgrade` + `plugin upgrade-all` + + seed before `api` starts, so `up -d` alone brings up a working site. + +--- + +## 1. Build the bundle (connected box) + +On a box with clean access to Docker Hub + PyPI + npm, from the repo root: + +```powershell +pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0 +``` + +This builds `shopdb-flask:0.7.0` (frontend + all Python deps baked in), pulls +`mysql:8.0`, and writes: + +- `shopdb-stack-0.7.0.tar.gz` - both images in one archive +- `shopdb-stack-0.7.0.tar.gz.sha256` - checksum to verify after transfer + +The image is self-contained: **no pip/npm/registry access is needed at the +site.** + +### Building behind Zscaler + +If the build box is itself behind GE Zscaler, the in-build `pip`/`npm` may fail +with `CERTIFICATE_VERIFY_FAILED` - the build container has its own cert store +and does not trust GE's re-signing root (host `PIP_CERT` / `NODE_EXTRA_CA_CERTS` +do NOT carry into a `docker build`). Easiest: build from a box with clean +internet (home, cloud, or CI). Otherwise the corp root CA must be trusted +**inside** the build (a Dockerfile change to accept the CA - not wired today; +see docs/DEVELOPMENT-SETUP.md section 0b for the all-roots PEM bundle). + +--- + +## 2. Transfer + verify + +Carry `shopdb-stack-0.7.0.tar.gz` (+ the `.sha256`), plus +`docker-compose.airgap.yml` and `.env.example`, to the site on approved media. +Verify the archive survived the trip: + +```powershell +# PowerShell +(Get-FileHash shopdb-stack-0.7.0.tar.gz -Algorithm SHA256).Hash.ToLower() +# compare against the .sha256 file +``` + +```bash +# Linux site +sha256sum -c shopdb-stack-0.7.0.tar.gz.sha256 +``` + +--- + +## 3. Load + run (air-gapped site) + +```bash +# 1) Load both images into the local Docker. +docker load -i shopdb-stack-0.7.0.tar.gz +docker image ls | grep -E 'shopdb-flask|mysql' # confirm both present + +# 2) Configure the site. +cp .env.example .env +# Edit .env - REQUIRED: +# IMAGE_TAG=0.7.0 # MUST match the loaded image tag +# MYSQL_ROOT_PASSWORD=... # strong, unique +# MYSQL_PASSWORD=... # strong, unique (the app's db user) +# SECRET_KEY=... # 32+ random bytes +# JWT_SECRET_KEY=... # 32+ random bytes, different from SECRET_KEY +# CORS_ORIGINS=https://shopdb.site.example # the site's browser origin(s) +# Optional: API_PORT (default 5001), LOG_LEVEL, ZABBIX_URL/ZABBIX_TOKEN. + +# 3) Bring it up. The migrate one-shot runs the schema + seed, then api starts. +docker compose -f docker-compose.airgap.yml up -d + +# 4) Watch the one-shot finish (it exits 0 when the schema + seed are done). +docker compose -f docker-compose.airgap.yml logs -f migrate +``` + +`IMAGE_TAG` in `.env` must equal the loaded tag (`0.7.0` here); otherwise compose +looks for an image that was never loaded and `api`/`migrate` will not start. + +--- + +## 4. Create the first admin + +Seeding creates permissions, settings, and reference data, but not a login. Make +one admin (choose the password; it is not automatable): + +```bash +docker compose -f docker-compose.airgap.yml exec api \ + flask seed admin --username --email --password +# Omit --password to have a strong one generated and printed once. +``` + +--- + +## 5. Verify + +```bash +docker compose -f docker-compose.airgap.yml ps # db + api "running", migrate "exited (0)" +docker compose -f docker-compose.airgap.yml logs api # gunicorn started, no tracebacks +curl -sf http://localhost:${API_PORT:-5001}/api/dashboard/health # or browse the site origin +``` + +Then log in at the site origin with the admin created in step 4. + +--- + +## Upgrading to a new version + +Build a new bundle on the connected box (`-Version 0.8.0`), transfer, then at the +site: + +```bash +docker load -i shopdb-stack-0.8.0.tar.gz +# set IMAGE_TAG=0.8.0 in .env +docker compose -f docker-compose.airgap.yml up -d +``` + +The `migrate` one-shot re-runs `db upgrade` + `plugin upgrade-all` + seed (all +idempotent) against the existing data before the new `api` starts. Back up first +(see docs/BACKUP-RESTORE.md); the `db_data` volume persists across upgrades. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| `service api is not running` / `manifest ... not found` | The image was not loaded, or `IMAGE_TAG` in `.env` does not match a loaded image. `docker image ls`, fix `IMAGE_TAG`. Also: never use `docker-compose.yml` here - its `build: .` needs internet. | +| Site loads but **no plugins / empty nav** | You used the wrong compose file. `docker-compose.yml` bind-mounts `./plugins` (absent here) over the baked plugins. Use `docker-compose.airgap.yml`. | +| `api` never starts, `migrate` shows an error | Read `logs migrate`. A DB-connection error means `db` is not healthy yet (`logs db`) or `MYSQL_PASSWORD` in `.env` differs from what the `db` volume was first initialised with. A fresh site with a stale `db_data` volume needs the volume removed (`docker compose ... down -v` - DESTROYS data). | +| `SECRET_KEY must be set` (and similar) on `up` | A required `.env` var is empty. Fill every REQUIRED key in step 3. | +| Build fails on the connected box at `pip`/`npm` | Zscaler cert - see "Building behind Zscaler" above. | diff --git a/scripts/build-offline-bundle.ps1 b/scripts/build-offline-bundle.ps1 new file mode 100644 index 0000000..5e61367 --- /dev/null +++ b/scripts/build-offline-bundle.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Build the shopdb-flask image on a CONNECTED box and bundle it (plus the MySQL + image) into one tarball for transfer to an air-gapped site. + +.DESCRIPTION + Air-gapped sites cannot pip install / npm ci / docker pull. This builds the + fully self-contained application image where connectivity exists, pulls the + MySQL image the stack needs, and `docker save`s both into a single gzipped + tarball with a SHA-256 checksum. Carry the .tar.gz to the site and follow + docs/DEPLOY-AIRGAP.md (docker load + docker-compose.airgap.yml). + + Run from the repo root. Requires Docker Desktop. The connected build box needs + clean access to Docker Hub + PyPI + npm. If it is behind GE Zscaler and the + build fails on pip/npm SSL (CERTIFICATE_VERIFY_FAILED), build from a box with + clean internet, or trust the corp root CA in the build - see + docs/DEPLOY-AIRGAP.md "Building behind Zscaler". + +.PARAMETER Version + Image tag for shopdb-flask (default 0.7.0). This is the IMAGE_TAG the site + sets in its .env so docker-compose.airgap.yml runs the matching image. + +.PARAMETER MysqlImage + MySQL image the stack runs (default mysql:8.0). Must match db.image in + docker-compose.airgap.yml. + +.PARAMETER OutDir + Where to write the tarball + checksum (default the repo root). + +.PARAMETER SkipBuild + Reuse an already-built shopdb-flask: image; only pull + save. + +.EXAMPLE + pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0 +#> +[CmdletBinding()] +param( + [string]$Version = '0.7.0', + [string]$MysqlImage = 'mysql:8.0', + [string]$OutDir = '.', + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' + +function Assert-LastExit([string]$What) { + if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" } +} + +# Fail early if docker is missing rather than midway through a long build. +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw 'docker not found on PATH. Install Docker Desktop and retry.' +} + +$appImage = "shopdb-flask:$Version" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Push-Location $repoRoot +try { + if (-not $SkipBuild) { + Write-Host "==> Building $appImage" -ForegroundColor Cyan + docker build -t $appImage . + Assert-LastExit 'docker build' + } else { + Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow + } + + Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan + docker pull $MysqlImage + Assert-LastExit 'docker pull' + + if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null } + $stem = "shopdb-stack-$Version" + $tar = Join-Path $OutDir "$stem.tar" + $gz = Join-Path $OutDir "$stem.tar.gz" + + Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan + docker save -o $tar $appImage $MysqlImage + Assert-LastExit 'docker save' + + Write-Host "==> Compressing to $gz" -ForegroundColor Cyan + $inStream = [System.IO.File]::OpenRead($tar) + $outStream = [System.IO.File]::Create($gz) + $gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress) + try { + $inStream.CopyTo($gzip) + } finally { + $gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose() + } + Remove-Item $tar + + $hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower() + $sumFile = "$gz.sha256" + "$hash $stem.tar.gz" | Set-Content -Path $sumFile -NoNewline + $sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1) + + Write-Host '' + Write-Host "Bundle ready: $gz ($sizeMb MB)" -ForegroundColor Green + Write-Host "SHA-256: $hash" + Write-Host "Checksum: $sumFile" + Write-Host '' + Write-Host 'At the air-gapped site (verify the checksum first):' + Write-Host " docker load -i $stem.tar.gz" + Write-Host " # set IMAGE_TAG=$Version in .env, then:" + Write-Host ' docker compose -f docker-compose.airgap.yml up -d' +} +finally { + Pop-Location +}