Skip to content

feat: air-gapped install - #114

Open
dawidaksamski wants to merge 13 commits into
feat/WB-596-connection-configfrom
feat/WB-596-air-gapped-install
Open

feat: air-gapped install#114
dawidaksamski wants to merge 13 commits into
feat/WB-596-connection-configfrom
feat/WB-596-air-gapped-install

Conversation

@dawidaksamski

@dawidaksamski dawidaksamski commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

  • The Dockerfile enforces its own offline boundary: every RUN after
    pnpm fetch is --network=none, so BuildKit cuts egress for the whole
    step — installs, lifecycle scripts and build commands alike (--offline
    on pnpm alone only stopped pnpm's resolver). Only pulling base images,
    installing pnpm itself, and pnpm fetch touch the registry.
  • tools/check-offline-build.mjs guards that boundary: it fails on a
    post-fetch RUN without the flag, anything chained onto the fetch step,
    any other --network= value, and a # syntax= directive. Wired into
    pnpm check, lint-staged and PR Check.
  • No # syntax= line: it downloaded the BuildKit frontend from Docker Hub
    by floating tag, an unpinned fourth network touch. Docker Engine 23+
    provides the frontend built in.
  • useNodeVersion is stripped inside the image — pnpm would otherwise
    download its own Node from nodejs.org, pointless on the pinned base image
    and fatal without network. Local dev keeps managed Node.
  • deploy/ai-studio/pack-offline.sh produces the bundle: build --pull,
    pull infra images, save one tarball, write its sha256 and a
    docker image inspect manifest, copy the compose files and
    .env.example. It ignores the packer's local .env and refuses an output
    directory inside the checkout (the repo root is the build context).
  • Images pinned to exact version tags (Postgres to 16.15). Digests were
    deliberately not used: they freeze base-image security patches with no
    Renovate to bump them, and docker save/load drops digest references so
    compose could not resolve them on the host. The manifest records the
    digests that shipped.
  • apps/icons: pinned @svgr/cli devDependency replaces the npx
    shell-out; lockfile diff confined to that graph.
  • Zero egress made exact: the SPA no longer loads Poppins from Google Fonts
    (the SDK stylesheet bundles it; a test guards index.html), and the README
    names the three optional runtime paths with the setting that closes each —
    AI_BASE_URL, TAVILY_API_KEY, Turnstile (always closed here).
  • README: "Air-gapped / offline install" walkthrough — pack connected, verify
    the checksum, docker load, up -d --no-build, how to connect. States the
    supported model (prebuilt images only; no registry mirror or .npmrc,
    superseding WB-601's original path) and the platform rule (build for the
    destination; esbuild/swc are per-platform, the Temporal core bundles all).
  • Runtime image still carries the whole monorepo store from pnpm fetch
    (pre-existing, ~1.5 GB) — tracked as WB-631, marked in the Dockerfile.

Verification

  • docker compose build of both targets with no env vars set; the log shows
    zero frontend image resolutions. Egress under --network=none confirmed
    blocked (DNS fails) on the built-in frontend.
  • Cache-mount independence: with pnpm fetch served from layer cache and
    the pnpm-store mount pruned, both --offline installs still pass — the
    fetch layer materialises the virtual store.
  • Guard fails on five mutation fixtures (flag inside the command, chained
    fetch, escaped trailing backslash, duplicate --network, # syntax=) and
    passes on the real file.
  • pack-offline.sh end to end with a decoy .env present: five-image
    tarball, checksum verifies. Host simulation: infra images removed, tarball
    loaded, docker compose up --no-build --dry-run creates every container
    without a pull.
  • Built SPA contains no external loads; ai-studio lint, typecheck and tests
    pass; lockfile regenerates to a fixpoint and --frozen-lockfile accepts it.
  • Full compose stack smoke-tested keyless: /api/health ok, SPA 200, worker
    RUNNING with zero restarts.

After deploy

Instruction on what to update once w decide to deploy the AI Studio:
https://claude.ai/code/artifact/8aa970be-58fc-4e92-8bbc-d30ee07c2cbb?via=auto_preview

@dawidaksamski dawidaksamski self-assigned this Sep 2, 2026
@dawidaksamski
dawidaksamski marked this pull request as ready for review September 2, 2026 11:05
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-connection-config branch from d348521 to cf4f5de Compare September 3, 2026 11:01
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-air-gapped-install branch from 8c7f3b7 to 1381f71 Compare September 3, 2026 12:02
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-connection-config branch from cf4f5de to 168c5d9 Compare September 4, 2026 11:50
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-air-gapped-install branch 3 times, most recently from a5727ba to d6c149d Compare September 8, 2026 14:42
`pnpm install --offline` only stops pnpm's own resolver; lifecycle scripts
and build commands still ran with network access, so a script could download
outside pnpm and the ordinary build would pass. Every RUN after `pnpm fetch`
now carries --network=none, which makes BuildKit block egress for the whole
step. Only base-image pulls, the pnpm bootstrap and `pnpm fetch` keep access.

Add tools/check-offline-build.mjs, which fails when a post-fetch RUN loses
the flag, and run it from `pnpm check`, lint-staged and pr-check.yml. Both
images build under the new flags; a whole-build `docker build --network none`
is not the test because bootstrap and fetch need the registry by design.
…inor tag

The air-gap procedure was four hand-typed commands and called the infra
images pinned while postgres:16 floated across every 16.x release. Pin it to
16.15 in both compose files and add pack-offline.sh, which builds, pulls,
saves one tarball and writes its sha256 plus a docker image inspect manifest,
so a shipped bundle is verifiable and traceable to the exact images it holds.

Tags stay tags: digest pins would freeze base-image security patches with no
Renovate to bump them, and docker save/load drops digest references, so
digest-pinned compose fields could never resolve against loaded images.
The air-gap section described the image bundle without saying it is the one
supported model. Record the scope decision: prebuilt-image transport only,
no registry mirror procedure and no .npmrc. Explain why the bundle is
platform-specific (base images plus the esbuild and swc native packages pnpm
selects at install; the Temporal core is not a constraint, one package carries
every platform's binary) and make "build for the destination platform" a rule
with the manifest's platform column as the check.
…ress conditions

The README called an internal AI_BASE_URL "zero egress" while the deployed SPA
still fetched Poppins from fonts.googleapis.com on every load, and the worker
had a fixed Tavily endpoint behind TAVILY_API_KEY. Remove the CDN links: the
SDK stylesheet already inlines Poppins 300-700, and a test now guards
index.html against external references. Document the three runtime egress
paths (LLM endpoint, Tavily, Turnstile) with the setting that closes each.
…d context

pack-offline.sh defaulted to a directory inside deploy/ai-studio, and the README
pointed at the repo root, which is the build context of both images: the next
`docker compose build` would copy a 1 GB tarball into the runtime image, and
git would show the bundle as untracked. Resolve the output path before the cd,
default to ~/ai-studio-offline, refuse any path under the checkout, and ignore
the directory and *.tar in .gitignore and .dockerignore as a second line.
`# syntax=docker/dockerfile:1` made every build download and run the Dockerfile
parser from a floating tag: a fourth network touch the README did not count,
and the one build input with no pin at all. Docker Engine 23+ ships a built-in
frontend that supports RUN --mount and RUN --network, so drop the directive,
have check-offline-build.mjs reject it if it comes back, and document the
Docker version the packing machine needs.
The runtime stage inherits pnpm fetch's whole-lockfile virtual store. Record
the limitation and the pnpm deploy direction at the stage instead of leaving
it to be rediscovered.
…from .env

check-offline-build.mjs accepted --network=none anywhere in the RUN text,
never checked the pnpm fetch step itself, and treated an escaped trailing
backslash as a continuation, so a chained command, a flag inside the shell
command, or a hidden RUN all passed. Check only the instruction's own flags,
require the boundary step to be exactly `pnpm fetch`, and count trailing
backslashes.

pack-offline.sh ran compose with the developer's ./.env: COMPOSE_FILE could
drop Temporal from the bundle while the shipped override still expected it,
and RUNTIME_IMAGE renamed what got saved. Build from .env.example defaults via
--env-file /dev/null, and pull base images so a stale local tag never ships.
…ild context

Fix drift the review found: the "What runs" table still said postgres:16, nginx
was called an exact pin, the Turnstile row implied an operator choice that does
not exist, Poppins was described as inline CSS while the SPA serves font files,
and the pnpm bootstrap comment called itself the only step needing the registry.
Shorten the Dockerfile comments to the three-line ceiling, isolate the first sed
step too, and drop the ticket IDs from the README and compose headers.

CLAUDE.md claimed knip runs in `pnpm check`; it never did. .dockerignore now
excludes npm/yarn auth files and key material, matches compose files at any
depth, and loses a negation for a directory that does not exist.
…ed LLM key

OPENROUTER_API_KEY was renamed to AI_API_KEY and AI_BASE_URL became required,
but a stale apps/backend/.env or apps/execution-worker/.env fails silently: the
apps boot with AI off and AI nodes report ai_not_configured. Preflight now
warns on the retired name, or on AI_MODEL without AI_API_KEY and AI_BASE_URL,
reading key names only. CLAUDE.md tells Claude to run preflight after a pull
and offer the migration without touching the key unasked.
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-air-gapped-install branch from d6c149d to c931bfb Compare September 8, 2026 15:23
@dawidaksamski
dawidaksamski removed this pull request from stack #115 September 9, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants