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.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.
Two steps, in that order
Section titled “Two steps, in that order”The first prepares the database, the second serves requests. They need different credentials, and the serving step 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-fa6c8aa 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-fa6c8aaOn 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. Pin a main-<sha>, not
:latest or a version tag, because only a sha tag names anything checkable.
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”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. 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. 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 reports no repository: the environment runs, and it does not appear in
the console. The response says so on the event, 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.
Related: the full control plane guide, every variable it reads, running it on Azure.