Skip to content

Shapes on every capacity, Fargate capacities, vCPU budgets and swap repair (#18) - #32

Merged
sodre merged 87 commits into
mainfrom
integration/fargate-capacities
Nov 14, 2026
Merged

sodre merged 87 commits into
mainfrom
integration/fargate-capacities

Conversation

@sodre

@sodre sodre commented Nov 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Spec #18. Every job names its full shape, and every capacity serves every shape. Two new Fargate capacities, ci-fargate-spot and ci-fargate, have no Docker daemon (ADR-0001). Caps become vCPU budgets, and swaps are repaired instead of prevented (ADR-0003). Terms are in GLOSSARY.md.

 label contract
-  self-hosted + one of ci-spot|ci-ondemand [+ linux, x64]
+  self-hosted + one capacity (ci-spot | ci-ondemand | ci-fargate-spot | ci-fargate)
+              + one size     (ci-cpu-2 | ci-cpu-4 | ci-cpu-8 | ci-cpu-16)   no default
+              + one arch     (x64 | arm64)                                   no default
+              [+ linux]
webhook
  queued       → contract → queue {label, vcpus, arch}
  in_progress  → runner launched for another job? → re-queue that job, re-tag the runner   (swap repair)
launcher
  budget: sum the capacity's live vCPUs (instances + tasks not yet STOPPED) → defer if over (soft)
  ci-spot / ci-ondemand    → EC2 Fleet: x64 m8i/m8a/m7i/m6i/m6a/m7a, arm64 m7g/m6g/m8g, .large–.4xlarge
  ci-fargate[-spot]        → RunTask: ECR image with SOCI index, no task role, 50 GB, idle-exit entrypoint
sweeper   → instances and tasks over 2 h; registrations with no instance or task
notifier  → EC2 Spot warning, or ECS SpotInterruption → failed check run on the right commit

Infrastructure:

  • deploy through an S3 bucket (1 MB template limit);
  • an ECR repo with a push role trusted only for main (the repo's immutable OIDC subject), reusing the account's existing OIDC provider (ADR-0002);
  • an ECS cluster and two task definitions;
  • an arm64 launch template;
  • the launcher's role carries ZAEPowerUserBoundary, so it can pass the task execution role.

The image work landed on main already (#33–#36): the multi-arch build, the SOCI/ECR push and the idle-exit wrapper.

Evidence

Each was proven live on the deployed integration branch. The evidence is on each ticket.

Before After
Sizes (#22) every runner .xlarge ci-cpu-8 → m6a.2xlarge, nproc 8
arm64 (#24) none EC2 m8g.xlarge and Fargate both print aarch64
Budgets (#25) 6/2 runners 24/8 vCPUs; "deferred": "4+4 of 4 on-demand vCPUs", then launched
Swaps (#21, #30) a deferred job could be stranded forever repaired live on EC2 (across repos) and Fargate (4 repairs, every job completed)
Fargate (#29) none jobs on both capacities; no AWS credentials in a task; idle exit at 120 s and 900 s
Start time (#28) EC2 ~100 s queued→started Fargate Spot 27–30 s
Reclaims on Fargate (#30) none a synthetic SpotInterruption produced ci-runner / j (4) with the real job name; a repeat logs already reported

make test: 144 pass, including the aws-emulator ECS tests. The template is 74,337 of 1,048,576 bytes.

Release note: breaking label change

From this deploy on, a job must carry a size label and an architecture label. runs-on: [self-hosted, ci-spot] is now outside the contract, and the job waits for a runner that never comes. Use, for example, [self-hosted, ci-spot, ci-cpu-4, x64], which is the old shape. Every repo in the ci-runner runner group was updated before the change went live (ci-runner-canary#2, zae-zcm#37, foresight#1194). SpotCap and OnDemandCap are replaced by SpotVcpuCap (24) and OnDemandVcpuCap (8), plus FargateSpotVcpuCap (48) and FargateVcpuCap (8). An override on the old names is dropped.

Merge Danger

Door: two-way for the code, one-way for the label contract in practice. The integration branch is already what the live stack runs, so this merge changes nothing live. Rolling back means redeploying main's template, which brings back the defaults and drops the Fargate capacities and the new parameters.

Blast Radius: every consumer's CI.

A job missing a size or architecture label now hangs; the runbook's traps table covers it. Budgets are soft (the owner's choice): a burst can exceed them, and this was seen live at 36–44 of 24 Spot vCPUs. The launcher's role depends on the account's ZAEPowerUserBoundary, which lives outside this stack.

Not proven live: a real Spot reclaim on Fargate, and a sweeper stop of a task older than 2 hours. #31, the human canary, stays open for those.

Closes #18
Closes #19, closes #20, closes #21, closes #22, closes #23, closes #24, closes #25, closes #26, closes #27, closes #28, closes #29, closes #30

🤖 Generated with Claude Code

sodre and others added 12 commits November 13, 2026 20:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each architecture builds and smoke-tests on its own runner (ubuntu-latest,
ubuntu-24.04-arm). On main each pushes by digest, and a publish job tags the
manifest list of both as :latest and :<date>-<sha>, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The template is past the 51,200 bytes CloudFormation takes inline, so both
upload it to $(STACK)-deploy-<account id>, made outside the stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-image

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All four cover the default budgets (24 / 8 / 24 / 8), so no increase
was requested. Fargate Spot and On-Demand are already at 140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and architecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sodre and others added 9 commits November 13, 2026 20:16
…runner with its shape

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The webhook's swap repair re-queues a runner's original job from its
instance's tags, and the launcher mints with the message's labels, so the
instance carries them as ci-runner:labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a stack runner starts a job other than the one it was launched for,
the webhook re-queues that job from the instance's tags and re-tags the
instance with the job it runs, so no job is stranded and a Spot reclaim
is reported on the right commit (ADR-0003, #21). Its role gains
ec2:DescribeInstances and ec2:CreateTags on the stack's instances, for
the job tags only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sodre added a commit that referenced this pull request Nov 14, 2026
## Summary

Landed early from the integration branch (#32), because only `main`
publishes. After this merges, `ghcr.io/zeroae/ci-runner:latest` is a
multi-arch manifest list.

```diff
 image.yml
-  build (ubuntu-latest, linux/amd64) → smoke → push :latest, :<date>-<sha>
+  build-amd64 (ubuntu-latest)     → smoke EXPECT_ARCH=x86_64  → push by digest
+  build-arm64 (ubuntu-24.04-arm)  → smoke EXPECT_ARCH=aarch64 → push by digest
+  publish (main only): imagetools create → :latest, :<date>-<sha>
```

`image/smoke.sh` now checks that `uname -m` and every binary the image
adds (docker, buildx, compose, gh, node, aws) are built for the expected
architecture.

## Evidence

- **Before:** `:latest` is a single linux/amd64 image.
- **After:** a branch run builds both architectures natively and passes
the smoke test on each:
https://github.com/zeroae/ci-runner/actions/runs/43539165229. Locally,
`EXPECT_ARCH=x86_64` on an arm64 machine fails every architecture check,
and `aarch64` passes.
- **Not yet run:** the publish job, which only runs on `main`. I'll
watch the first run after merge and check that `:latest` lists amd64 and
arm64.

## Merge Danger

**Door:** two-way. Reverting brings back the single-arch build; the next
weekly or `image/**` build republishes `:latest`.

**Blast Radius:** runners.

Every EC2 CI runner pulls `:latest` at boot. An amd64 host pulls the
amd64 entry of the manifest list, so it gets the same image as before.
If the publish job fails, `:latest` simply isn't updated.

Part of #18, closes #23 on the integration side.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
sodre and others added 7 commits November 13, 2026 20:20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpotVcpuCap (24) and OnDemandVcpuCap (8) replace SpotCap and OnDemandCap.
The launcher sums the vCPUs of a capacity's live instances from their
shape tags, across both architectures; an instance from before the tags
counts as 4 on the capacity its InstanceLifecycle says. A job that would
go over its budget is deferred, logged as "20+8 of 24 spot vCPUs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The override examples and the traps table use SpotVcpuCap and
OnDemandVcpuCap and name the EC2 quota each draws on; the deferral line
is in vCPUs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… template

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…both

The conventions test now reads env var names with digits (ARM64_...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nch template

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sodre and others added 23 commits November 13, 2026 22:24
…ution role

Live, every Fargate launch failed: RunTask passes the task definition's
execution role, and the PowerUserAccess boundary allows no iam:PassRole
at all. The account's ZAEPowerUserBoundary is PowerUserAccess plus
iam:PassRole on PowerUserPB* roles, and the launcher's own policy
narrows that to the execution role, passed to ECS tasks only. The
conventions test now requires that boundary on a role that passes one,
and PowerUserAccess on every other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both are offered at .large to .4xlarge in all three of the stack's AZs
(use1-az1/az2/az4), and their .xlarge Spot prices are at or below m7i's.
Six families x three subnets = 18 fleet overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er job

aws-emulator drops a task's tags once it is told to stop, so the first
SpotInterruption change (the warning, while the task runs) may be the only
one the notifier can read. The notifier now skips a reclaim already
reported on the commit, for instances too. Emulator tests cover the task's
swap, sweep and reclaim against real ECS and EventBridge shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roof pending

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live, a Fargate task's idle exit logged "no job within 120s, stopping"
and the task kept running: run-helper.sh and Runner.Listener sit in
their own process group inside the runner's session, so the TERM sent to
the session leader's group missed them and the wrapper waited forever.
The stop now signals the whole session (pkill -s), then KILLs it after
IDLE_GRACE_SECONDS (default 30, as docker stop gives an EC2 runner).
With a real JIT runner the session-wide TERM alone stops it at once.

The smoke test adds a fake runner like the real one: it ignores TERM
and has a child in another process group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The launcher's role needs ZAEPowerUserBoundary for iam:PassRole; the
idle exit stops the runner's whole session, then KILLs it after a grace;
the Docker trap quotes the error a Fargate job really gets; two new
traps for the PassRole failure and a stale idle exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured live on 2026-11-14: after StopTask, describe-tasks --include TAGS
returned the task's tags through DEPROVISIONING and STOPPED and minutes
after, while ListTagsForResource refused the task as stopped. The docs
said ECS drops them, which only aws-emulator does. No code relied on it:
the sweeper, launcher and webhook list desired-RUNNING tasks, and the
notifier's check-run lookup keeps a reclaim to one report per job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… STOPPED

The budget sums the tasks not yet STOPPED, but listed only desired RUNNING,
so a task told to stop but still deprovisioning dropped out early. List the
desired-STOPPED tasks too and count those whose last status isn't STOPPED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 4 vCPUs / x64 default for old messages and untagged machines was
written in four places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ader

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
webhook: shape() -> shapeOf(), returning the capacity label as capacity
(the queued message keeps its label field); sh -> shape, caps ->
capacities, and the job a swapped runner ran is ran, not now. notifier:
real -> ghJob, done -> existing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an object

Each file keeps its own copy (no shared module, per docs/stack.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tags

Measured live (6dbaa56); only aws-emulator drops them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re *VcpuCap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Docker promise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Launch Spot or On-Demand with the launcher's fleet options, and label and tag
the runner with that capacity. A Fargate capacity is refused with a pointer
to the runbook's "Run a task by hand". The LABEL override is gone: CAPACITY
is the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sodre sodre changed the title Shapes on every capacity, Fargate capacities, vCPU budgets and swap repair Shapes on every capacity, Fargate capacities, vCPU budgets and swap repair (#18) Nov 14, 2026
@sodre
sodre marked this pull request as ready for review November 14, 2026 04:45
sodre and others added 3 commits November 14, 2026 00:10
With CI_RUNNER_VCPUS set, ci-runner-idle-exit writes
/home/runner/.cargo/config.toml with [build] jobs = <n>, leaving an
existing file alone. A CARGO_BUILD_JOBS in the environment changes
Swatinem/rust-cache's key (it hashes every CARGO*, CC*, CFLAGS*, CXX*,
CMAKE* and RUST* variable), so a Fargate job never hit the cache an EC2
job saved. Cargo reads .cargo/config.toml from every ancestor of its
working directory, so the file reaches every checkout under _work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swatinem/rust-cache hashes every CARGO*, CC*, CFLAGS*, CXX*, CMAKE* and
RUST* environment variable into its key, so a task's CARGO_BUILD_JOBS
and CMAKE_BUILD_PARALLEL_LEVEL gave Fargate jobs a key of their own: they
never hit the cache an EC2 job saved. A task now gets CI_RUNNER_VCPUS,
which the image's entrypoint turns into ~/.cargo/config.toml's
[build] jobs, and keeps MAKEFLAGS=-j<n>. stack.md, the runbook (with a
traps row) and the README say why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live at 48 since the zae-zcm sample: at 24, a 9-job matrix at ci-cpu-8 spent
most of its queued time waiting on the budget. The Fargate Spot quota is 140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sodre
sodre merged commit c137ee0 into main Nov 14, 2026
1 check passed
@sodre
sodre deleted the integration/fargate-capacities branch November 14, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant