Remote state¶
State defaults to a local SQLite file, which only one machine can use. Point
[state] at a shared backend and the same commands work for a whole team or a CI
runner.
[state]
backend = "s3" # "local" (default) | "s3" | "postgres"
bucket = "acme-atlantide-state"
key = "prod/atlantide.json"
lock_table = "atlantide-locks" # DynamoDB table, hash key `node_id` (S)
region = "eu-north-1"
kms_key_id = "alias/atlantide" # optional; SSE-S3 (AES256) otherwise
lock_ttl = 300 # seconds a lease lasts before it lapses
lock_renew_interval = 100 # how often a live run pushes that out
[state]
backend = "postgres" # needs the extra: pip install atlantide[postgres]
dsn = "postgresql://…" # or the ATLANTIDE_STATE_DSN env var
schema = "atlantide"
Concurrency¶
Locks are held per node id rather than over the whole store, but an apply scope
is wider than the changeset it starts from. Apply re-diffs once it holds the lease,
and an action can appear or disappear between the two diffs, so the lease must
cover the whole desired graph plus everything already recorded in state.
The practical consequence: two applies run concurrently only if both their configs and their prior state are disjoint. Two applies over the same stack serialize. Size CI concurrency accordingly.
A run that loses its lease — because the hold lapsed and another run claimed it —
stops and reports that it has done so. It does not roll back, because a
compensation is itself a state write and the lease no longer authorises one. Run
atlantide refresh to recover.
Preparing the backend¶
The bucket and lock table are not created for you. They are the trust root for shared state, so they are expected to exist and to be configured deliberately:
- Bucket versioning, so a bad state write can be recovered.
- A DynamoDB TTL on
expires_at, so abandoned leases evict themselves.
state check verifies all of this up front, rather than surfacing one failed API
call at a time. It also probes whether the endpoint genuinely honours conditional
writes — an S3-compatible store may accept the request and ignore the condition,
which would silently defeat every compare-and-swap the backend relies on:
atlantide state check # --no-probe skips the scratch write
Managing state¶
atlantide state list # every recorded resource
atlantide state show <id> # one resource's stored inputs and outputs
atlantide state rm <id> # forget a row without destroying anything
atlantide state backup # ./atlantide-state-<serial>-<ts>.atlas-state
atlantide state restore snap.atlas-state
atlantide state migrate --from atlantide.db # local -> remote
atlantide state migrate --to-local atlantide.db # remote -> local
atlantide state unlock # who holds what
atlantide state unlock --owner ci-runner-7 -y # break a dead run's holds
backup and restore run under the state lock, so a snapshot cannot capture
half of a concurrent apply. restore refuses a snapshot if state has been written
since it was taken, unless given --force, and previews which nodes it will stop
tracking.
Restoring rewrites atlantide's record and touches no cloud resource. An old snapshot therefore creates drift rather than undoing it.
state rm is the repair for a row that has stopped describing anything real.
It does not call the provider. A resource that does still exist becomes untracked,
and the next apply will build a second one. The command snapshots state first and
says so.
state migrate copies in a single write in either direction, so an interrupted
migration cannot leave a half-populated destination.
Upgrading a shared backend
The schema is versioned and migrates forward automatically. Take a snapshot first, and upgrade every machine sharing the backend together — an older atlantide refuses a newer store rather than misreading it.
Secrets¶
Secret values can come from a remote store. The reference held in config, in the IR and in state is a name either way:
[secrets]
provider = "ssm" # "keyfile" (default) | "env" | "ssm"
prefix = "/atlantide/prod/" # secret `db_password` -> /atlantide/prod/db_password
region = "eu-north-1"
Point secrets_key at a shared path when state is shared
The keyfile does more than encrypt the local value store. It also salts the rotation digests and seals sensitive outputs at rest.
A teammate without that keyfile cannot unseal those outputs, and will see
every secret field as rotated. plan recognises the pattern — every secret in
state mismatching, none intact — and warns rather than rendering a page of
spurious updates. A KMS-backed key would be the better answer and is not
implemented yet.