When one machine is not enough
The first two pages need no server. af up builds an environment on the
machine it runs on, af ci does the
same inside a workflow, and nothing calls home.
A control plane is what a team adds when one person’s laptop stops being the right place for the answer: environments that outlive a CI job, a reviewer who can open one, scheduling across a queue, quotas, and history.
Nothing breaks without it
Section titled “Nothing breaks without it”AF-CP-001 The control plane at https://cp.example.com could not be reached. Next: Antifailure works without it. Run af logout, or unset AF_CONTROL_PLANE_URL, to work fully locally.That is the design rather than a consolation. Events are buffered and delivered when it returns, environments keep running, and teardown still works, because teardown reads the local journal and not the control plane. Adding one is a decision you can reverse by deleting a line.
Two steps, in that order
Section titled “Two steps, in that order”The first prepares the database. The second serves requests. They are separate because they need different credentials, and the serving step deliberately has no migration credential at all.
# 1. Apply the schema, create the application role, grant it its membership.docker run --rm \ -e AF_MIGRATION_DATABASE_URL=postgres://owner:...@db:5432/antifailure \ -e AF_DATABASE_URL=postgres://af_app:...@db:5432/antifailure \ ghcr.io/antifailure/control-plane:main-b53906a node bootstrap.mjs
# 2. Serve.docker run \ -e AF_DATABASE_URL=postgres://af_app:...@db:5432/antifailure \ -e AF_GITHUB_CLIENT_ID=... \ -e AF_GITHUB_CLIENT_SECRET=... \ -e AF_GITHUB_REDIRECT_URI=https://cp.example.com/auth/github/callback \ -p 8080:8080 ghcr.io/antifailure/control-plane:main-b53906aOn Kubernetes, the chart in deploy/helm/antifailure-control-plane runs step 1
as a Job before the Deployment rolls.
The tag names the commit the image was built from, and every push to main
publishes one. Do not run :latest or :v0.1.1: they are the same image and it
predates this page. Which tag to
run has the details and the
command that lists what is published.
Do not skip step 1, and do not trust a 200
Section titled “Do not skip step 1, and do not trust a 200”This is the one that catches people, so it is worth knowing before it happens to you rather than after.
Step 1 is what grants the application role its membership. Skip it and the
failure is quiet instead of loud: the server starts, /health answers 200, the
container reports healthy, and every query fails with
ERROR: relation "organizations" does not existwhich reads like a missing migration and is not one. Postgres does not tell a
role that lacks USAGE on a schema that it lacks permission. It tells it the
relation is not there.
So the check that means anything is the membership itself, not the health endpoint:
SELECT pg_has_role('af_app', 'antifailure_app', 'MEMBER');Point this machine at it
Section titled “Point this machine at it”The control plane is not a manifest key. A manifest describes an application, and which control plane you happen to be signed in to is a fact about your machine rather than about the code, so it lives with the credential:
af login --control-plane https://cp.example.comThe token goes straight into the operating system’s credential store. It is
never shown, never copied through a clipboard, and never written to a shell
history file. AF_CONTROL_PLANE_URL sets the same thing for a runner that
cannot open a browser, and af logout removes it and revokes it everywhere.
Then set github.mode to app in the manifest, which is a fact about the
repository, so environments outlive the job and a reviewer can open one:
github: mode: appWhat the control plane has to be told
Section titled “What the control plane has to be told”An environment appears in the console because the engine reported it, not
because anything here created it. Every environment.* event carries the
repository as owner/name, the branch, the pull request number when there is
one, and the lifetime runtime.ttl declares, and the control plane creates the
environment from whichever of those events reaches it first.
Each of those events also carries the instant the environment began existing, which is not the instant the event fired: an environment is reported ready after its build, and the build is the expensive part of a cold run. Usage and the expiry are both measured from the earlier instant.
The repository name comes from GITHUB_REPOSITORY when the run is in GitHub
Actions, and otherwise from the origin remote of the checkout. A checkout
with neither, which is a directory somebody is trying the tool in, reports no
repository: the environment runs, and it does not appear in the console. The
response says so on the event rather than accepting it silently, and the
control plane counts it as af_ingest_events_total{outcome="unprojected"}.
A repository the GitHub App has never mentioned is created from the name the engine reports rather than refused, so an engine running against a repository nobody has connected still shows up.
Related: the full control plane guide, every variable it reads, running it on Azure.