Environment lifetime and cost caps
An environment is not free while nobody is looking at it. Each one holds a database branch, a network, and a container per service, for as long as it exists. This page is how long that is, what ends it, and how to say “not yet”.
The lifetime
Section titled “The lifetime”Every environment is created with a stated lifetime, taken from runtime.ttl
in the manifest of the repository it belongs to.
runtime: ttl: 24h max_ttl: 168httl defaults to 24h. max_ttl defaults to 168h and is the furthest an
environment can ever be extended to. A manifest that states no ttl inherits
the 24h default rather than living forever: nothing is created without an
expiry. A ttl of 0 (or any non-positive duration) is refused for the same
reason, because it would be an environment born with no lifetime and no way for
a sweep to ever collect it. There is no “never expires”.
A af ci run does not use the day-long default. It stamps its throwaway
environment with a much shorter lifetime, its own run budget (the --timeout,
30 minutes by default) plus a grace, and never more than runtime.ttl. The run
tears the environment down when it finishes, is cancelled, or fails; the short
lifetime is the backstop for the one path the run cannot clean up itself, a
process killed before its teardown runs. On that path the sweep below collects
the environment within the hour instead of a day later.
The lifetime is stamped onto the environment’s resources when they are created. That matters more than it sounds: a sweep reads the expiry off each environment’s own resources, never out of the manifest it happens to be running with. A machine holding environments from three repositories with three different lifetimes gets all three right, and running a sweep from a repository with a two hour lifetime cannot remove somebody else’s week long environment.
Removing what has expired
Section titled “Removing what has expired”af env reap # lists what has expired; nothing is removedaf env reap --yes # removes exactly what that listedaf env reap finds every environment on this machine that has passed its
stated lifetime, and nothing else. Run bare it lists them and stops; --yes
removes them, and a scheduled job passes --yes.
It is not af env prune, and the difference is who chose the cutoff. af env prune --older-than 48h takes a cutoff from you and applies it to everything on
the machine, which is the right shape for “this laptop is full”. af env reap
applies each environment’s own lifetime, which is the only shape safe to run
unattended.
Three things it will never remove:
- An environment that states no lifetime. Everything created before this
feature existed carries no expiry. Reading “states no lifetime” as “lifetime
already over” would turn an upgrade into a machine wipe. Use
af env prune --older-thanfor those, where you name the cutoff yourself. - An environment something is running against. A sweep takes each
environment’s own lock before removing it, the same lock every other command
on that environment takes. If
af testis running, the sweep reports the environment as deferred and moves on. The environment is still expired and the next sweep takes it, so this is a deferral of one sweep rather than a reprieve. - Anything that is not an environment, such as the sidecar image every environment on the machine shares.
A deferral is not a failure and does not change the exit code. A teardown that errored is, because something is then neither removed nor accounted for.
Running the sweep automatically
Section titled “Running the sweep automatically”Cost should never depend on a human remembering to run af env reap. Run it on
a schedule, on the same machine or cluster your environments live on, with the
same credentials the workflow that creates them uses.
The ready-made way is a scheduled GitHub Actions workflow. Copy
examples/github-reaper-workflow.yml
to .github/workflows/; it checks the repository out, installs af, and runs
af env reap --yes on a cron. It belongs beside the create workflow because it
needs the same access:
- Local (Docker) runtime. The sweep reads the Docker daemon on the runner. A GitHub-hosted runner is fresh every job and holds nothing, so schedule this on a persistent self-hosted runner, where environments actually accumulate.
- Kubernetes runtime. The sweep reads the cluster the manifest’s
kubeconfig_contextnames. Give the scheduled job the same cluster access the create workflow has. This is where the sweep earns its keep: a namespace left up by a killed run keeps costing money until something removes it.
af env reap needs the repository’s antifailure.yaml on disk, which the
checkout provides, to know which runtime to sweep. It never reads a lifetime
from it; each environment carries its own. Any scheduler works: the same command
under a host cron, a systemd timer, or an in-cluster CronJob running an image
that carries af, does the same thing.
Keeping one you are using
Section titled “Keeping one you are using”af env extend af-app-main-a1b2c3 --for 8h --reason "bisecting a flake"This is the answer to the question a lifetime has to answer before it is a product rather than a timer: what happens to an environment somebody is in the middle of using.
Destroying it silently takes away work from a person who did not know the policy applied. Letting anyone push the expiry back forever means there is no lifetime at all, only a chore nobody does. So an extension is granted, and it is bounded.
No extension may take an environment past runtime.max_ttl, measured from when
the environment was created, not from now. Measuring from now would mean an
environment extended late in its life was entitled to a longer total lifetime
than one extended early, and each extension would carry the limit forward with
it. Measured from creation, twenty extensions and one reach the same ceiling.
Asking for more than the maximum grants the maximum and tells you so, rather than refusing. Being given less time than you asked for without being told is how you come back to an environment that is gone.
An extension is local to the machine holding the environment. The sweep that
would have destroyed it runs there and reads the lease there, so the extension
takes effect. What it does not yet do is tell the control plane: the expiry
shown for an environment in the console is the one it was created with, and an
extension does not move it. The environment lives, the console is behind. This
is a known gap rather than a design decision, and it is recorded in
docs/plan/STATUS.md.
If an environment genuinely needs longer, raise runtime.max_ttl in the
manifest. That is a deliberate, reviewable change to the repository, which is
the right place for a decision about what this project’s environments cost.
Cost caps
Section titled “Cost caps”The control plane bounds spend in environment-hours: one environment, held for one hour. It is the unit the caps use because it is the only thing here that is both what actually costs money and what the system already records. A cap in dollars would need a price list per runtime, per region and per service size, and a cap that cannot be measured is decoration.
There are two, and they refuse different mistakes.
- Per run bounds what a single creation may commit to. An environment asked for with a thirty day lifetime is 720 environment-hours promised in one call.
- Per day bounds accrual over a rolling twenty four hours. A workflow stuck in a loop creating one environment per push stays inside every per-run cap and still produces a bill nobody expected. The window rolls rather than resetting at midnight, because midnight is the middle of the afternoon for somebody, and a runaway that starts at 23:00 should not be handed a fresh allowance an hour later.
| Plan | Per run | Per rolling day |
|---|---|---|
| free | 24 hours | 72 hours |
| team | 168 hours | 2,000 hours |
| enterprise | 720 hours | 20,000 hours |
The free per-run cap is exactly the default runtime.ttl, so the ordinary case
of one environment for one branch is never refused.
Usage counts the overlap with the window, not the whole lifetime. An environment created three days ago and still running has contributed 24 hours to a 24 hour window, not 72. The other reading would make one long-lived environment exceed every daily cap forever. An environment that is still running counts up to now, so an organization cannot hold a hundred of them and report nothing.
When a run is refused
Section titled “When a run is refused”A refusal names the cap, what has been used, and who can change it:
This organization has used 71.5 hours of environment time in the last 24hours, and the free plan allows 72 hours. This run would need another 24hours. Tear down an environment you are finished with, wait for the window tomove, or ask an owner of this organization to change the plan. Nothing wascreated and nothing was removed.All three parts are there on purpose. Without the number nobody knows how far over they are; without the role nobody knows who to ask; and a refusal that reads like a failure sends somebody looking for wreckage that is not there.
Reaching a cap refuses the next creation. It never removes anything that already exists.
Cost attribution
Section titled “Cost attribution”A bill that says “you used 900 environment-hours” tells nobody which repository to look at or which branch left something up over a weekend. Usage is recorded per environment, and each line names the repository, the branch, when it was created, when it was torn down, and how many runs were made against it, so the questions people actually ask are answerable from the record.