Small scripts for verifying an EC2 (or Firecracker) deployment end to end, as
the README's Verifying a deployment end to
end describes. They wrap the aws
and gh calls such a check needs, with deadlines, so a person or an agent
gets an answer, not a hung terminal. Use them rather than raw aws calls: they
know which resources are yours.
These touch real resources. control-vm.sh up creates a stack and a VM.
dispatch-and-measure.sh starts workflow runs, which launch billable runner
instances, and so does lifecycle.sh without --run. remote-exec.sh runs
commands as root on an instance. inventory.sh --cleanup --yes and
control-vm.sh down delete things. The others only read.
| script | does |
|---|---|
env.sh |
shared configuration; run it directly to see what is set, your owner, and who else is using the environment |
control-vm.sh up|status|install FILE|down|list |
your own control VM: a stack made from deploy/cloudformation/control.yaml |
inventory.sh [--owner O] [--cleanup [--yes]] |
an owner's instances, spot requests and volumes; cleanup of your own |
remote-exec.sh [INSTANCE] -- CMD… |
runs a command through SSM, prints its output, exits with its status |
console.sh [INSTANCE] [-n LINES] |
prints the tail of an instance's serial console |
watch-run.sh RUN_ID |
waits for a workflow run and fails as soon as a job fails or none starts; saves the failed runner's console |
dispatch-and-measure.sh [WORKFLOW] N [-f K=V]… |
dispatches N runs and reports each job's queue and run seconds |
lifecycle.sh [WORKFLOW] N or --run RUN_ID… |
each job's runner instance from launch to terminated, sampled every -i seconds (1), e.g. job end to shutting-down |
Each script has --help. Exit statuses are shared: 0 success, 1 the thing
under test failed, 2 usage or configuration, 3 no job started in time, 124 a
deadline passed. Results meant for a program are JSON lines on stdout; the
last line is the summary. Progress and hints go to stderr.
Several sessions, people or agents, may use one AWS account and one GitHub organisation for e2e at the same time. On 2026-10-23 one session terminated a pool instance that another session's control VM had adopted two minutes earlier. So:
- Your own owner. Every session runs its control VM with its own
FR_OWNER. firerunner tags every instance, volume and spot request it createsfirerunner:owner=<FR_OWNER>, and its reaper (--ec2-reaper) touches only its own owner's.env.shderives one per worktree:e2e-<worktree name>-<hash>, ore2e-<branch>-<hash>outside a worktree, never frommain. The hash covers the worktree's full path, or the branch, and the host. So two worktrees whose names share a prefix, or one branch on two machines, never share an owner. Owners are lower case. SetFR_OWNERto reuse an older owner and adopt its pool. - Your own tier names. A tier's name is its scale set's name. Two control
VMs on one name share one scale set and take each other's jobs.
control-vm.sh upsuffixes every tier name and label with-FR_E2E_TIER_SUFFIX(default: your owner withoute2e-); use those inruns-on. - Clean up only your own owner's resources. Prefer the reaper. Otherwise
use
control-vm.sh downorinventory.sh --cleanup, which refuse any owner but yours, and refuse while that owner's control VM is live, or while a control VM's owner cannot be read. Both refuse the shared stack's own owner, and refuse a session stack that another owner made. Never terminate an instance or cancel a spot request by hand unless you have checked itsfirerunner:ownertag. - Look before you touch.
scripts/e2e/env.shandscripts/e2e/control-vm.sh listshow the live control VMs and their owners. A VM whose owner shows asunknownwas made by hand: ask before assuming it is abandoned.
A session's own stack scopes its VM's role to its owner (see
deploy/cloudformation/control.yaml), so even a
misconfigured firerunner cannot terminate another owner's instances.
Environment variables, or a gitignored .claude/e2e.env of KEY=VALUE
lines. No default names an account, a host or a repository:
# .claude/e2e.env in the main checkout: read by every worktree of it
AWS_REGION=… # region of the control VMs and the runners
AWS_PROFILE=… # optional; otherwise the default credential chain
FR_E2E_REPO=OWNER/REPO # where the e2e workflows run
FR_E2E_STACK=… # the shared deploy/cloudformation/control.yaml stack (control-vm.sh)
FR_E2E_BUCKET=… # optional: for control-vm.sh installscripts/e2e/env.sh checks it. The file is read at every call, so it works in
any shell. Order of precedence: the environment, $FR_E2E_ENV_FILE, this
checkout's .claude/e2e.env, then the main checkout's, which serves every
agent worktree under .claude/worktrees/. Only AWS_PROFILE, AWS_REGION
and FR_* lines count, and their values are literal, with no expansion. The
main checkout's file never sets FR_OWNER or FR_E2E_TIER_SUFFIX, not even
for the main checkout: those are per session. Export them, or put them in a
worktree's own file. In Claude Code, the SessionStart hook
(scripts/agent-env.sh) also exports the file's values. So raw aws calls
get the same AWS_PROFILE and AWS_REGION without an export in every Bash
call. After you edit the file, start a new session, or run
eval "$(scripts/e2e/env.sh --exports)".
The aws CLI reads AWS_PROFILE and AWS_REGION itself, so no script builds
--profile/--region flags. Building them in a variable such as
A="--profile x"; aws $A breaks in zsh, which does not split words.
Optional: FR_OWNER, FR_E2E_TIER_SUFFIX, FR_E2E_VERSION (the release
control-vm.sh up runs; default: the shared stack's), FR_E2E_ARTIFACTS
(where watch-run.sh saves consoles; default
$TMPDIR/firerunner-e2e/<owner>), FR_E2E_CONTROL_INSTANCE (default target
of remote-exec.sh and console.sh), FR_E2E_WORKFLOW and FR_E2E_REF (for
dispatch-and-measure.sh). Deadlines: FR_E2E_START_DEADLINE (300 s for a
job to start), FR_E2E_RUN_DEADLINE (1800 s for a run to finish),
FR_E2E_EXEC_DEADLINE (300 s for a remote command), FR_E2E_STACK_DEADLINE
(1200 s for a control VM to come up or go) and FR_E2E_POLL (5 s).
control-vm.sh up creates the stack FR_E2E_STACK-FR_OWNER from
deploy/cloudformation/control.yaml. It has the shared stack's network and instance
parameters, Owner=FR_OWNER, and the JIT name <FR_OWNER>.<shared zone>.
Its configuration lives under <shared prefix>/sessions/<FR_OWNER>/: the
shared env and app key, the shared tier catalog (or --tiers FILE) with
your suffix, any runner AMI parameter the catalog names, and the release. It
waits until firerunner is active on the VM and prints the session as JSON,
tier names included. Running up again only waits.
The shared catalog arrives without idle capacity: no ec2.pool, no
schedule, min 0. A session then pays only for the jobs it runs, not for
stopped pool instances' disks or hot runners. To test a pool or hot runners,
write the catalog yourself and pass --tiers FILE; it is used as written.
--cache off|disk|s3 sets the stack's Cache parameter (default off), so
the VM runs cache-server for tiers that set ec2.cache. With s3 the stack
makes a bucket and keeps it on delete; down empties and deletes it, since a
session's bucket is that session's alone.
control-vm.sh install FILE swaps in a development build (GOOS=linux, the
VM's architecture). It goes up to FR_E2E_BUCKET and the VM fetches it
through a presigned URL. A VM that the group replaces boots the release again.
control-vm.sh down deletes the stack first, so that nothing relaunches
runners, then runs inventory.sh --cleanup --yes. Then it deletes the JIT
name and the session's parameters. The runner scale sets stay registered on
GitHub; the next up with the same owner reuses them.
The shared stack itself, made and deployed as
deploy/cloudformation/README.md describes, is never
changed by these scripts.
Three separate credentials are involved, and none of them lives in this repository:
- AWS: a profile or role in the deployment's account. A profile can live in
~/.aws/credentials([name]) or~/.aws/config([profile name]);env.shsays which file holdsAWS_PROFILE, or that neither does. Reading needsec2:Describe*,ec2:GetConsoleOutput,ssm:SendCommand,ssm:GetCommandInvocation,ssm:CancelCommandandcloudformation:DescribeStacks.control-vm.shneeds, in addition, rights to create and delete the stack's resources (CloudFormation, IAM role, EC2 launch template and security group, Auto Scaling, Route 53 records) and to read and write the SSM parameters under the prefix. Cleanup needsec2:CancelSpotInstanceRequests,ec2:TerminateInstancesandec2:DeleteVolume.remote-exec.shneeds the instance to run the SSM agent with a role that allows it; the control VM's does. - GitHub, for
gh: your owngh auth login, with the Actions read and write permission onFR_E2E_REPO. Dispatching needs write access. - GitHub, for firerunner: the GitHub App private key that the deployment
under test uses. It lives in the shared stack's SSM parameters, and
control-vm.sh upcopies it, as a SecureString, without passing it on a command line. Never copy it into a repository, an issue or a log.
# 0. Who is here, and what am I?
scripts/e2e/env.sh
# 1. My control VM: prints JSON with the instance and my tier names.
scripts/e2e/control-vm.sh up
scripts/e2e/control-vm.sh install ./firerunner-linux-arm64 # optional dev build
scripts/e2e/remote-exec.sh i-… -- firerunner status --json
# 2. One run, watched to the end (exits early on failure or no runner, and
# saves the failed runner's console first).
gh workflow run e2e-selftest.yml --repo "$FR_E2E_REPO"
run_id=$(gh run list --repo "$FR_E2E_REPO" --workflow e2e-selftest.yml --limit 1 --json databaseId --jq '.[0].databaseId')
scripts/e2e/watch-run.sh "$run_id"
# 3. What do I have running?
scripts/e2e/inventory.sh
# 4. Launch latency: 5 runs, per-job queue seconds and a p50/max summary.
scripts/e2e/dispatch-and-measure.sh e2e-selftest.yml 5
# 5. Done: the stack, my runners, my JIT name and my parameters go.
scripts/e2e/control-vm.sh downremote-exec.sh arguments arrive on the instance exactly as given. Each one is
quoted, so a presigned URL's & or a $ survives. To use pipes, pass a
single argument, which runs as a shell script, or pipe a script on stdin. The
instance kills the command at the deadline. Still bound commands that never
end yourself, such as tcpdump -c 50 or timeout 30 …, so that their output
is kept.
scripts/e2e/test-e2e.sh runs every script against fake aws, gh, curl,
date and sleep. It needs no account, no token and no real waiting, and
runs on macOS's bash 3.2. make check runs it, and so does CI's test job.