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