Feature Specification: Deployment method tf

Created: 2026-10-07 Status: Implemented Input: Reconstructed from the code of core/effects/tf/ and core/setups/ at 14504f80d, issues #336, #723, #882.

The tf method updates NixOS hosts that already exist, from an OpenTofu graph. The graph holds one resource per node. That resource runs the SSH transport of the ssh method (ssh.md) against its node. The state lives on an HTTP state backend.

The method creates no machine. Compared to ssh, it adds state: a node deploys again only when its trigger values change. The bootstrap wrapper, the verdict and the key sets are in spec.md. The verbs and the state rules are in 004.

User Scenarios & Testing (mandatory)

User Story 1 - Deploy existing hosts with tracked state (Priority: P1)

An operator runs the tf program against a running state backend. OpenTofu switches each node whose inputs changed and records the result in the backend.

Why this priority: This is the only purpose of the method.

Independent Test: A VM test runs a state backend on the deployer and deploys two targets. It checks the state at the backend and that TLS ends on the ssl resource on both targets. A second VM test runs the all program of the setup and deploys the application nodes of a test group. The VM tests call the effect directly with their own setup as the caller, so that the plan uses their test groups and not the in-tree groups.

Acceptance Scenarios:

  1. Given a reachable backend, When the operator runs the program, Then OpenTofu applies the nodes in parallel and prints the outputs as JSON.
  2. Given a node whose plan, address, pinned host key and SSH options did not change, When the operator applies again, Then OpenTofu does not replace that node, and the host is not deployed again.
  3. Given a node whose closure changed, When the operator applies, Then OpenTofu replaces the node and deploys it.

User Story 2 - Bring up the graph's own state backend (Priority: P1)

The graph deploys the terraform-backend node that holds its own state. On a fresh provider, that backend does not exist yet. One bootstrap run applies with local state, waits for the backend, migrates the state up and applies again.

Why this priority: Without it, a fresh provider cannot get its first state backend from the graph that needs it.

Independent Test: A VM test deploys a backend target and an application target from a deployer with no backend. It checks the bootstrap phases, the state on the backend, the marker of the migration, and a steady run after the bootstrap.

Acceptance Scenarios:

  1. Given an unreachable backend and the bootstrap mode, When the operator runs the program, Then the first cycle applies against local state in a working directory per state key.
  2. Given that the first cycle started the backend node, When the backend serves, Then the wrapper migrates the local state to it, marks the local copy as migrated, and applies again.
  3. Given the migration marker, When the operator runs the program again, Then the wrapper takes the steady path against the backend.

User Story 3 - Run a lifecycle verb against the same state (Priority: P2)

An operator runs plan, destroy, a state listing or another verb against the graph.

Why this priority: The operator must be able to inspect and remove the graph without a full apply.

Independent Test: Integration tests of the wrapper: a plan applies nothing, a destroy detaches the state first, the wrapper reclaims its own stale lock, and a steady apply refuses lost state. The VM tests run a plan after the apply, and fail when the plan shows changes.

Acceptance Scenarios:

  1. Given the plan verb, When the wrapper runs, Then it uses the same state address, node filter and tunnel as an apply, and changes nothing.
  2. Given the destroy verb on this self-backend graph, When the wrapper runs, Then it moves the state from the backend to local state first, and destroys against the local copy.
  3. Given the bootstrap mode together with a verb, When the wrapper starts, Then it refuses.

Edge Cases

  • No backend address is given: the setup uses a local example address, which does not count as pinned. The bootstrap migration then derives the reachable address.
  • The backend has no state for the key, but the local working directory holds the state of a fleet: the apply refuses, unless the operator explicitly allows it. A verb other than apply warns and runs.
  • The backend is unreachable outside the bootstrap mode: the run fails.
  • A stale lock that this operator on this host holds: the operator can ask the wrapper to release it before the verb.
  • A failed apply leaves an errored state: the wrapper salvages it into the working directory.
  • A submission is given: the graph drops the nodes that the submission turns off, and an apply with a different submission fails (spec.md).
  • A destroy removes the resources from the state. The hosts keep their last configuration, because the graph does nothing to a host on destroy.
  • The backend still answers after a detached destroy: the wrapper warns that the backend holds a superseded copy.

Requirements (mandatory)

Functional Requirements

  • FR-001: The method MUST run its OpenTofu graph through the shared OpenTofu runner of spec.md, with OpenTofu and its providers from the Nix store.
  • FR-002: The graph MUST hold one resource per node, keyed by the deployment name. The resource MUST be replaced when the node plan, the address, the pinned host key or the SSH options change. A change of the pinned host key or of the SSH options alone thus deploys the node again.
  • FR-003: Each resource MUST deploy its node through the SSH transport of ssh.md, with a shell from the Nix store and with the SSH settings of the node.
  • FR-004: The graph MUST keep its state on an HTTP state backend, with locking on the state address.
  • FR-005: The setup MUST read the backend address at evaluation. The address MUST count as pinned only when the operator gives it.
  • FR-006: In the all environment, each group MUST get its own state address.
  • FR-007: The setup MUST mark the graph as one that holds its own state backend, so that the wrapper takes the self-backend paths.
  • FR-008: The effect MUST publish its node names, so that a run with a submission takes its node set from the submission.
  • FR-009: The wrapper MUST refuse the bootstrap mode together with a verb, and MUST refuse an apply over lost backend state.
  • FR-010: The wrapper MUST keep one local working directory per state key, derived from the backend address.
  • FR-011: The method MUST read the shared SSH user and the SSH node map, and MUST layer the SSH identity of each node as FR-011 of ssh.md states.

Key Entities

  • Node entry: The node plan, user, address, key, pinned host key and SSH options of one node, keyed by its deployment name. An empty pinned key means trust on first use. The SSH options are bare Key=Value items (FR-017 of ssh.md).
  • HTTP backend: The state address, whether it is pinned, the lock and unlock addresses, the credentials and the retry settings.
  • State key: A file-safe form of the backend address. It names the local working directory.
  • Migration marker: A local marker that means that the backend holds the state.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: After an apply, the backend holds the state, and every changed node runs its closure.
  • SC-002: A second apply with no change deploys no node. After an apply, a plan shows no changes, for the effect and for the all program of the setup.
  • SC-003: One bootstrap run on a fresh provider ends with the state on the backend.
  • SC-004: An apply never writes over a backend key that lost its state, unless the operator explicitly allows it.

Assumptions

  • Each host already runs NixOS and accepts SSH, as for the ssh method.
  • One operator applies a given state key at a time. The backend lock enforces this.
  • The terraform-backend node of the graph serves the given backend address, or the address that the migration derives.
  • The deployer can build every closure, or substitute it.

Known Deviations

  • With no TF_HTTP_ADDRESS, the environment reader (core/effects/env-config.nix) gives the HTTP backend the local example address http://localhost:8080/state/project1/example (#2220).