Changing Your IPAM Should Not Rewrite Every Deployment

J

Jeleel Muibi

Guest
Suppose a network team moves authoritative IPAM data from NetBox to Nautobot.

The deployment still needs the same thing it needed yesterday: trusted prefix and inventory data before it creates infrastructure. Yet operation definitions across the environment now need editing because they all say requires_authority: netbox.

That migration should alter one binding. Instead, it has spread into unrelated consumers.

The operation depends on an authority capability, while the environment decides which product supplies it. When both decisions occupy the same field, a product choice becomes part of every consumer's contract.

Disclosure: The implementation examples in this article come from HybridOps Core, which I maintain. I have no affiliation with NetBox or Nautobot.

Start With the Requirement​


NetBox describes a network source of truth as the data foundation that tells automation what assets are in scope and what intended network state should be. Nautobot describes itself as a network source of truth and automation platform. Each product brings its own data model, APIs, extensions, and operating surface to that role.

A consuming operation has a narrower question: does it need authoritative IPAM inventory, approved device inventory, DNS ownership, placement data, or some other governed input?

This distinction builds on an argument I made in Your Automation Pipeline Is Not a Source of Truth. The pipeline should consume declared intent rather than become the place where intent is hidden. But once a real authority exists, another coupling problem appears: workflows often depend on the authority's product name instead of the role it performs.

A product-coupled declaration might look harmless:

Code:
steps:
  - id: allocate_platform_address
    module_ref: platform/onprem/platform-vm
    contracts:
      addressing_mode: ipam
      requires_authority: netbox

The word netbox is carrying too much meaning. It stands for a product, an instance, a data capability, a readiness expectation, and possibly an authentication method. Its actual requirement remains unstated.

The practical cost appears during migration. Every consumer must be found and edited, followed by another search for product-specific assumptions that were hidden by the original declaration.

Declare the Capability, Then Bind the Provider​


A cleaner contract gives the authority a logical name and states its capability:

Code:
authorities:
  primary_ipam:
    capability: inventory_ipam
    provider: nautobot
    config:
      endpoint_env: NAUTOBOT_API_URL
      token_env: NAUTOBOT_API_TOKEN
      api_version: "2.4"
      instance_id: shared-nautobot

steps:
  - id: allocate_platform_address
    module_ref: platform/onprem/platform-vm
    contracts:
      addressing_mode: ipam
      requires_authority:
        ref: primary_ipam
        capability: inventory_ipam

The step now expresses a stable requirement: it needs inventory_ipam from the environment's primary_ipam authority. The environment binds that logical authority to Nautobot.

If the environment uses NetBox, the step does not change. Its binding does:

Code:
authorities:
  primary_ipam:
    capability: inventory_ipam
    provider: netbox

Now the provider can change without changing the operation. The adapter remains responsible for proving that its product can satisfy the contract.

The runtime completes that boundary by enforcing it. Reduced to its essential control flow, the resolver looks like this:

Code:
class AuthorityResolver:
    def enforce(self, requirement, declarations, context):
        declaration = declarations.get(requirement.logical_ref)
        if declaration is None:
            raise ValueError(
                f"missing authority binding: {requirement.logical_ref}"
            )

        if declaration.capability != requirement.capability:
            raise ValueError(
                f"authority capability mismatch: "
                f"{declaration.capability} != {requirement.capability}"
            )

        provider = self._registry.resolve(declaration.provider)
        if requirement.capability not in provider.capabilities:
            raise ValueError(
                f"{provider.name} does not support "
                f"{requirement.capability}"
            )

        provider.validate_configuration(declaration, context)
        evaluation = provider.evaluate(declaration, context)
        if not evaluation.healthy:
            raise ValueError(
                f"authority {requirement.logical_ref} is not ready: "
                f"{evaluation.result}"
            )

        return AuthorityReceipt(
            logical_ref=requirement.logical_ref,
            capability=requirement.capability,
            provider=provider.name,
            state=evaluation.state,
            result=evaluation.result or "admitted",
            instance=evaluation.instance,
            endpoint=evaluation.endpoint,
            revision=evaluation.revision,
            freshness=evaluation.freshness,
        )

The provider name resolves a registered adapter. The execution path remains driven by a capability requirement and a provider evaluation, which is the boundary the configuration promises.

Figure 1. The operation declares the capability it needs; the environment selects and verifies the provider, then records a secret-free authority receipt.


Scope the Contract to a Capability​


The contract is deliberately scoped to inventory_ipam. NetBox and Nautobot retain their own synchronization behavior, allocation workflows, custom fields, jobs, plugins, deployment models, and lifecycle concerns. The shared binding establishes one fact: the configured provider can satisfy this operation's IPAM authority requirement.

A vague source_of_truth interface would become a lowest-common-denominator wrapper or accumulate product checks in the generic execution path. Neither outcome creates a useful boundary.

When an operation needs a provider-specific feature, its contract can name a provider-specific capability. The declaration then remains precise about the dependency.

Capability names also need operational meaning. inventory_ipam is stronger than api_available. The former tells the reader why the operation needs the authority. The latter only describes a transport condition.

Provider-Neutral Still Requires Provider Checks​


Provider neutrality changes where validation lives. Product-specific checks move behind the adapter boundary while the operation keeps a stable contract.

Before admitting an operation, the resolver should answer several questions:

  1. Does the logical authority binding exist?
  2. Does its declared capability match the operation's requirement?
  3. Is the selected provider registered for that capability?
  4. Is the provider configuration valid?
  5. Can that provider prove the authority is ready now?

