Skip to content

Read a plan

nerthus-config plan prints the ordered changes that would make the estate match its declaration in Nerthus.Infra, and does nothing else: it writes no state file, asks no provider to change anything, and cuts no branch. apply carries out a plan rather than a comparison of its own, and the hourly drift job runs a plan whose declaration is the recorded state: one computation, three callers. This page is what a plan says and what each answer obliges you to do.

nerthus-config plan ../Nerthus.Infra                            # against the state/ tree
nerthus-config plan ../Nerthus.Infra --against live             # against the providers
nerthus-config plan ../Nerthus.Infra --against recorded --recorded live.yaml
nerthus-config plan ../Nerthus.Infra --only cloudflare_dns_record
nerthus-config plan ../Nerthus.Infra --only 'cloudflare_*'      # every kind the prefix names
nerthus-config --json plan ../Nerthus.Infra

Three exit codes

A pipeline branches on them, so they are three answers and not two:

Code Meaning
0 nothing to do. The populations are printed beside it, because "agreed" and "nothing was compared" must not read the same.
2 something to do. The rows say what, in dependency order.
1 something is not known, or the command refused: a resource the current side could not read, a change no apply may carry out, a declaration validate refuses, a kind nobody declared.

Six actions

Action When
create declared, and the current side says it does not exist
update it exists and an editable attribute differs
replace an attribute the provider fixes at creation differs, so it is deleted and made again
delete it exists and nobody declares it; the row offers import beside it, because a resource nobody declared is either adopted or removed
noop it exists and every declared attribute agrees
unknown the current side could not say. A plan carrying one refuses to apply.

The generated resource reference (docs/resources.md in Nerthus.Config) marks which attributes are immutable and which references are ordering edges (needs) rather than assignments (names).

A change may also be blocked: not a seventh action, but a sentence saying nothing this tool runs can carry it out. A host is adopted and never created, a site and an instance are Platform's, and a mesh peer is written by a converge. The row names the route that does bring such a resource into existence, and the plan refuses to apply.

--against live

A provider is registered only when this machine holds its credential, read from the instance's sops store; a resource whose provider is not registered reads unknown, saying so, and the command exits 1. An arm that reported agreement because it asked nobody would be the one answer a plan must never give.

nerthus secrets set CLOUDFLARE_API_TOKEN --file -     # the value on stdin
nerthus-config plan ../Nerthus.Infra --against live --only 'cloudflare_*'

The live arm also asks each provider what it holds, so the plan's population is the declaration, plus what state/ records, plus what a listing enumerates. A resource in neither of the first two appears as a delete row with the import route beside it. A listing that was refused contributes no ids and prints a note naming the kind: a 403 is unknown, never an empty estate.

The GitLab group and the Cloudflare account every listing enumerates are in estate.yaml at the declaration repository's root. NERTHUS_CONFIG_GITLAB_GROUP and NERTHUS_CONFIG_CLOUDFLARE_ACCOUNT override them where set. NERTHUS_CONFIG_FIXTURES points every provider at a recorded exchange file instead of the network, and NERTHUS_CONFIG_RECORD writes one from a real session.

A recorded live view

--against recorded reads a capture of a provider read, so a plan can be recomputed without touching anybody's API. One row per resource, status being present, absent or unknown:

format: 1
resources:
  - kind: cloudflare_tunnel
    id: dev-hub
    status: present
    declared: { host: nerthus-host-1, hostname: dev.nerthus.pl }
    observed: { tunnel_id: 11111111-1111-4111-8111-111111111111 }
  - kind: cloudflare_dns_record
    id: "CNAME:stary.nerthus.pl"
    status: absent
  - kind: gitlab_project
    id: margonem/nerthus/it/nerthus.config
    status: unknown
    reason: the API answered 403

A sensitive attribute is dropped on the way in, because a capture is a file a person can edit and state holds no secret; a status the format does not define is refused. A view that says nothing about a resource does not say it is absent: the plan reads it as unknown, because a create proposed on the strength of a question nobody asked is how an estate acquires a second copy of something.

A recorded exchange file

What NERTHUS_CONFIG_FIXTURES names and NERTHUS_CONFIG_RECORD writes: one row per HTTP round trip, matched on method, path and query. It is what makes the suite run without a network and what turns a real session into a fixture.

{
  "format": 1,
  "exchanges": [
    {
      "request": {"method": "GET", "path": "/zones", "query": [["per_page", "100"]], "body": null},
      "response": {"status": 200, "retry_after": null, "body": {"success": true, "result": []}},
      "repeatable": true
    }
  ]
}

An exchange answers the first call that matches it and is then used up, so a create and the read after it can answer the same path differently. One marked repeatable is never used up: that is how a fixture describes a world rather than a script. A recorder always writes repeatable: false; only a person turning a recording into a fixture may claim a call is idempotent.

A request the file does not hold is a transport failure naming it, never a 404 and never an empty result. The recorder refuses to write an exchange carrying the credential's own bytes, wherever in the document they sit; it refuses rather than masks, because a masked fixture looks like a real answer.