Cluster operations

Day-two operations on a registered data-plane cluster: inspecting health, rotating the agent's join token, and tearing down infrastructure. For first-time provisioning, see Getting started: admin.

Listing and inspecting

$ adj cluster ls
$ adj cluster get prod

adj cluster get shows agent health, active instance count, and when the agent last checked in.

Status: provisioning progress + agent health

$ adj cluster status prod
ID:            1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42
Provisioning:  succeeded
Started:       2026-07-18T09:14:02Z
Log:           /home/dana/.config/adj/provision/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42.log
Name:          prod
Agent health:  connected
Instances:     14
Last seen:     2026-07-19T08:12:41Z

Use this while adj cluster provision is running detached to watch the Provisioning: line move from running to succeeded or failed (a failure also shows the exit code and points at the log file). The Provisioning: / Started: / Log: lines come from local provision state, so they only appear on the machine that ran adj cluster provision.

Once provisioning has succeeded, the command doubles as an ongoing health check: Agent health: is connected, stale, or never (agent has never checked in), and Last seen: is the agent's last check-in time. A stale agent is the first sign of a data-plane connectivity problem (see Troubleshooting's 503/504 entries).

Kubeconfig

$ adj kubeconfig
/home/dana/.config/adj/kubeconfigs/prod.yaml

Prints the path to the kubeconfig adj cluster provision wrote for the active context — resolution order is --kubeconfig, then $ADJ_KUBECONFIG, then that generated file. Use it directly:

$ export KUBECONFIG=$(adj kubeconfig)
$ kubectl get pods -A

Your own ~/.kube/config is never touched by adj cluster provision.

Rotating the join token

$ adj cluster rotate-token prod
⚠  Agent join token (shown once — the prior token is now invalid):
x7Dq2m4T9vKcW1pZa8Rf3sLn0uYbEjH5gOiA6dNqVwM

Invalidates the cluster's previous agent join token immediately. Use this if a token may have leaked, or as routine credential hygiene. The agent running in your data plane keeps its existing connection; the new token only matters the next time an agent needs to (re)join. Note that adj cluster bootstrap and adj cluster provision also rotate the token as a side effect — if you've re-run either of those recently, rotate-token isn't the first thing that changed it.

Deprovision vs. rm — two different operations

These are easy to conflate. They do different things:

adj cluster deprovision adj cluster rm
What it does Destroys the AWS infrastructure (EKS, FSx, every clone on it) via a local OpenTofu destroy Deletes the cluster's control-plane record
Blocked by active instances? No — it destroys them along with everything else Yes — 409 while any instance is still registered against it
Requires login? Only if you pass a cluster name Always
Requires local tools? tofu + aws on this machine No

Deprovision

$ adj cluster deprovision prod
This DESTROYS the AWS data plane (EKS + FSx + all clones) for cluster prod. Proceed? [y/N]: y

Runs the OpenTofu destroy locally (including the two-pass teardown needed for stuck Kubernetes-provider resources). It does not deregister the cluster record — that's a separate step (cluster rm).

Image defaults and the version gate

Both adj cluster provision and adj cluster deprovision fetch the control plane's client config before showing their confirmation prompt:

If you pass a UUID cluster id instead of a name, deprovision needs no login — the only control-plane contact is an unauthenticated GET /v1/clientconfig call for the version gate, which proceeds (fail-open) if the control plane is unreachable — and then goes straight to the local tofu destroy using your local AWS credentials. That's deliberate: a logged-out admin, or a break-glass script, can still tear down infrastructure even if the control plane is unreachable. Passing a cluster name instead requires resolving it via the API first, which does require being logged in.

--aws-profile selects which AWS profile tofu and the AWS CLI use (default: ambient credentials / AWS_PROFILE); --yes skips the confirmation prompt.

By default deprovision (like provision) uses the OpenTofu module embedded in adj — no checkout needed. --module-dir <path> is the developer/ from-source override: point it at a local module checkout instead (needed if you originally provisioned with --module-dir; see "Where provisioning state lives" below). Deprovision needs tofu and aws in both embedded and --module-dir modes — adj manages both the same way it does for provision (PATH first, pinned managed download otherwise; see Install § "Tools adj manages").

Where provisioning state lives

The control plane is the durable home of a cluster's OpenTofu state. Every provision, deprovision, and state push syncs the tofu state (terraform.tfstate) to adjd, where it's stored encrypted at rest (AES-256-GCM) alongside a per-cluster operation lock. That means:

See Security model's "What the control plane stores" section for exactly what data that stored state contains (and doesn't).

Operation locking

Only one admin can run tofu against a given cluster at a time. provision, deprovision, and adj cluster state push all acquire a per-cluster lock before touching state and hold it for the duration of the run, heartbeating every 60 seconds. A second admin who tries to start an operation against a locked cluster is refused immediately, before any tofu runs:

$ adj cluster deprovision prod
provision in progress by [email protected] on dana-laptop (started 3m12s ago); try later or run 'adj cluster force-unlock <cluster>'

A lock whose heartbeat is more than 5 minutes old is considered stale — the next admin to acquire it steals it automatically (audited), rather than being blocked forever by a crashed process. If you're certain the holder is actually dead (their machine crashed, the process was killed) and don't want to wait out the 5-minute staleness window, break it explicitly:

$ adj cluster force-unlock prod
You are about to force-release the operation lock on cluster "prod".
Type the cluster name (prod) to confirm: prod
Lock on prod released.

