feat: air-gapped install - #114
Open
dawidaksamski wants to merge 47 commits into
Open
dawidaksamski wants to merge 47 commits into
dawidaksamski wants to merge 47 commits into
Conversation
dawidaksamski
marked this pull request as ready for review
September 2, 2026 11:05
dawidaksamski
requested review from
librowski,
lukasz-jazwa,
piotrblaszczyk and
szymon-t-sc
as code owners
September 2, 2026 11:05
dawidaksamski
force-pushed
the
feat/WB-596-connection-config
branch
from
September 3, 2026 11:01
d348521 to
cf4f5de
Compare
dawidaksamski
force-pushed
the
feat/WB-596-air-gapped-install
branch
from
September 3, 2026 12:02
8c7f3b7 to
1381f71
Compare
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
force-pushed
the
feat/WB-596-connection-config
branch
from
September 4, 2026 11:50
cf4f5de to
168c5d9
Compare
…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
force-pushed
the
feat/WB-596-air-gapped-install
branch
2 times, most recently
from
September 7, 2026 08:02
10eb079 to
f381195
Compare
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
force-pushed
the
feat/WB-596-air-gapped-install
branch
from
September 8, 2026 14:42
a5727ba to
d6c149d
Compare
dawidaksamski
force-pushed
the
feat/WB-596-air-gapped-install
branch
from
September 8, 2026 15:23
d6c149d to
c931bfb
Compare
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.
…ack-offline from the shell env
dawidaksamski
force-pushed
the
feat/WB-596-air-gapped-install
branch
from
September 15, 2026 11:54
c931bfb to
ab74f64
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
RUNafterpnpm fetchis--network=none, so BuildKit cuts egress for the wholestep — installs, lifecycle scripts and build commands alike (
--offlineon pnpm alone only stopped pnpm's resolver). Only pulling base images,
installing pnpm itself, and
pnpm fetchtouch the registry.tools/check-offline-build.mjsguards that boundary: it fails on apost-fetch
RUNwithout the flag, anything chained onto the fetch step,any other
--network=value, and a# syntax=directive. Wired intopnpm check, lint-staged and PR Check.# syntax=line: it downloaded the BuildKit frontend from Docker Hubby floating tag, an unpinned fourth network touch. Docker Engine 23+
provides the frontend built in.
useNodeVersionis stripped inside the image — pnpm would otherwisedownload 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.shproduces the bundle:build --pull,pull infra images, save one tarball, write its sha256 and a
docker image inspectmanifest, copy the compose files and.env.example. It ignores the packer's local.envand refuses an outputdirectory inside the checkout (the repo root is the build context).
16.15). Digests weredeliberately not used: they freeze base-image security patches with no
Renovate to bump them, and
docker save/loaddrops digest references socompose could not resolve them on the host. The manifest records the
digests that shipped.
apps/icons: pinned@svgr/clidevDependency replaces thenpxshell-out; lockfile diff confined to that graph.
(the SDK stylesheet bundles it; a test guards
index.html), and the READMEnames the three optional runtime paths with the setting that closes each —
AI_BASE_URL,TAVILY_API_KEY, Turnstile (always closed here).the checksum,
docker load,up -d --no-build, how to connect. States thesupported 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).
pnpm fetch(pre-existing, ~1.5 GB) — tracked as WB-631, marked in the Dockerfile.
Verification
docker compose buildof both targets with no env vars set; the log showszero frontend image resolutions. Egress under
--network=noneconfirmedblocked (DNS fails) on the built-in frontend.
pnpm fetchserved from layer cache andthe
pnpm-storemount pruned, both--offlineinstalls still pass — thefetch layer materialises the virtual store.
fetch, escaped trailing backslash, duplicate
--network,# syntax=) andpasses on the real file.
pack-offline.shend to end with a decoy.envpresent: five-imagetarball, checksum verifies. Host simulation: infra images removed, tarball
loaded,
docker compose up --no-build --dry-runcreates every containerwithout a pull.
pass; lockfile regenerates to a fixpoint and
--frozen-lockfileaccepts it./api/healthok, SPA 200, workerRUNNINGwith 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