The fifth check remains provider-specific because readiness means different things for different providers.

For one NetBox-backed environment, readiness may begin with a retained module state and an optional authenticated API probe. If state freshness is bounded, an absent or old timestamp must block the operation rather than pass on the memory of an earlier successful run.

For Nautobot, the adapter can use the documented health endpoint and then make an authenticated read against the IPAM API. The second request is required because service health and authorized data access are separate conditions. Nautobot's REST authentication and versioned API give the adapter a product-specific way to verify that path.

The two requests serve different purposes in the adapter:

Code:
def verify_nautobot_authority(
    base_url, token, api_version, timeout_s, verify_tls
):
    health_error, _ = _probe(
        f"{base_url}/health/",
        headers={"Accept": "text/html"},
        timeout_s=timeout_s,
        verify_tls=verify_tls,
        product="Nautobot health endpoint",
    )
    if health_error:
        return AuthorityEvaluation(
            healthy=False,
            state="unhealthy",
            result=health_error,
        )

    accept = "application/json"
    if api_version:
        accept += f"; version={api_version}"

    api_error, headers = _probe(
        f"{base_url}/api/ipam/prefixes/?limit=1",
        headers={
            "Authorization": f"Token {token}",
            "Accept": accept,
        },
        timeout_s=timeout_s,
        verify_tls=verify_tls,
        product="Nautobot IPAM API",
        expect_paginated_json=True,
    )
    if api_error:
        return AuthorityEvaluation(
            healthy=False,
            state="unhealthy",
            result=api_error,
        )

    revision = headers.get("API-Version", api_version)
    return AuthorityEvaluation(
        healthy=True,
        state="ready",
        result="admitted",
        revision=revision,
    )

The first response establishes service health. The second proves that the configured credential can reach the expected API and that the response has the shape the adapter understands. The API route is admitted only when it returns the expected paginated JSON; an HTML response with a 200 status fails the check.

This is where fail-closed behavior earns its keep. Missing bindings, unknown providers, capability mismatches, rejected credentials, stale state, unreachable endpoints, invalid response shapes, and unhealthy instances should stop the dependent operation.

The shared obligation is simple: every provider must prove readiness through its own checks.

Record Which Authority Admitted the Run​


Passing the gate should leave a receipt showing which authority was used. A reduced record might look like this:

Code:
{
  "logical_authority": "primary_ipam",
  "capability": "inventory_ipam",
  "provider": "nautobot",
  "verified_state": "ready",
  "result": "admitted",
  "instance": "shared-nautobot",
  "endpoint": "https://<authority-endpoint>",
  "revision": "2.4",
  "freshness": {
    "source": "live",
    "checked_at": "<timestamp>"
  }
}

The receipt answers the question that matters during review or recovery: which authority allowed this operation to proceed?

It should identify the logical authority, capability, provider, instance, and verification result without copying a token into the run record. A secret-free receipt is useful for audit and diagnosis. A credential-bearing receipt is a new incident.

This also prevents provider neutrality from erasing attribution. The operation contract remains stable, while the run record still says exactly which provider and instance were consulted.

Compatibility Is Part of the Design​


Existing contracts rarely disappear on the day a better model is introduced.

Suppose older blueprints contain this form:

Code:
policy:
  ipam_authority: netbox

contracts:
  addressing_mode: ipam
  requires_authority: netbox

Breaking every existing blueprint forces an unrelated rewrite. Quietly interpreting the old fields with new semantics makes compatibility impossible to reason about.

An explicit compatibility form avoids both outcomes. The old declaration can be translated internally to a named legacy authority with the inventory_ipam capability and the NetBox provider. Existing state resolution and live-check behavior remain covered by regression tests, while new blueprints use the canonical logical form.

The compatibility layer gives teams room to migrate without making the new contract ambiguous.

Prove the Boundary With Tests​


The decisive test is whether generic execution stays free of product branches. An interface can still hide a convenient if provider == "netbox" condition that restores the original coupling.

The tests therefore need to exercise the boundary itself. A useful regression set proves that:

  • the same logical requirement resolves through NetBox and Nautobot;
  • a registered test provider can satisfy the resolver without product code;
  • the generic executor contains no provider-specific runtime branch;
  • missing bindings and unknown providers fail closed;
  • mismatched and unsupported capabilities are rejected;
  • stale or unhealthy authority state blocks execution;
  • inline tokens are rejected from declarative configuration;
  • an API response must have the expected shape; and
  • canonical and legacy schema forms remain deliberate.

I maintain HybridOps Core, where this contract is implemented as a typed authority resolver with NetBox and Nautobot bindings. Its public implementation and regression suite cover the cases above. The same design test applies anywhere infrastructure operations depend on an external authority.

A Practical Design Test​


Take one operation that consumes authoritative data and ask five questions:

  1. Does the operation name the capability it needs, or only the product it uses today?
  2. Can the provider binding change without editing the operation?
  3. Can the selected adapter prove authenticated access to the required data, not merely server availability?
  4. Does a failed authority check stop execution before mutation?
  5. Does the run record show which authority admitted the operation without exposing credentials?

If the first two answers are no, the product choice has leaked into the operational contract. If the last three are no, the abstraction is hiding risk rather than controlling it.

The product serving as source of truth is an implementation choice; the authority an operation needs belongs to its contract. Keeping those decisions separate limits a provider migration to the place where the provider is actually selected.
 

Thread statistics

Created
Jeleel Muibi,
Replies
0
Views
16
Back
Top