Amends 0166 CI2 (the ci-box
becomes the testbed; it stays advisory and is never a required check). Phase 2 (decision 12) would
amend CI1 and is not decided here.
Context
Every heavy check of every builder session (install, build, gen, typecheck, the 2,111-test playground e2e, integration tests) runs on eight shared EC2 boxes driven by~/.local/bin/ci-box, a 72-line shell script on the owner’s Mac. The
boxes serve Academorix, Retail OS, Legal OS and Figentra alike. Facts read on 2026-10-30:
- All eight are
c7i.2xlarge(8 vCPU, 16 GiB), four on-demand and four spot, in four regions (eu-west-1, eu-central-1, us-east-1, eu-west-2), each with one 150 or 200 GB gp3 root disk holding the OS, every checkout, the pnpm store, Docker and~/.ci-env. They were built by hand, have no instance role, and differ by region AMI. - Box 1’s disk reached 100%.
ci-box put-envthen truncated its~/.ci-envto nothing while printing “set”, and every build token on it was lost. Boxes 3 and 4 sit at 94–97%. Nothing on a box cleans up after a job. - Average CPU over the last seven days is 3–17%; peaks reach 100%. Boxes 1 and 3 ran all 168 hours: the 60-minute idle stop does not hold when something keeps them warm.
- Box 8 (spot) was reclaimed twice in the middle of a heavy run.
Decision
- Code home. The testbed is the Pulumi package
@stackra/infra-testbedatpackages/infrastructure/infra-testbed(the plan pure in., the Pulumi program in./node, like the otherinfra-*packages), and the commandstackra testbedin@stackra/cli.stackra testbedreplaces~/.local/bin/ci-box; during the migrationci-boxis a shim that calls it. - One golden image, built from code. One Dockerfile (
images/job/) defines the job toolchain: Ubuntu 24.04, mise with the repository’s pins (Node 24.22.0, pnpm 12), the Encore CLI, Playwright’s browsers and their system libraries. One EC2 Image Builder recipe defines the host AMI: Ubuntu 24.04, Docker, the job image and thepostgresimage preloaded, the box agent (testbed-agent) and a warm pnpm store. The pipeline runs weekly and on any change to the recipe, the Dockerfile,mise.tomlor the catalogs’ toolchain pins. The newest AMI is published to an SSM parameter; boxes are never patched by hand. A sick box is replaced, not repaired. - Disks. A 40 GB gp3 root disk holds the OS and the images. A separate 200 GB gp3 data volume, formatted XFS
with reflinks, is mounted at
/dataand holds/data/jobs,/data/seed,/data/pnpm-store, the turbo cache and Docker’s data root. Both disks are deleted with the instance. - Disk guard.
testbed-agentmeasures/dataand/before every job and every five minutes. Under 70% is normal. At 70% it cleans up: finished job folders, stopped containers and dangling images, seed copies unused for seven days, the turbo cache down to 20 GB. At 85% (or under 10 GB free on/) the box refuses new jobs, reports unhealthy, and once its running jobs finish it marks itself unhealthy in the Auto Scaling group, which replaces it. - A container per job. Every job runs in a fresh container from the job image, named and labelled with its job
id. Its workspace is
/data/jobs/<id>, cloned by reflink from/data/seed/<project>and then brought up to date byrsyncfrom the caller, and deleted when the job ends, whatever the outcome. The shared pnpm store is mounted read-only; the agent alone writes it (pnpm fetchagainst the job’s lockfile, one writer at a time) before the container starts, and the job installs offline. Each job gets its own Docker network and, when asked, its own Postgres container on tmpfs. Containers a job starts (Encore’s test database, Testcontainers) carry the job label and are removed with it. There is no VM per job. - Slots stay. A box runs one heavy job and one light job at a time, with today’s classification (
encore test,playwright,e2e,act,buildare heavy; everything else is light) and today’s Mac-side claim so parallel callers never pick the same slot. A command that callsci-boxorstackra testbedis refused, as today. - Secrets from Infisical, never a file.
~/.ci-envis retired. The instance role authenticates to Infisical by AWS IAM auth as the shared gate identity (INFISICAL_GATE_IDENTITY_ID, the one the Quality Gate uses over GitHub OIDC), with AWS Auth added beside its OIDC auth; no new identity is created (amended 2026-10-30). At job start the agent obtains a short-lived access token (time to live: the job’s timeout) with read access to the paths the job names and only those (todayfigentra-ci/ci:HEROUI_AUTH_TOKEN,UNIWIND_AUTH_TOKEN;/ci/turboonly once the gate identity can read the turbo project); the container runs the command underinfisical run. The Figentra Flow gate key is not a job grant: it lives in its own project,figentra-flow-gate(prod/ci/gate), read only by thefigentra-flow-boxidentity through figentra-flow’s own poster (amended 2026-10-31). No value is written to a disk, printed, or passed on a command line. Which project and paths a job may read is configuration ininfra-testbed, reviewed like code. - Pricing and placement. One region (proposed: eu-west-1, the cheapest EU region checked). Two Auto Scaling
groups from one launch template:
heavy, on-demandc7i.2xlarge;light, spot acrossc7i.2xlarge,c6i.2xlargeandm7i.2xlargewith capacity rebalancing. A heavy job never lands on spot. A light job that loses its spot box is retried once on another box. - Scaling and idle. Each group has a minimum it never goes below.
stackra testbed runraises the desired count by one, up to the maximum, when every slot of its kind has been busy for two minutes. A box above the minimum that has run no job for 60 minutes terminates itself and lowers the desired count. A box running a job holds scale-in protection, so neither scale-in nor an instance refresh to a new AMI ever ends a running job. - Health. Before every job the agent checks disk (decision 4), free memory, Docker, a Postgres container starting, and an Infisical token being issued. A box that fails refuses the job and the caller picks another. The groups replace boxes the agent marks unhealthy and boxes whose EC2 status checks fail.
- Fallback and the Mac rule. When no box is healthy,
stackra testbed run --preset <name> --ref <ref>dispatches thetestbed-fallbackworkflow on GitHub-hosted runners for a pushed ref and a named preset (verify,e2e,integration); arbitrary commands and unpushed worktrees wait for a box instead. The Mac rule stands: only oxfmt and oxlint run locally, andstackra testbednever falls back to the Mac. - Later phase (not decided here). Ephemeral self-hosted GitHub Actions runners from the same AMI, so the tester’s boxes and CI run the same image. It needs its own ADR because it amends 0166 CI1.
Consequences
- A full disk becomes a replaced box, not an outage, and a lost token is not possible: there is no token file.
- Every box is the same image, so “works on box 3” means “works on every box”.
- The data volume is 200 GB per box against today’s 150–200 GB shared disk, and job folders are deleted, so the pressure that filled box 1 has a ceiling and a cleaner.
- The reachable cost drops: at eu-west-1 prices read on 2026-10-30 an on-demand
c7i.2xlargeis $0.383 an hour (about $280 a month always on), spot about $0.215, gp3 $0.088 per GB-month (240 GB: about $21 a month a box). Today's eight boxes running around the clock are about $1,900 a month with their disks; the proposed shape (decision 8, with the minimums the plan proposes) is about $780 to $1,100. - New chores: the weekly image build must stay green (a red build keeps the last good AMI); the job-identity’s Infisical access list is reviewed with each new project; the instance role is the one AWS credential on a box.
- Agents keep their verbs:
run,status,unlock,ip,ssh,synckeep their meaning.put-envis removed; a missing token is an Infisical change, made by the owner. stackra testbedshells out to theaws,sshandrsyncbinaries the Mac already has; it adds no dependency.
Enforcement
planned — STA-1626:infra-testbedunit tests assert the plan: two disks, the data volume at/data, on-demand heavy, spot only for light, one region, no secret in user data, scale-in protection on.stackra testbedunit tests assert the heavy/light classification, the nested-call refusal and the health refusal.testbed-agenttests (in the package, run in the image build) assert the 70% cleanup and the 85% refusal.pulumi previewof the program in the tester’s lane; no apply without the owner.
Alternatives
- Keep the boxes and add a cron cleaner. Rejected: it treats the symptom; the boxes stay hand-built and divergent, and the token file stays.
- A VM per job (Firecracker, a fresh EC2 per job). Rejected for now: a fresh EC2 per job costs two to four minutes of boot and warm-up per run, and the container per job already gives a clean workspace and network.
- Packer for the image. Rejected: EC2 Image Builder is declared in the same Pulumi program, needs no new tool and no licence review.
- GitHub-hosted runners only. Rejected for agent work: unpushed worktrees cannot run there, and the e2e suite needs more than a standard runner holds within 0166’s budget.
Supersedes and sources
Supersedes: none. Amends: 0166 CI2. Related: 0173 (Infisical is the source), 0293 (rawinfisical run on the Mac asks the owner; the
box runs it under the gate identity). Amended 2026-10-30 (owner): the boxes use the shared gate identity instead of
a new figentra-ci-boxes identity (decision 7). Amended 2026-10-31 (owner): the project figentra-ci-gate is
renamed figentra-ci (same id), and the Figentra Flow gate key moved out of it into figentra-flow-gate, which no job grant
names (decision 7). Sources: ~/.local/bin/ci-box as of 2026-10-30; EC2 and CloudWatch reads of the
eight boxes on 2026-10-30; the owner’s brief for STA-1626.