Skip to content

feat: air-gapped install - #114

Open
dawidaksamski wants to merge 47 commits into
mainfrom
feat/WB-596-air-gapped-install
Open

dawidaksamski wants to merge 47 commits into
mainfrom
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
OPENROUTER_API_KEY is no longer read. As an unconditional fallback for
AI_API_KEY it was sent as a bearer token to whatever AI_BASE_URL pointed
at, so an old .env plus a repointed endpoint leaked the OpenRouter
credential. There are no external deployments to keep compatible;
rename the variable instead.

AI_BASE_URL and AI_MODEL lose their code and compose defaults too, so
nothing in the code points outside the network. The OpenRouter values
live in .env.example only. AI is configured when all three AI_* vars are
set; otherwise the worker boots and names the missing ones, AI Agent
nodes fail with ai_not_configured, and the adapt route returns 501.
Missing AI configuration cannot recover on retry, yet the plain
NodeExecutionError was retried once and lost its code crossing the
activity boundary — node_failed carried only the message. Thrown as
PermanentNodeExecutionError it stops on the first attempt and the code
survives via the classified-error envelope.

Adds a test through a real Temporal dev server asserting the
node_failed code, a single attempt, and the workflow's failure type;
the unclassified path is pinned alongside as the contrast.
The apps already read TEMPORAL_TLS_CA_PATH / _CERT_PATH / _KEY_PATH, but
compose passed none of them and the docs told users to edit the
manifest. Both services now take every TEMPORAL_* variable from one
shared YAML block, so they cannot drift, and mount ./tls (override via
TEMPORAL_TLS_DIR) read-only at /etc/workflowbuilder/tls. The directory
ships empty with a .gitignore so PEMs never reach git.
Setting TEMPORAL_ADDRESS to an operated cluster or Temporal Cloud still
started temporal and temporal-db, and the apps' depends_on edges let that
unused stack block them. The bundled cluster, its volume, its debug UI and
the start-order edges now live in docker-compose.override.yml, applied by
default; COMPOSE_FILE=docker-compose.yml in .env leaves it out, so the
apps depend only on app-db. The debug UI is documented as showing the
bundled cluster only.
loadEnv only stubbed the values a case supplied, so variables inherited
from the shell leaked into the fresh module and cases asserting "unset"
tested whatever the runner happened to carry. Every variable env.ts reads
is now unset before each import, derived from the module's own keys so a
new one cannot be missed, and restored afterwards.
The standalone quick start covered AI_BASE_URL and keyless startup but
none of the Temporal connection variables. Adds the namespace, TLS,
API-key and mTLS table with the same semantics as the backend README,
plus Temporal Cloud and private-CA examples. Also corrects the LLM
section, which still described a built-in OpenRouter default, and moves
the env snippets to the dotenv grammar the highlighter actually has.
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-connection-config branch from cf4f5de to 168c5d9 Compare September 4, 2026 11:50
…the VM

The workflow drove a VM-local docker-compose.yml with -f, which disables
the automatic override and ignores COMPOSE_FILE — after the bundled
cluster moved into docker-compose.override.yml the demo VM would have
run without Temporal. The deploy step now copies both compose files from
the repo on every run, executes compose from /app/ai-studio, and passes
the pushed tags as RUNTIME_IMAGE / WEB_IMAGE, so one compose file serves
local builds and the VM.
The build context is the repo root and .dockerignore excluded only .env
files, so PEMs dropped into deploy/ai-studio/tls per the mTLS docs were
copied into the runtime image by `COPY . .`. The directory is now
excluded; the files reach the containers through the read-only mount
only.
The examples carried the placeholder `sk-or-...`, which envOptional
treats as a configured key: a verbatim copy skipped the boot warning and
sent requests to OpenRouter with a bogus token, surfacing a provider 401
instead of the documented ai_not_configured / 501 paths. The value is now
empty and the key format lives in the comment.
The Full Stack Demo section still described AI_BASE_URL as defaulting to
OpenRouter and listed a two-variable setup, contradicting the code and
the docs site. It now mirrors the docs page: three variables, pre-filled
by setup:env, no built-in default.
Compose no longer passes the retired variable, so a pre-rename .env came
up with AI silently off and only a warn-level log to explain it. A
compose-level guard now fails interpolation with a message naming the
rename and the two new variables; the README and .env.example carry the
upgrade note.
The troubleshooting row claimed the backend exits on a contradictory
TEMPORAL_* setup; it connects on first use, so it boots, passes its
healthcheck and fails on the first Play. Also aligns wording across the
READMEs, .env.example files and docs page with the code: no built-in
LLM default, any credential implies TLS, provider-neutral phrasing.
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-air-gapped-install branch 2 times, most recently from 10eb079 to f381195 Compare September 7, 2026 08:02
Only deploy/ai-studio/tls was dockerignored, so TEMPORAL_TLS_DIR=./certs
with a key inside the checkout was copied into the runtime image by
COPY . . on a local build.

Exclude deploy/ as a whole (re-including only deploy/ai-studio/nginx,
the one file the Dockerfile copies) and *.pem/*.key/*.crt/*.cer/*.p12/
*.pfx repo-wide. Document that TEMPORAL_TLS_DIR supports exactly ./tls or
a directory outside the checkout.

Verified with control files: none of the in-repo locations reach the
build context; nginx/default.conf and a repo-root positive control do.
…ons apart

The rule that AI_API_KEY, AI_BASE_URL and AI_MODEL must be set together
lived three times: the backend's adapt route, the worker's executor
factory and its startup warning. Both apps also normalised empty values
on their own.

Add a private source-only workspace, @workflow-builder/ai-config, whose
aiConfig() returns a complete config or the names of the missing
variables, and make all three sites read it. What each app does when AI
is unavailable is unchanged and stays in the app: the backend answers 501
after authorization and the guard, the worker boots and fails an AI Agent
node with permanent ai_not_configured only when a run reaches it.

The package README is the canonical description of the contract; both
app READMEs and .env.example files point at it. The first-run guides
named a template that does not exist and invited Play before the LLM
section; they now say every bundled template has AI Agent nodes and what
to expect without an LLM.
Several docs said an internal AI_BASE_URL keeps all traffic in your
network, or that nothing in the code points outside it. The optional
web-search tool calls Tavily's API whenever TAVILY_API_KEY is set, a node
enables search and the model invokes the tool, regardless of where the
model runs.

Say "model requests stay inside it" instead, note that the Tavily key
must stay unset if nothing may call out, and remind readers that Temporal
and the database go wherever their addresses point. Wording only; no
behaviour changed.
Remove the file header on the AI agent executor factory that repeated
what its name and body already say. The comments explaining the real
contracts stay: TEMPORAL_TLS is tri-state on purpose, and the AI
configuration error is deferred to node execution so a keyless worker
still boots. Rename the certificate reader readPemFile so call sites say
what kind of file they read.
@dawidaksamski
dawidaksamski force-pushed the feat/WB-596-air-gapped-install branch from a5727ba to d6c149d Compare September 8, 2026 14:42
@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
`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 c931bfb to ab74f64 Compare September 15, 2026 11:54
Base automatically changed from feat/WB-596-connection-config to main September 15, 2026 13:41
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