feat(compute): give every workspace its own volume, and bake a golden image - #88
Conversation
… image Two changes that share one goal: a workspace should survive its VM, and a new one should not pay for work a disk image can already hold. Phase 1 — the volume is now the workspace's own disk. Every create on a volume-capable provider makes a 50 GB volume named after the workspace and attaches it. `/var/lib/blitz` lives there, which is the docker store and `/workspace` both, so a destroyed workspace can come back. - The volume goes in Hetzner's server-create `volumes` field instead of an attach call afterwards. The bootstrap scans /dev/disk/by-id once with no retry, so the old ordering raced that scan and could leave a box with no persistent disk and no error to show for it. - Workspace names are not unique and Hetzner volume names must be, so the workspace name is tried first and a name carrying the workspace id is the fallback. - Destroy detaches and keeps the volume for seven days. A janitor reclaims it after that. A volume the operator made through POST /volumes carries no clock and is never reclaimed. - GET /workspaces/history lists destroyed workspaces whose volume is alive. POST /workspaces/:id/recreate brings one back on that volume. - A create that fails after the volume exists deletes it. So does an ownership row that cannot be written. Neither may leave a monthly bill nothing can reach. Phase 2 — HETZNER_SERVER_IMAGES names a golden snapshot per location. Measured on cx23@hel1 on 2026-08-27, a stock boot spends 18.3 s on apt update, 17.4 s installing docker, and about 58 s downloading and loading the box image. A snapshot that already holds all three removes that work. - The bootstrap skips apt when docker and curl are already present. The stock path is untouched, so a plain Ubuntu image still installs them. - A refused snapshot falls back to stock Ubuntu with a warning. Hetzner answers before allocating, so the retry cannot duplicate a server. An ambiguous failure is never retried. - The image map reaches the deployment credential only. A snapshot lives in one project, so a BYOK organization boots stock Ubuntu. - scripts/bake-golden-image.mjs builds the snapshot. It shares the emitted image-setup bash with the bootstrap rather than copying it, so the two sides of that contract cannot drift. Gates: typecheck, lint ratchet at the 102 baseline with max-lines back to 7 (hetzner.ts split into hetzner-config.ts), 604 control-plane tests, and the python/bash/scripts conformance suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Do not merge yet — not validated against real Hetzner. Every gate in this PR is unit-level. No part of the golden-snapshot path has run against real infrastructure:
The only live measurement behind this PR is a boot-floor probe on a stock Moving to draft until a real create, destroy and recreate cycle passes on real Hetzner resources. |
Ran the full cycle against real Hetzner on 2026-08-27: bake a snapshot, create a workspace on it, write a marker into /workspace, destroy, recreate on the surviving volume, read the marker back. Each of these stopped that cycle. 1. The bake hung for thirty minutes. `boxImageSetupScript` emits a call to `retry`, which `buildBootstrapScript` defines in its own preamble. The bake script embedded the setup without it, so the builder died on "retry: command not found" under `set -e` and never powered off. The bake then waited for a shutdown that could not come. `BOX_IMAGE_SETUP_HELPERS` now holds that helper, the bootstrap emits it from there, and the bake script emits it too. One source, both callers. The builder also traps ERR and powers off, so a broken bake costs two minutes instead of thirty. 2. Recreate failed with "volume already attached". `detachVolume` fired the detach and returned. A Hetzner detach is an async action, so destroy reported success while the volume was still attached, and an immediate recreate on it was refused. It now polls until no server holds the volume. A timeout returns quietly: the janitor retries, and a destroy must not wedge on a volume Hetzner is slow to release. 3. A recreated workspace had no authorized SSH key. The workspace row never stored one, so the restored box came up with no way in and the owner could not reach their own restored disk. `POST /workspaces/:id/recreate` now takes an optional `sshPublicKey`. An empty body still restores the workspace as it was. The key belongs to whoever is asking, not to the row being restored. Evidence from the passing run: snapshot 425005770 (2.62 GB), volume `e2e-golden` 50 GB in hel1 attached during server create, create to ready 69.9 s, apt skipped on the golden image, /var/lib/blitz on /dev/sdb, volume survived the destroy, recreate to ready 68.4 s, and the marker file read back intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Validated against real Hetzner — full cycle passesRan the complete cycle on real resources on 2026-08-27, through the branch's control plane (run locally behind a cloudflared tunnel; a branch is never deployed to canary). create to ready: 69.9 s, down from the 130.6 s baseline. The golden snapshot is 2.62 GB and costs about $0.05 a month. The run found three real defects, now fixed in b5d0533
None of these were visible to unit tests. All three are now fixed and the cycle passes end to end. Gates after the fixestypecheck, lint ratchet at the 102 baseline, 604 control-plane tests, python 1/1, bash 4/4, scripts 59/59. State left behindSnapshot |
Create to ready falls from 64 s to 45.3 s, measured on real Hetzner across two full cycles. Against the 130.6 s stock baseline that is a 65% cut. Three levers went in together; only one paid. - **Bake the sshd move (the win).** The bootstrap moved host sshd to 2222 and waited for port 22 to free on every boot. A golden image can hold that state already, so the bake writes the port drop-in and masks ssh.socket, and the bootstrap skips the whole block when a listener is on 2222 and 22 is free. Worth about 19 s, well over the 5-10 s estimated. - **Disable unused units (no measurable gain).** snapd, unattended-upgrades, multipathd and the apt timers are off in the image. Boot to sshd moved from 40.9 s to 41.3 s, inside the run-to-run noise. Kept because a workspace never uses them, not because it is faster. - **Shrink the snapshot (no gain).** Reclaiming apt lists, docs and caches took 2.6157 GB to 2.6050 GB. The box image is the snapshot, so there is nothing else to give back. Estimated 2-5 s; delivered none. Also: phase markers. `blitz_phase` logs seconds since the script started at the apt, volume, sshd and image points. tools/e2e/GAPS.md has flagged their absence since 2026-08-13, and without them the tail could only be attributed by subtraction, which made every tuning decision an estimate. Also: root's password is no longer expired in the golden image. Hetzner expires it on an image built without an SSH key, and undoing that once does not hold — cloud-init re-expires it on every clone, so the bake disables that default instead. This is host debuggability only; workspaces were never affected, because cloud-init runs the bootstrap as root rather than over SSH. Verified end to end on snapshot 425047509: create 45.3 s, marker written to /workspace, destroy, recreate 45.9 s on the surviving volume, marker read back intact. Remaining budget is about 27 s of Hetzner allocation and boot, which no image can remove, and about 18 s of bootstrap tail. Under 30 s still needs a warm pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Speed pass: 130.6 s → 45.3 s, measuredRe-baked and re-ran the full cycle on real Hetzner (snapshot
Three levers went in together. Only one paid, and I am reporting the other two honestly rather than claiming the whole 19 s.
Two supporting fixes
Measured, and a correction to an earlier claimA golden snapshot boots slower than a stock system image: 24.1 s vs 14.6 s to Where the remaining 45 s goesAbout 27 s of Hetzner allocation and boot, which no image can remove, and about 18 s of bootstrap tail. Under 30 s still needs a warm pool, which was ruled out on cost. StateSnapshot |
Two loose ends from the speed work. The snapshot id is now set in `canary.yml` beside `BLITZ_DEPLOY_VAR_CLOUD_WORKSPACE_CREDENTIAL_POLICY`, which is where the other per-deployment non-secret settings already live. It was never a secret — it is a plain [vars] entry — so nobody should have to set it by hand after a merge. A rebake changes the value in one place, in version control. It cannot be a default in code. `DEFAULT_HETZNER_MACHINE_TYPES` holds names Hetzner offers to everyone; a snapshot id exists in exactly one project, so a hardcoded default would make every self-hoster and every BYOK org attempt an image they cannot see. The adapter would fall back correctly, and log a warning on every create forever. `plans/SUBSCRIPTION-COMPUTE.md` covers the second loose end. The golden image reaches the deployment credential only, and both hosted deployments run byok-required, so today it reaches nobody. The intended product behaviour — a subscribed org runs on our infrastructure — is not implemented: the credential policy is one deployment-wide env var with no per-org lookup, and the billing seam can write only `seat_limit` and `orgs.vm_limit`, so the billing service has no way to say "this org may use our key". The plan closes that with one integer in the entitlements seam and one branch in `resolve()`, keeping the rule that core never learns a plan name. It also states what a lapse must not do: `compute_credential_source` is pinned per row, so a downgrade refuses the next create and never touches a running workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snapshot id no longer needs setting by hand
BLITZ_DEPLOY_VAR_HETZNER_SERVER_IMAGES: hel1=425047509It was never a secret — it is a plain It deliberately is not a default in code.
And the reason it is still inertBoth hosted deployments run The intended behaviour — a subscribed org runs on platform infrastructure — is not implemented. Verified three ways: the credential policy is one deployment-wide env var read in two places with no per-org lookup (
|
# Conflicts: # packages/control-plane/test/core-imports.test.ts
Main gained 0036_box_token_grace, 0036_template_repo_private and 0037_workspace_repos while this branch was open, so 0036_workspace_volumes collided with two of them. It is 0038 now. The only merge conflict was the module count in core-imports.test.ts: both sides added modules. Resolved by counting the real files (99), not by picking a side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what my local gates could not. blitzdev-emitter.test.ts is vendor-only and skips unless BLITZDEV_MANAGED=1, which CI sets and I did not, so the emitted-file manifest went unchecked through four green local runs. core/workspace-volumes.ts and core/compute/hetzner-config.ts are emitted by the managed build, so both belong in the pinned list, in CORE_MANIFEST order. The file count moves to 102. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(entitlements): let a subscribed org run on platform compute A paying organization can now create cloud workspaces without bringing its own Hetzner key. That is also the only way the golden image ever reaches a customer: a snapshot lives in one project, and until now every hosted create resolved to a project we do not own. One integer, not a plan name. `org_entitlements.platform_compute` is 0 or 1 and the billing service translates its plan into it through the existing `PUT /orgs/:id/entitlements`. A plan name in core would be the first crack in the seam migration 0031 built, and the enforcement here would then depend on a string only the billing side can define. The column is appended by 0037 with `DEFAULT 0`, so a missing row and a written 0 say the same thing — the same reasoning 0031 uses for `seat_limit`. An organization credential still wins. `resolve()` reads the entitlement only after the org-credential branch has already returned, and only under `byok-required`: a team that adds its own key keeps using it while subscribed, which preserves the strict source pinning that stops a deployment key from touching an organization's resources. Under `deployment-fallback` the resolver has already fallen back, so the extra lookup would buy nothing and is skipped. `providerStatuses()` learned the same flag, which the plan did not call for. Without it a subscribed organization would pass `resolve()` and still be shown an empty machine catalog, because `/machine-types` excludes any provider whose access reads `credential-required`. The refusal would then happen one screen earlier than the resolver decides it, and for a reason that no longer holds. A lapse never touches a running workspace. Writing the flag back to 0 only refuses the next create: every workspace row pins `compute_credential_source`, so destroy and the janitors keep resolving the key that made the VM. Sweeping running VMs on downgrade would delete a customer's work to collect a bill. The spend ceiling stays `orgs.vm_limit`, already enforced inside the create transaction, so no new cap is introduced. The write body is now parsed into a named `EntitlementsRequest` on both sides of the wire contract rather than field by field, because the house rule is that external data becomes a named type at the boundary. `platformCompute` is optional there and absent means 0: the body states an organization's whole entitlement, so a billing service that omits the flag is saying the organization does not have it, exactly as a missing row does. Treating absent as "leave alone" would make one write mean two different things. `GET /orgs/:id/usage` reports the flag unconditionally, so an admin reading that page can see why a create was refused or allowed instead of inferring it. Fixtures gain a subscribed case on both the write and the usage read, and a rejected case for a non-boolean flag, because the billing service pins the same corpus and a field renamed on one side has to fail in a test rather than in a checkout page nobody can reach. `corpus.sha256` was regenerated — the checked-in value is not reproducible from any plain reading of its own README, so the README now states the exact recipe that produces the new one. The blitzdev teenybase schema mirrors the new column so the managed deployment does not drift from the migration. The plan's webapp string is deliberately not implemented. It asked the 402 to say "your plan includes compute" instead of "add a credential", gated on the usage field — but an organization with the flag set never receives that 402, and one without it has no such plan. The branch would be unreachable in any correct configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fe6oN7cZ37CLcoMGb7147s * docs(plan): amend SUBSCRIPTION-COMPUTE where the build proved it wrong Two corrections the implementation earned. The webapp string is struck: the branch cannot be reached. An org with the flag never sees the 402, and an org without it has no plan to name. providerStatuses() is added: /machine-types drops any provider whose access reads credential-required, so a subscribed org would have passed resolve() and still seen an empty machine catalog. The plan missed it; the build found it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: merge the updated base, and renumber the entitlements migration Main gained 0037_workspace_repos while this branch was open, and the volumes branch moved its own migration to 0038. platform_compute is 0039 now, and the plan's schema section names the new number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Two changes that share one goal: a workspace should survive its VM, and a new
workspace should not pay for work a disk image can already hold.
Validated end to end on real Hetzner resources. Create, destroy and recreate
all run against real infrastructure, and a file written to
/workspacesurvivesthe machine being destroyed. Evidence is in the comments below.
Headline numbers, all measured
GAPS.md:35)Supporting measurements taken live on cx23 @ hel1 on 2026-08-27:
ubuntu-24.04POST /serversreturnsrunningapt-get updateapt-get install docker.ioTwo corrections to figures already in the repo:
GAPS.md:77records "VM-ready latency varies 46-71 s". That includes the aptwork. It is not a boot floor, and reading it as one led two earlier analyses
to conclude a warm pool was mandatory.
GAPS.md:35records docker install at 9 s. This run measured 35.7 s for aptupdate plus install together.
still wins by a wide margin, but that cost is real.
Phase 1 — the volume is the workspace's own disk
Every create on a volume-capable provider makes a 50 GB volume named after the
workspace and attaches it.
/var/lib/blitzlives there, which is the dockerstore and
/workspaceboth, so a destroyed workspace can come back.volumesfield instead of anattach call afterwards. This fixes a real race: the bootstrap scans
/dev/disk/by-idonce with no retry, so the old ordering could leave a boxwith no persistent disk and no error to show for it.
workspace name is tried first; a name carrying the workspace id is the
fallback. Both paths fired during the live runs.
after that. A volume created through
POST /volumescarries no clock and isnever reclaimed.
GET /workspaces/historylists destroyed workspaces whose volume is alive.POST /workspaces/:id/recreatebrings one back on that volume, and takes anoptional
sshPublicKey.ownership row that cannot be written.
Phase 2 —
HETZNER_SERVER_IMAGESnames a golden snapshot per locationthe sshd relocation when the image already made it. The stock path is
untouched, so a plain Ubuntu image still does both.
before allocating, so that retry cannot duplicate a server. An ambiguous
failure is never retried.
project, so a BYOK organization boots stock Ubuntu.
scripts/bake-golden-image.mjsbuilds the snapshot. It shares the emittedimage-setup bash and its shell helpers with the bootstrap rather than copying
them, so the two sides of that contract cannot drift.
Five defects the live runs exposed, all fixed here
None were visible to unit tests.
retry,which the bootstrap defines in its own preamble. The bake embedded the setup
without it, so the builder died under
set -eand never powered off.async action;
detachVolumefired it and returned, so destroy reportedsuccess while the volume was still attached.
one, so the restored box came up with no way in.
cloud-init re-expires on every clone, so the bake disables that default.
Host debuggability only — workspaces were never affected.
GAPS.md:36since2026-08-13.
blitz_phasenow logs seconds since start at four points.What this does not do
It does not reach 30 s. About 27 s is Hetzner allocation and boot, which no
image can remove, and about 18 s is bootstrap tail. A warm pool of pre-booted
VMs is the only path below 30 s, and it was ruled out on cost: an idle slot
bills about $14.76 a month.
Two of the three speed levers paid nothing and are kept only on their merits:
disabling unused units moved boot to sshd by 0.4 s, inside noise, and shrinking
the snapshot reclaimed 11 MB, because the box image is the snapshot.
Not covered by the live runs: the seven-day janitor was not time-travelled on
real resources, and the BYOK path, arm64 and the
hillocation were notexercised. The bake script does not verify its own snapshot; this one was
verified by booting a workspace on it.
Cost
Read from
GET /v1/pricingon the deployment account (USD, VAT 0).Gates
npm run typecheck,npm run lint:gateat the 102 anti-slop baseline withmax-lines back to 7 (
hetzner.tssplit intocompute/hetzner-config.ts), 604control-plane tests, and the python, bash and scripts conformance suites. Seven
new tests. Extracting
boxImageSetupScriptandBOX_IMAGE_SETUP_HELPERSout ofbootstrap.tsis a pure refactor: all 44 pinned bootstrap-byte tests passunchanged.
To turn Phase 2 on after merge
Snapshot
425047509(hel1, 2.61 GB) is already baked and kept. SetHETZNER_SERVER_IMAGES=hel1=425047509. Until that var is set, every createtakes the stock path and behaves exactly as it does today.
Note the live snapshot predates fix 4 above, so a rebake
(
npm run golden:bake -- --location hel1) is worth doing before relying on hostSSH for debugging.
Review notes
migrations/0036_workspace_volumes.sqladdsauto_created,detached_atand
workspace_idtovolume_ownership.test/bootstrap-bash.test.mjsnow slices at the docker-presence guard,because slicing at
apt_watchdog updatewould cut the enclosingifin half.test/org-compute-credentials.test.tsneeded its fake to hand out uniquevolume ids and to answer the volume lifecycle. Workspaces now carry a volume
on every cloud create, so the destroy path detaches one every time.
🤖 Generated with Claude Code