Feature Specification: Effect octodns-zone

Created: 2026-10-07 Status: Implemented Input: Reconstructed from the code at 093e68b6f (core/effects/octodns/effect.nix, core/effects/octodns/octodns_fediversity.py, core/effects/octodns/netbox-zone.py, core/effects/octodns/kv-txt.py, core/effects/octodns/kv-secret.py, core/effects/octodns/served-zone.py, core/render/catalog.nix, core/setups/hosts-common.nix).

The octodns-zone effect publishes the DNS records of one group into one zone. The zone lives at an external DNS service or at the group's own named. The effect deploys no nodes. The tf-incus-hosts setup builds it as setup.octodns (see ../009-deployment-methods/tf-incus.md). The family behavior of standalone effects is in spec.md. The program grammar, the verb names and the state rules are in ../004-deployment-programs/spec.md.

User Scenarios & Testing (mandatory)

User Story 1 - Publish the group zone (Priority: P1)

An operator deploys a group. The operator runs plan to see the record diff. Then the operator runs sync to push the records into the DNS service. The service then answers for every node and every served name.

Why this priority: Without the records, no user reaches a deployed service by name.

Independent Test: integrations-octodns-powerdns (a whole zone pushed into an empty PowerDNS zone) and integrations-octodns-bind (the group zone pushed into the group's own named over RFC2136, then transferred to the second nameserver).

Acceptance Scenarios:

  1. Given a rendered zone and a provider token, When the operator runs plan, Then the effect prints the diff and changes nothing.
  2. Given the same inputs, When the operator runs sync, Then the DNS service serves the records of the rendered zone.
  3. Given a provider secret that neither secretFiles nor kvSecrets supplies, When the effect evaluates, Then the eval fails and names the secret.

User Story 2 - Render the zone from NetBox at run time (Priority: P1)

The group uses external IPAM. At eval time every address is a placeholder. The effect asks NetBox for the allocated addresses at run time and renders the zone from them.

Why this priority: Under external IPAM, an eval-time zone holds no real address, so a push from it is wrong.

Independent Test: integrations-octodns-netbox. The choice between netboxZone and zone is asserted in core/setups/zone-publication-test.nix (suite nix-unit-setups).

Acceptance Scenarios:

  1. Given netboxZone with a cluster name, When sync runs, Then the zone holds one A/AAAA record per NetBox address of that cluster, at a 300 s TTL.
  2. Given a cluster name that NetBox does not hold, When sync runs, Then the render fails with no NetBox cluster named <name>.

User Story 3 - Share a zone that other parties also write (Priority: P2)

A third party holds the zone and writes records into it. With ownership on, the effect changes only the records it wrote before. It does not delete or adopt the other records.

Why this priority: A push into a shared zone must not remove what another party publishes.

Independent Test: integrations-octodns-powerdns (partial-zone ownership over zones that already hold unowned records).

Acceptance Scenarios:

  1. Given ownership = true and an unowned record in the zone, When sync runs, Then the unowned record stays and stderr names it.
  2. Given ownership = true and a rendered CNAME or same-type record at an unowned name, When sync runs, Then the push fails with UnownedRecordCollision.
  3. Given ownership = true and a plan that deletes more than 5% of the records, When sync runs, Then OctoDNS refuses the plan.

User Story 4 - Publish a TXT value that a node minted (Priority: P3)

A node writes a public value to OpenBao KV, for example a key that a mail relay generated. No eval can know that value. The effect reads it at run time and appends it as a TXT record.

Why this priority: Some records hold data that exists only after the node starts.

Independent Test: integrations-octodns-kv-txt (every way that the KV read fails).

Acceptance Scenarios:

  1. Given kvTxt and openbao, When the KV store holds the value, Then the zone holds the TXT record.
  2. Given kvTxt and openbao, When one value is missing, Then the render writes the other records and the verb exits non-zero.
  3. Given OCTODNS_KV_TXT_OPTIONAL=1, When one value is missing, Then the effect skips that record and the verb succeeds.

Edge Cases

  • netboxZone.clusterName is empty: netbox-zone.py looks up addresses without a cluster scope and prints a note on stderr.
  • kvSecrets is set without openbao: the eval fails.
  • kvSecrets and secretFiles name the same secret: the eval fails.
  • OCTODNS_NO_WITHDRAWALS is set: the NoWithdrawals processor drops every delete from the plan and prints the dropped deletes. The CI publish script sets it for its additive fallback.
  • The plan changes the apex NS set: ApexAuthority accepts that change but keeps the percentage guards for all other changes.
  • verify cannot reach a nameserver over IPv6, but another address of that nameserver answers: served-zone.py sets the IPv6 failure aside.

Requirements (mandatory)

Functional Requirements

  • FR-001: The catalog MUST offer octodns-zone for a group only when the group DNS routing is external or the group has DNS nodes.
  • FR-002: The effect MUST read its coordinates from the env keys of the tf-incus method (envKeysFor "tf-incus"). It MUST also declare the run-time switches OCTODNS_NO_WITHDRAWALS and OCTODNS_KV_TXT_OPTIONAL, from the octodns reader of core/effects/common/standalone-env.nix.
  • FR-003: The effect MUST offer the verbs validate, plan, sync and verify, and run MUST be sync.
  • FR-004: validate MUST run octodns-validate --all on the rendered configuration.
  • FR-005: plan MUST run octodns-sync without --doit, and sync MUST run it with --doit.
  • FR-006: verify MUST ask every nameserver of the zone, without recursion, for every rendered record except SOA, and MUST exit 0 when all records are served, 1 on a shortfall and 2 when it cannot ask.
  • FR-007: The effect MUST take the zone from zone at eval time, or from zoneCommand at run time into $OCTODNS_ZONE_DIR/<domain>.
  • FR-008: When netboxZone or kvTxt is set, the default zoneCommand MUST run netbox-zone.py and then kv-txt.py.
  • FR-009: netbox-zone.py MUST write node records at TTL 300, the apex NS at TTL 300, other records at TTL 86400, and a placeholder SOA.
  • FR-010: The effect MUST pass each provider secret to OctoDNS as the env var <PROVIDER>_<SECRET>, read from a file at run time, and never put it in the store.
  • FR-011: kv-secret.py MUST read each kvSecrets entry from OpenBao KV at secret/<instance>/<file> before OctoDNS runs.
  • FR-012: The effect MUST support the providers desec, hetzner, powerdns and bind (RFC2136 with a TSIG key, hmac-sha256 by default).
  • FR-013: The provider MUST carry update_pcent_threshold = 1.0, and under ownership also delete_pcent_threshold = 0.05.
  • FR-014: Under ownership, the processors MUST be ownership and then no-withdrawals; otherwise they MUST be no-withdrawals and then apex-authority.
  • FR-015: Under ownership, the effect MUST NOT change, delete or adopt a record that it did not write.
  • FR-016: When openbao is set, kv-txt.py MUST process every kvTxt label and then exit non-zero if one read failed.
  • FR-017: The effect MUST remove its scratch directory on exit.
  • FR-018: zone MUST have the type dns.lib.types.subzone of dns.nix: the records of the zone apex and its subdomains, with no SOA and no zone TTL. The effect adds the SOA when it renders the zone file for domain. An unknown record type MUST fail at evaluation.
  • FR-019: providers.<name>.configuration MUST hold mkOption declarations. When provider is <name>, they are the options of the top-level configuration, which types each value. A value in place of a declaration MUST fail at evaluation.

Key Entities

  • Rendered zone: an RFC1035 zone file for <domain>, from zone or from zoneCommand.
  • Provider: the DNS service that sync writes to, with its secrets.
  • Ownership marker: the OctoDNS ownership records that mark the records this effect wrote.
  • KV TXT value: a public value that a node wrote to OpenBao KV under kvMount/kvPrefix.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: After sync, verify exits 0 for the rendered zone.
  • SC-002: A second plan after sync shows no change.
  • SC-003: Under ownership, the count of unowned records is the same before and after sync.
  • SC-004: No provider secret occurs in the store or in the rendered configuration.
  • SC-005: nix-unit-env-config passes, so the verbs read no env key that the effect does not declare.

Assumptions

  • The DNS service API is reachable from the machine that runs the verb.
  • Under netboxZone, the apply of the tf-incus method already recorded the addresses in NetBox.
  • Under kvTxt, the nodes already wrote their values to OpenBao KV.
  • The OctoDNS version keeps the percentage thresholds on the provider. Zone-level thresholds are discarded.