Feature Specification: Effect netbox-seed

Created: 2026-10-07 Status: Implemented Input: Reconstructed from the code at 093e68b6f (core/effects/tf/netbox/seed/effect.nix, core/effects/tf/netbox/seed/main.tf, core/effects/common/standalone-env.nix, core/render/catalog.nix, core/setups/hosts-common.nix).

The shared behavior of a standalone effect is in spec.md. The graph grammar, the verbs and the state rules are in ../004-deployment-programs/spec.md. The incus deploy that reads the seeded objects is in ../009-deployment-methods/tf-incus.md. This file does not repeat them.

User Scenarios & Testing (mandatory)

User Story 1 - Seed a fresh NetBox for the incus deploy (Priority: P1)

An operator has a NetBox instance with no fediversity objects. They run netbox-seed once with NETBOX_SERVER_URL and a token file. The effect creates the attribution tags, the cluster type and the cluster. The incus deploy then finds these objects by name.

Why this priority: The incus deploy reads the cluster and the tags. It does not create them. Without the seed, the deploy has no cluster to put its virtual machines in.

Independent Test: No check applies the seed graph itself. Check apps-tf-incus creates the equivalent objects through the tf-netbox-* effects instead.

Acceptance Scenarios:

  1. Given an empty NetBox and NETBOX_SERVER_URL set, When the operator runs netbox-seed, Then NetBox holds tag fediversity, cluster type Incus and cluster incus.
  2. Given the seed has run, When the operator runs netbox-seed again with the same inputs, Then tofu reports no changes.
  3. Given the seed has run, When the operator reads the outputs, Then they see cluster_id, tenant_id, cluster_type_id and tag_slugs.

User Story 2 - Seed a second deployment in the same cluster (Priority: P2)

An operator runs two deployments against one NetBox cluster. They set NETBOX_TENANT for each seed. Each seed creates one tenant. Each deploy then keys its virtual machines on its own tenant.

Why this priority: NetBox keys a virtual machine's uniqueness on cluster, tenant and name. Two deployments without tenants collide, and NetBox returns a 400.

Independent Test: No check covers a tenant seed.

Acceptance Scenarios:

  1. Given NETBOX_TENANT=a, When the operator runs netbox-seed, Then NetBox holds tenant a with the attribution tags.
  2. Given NETBOX_TENANT reaches the deploy but not the seed, When the deploy runs, Then the deploy fails because the tenant does not exist.

User Story 3 - Seed against pre-existing objects (Priority: P3)

An operator's NetBox already holds the cluster type or the tags. They set createClusterType = false or createTags = false. The effect then reads the cluster type by name and does not manage the tags.

Why this priority: A shared NetBox can hold objects that other teams own. The seed must not take ownership of them.

Independent Test: No check covers the read-only modes.

Acceptance Scenarios:

  1. Given cluster type Incus exists and createClusterType = false, When the seed applies, Then the cluster refers to the existing type and the state holds no netbox_cluster_type.
  2. Given NETBOX_TAGS='[]', When the seed applies, Then it creates no tags and the cluster carries no tags.

User Story 4 - Plan without a change (Priority: P3)

An operator runs netbox-seed-plan against production NetBox. The plan shows the changes and creates nothing.

Why this priority: A plan that applies changes production without review.

Independent Test: Check integrations-plan-does-not-apply asserts that TF_VERB=plan holds, and that BOOTSTRAP_NO_HTTP_BACKEND=1 beats an exported TF_HTTP_ADDRESS.

Acceptance Scenarios:

  1. Given an empty NetBox, When the operator runs netbox-seed-plan, Then NetBox still holds no cluster.

Edge Cases

  • NETBOX_SERVER_URL is unset: the graph evaluates, and tofu fails at run time on the empty URL.
  • NETBOX_CLUSTER_NAME is empty: the effect creates no cluster and does not read the cluster type.
  • NETBOX_TAGS is not a JSON array: evaluation fails.
  • NETBOX_SEED_STATE is unset: the state is a local file, keyed on the cluster name. A second workstation does not see it.
  • Two seeds use different cluster names: each gets its own state key, so the second seed does not rename the first cluster.
  • TF_VAR_netbox_api_token is exported: it wins over apiTokenFile.

