Runbook #
How to run this repo. It is applied by hand from a laptop, by one person, with credentials that live in that person's shell and nowhere else.
Requirements #
- OpenTofu >= 1.11 (S3-native state locking;
dynamodb_tableis deprecated from that release). Terraform >= 1.11 behaves identically here — substituteterraformfortofuthroughout. - The AWS CLI, for the bootstrap check and for deploying the frontend.
- Administrator credentials for the one-time bootstrap. Everything else runs as the apply role. See Credentials.
Credentials #
Nothing is stored in this repo. The two phases use two different credentials.
Bootstrap, once: administrator credentials. Bootstrap creates IAM resources, so lesser credentials fail. How you get them is not recorded in this repo. Put them behind a local profile:
export AWS_PROFILE=<bootstrap profile>
Everything after: the lance-blue-apply role. Any Identity Center
session in this account can assume it, if the session's own policy allows
sts:AssumeRole. There is no IAM user and no access key. Bootstrap prints a
ready-made ~/.aws/config block as the aws_config output; with it in
place:
export AWS_PROFILE=lance-blue
Terraform and OpenTofu read AWS profiles natively, including SSO ones.
Anything sensitive that Terraform itself needs is passed at the command line:
tofu -chdir=envs/lance.blue plan -out=lance.blue.tfplan -var 'some_token=…'
TF_VAR_some_token=… tofu -chdir=envs/lance.blue plan -out=lance.blue.tfplan
First time, in order #
1. Confirm the account. Every later step is destructive in the wrong one.
The account is 179302349187.
aws sts get-caller-identity
2. Bootstrap. Create the state bucket and the apply role.
cd envs/bootstrap
tofu init
tofu apply -var 'region=us-east-1'
3. Set up the profile. Append bootstrap's aws_config output to
~/.aws/config, set its source_profile to your SSO profile for this
account, then check it:
export AWS_PROFILE=lance-blue
aws sts get-caller-identity # expect assumed-role/lance-blue-apply
The administrator credentials are only needed again to change the apply role itself: the role cannot change its own permissions.
4. Initialise the environment. envs/lance.blue/backend.tf already contains
the bucket name. (A backend block cannot use variables, so it is written
out.)
tofu -chdir=envs/lance.blue init
5. Check the environment's values. Both variable files are committed and
load on their own, so there is nothing to copy: terraform.tfvars holds the
constants you edit by hand — account id, domain, task sizes, DIDs — and
releases.auto.tfvars.json holds what is deployed, which a deploy rewrites.
Neither holds a secret; a secret would be passed as -var or TF_VAR_*.
6. Create the hosted zone and delegate the domain. The site certificate validates through DNS, so the zone must exist and the domain must be delegated before the full apply can finish.
tofu -chdir=envs/lance.blue apply -target=module.dns
tofu -chdir=envs/lance.blue output nameservers
Set the four nameservers at the registrar, by hand. dig NS lance.blue
shows when the change is live.
7. Plan, read the plan, apply.
tofu -chdir=envs/lance.blue plan -out=lance.blue.tfplan
tofu -chdir=envs/lance.blue apply lance.blue.tfplan
Day to day #
prek run --all-files # fmt + validate, via the pre-commit hooks
tofu -chdir=envs/lance.blue plan -out=lance.blue.tfplan
tofu -chdir=envs/lance.blue apply lance.blue.tfplan
Applying the saved plan rather than re-planning means what you read is
what runs. The plan file is gitignored (*.tfplan) because a plan can embed
resolved variable values, including secrets passed on the command line.
scripts/deploy.sh #
The same thing in one command, and the reason the deploys are visible:
export AWS_PROFILE=lance-blue
scripts/deploy.sh # add --yes to skip the prompt
It plans, prints the plan, asks once, applies that plan file, and then posts to
lance.blue's Bluesky account when a release id moved — nothing to announce on
an infrastructure-only change, and nothing at all when the plan is empty.
--dry-run stops after the plan; --no-post applies silently.
The post is built from two outputs and no others: releases and site_url.
Both are public knowledge. Bucket names, the instance id and
the security group ids are outputs too and deliberately stay out of it, so
adding an output can never leak one by accident — posting more means editing
the script.
What the post says #
The first post is the release ids that moved and a link card for the site.
Bluesky unfurls nothing on its own — a post shows a card because the client
that wrote it put one in the record — so the script reads the site's own og:
tags, uploads the card image as a blob and writes the card itself. The address
is in the card and not in the text; when the card cannot be built, the address
goes back in the text as a plain link, because a post announcing a deploy has
to link to it.
That address carries the deploy's timestamp as ?d=, so every deploy links to
a URL of its own and can be counted separately. Nobody sees it: a card prints
the domain, not the query.
Under the first post is the changelog. Every release id ends in the commit it
was built from, so the pair either side of an apply is a commit range, and the
script reads it out of the sibling checkouts — ../headquarters for both api
and site, ../arena, ../helm. LANCE_REPOS points somewhere else if they
are not siblings of this repo. A checkout that is missing, or that has not
fetched the commits, is skipped without comment: a deploy is not the moment to
find out somebody's clone is behind.
A post is 300 characters, which one repo's commits routinely are not, so the
changelog rides in the first post when it fits and becomes a thread when it
does not. A thread stops at 25 posts and says how many it left out;
DEPLOY_MAX_POSTS moves that.
scripts/lib/post.sh decides all of this and talks to nothing.
bash scripts/lib/post.test.sh checks the two parts worth checking — the
commit ranges and the chunking — against a fixture repository, with no AWS and
no PDS.
The app password #
An app password for the lance.blue account, never the account password. It lives in the login keyring, and is stored once:
sudo apt install libsecret-tools # Debian/Ubuntu, one time
secret-tool store --label='lance.blue app password' \
service lance.blue key atp-app-password
secret-tool is libsecret's CLI and needs a Secret Service running on the
session — gnome-keyring or KWallet. Over ssh, or on a window manager without
one, there is nothing to read: set ATP_APP_PASSWORD in the environment
instead and the keyring is not consulted. ATP_SECRET_SERVICE and
ATP_SECRET_KEY change the attributes if you stored it under other ones.
Not in AWS, on purpose. An SSM SecureString would be the obvious home, next to the api host's secrets, and the apply role can already read those. It is the wrong place: an app password is unscoped — it can read direct messages, delete posts and change the profile — so parking it there would mean whoever takes the apply role also takes the account that speaks for the project. The infrastructure can be rebuilt with an apply. The handle cannot.
Wherever it comes from, the password is never printed and never passed as an
argument, so the process list does not show it. Without one the deploy still
runs and says it did not post. The account itself lives on a PDS this repo does
not run; ATP_PDS points elsewhere if it ever moves.
The releases output is what makes "a release id moved" answerable: the script
reads it before the plan and again after the apply, and posts the difference.
An apply that predates the output has nothing to compare against, so the first
run after adding it says so and posts nothing.
Formatting is prek's job — the terraform_fmt hook runs tofu fmt over the
files that changed on every commit (a commit that needed formatting fails;
stage the result and commit again); prek run --all-files runs it by hand.
After the first apply #
Tell a repo where to push. Two outputs, because docker login wants the
registry host and a push script wants the host plus namespace — each appends
the basename of its own IMAGE_NAME:
cd ../arena # from an infra checkout
INFRA=../infra/envs/lance.blue
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin \
"$(tofu -chdir=$INFRA output -raw ecr_registry_host)"
./scripts/build.sh
REGISTRY=$(tofu -chdir=$INFRA output -raw ecr_push_registry) ./scripts/push.sh
The same REGISTRY works for every repository in the namespace. To see what
exists: tofu -chdir=envs/lance.blue output -json ecr_repositories.
Deploy the frontend. Headquarters pushes each build to a release prefix
in the site bucket (releases/<id>); serving it is a Terraform change:
# envs/lance.blue/releases.auto.tfvars.json:
# "site": "<id>" <- the id alone, not a path
tofu -chdir=envs/lance.blue plan -out=lance.blue.tfplan
tofu -chdir=envs/lance.blue apply lance.blue.tfplan
The apply flips the distribution to the new prefix and invalidates the cache
when the path changed — flipping alone does not bust the cache, because
CloudFront caches by viewer path. site_cloudfront_url is the cloudfront.net
address, which works before the domain is delegated.
Deploy the API. Two SecureString parameters must exist before the api host's first boot — seed them once, by hand, so the secrets never touch Terraform state:
aws ssm put-parameter --type SecureString \
--name /lance-blue/prod/api/session-secret \
--value "$(openssl rand -base64 48)"
aws ssm put-parameter --type SecureString \
--name /lance-blue/prod/api/private-key-jwk \
--value "$(node -e 'const{generateKeyPairSync}=require("crypto");const j=generateKeyPairSync("ec",{namedCurve:"P-256"}).privateKey.export({format:"jwk"});j.kid=Date.now().toString(36);console.log(JSON.stringify(j))')"
Then a deploy is: push an arm64 image from headquarters
(services/api/push.sh prints the tag), set api in
releases.auto.tfvars.json, plan and apply. The apply updates an SSM parameter in
place; the host polls it and swaps the container within ten seconds -
seconds of downtime, no instance replacement. To check on the box:
aws ssm start-session --target "$(tofu -chdir=envs/lance.blue output -raw api_instance_id)"
curl -s https://api.lance.blue/healthz
Setting arena in the same file works the same way, on its own parameter. The
api reports that tag at /api/version and the site puts it in the footer, so an
arena release restarts the api container too - same ten seconds, still no
instance replacement.
MegaMek's art, once per release #
The site draws units in MegaMek's own sprites, which are 39MB of CC BY-NC-SA art in the release rather than anything of ours. They live in their own bucket and are served through the site's distribution at the path they have inside an install:
https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/Atlas.png
So anything that already knows which file it wants out of MegaMek builds the
address rather than asking for a mapping. In the bucket the key is
0.51.0/data/images/units/meks/Atlas.png — the top level is release versions
and nothing else, and the distribution takes /assets/megamek off at the edge.
We upload the unit art and not the rest of the tree; a subset of the same
layout costs nothing.
Run this when the pinned MegaMek moves, not on a deploy. The release tarball is
639MB against the 39MB of art it carries, so it is cached under
~/.cache/lance-blue/megamek and reused whenever its digest still matches. Take both arguments
from arena's versions.env — MEGAMEK_VERSION and MEGAMEK_RELEASE_SHA256 —
so the art in the bucket is provably the release the container runs:
scripts/assets-megamek.sh --dry-run 0.51.0 5d673cda… # read the diff first
scripts/assets-megamek.sh 0.51.0 5d673cda…
A version's prefix is written once and never rewritten. A bump adds a prefix beside the old one and both stay live, because a match started under the old image has to keep drawing the art it plays with.
Which picture belongs to which design is not in the tree: MegaMek maps it
through mekset.txt, and helm art --megamek <install> resolves that to the
paths this sync uploads. cargo test -p helm-unitfile -- --ignored in helm is
the check that every file the mapping names is one the release ships.
helm's index, when it changes #
The unit index a force-building screen filters on is generated by helm and published beside the art it describes:
helm index --megamek <install> --bridge-dir <dump> --out units.json # in helm
scripts/assets-helm.sh --dry-run 0.51.0 units.json
scripts/assets-helm.sh 0.51.0 units.json
https://lance.blue/assets/helm/0.51.0/units.json
The version argument is checked against what the file says it describes, and a mismatch stops the upload: an index published under the wrong release is the one failure here that looks fine and is not, because a screen would fetch it and then ask for sprites under a release that never had them.
Unlike the art, this is ours and gets regenerated — a new helm, a corrected figure — so the script invalidates the path it overwrote. That is why it needs the distribution as well as the bucket.
The api reads this file too, once at startup: it is what tells a match report
which of MegaMek's pictures each design is drawn with, so a report shows the
machines that fought rather than one silhouette per weight class.
megamek_index_version is which one it reads, and the api takes the release
and the address of the art from inside the file rather than from the variable.
Publish the index first and move the variable after — a version with no index
published is a report drawing silhouettes, and the api will say so in its log
at startup rather than failing.
Starting a match by hand #
Headquarters will do this eventually. Until it exists, this is how to run one task for testing. Everything per-match is a container override — the manifest URL is a bearer credential and does not belong in a task definition revision.
aws ecs run-task \
--cluster "$(tofu -chdir=envs/lance.blue output -raw match_cluster_name)" \
--task-definition "$(tofu -chdir=envs/lance.blue output -raw match_task_definition)" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[$(tofu -chdir=envs/lance.blue output -json public_subnet_ids | jq -r 'join(",")')],
securityGroups=[$(tofu -chdir=envs/lance.blue output -raw match_task_security_group_id)],
assignPublicIp=ENABLED}" \
--overrides '{"containerOverrides":[{"name":"arena","environment":[
{"name":"ARENA_MANIFEST_URL","value":"https://…presigned…"}]}]}'
assignPublicIp=ENABLED is not optional. There is no NAT gateway, so without
it the image pull hangs and then times out — which reads as the wrong problem.
Logs land in the group match_log_group names.
What a match cost #
./scripts/matches/cost.sh collect # ask AWS, file the facts
./scripts/matches/cost.sh report # price them
collect reads six sources — a live task from ECS, RunTask from CloudTrail,
the match log group, Container Insights, the artifacts in S3, and the match's
own result.json — and writes what each of them said into a ledger at
~/.local/state/lance.blue/match-costs.json. report prices that ledger
against scripts/matches/prices.json and never touches AWS, so a corrected
assumption re-prices every match already recorded.
The ledger is deliberately outside the repository. It holds what AWS has
since forgotten, which is the one thing here that cannot be collected again,
and a checkout or a worktree it lived in would take it when it went.
--ledger reads or writes another.
Which cluster, bucket and log group to look at come from envs/lance.blue's
outputs, read once per run. scripts/matches/cost.py can be run directly and
does the same thing; the wrapper only fails faster when collect has no
credentials. MATCH_CLUSTER, ARTIFACTS_BUCKET and MATCH_LOG_GROUP
override the outputs one at a time.
Run collect on a schedule. Every source forgets, and they forget at
different speeds: about an hour for a stopped task in ECS, a day for
Container Insights, seven days for the match logs, thirty for the artifacts,
ninety for CloudTrail. Run daily it keeps exact numbers; run monthly it keeps
inferred ones. Nothing already in the ledger is downgraded by a later run.
Totals and billed times are coloured by how much they should worry you: amber
at ten cents or an hour, red at a dollar or a day, against a match that
finishes costing about a cent and a half and playing for under ten minutes.
The two agree closely, because cost is very nearly linear in billed time —
a row amber on one and not the other is worth a look. Both scales are the
CostThreshold and TimeThreshold enums at the top of cost.py and move by
editing them; --color never turns the colour off, and so does piping the
output anywhere or setting NO_COLOR.
The src column says where each match's billed window came from, as
start/end — ecs is the task's own record, trail/s3 is an exact RunTask and
an end taken from the last artifact upload. --explain <match-id> prints one
match line by line, including which sources were still readable for it.
./scripts/matches/cost.sh collect --hours 2160 # backfill CloudTrail's 90 days
./scripts/matches/cost.sh report --explain 5fe10a12
./scripts/matches/cost.sh report --format csv > costs.csv
./scripts/matches/cost.sh prices --refresh # re-read the AWS price list
Prices come from the AWS Price List API and are committed, so a report is reproducible and a price change is a diff. The assumptions beside them — what a Spot task costs, how long provisioning takes, how often artifacts are read back — are hand-maintained and a refresh leaves them alone.
scripts/matches/cost.py documents what is counted and what is not. The
short version: Fargate is around 95% of a match, and serving the report pages
afterwards is the one real per-match cost that is measurable and not measured,
because it lives in CloudFront's access logs and needs Athena to read.