docker: air-gapped deploy kit (image bundle + offline compose + runbook)
Air-gapped sites cannot pip install / npm ci / docker pull, so a build-at-site compose (build: .) fails and reports 'service api is not running'. Add a build-once-ship-image path: - scripts/build-offline-bundle.ps1: on a connected box, build shopdb-flask + pull mysql:8.0, docker save both into one gzipped tarball with a sha256. - docker-compose.airgap.yml: runs pre-loaded images (image:, never build:), drops the ./plugins bind mount (which would mask the image's baked-in plugins with an empty host dir and load zero plugins at an image-only site), and adds a one-shot migrate service (db upgrade + plugin upgrade-all + seed) that api waits on via service_completed_successfully, so 'up -d' brings a working site. - docs/DEPLOY-AIRGAP.md: full runbook (build, transfer+verify, load+run, admin, verify, upgrade, troubleshooting), incl the Zscaler in-build cert caveat. - .env.example: IMAGE_TAG for the air-gap compose to pin the loaded image tag.
This commit is contained in:
@@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
|||||||
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
API_PORT=5001
|
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 integration (optional, for printer supply monitoring) ----
|
||||||
ZABBIX_URL=
|
ZABBIX_URL=
|
||||||
|
|||||||
90
docker-compose.airgap.yml
Normal file
90
docker-compose.airgap.yml
Normal file
@@ -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-<version>.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 <user> <email> <password>
|
||||||
|
|
||||||
|
# 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:
|
||||||
149
docs/DEPLOY-AIRGAP.md
Normal file
149
docs/DEPLOY-AIRGAP.md
Normal file
@@ -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 <username> --email <email> --password <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. |
|
||||||
108
scripts/build-offline-bundle.ps1
Normal file
108
scripts/build-offline-bundle.ps1
Normal file
@@ -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:<Version> 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user