Plan before you push — the change-control gate

Problem — fabric changes should ship like code: proposed in a branch, reviewed as a diff, applied on merge, idempotent on re-run. Designs make this natural — they are code, plan is the review artifact, push is the deployment, and because pushes are upserts, re-running converges instead of duplicating. This recipe turns the commerce designs into a CI pipeline whose gate is the plan.

A design module is import-safe

Building a design performs no I/O, so CI can import every module just to plan it. One module per logical unit of intent, each exporting a build():

from niwaki.design import tenant


def build():
    config = tenant("commerce")
    config.vrf("prod")
    config.bd("bd-web", unicast_routing=True).bind(vrf="prod").subnet("10.30.10.1/24")
    return config

Nothing above touches the network — the module is safe to load in a linter, a test, or a plan-only CI job.

The runner

Credentials come from the environment — the CI runner’s secret store, never the repository. The runner prints the plan as a diff, and only writes when asked:

import os

from niwaki import Niwaki


def apply(config, *, plan_only: bool) -> bool:
    with Niwaki() as aci:                     # APIC_* environment variables
        plan = config.push(aci, mode="plan")
        for dn in plan.creates:
            print(f"+ {dn}")
        for dn, fields in plan.updates.items():
            for field, (current, desired) in fields.items():
                print(f"~ {dn} {field}: {current!r} -> {desired!r}")
        if plan_only or not plan.has_changes:
            return plan.has_changes
        config.push(aci)
        return plan.has_changes


# In CI these come from the runner's secret store; set here only so the page is
# self-contained and runnable.
os.environ["APIC_HOST"] = "https://apic.example.com"
os.environ["APIC_USERNAME"] = "admin"
os.environ["APIC_PASSWORD"] = "from-the-secret-store"

changed = apply(build(), plan_only=True)     # the merge-request job
assert changed is True                        # empty fabric: the tenant is new

The + / ~ lines are the merge-request comment: reviewers approve DNs and field transitions, not screenshots.

Apply on merge, and converge

The main-branch job drops plan_only. Run it twice and watch idempotence: the first apply writes, the second is a no-op because the design is converged:

apply(build(), plan_only=False)               # main-branch job: writes
assert apply(build(), plan_only=False) is False   # re-run converges to a no-op

The pipeline

Plan on every merge request, push on merge to main — the same two commands whatever the CI system:

plan:            # merge-request job
  script: python apply.py --plan

apply:           # main-branch job, behind a protected environment
  script: python apply.py

Why this works

  • Eager failure — reference typos and bad values fail the plan job, in the merge request, before anything reaches the fabric (When a push fails).

  • Reviewable atomicity — one design merges as one atomic strict POST: the fabric never holds half a merge request.

  • Idempotence — a re-run after a flaky runner converges instead of duplicating; a green re-run is a no-op (plan.has_changes is False).

Variations & pitfalls

  • Never echo secrets — the plan output is configuration, not credentials; keep it that way by never printing the environment in CI logs (Connection & transport).

  • Many small designs — one per tenant, one per rack; each plan then reads as one reviewable change, and the blast radius of any apply is one design.

  • Deletions stay explicit — a design never prunes; removals are their own reviewed change (a script around aci.node(...).delete()), not a side effect of a merge.

  • Lab first — point the same pipeline at a lab APIC through the environment, not the code: designs are environment-free by construction.