force-unlock is interactive only (no --yes), requires typing the exact cluster name, and is audited server-side. It's for genuinely dead holders, not for wresting control from a still-running apply — breaking the lock does not stop the other admin's tofu process. Its heartbeat will start failing (a non-fatal warning on their side), but the apply keeps running to completion and still pushes its state at the end. Use force-unlock when you're sure the other side is gone, not as a way to interrupt a live run.

Reading and pushing state directly

Two commands bypass the full provision/deprovision flow for direct access to the control-plane copy:

$ adj cluster state pull prod -o ./terraform.tfstate
Wrote tfstate (serial 7) to ./terraform.tfstate (mode 0600).

$ adj cluster state push prod
Push local state (serial 8) for cluster prod to the control plane? [y/N]: y
Pushed local state (serial 8) for cluster prod.

adj cluster status gains a State: line summarizing the sync state:

State: synced (serial 7, pushed 2026-08-01T14:02:03Z by [email protected])

The forms you'll see:

Form Meaning
synced (serial N, pushed <ts> by <email>) Local and remote agree on serial N.
local-only (never pushed) No state has ever been pushed for this cluster.
stale-local (remote serial X > local Y) The control plane has a newer copy than this machine's local bundle — the next provision/deprovision run pulls it automatically.
local-newer (local serial Y > remote X — run 'adj cluster state push') This machine's local copy is ahead of the control plane — push it.
unknown (control plane state sync unavailable) Couldn't determine sync state (control plane unreachable, or the local bundle isn't on this machine).

Degradation: control plane unreachable or unconfigured

State custody is designed to fail safely in both directions:

Situation provision / deprovision (by name) deprovision <uuid> (break-glass, no login)
Control plane unreachable Refuses — "control plane unreachable — cannot safely provision (state locking/sync required). Retry when reachable, or use --module-dir for a deliberate local-only run." Proceeds local-only, printing control plane unreachable — locking and state sync bypassed (break-glass). A teardown is never blocked by an unreachable control plane.
Control plane reachable but ADJ_STATE_ENC_KEY not configured Proceeds local-only with one warning: state sync not configured on the control plane; proceeding local-only — behaves exactly like a pre-state-custody adj. Same — proceeds local-only, same warning.
Control plane reachable, state configured Full lock + pull-if-fresher + heartbeat + push, as described above. Full lock + sync, same as any other run — the break-glass bypass only applies when the control plane is actually unreachable.

Named-path (prod, not a UUID) operations refuse outright when the control plane can't be reached, because a lock-less, sync-less run defeats the whole point of shared custody — the admin can retry once connectivity is back, or deliberately opt into a local-only run with --module-dir. The UUID break-glass path exists specifically so a teardown is never blocked by control-plane connectivity, at the cost of losing the concurrency guarantee for that one run (see the failure-modes table below).

Two admins running break-glass UUID deprovisions against the same cluster at the same time while the control plane is down are not protected against each other — there's no lock authority to arbitrate. This is the same exposure that existed before state custody; it's unchanged, and documented here for completeness.

Failure modes

Scenario Behavior
Two admins provision the same cluster at once The second acquire gets a 409 with the current holder's identity before anything runs.
CLI crashes mid-apply Heartbeat stops; the lock becomes stealable after 5 minutes; state syncs on that machine's next adj contact, or via adj cluster state push.
Control plane goes down mid-apply tofu completes locally; the final push fails with a warning (and the state push hint); the local cache stays authoritative; heartbeat failures are logged but non-fatal, and the lock simply expires server-side.
Control plane down before deprovision Break-glass UUID path proceeds local-only with a loud warning; the named path refuses.
Push rejected (serial conflict) Never silently overwrites — the CLI/server print both serials and point at manual resolution (state pull to inspect the remote copy).
Lineage mismatch (local and remote state came from two different tofu init lineages) Hard error before anything runs — the CLI prints both lineage ids and stops. There is no override flag; this means two separate provisions were run for one cluster id, and you must decide by hand which state is authoritative (state pull the remote, compare, and state push the correct one).
ADJ_STATE_ENC_KEY unset State/lock endpoints return 503; the CLI warns once per run and operates local-only, exactly as before this feature existed.
Two break-glass deprovisions at once Unprotected — the control plane being down means no lock authority exists. Same exposure as before this feature; documented, not fixed.

When a pull replaces your local terraform.tfstate with a fresher remote copy, the previous local file is kept alongside it as terraform.tfstate.pre-pull in the bundle directory — a safety net if you need to recover what was on this machine. The first time a cluster's state reaches the control plane (a cluster provisioned before this feature, or its very first push), the CLI prints state adopted by the control plane (first sync) — no manual migration is needed; existing clusters adopt themselves on the next operation.

Use adj cluster bundle export <dir> to get a look at the module without provisioning anything:

$ adj cluster bundle export ./adjoint-module
exported adj v0.2.0 bundle to ./adjoint-module
module: ./adjoint-module/deploy/tofu/adjoint-cluster

It refuses to write into a non-empty directory. Two uses: read the HCL before you run it, or seed a --module-dir workflow (e.g. to carry local patches across provisions instead of relying on the embedded module).

Deregistering

$ adj cluster rm prod

Deletes the cluster's record from the control plane. A cluster with active instances cannot be removed — you'll get a 409 until every instance against it is destroyed. This is the record-keeping step; it doesn't touch AWS infrastructure at all. The normal teardown order is: destroy instances → adj cluster deprovisionadj cluster rm.

Next