Feature Specification: Resource netbox

Created: 2026-10-07

Status: Implemented

Input: Reconstructed from the code at 093e68b6f (core/resources/netbox/default.nix, core/resources/netbox/contract.nix, core/resources/netbox/self-hosted.nix, core/resources/netbox/external.nix, core/lib/coordinate-resource.nix, core/lib/coordinate-provider.nix, core/setups/wiring/netbox-routing.nix, core/setups/wiring/resource-routing.nix, core/setups/hosts-common.nix, core/components/hosting-provider/windmill/default.nix), pull request #1234.

User Scenarios & Testing (mandatory)

The netbox resource gives a node the coordinates of the NetBox IPAM of its deployment: the server URL, a path to the API token, and the cluster, tenant and pool to allocate from. It does not allocate addresses. OpenTofu on the deployer allocates them. The shared resource rules are in the resource family spec. The resource has the same shape as dns: both use core/lib/coordinate-resource.nix.

User Story 1 - Find a self-hosted NetBox (Priority: P1)

A node needs to reach the NetBox node that its deployment stands up. The netbox setup argument has no serverUrl. The routing row selects the netbox provider and sets fediversity.netbox-self-hosted.host to the FQDN of the node that provides netbox. The result is serverUrl = "http://<host>:8001".

Why this priority: a deployment without an external IPAM allocates from its own NetBox.

Independent Test: nix-unit-setups-netbox-routing, tests test-self-hosted-result and test-roster-keeps-self-hosted-node.

Acceptance Scenarios:

  1. Given a self-hosted route with host = "netbox.example.com", When the result renders, Then serverUrl is http://netbox.example.com:8001 and apiTokenFile is "".
  2. Given a self-hosted route with host = "", When the result renders, Then serverUrl is "", and the incus graph finds the address at apply time.

User Story 2 - Use an external NetBox (Priority: P1)

A hosting provider already runs NetBox. The netbox setup argument has a non-empty serverUrl. The routing row selects the external provider and passes serverUrl and apiTokenFile. The roster drops the node that provides netbox. Each external setting that is not empty replaces the matching request field.

Why this priority: the deployment must allocate from the IPAM that the hosting provider already uses.

Independent Test: nix-unit-setups-netbox-routing, tests test-external-result, test-external-overrides-request, test-external-overrides-range, test-external-overrides-tenant and test-roster-drops-node-when-external.

Acceptance Scenarios:

  1. Given an external route, When the result renders, Then serverUrl and apiTokenFile come from fediversity.netbox-external.
  2. Given an external prefixSelector with a non-empty kind, When the result renders, Then it replaces the requested selector.
  3. Given an external route, When the roster is built, Then it has no node that provides netbox.

User Story 3 - Name the address pool, or fail at eval (Priority: P2)

An operator states the pool through NETBOX_PREFIX_SELECTOR (<kind>:<value>) or through effect-defaults.<tag>.netbox.prefixSelector in HOSTING_CONFIG. requirePool returns the coordinates when they hold a prefixSelector. It returns null for no coordinates. Coordinates without a prefixSelector fail the evaluation with a message that names both inputs.

Why this priority: an allocation without a pool picks addresses from the wrong range.

Independent Test: nix-unit-setups-netbox-routing, tests test-pool-missing-is-an-error, test-pool-alone-is-enough and test-pool-absent-coordinates-are-null.

Acceptance Scenarios:

  1. Given coordinates without prefixSelector, When requirePool evaluates, Then the evaluation fails.
  2. Given only a prefixSelector, When requirePool evaluates, Then it returns the coordinates.

User Story 4 - Pass the API token as a path (Priority: P3)

The deployer reads the NetBox API token at apply time. The secret environment of the incus hosts graph carries netbox_api_token as the path apiTokenFile. The token value never enters the Nix store.

Why this priority: the token is a credential.

Independent Test: nix-unit-setups-netbox-routing, tests test-secret-env-carries-path and test-secret-env-empty-without-coordinates.

Acceptance Scenarios:

  1. Given apiTokenFile = "/run/secrets/netbox-api-token", When the secret environment renders, Then netbox_api_token is that path.

Edge Cases

  • The external provider is inert when its serverUrl is "".
  • An empty rangeSelector.kind means the whole prefix.
  • kind is a free string: cidr, id, or a NetBox prefix filter name.
  • The self-hosted token lives in OpenBao KV. core/effects/tf/incus/hosts/main.tf reads it, which works only from the second apply.

Requirements (mandatory)

Functional Requirements

  • FR-001: The netbox contract request MUST hold clusterName, tenant, prefixSelector, rangeSelector and allocateIpv4. The result MUST hold serverUrl, apiTokenFile and those five fields.
  • FR-002: The policy MUST default clusterName to incus, tenant to "", prefixSelector to { kind = "tag"; value = "fediversity"; }, rangeSelector to an empty selector and allocateIpv4 to false, and MUST copy the five into the request.
  • FR-003: The deploy MUST NOT create NetBox infrastructure. The cluster and the pool MUST already exist.
  • FR-004: The netbox provider MUST fulfill serverUrl = "http://<host>:<port>", or "" when host is empty, and apiTokenFile = "". The default port is the port that netbox.nix binds, read from core/resources/netbox/gunicorn-port.nix.
  • FR-005: The external provider MUST pass serverUrl and apiTokenFile through and MUST replace each request field other than allocateIpv4 with its own value when that value is not empty. It MUST pass allocateIpv4 through.
  • FR-006: netbox-routing.nix MUST treat NetBox as external only when the netbox argument is not null and has a non-empty serverUrl.
  • FR-007: requirePool MUST fail the evaluation for coordinates without prefixSelector.
  • FR-008: core/setups/hosts-common.nix MUST set "ipam".netbox with enable = true, the setup coordinates where present, and routing = mkDefault providerModules.netbox, and MUST add the consumerSide of its process to each node.
  • FR-009: The netbox request MUST hold only data: enable and consumerName. The consumerSide MUST declare the want of the request, so the node gets the contract result at contracts.netbox.results.<consumerName>.default. A client module MUST import core/resources/netbox/contract.nix to read it.

Key Entities

  • netbox contract: local contract in core/resources/netbox/contract.nix.
  • Selector: { kind; value; } for prefixSelector and rangeSelector.
  • fediversity.netbox-self-hosted: options host and port.
  • fediversity.netbox-external: options serverUrl, apiTokenFile, clusterName, tenant, prefixSelector and rangeSelector.
  • Consumer windmill: requests "ipam".netbox with consumerName = "windmill". A client module imports the contract module and, gated on the result, builds the operator curation from contracts.netbox.results.windmill.default.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: nix-unit-setups-netbox-routing passes.
  • SC-002: nix-unit-components-operator-curation passes.
  • SC-003: nix-unit-resources-coordinate-consumer and nix-unit-resources-plain-requests pass.

Assumptions

  • The NetBox cluster, tenant and prefix exist before a deploy.
  • The external API token is a file on the deployer.

Known Deviations

  • The self-hosted route reads its token from OpenBao KV only from the second apply. The first apply of a cold deployment cannot reach the self-hosted NetBox. The external route has no such asymmetry.