Requirements (mandatory)

Functional Requirements

  • FR-001: The effect MUST create a netbox_tag for each name in tagNames when createTags is true, and none when it is false.
  • FR-002: The effect MUST create a netbox_cluster_type named clusterTypeName when createClusterType is true and clusterName is not empty.
  • FR-003: The effect MUST read the cluster type by name when createClusterType is false and clusterName is not empty.
  • FR-004: The effect MUST create a netbox_cluster named clusterName, tagged with tagNames, only when clusterName is not empty.
  • FR-005: The effect MUST create a netbox_tenant named tenantName, tagged with tagNames, only when tenantName is not empty.
  • FR-006: The cluster and the tenant MUST depend on the tags, because NetBox resolves tags by name.
  • FR-007: The effect MUST read the API token at run time from apiTokenFile, and an exported TF_VAR_netbox_api_token MUST win.
  • FR-008: The derivation MUST hold only the token file path, never the token.
  • FR-009: The state key MUST end in clusterName, so that seeds for different clusters keep separate state.
  • FR-010: With httpBackend = null, the state MUST be a local file, and an exported TF_HTTP_ADDRESS MUST NOT change it.
  • FR-011: NETBOX_SEED_STATE MUST set the HTTP backend address. The effect MUST NOT read TF_HTTP_ADDRESS for it.
  • FR-012: The effect MUST read only its declared env keys: NETBOX_SERVER_URL, NETBOX_SEED_STATE, NETBOX_API_TOKEN_FILE, NETBOX_CLUSTER_NAME, NETBOX_TENANT, NETBOX_CLUSTER_TYPE_NAME and NETBOX_TAGS.
  • FR-013: An unset env key MUST keep the effect's default.
  • FR-014: Each env key MUST have the same name as the key the incus deploy reads, so that seed and deploy agree.
  • FR-015: The effect MUST expose the verbs of tfVerbs as netbox-seed and netbox-seed-<verb>.
  • FR-016: The effect MUST use provider e-breuninger/netbox at the version in npins. main.tf gives no version. The plugin mirror of the effect holds only the pinned version, and the lock file that tofu init writes records it.
  • FR-017: The effect MUST declare no effect-meta and MUST be in buildingBlocks, so the operator form does not offer it. The operator runs it as the standalone netbox-seed.

Key Entities

  • Tag: an attribution marker, by default fediversity. Its description names the group.
  • Cluster type: by default Incus. The seed creates it or reads it.
  • Cluster: by default incus. The incus deploy puts its virtual machines here.
  • Tenant: optional. It separates the virtual machines of two deployments in one cluster.
  • Seed state: an OpenTofu state, keyed on the cluster name. It is a local file, or it lives at NETBOX_SEED_STATE.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: After one apply on an empty NetBox, the incus deploy finds its cluster and tags by name.
  • SC-002: A second apply with the same inputs reports zero changes.
  • SC-003: A plan creates zero NetBox objects.
  • SC-004: nix-unit-env-config passes, so the effect reads no undeclared env key.
  • SC-005: nix-unit-effects-tf-provider-pins passes, so main.tf has no version literal, and tf-providers passes, so the lock file of the effect selects the netbox version in npins.
  • SC-006: nix-unit-effects-registry passes, so the effect is in buildingBlocks and declares no effect-meta.

Assumptions

  • The operator has a NetBox API token with write access to tags, cluster types, clusters and tenants.
  • The NetBox instance is reachable from the machine that runs the effect.
  • The operator runs the seed before the first incus deploy.
  • Tags that the deploy names in netboxTags exist before the deploy. The seed creates them, or another party does.

Known Deviations

  • No check applies the seed graph. apps-tf-incus creates the same kinds of objects through the tf-netbox-* effects.