Skip to content

Declare DHCP reservations on the guest shapes and reconcile them - #423

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/dhcp-reservations
Aug 16, 2026
Merged

Declare DHCP reservations on the guest shapes and reconcile them#423
ChrisonSimtian merged 1 commit into
mainfrom
feat/dhcp-reservations

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Closes #416.

Important

Draft — blocked on Chrison-dev/UnifiSharp#17. The engine pins Chrison.UnifiSharp 0.2.0, which isn't on nuget.org yet, so restore fails in CI until that PR merges and a v0.2.0 tag ships the package. Everything here was built and tested against a locally-packed 0.2.0.

What this does

A member declares its reservation on its own shape:

# stacks/monitoring/podman-host.lxc.yaml
  network:
    reservation:
      fixedIp: 10.10.204.35
      localDnsRecord: monitoring.homelab.chrison.internal

…and converge reconciles it after create, reading the guest's live MAC off pct config rather than making the shape predict one. That was the design question #416 left open, and reading it live keeps the existing decision not to pin hwaddr (homeassistant.lxc.yaml:73) intact. The UniFi network is resolved from the VLAN the shape already declares, so it's never restated.

▸ podman-host  (ctid 4001, app 'podman', node hpe-01)
    unifi: net0: reconcile the UniFi reservation → 10.10.204.35 + DNS monitoring.homelab.chrison.internal

Two blockers had to go first

converge-unifi could not authenticate at all. The legacy adapter only knew session auth, so it wanted UNIFI_USERNAME/UNIFI_PASSWORD — and secrets.env has never carried them. The path that owns the public :443 port-forward was dead on any machine rebuilt from secrets-sync.sh:

$ dotnet run --project Infrastructure/engine -- converge-unifi Infrastructure/unifi/network.yaml
Missing UniFi legacy config. Set UNIFI_LEGACY_BASE_URL (…), UNIFI_USERNAME, UNIFI_PASSWORD …

Now, from the same shell:

converge-unifi: homelab-ingress (1 port-forward(s) declared) — dry-run
  = pangolin-https (present)
All declared port-forwards present and matching — nothing to do.

UnifiConverge.Plan matched on name alone, so a port-forward whose target had been re-pointed in the UI read as AlreadyPresent and converge reported success forever — the same write-once failure the CF Access bypass had (#417). It now compares field by field and corrects the drift. That had to be true before reservations were worth building, because drift is the failure mode for a reservation.

Three guardrails

  • Add-only by identity. The controller keeps a row per MAC it has ever seen — 242 of them against 19 reservations — so every write is keyed to a MAC a shape declares. Undeclared reservations are reported, never pruned.
  • parked: records why a reservation is held for a deliberately-stopped guest, so it is neither resurrected nor flagged as drift on every run. This matters more than it sounds: of the 22 reservations audited, four were parked (HA VM 2000's three NICs, Plex-VM) and three were genuine orphans, and the only way to tell them apart is to cross-check Proxmox by hand.
  • A shape that declares no localDnsRecord doesn't claim the field, so converge never blanks a record that was set by hand for a reason.

The audit command

$ homelab-infra unifi-reservations stacks
unifi-reservations — 4 declared across 7 stack(s), 19 live on the controller

  = 10.10.182.200    Media/audiobookshelf  (present)
  = 10.10.255.254    Media/podman-host  (present)
  = 10.10.15.6       Media/seerr  (present)
  = 10.10.204.35     Monitoring/podman-host  (present)

15 reservation(s) on the controller that no shape declares:
  ? 10.0.0.10        DS1813-01  dns=nas.homelab.chrison.internal
  …

It matches on address, not MAC, so it runs from the repo alone — no Proxmox, nothing converged first.

A subtle fix worth reviewing

member.Network ??= defaults.Network was all-or-nothing, so a member declaring only network.reservation would have silently forfeited its bridge, VLAN and address, and been rendered onto the wrong network. It's field-wise now — a no-op for all three shapes that declare the block today. A default reservation is deliberately not inherited, or every member in a stack would be handed the same fixed address. Both cases are tested.

Scope

Backfills the four in-tree reservations. SmartHome (9) and DevOps (1) are submodules and follow separately — their validate.yml pulls the schema from the schema-v1 release, so that asset has to carry the new reservation key before they can adopt it. Four more have no guest shape to live on: the NAS, the Zigbee gateway, PDM (CT 2002, no shape exists) and Plex-VM.

Verification

  • 288 engine tests pass (17 new for the reconcile, 2 for the merge fix, 3 for port-forward drift).
  • All in-tree stacks validate against the updated schema.
  • converge-unifi and unifi-reservations both run live against the real controller.
  • The reservation write path is unit-tested but has not yet been exercised live — live state already matches the four backfilled shapes, so the first real converge should report ok for each. The underlying PUT /rest/user/<id> is proven: it's what cleared the three orphans found during the audit.

🤖 Generated with Claude Code

22 reservations were hand-made, five shapes across four stacks carried "⚠ needs a
DHCP reservation" comments, and nothing detected a lost or edited one. Six of them
now carry a per-client local_dns_record, so the hand-made object stopped being just
an address — it is the name services are targeted by.

A member declares `network.reservation` and converge reconciles it after create,
reading the guest's live MAC off `pct config` rather than making the shape predict
one. That keeps the existing decision not to pin hwaddr intact, and the UniFi
network is resolved from the VLAN the shape already declares.

Two blockers had to go first.

converge-unifi could not authenticate at all. The legacy adapter only knew session
auth, so it wanted UNIFI_USERNAME/UNIFI_PASSWORD, and secrets.env has never carried
them — meaning the path that owns the public :443 port-forward was dead on any
machine rebuilt from secrets-sync.sh. Chrison.UnifiSharp 0.2.0 adds X-API-KEY on the
legacy API and derives its site URL from UNIFI_LOCAL_HOST.

UnifiConverge.Plan matched on name alone, so a port-forward whose target had been
re-pointed in the UI read as "present" and converge reported success forever. It now
compares field by field and corrects the drift — the same write-once failure the CF
Access bypass had (#417). Drift IS the failure mode for reservations, so that had to
be true before this was worth building.

Three guardrails, all load-bearing:

- Add-only by identity. The controller holds a row per MAC it has ever seen — 242 of
  them against 19 reservations — so every write is keyed to a MAC a shape declares.
  Undeclared reservations are reported as candidates, never pruned.
- `parked:` states why a reservation is held for a deliberately-stopped guest, so it
  is neither resurrected nor flagged as drift every run. Without it the only way to
  tell a parked reservation from an orphan is to cross-check Proxmox by hand.
- A shape that declares no localDnsRecord does not claim the field, so converge never
  blanks a record set by hand.

`homelab-infra unifi-reservations <stacks-dir>` audits declared against live and
names what no shape accounts for. It matches on address, not MAC, so it runs from the
repo alone — no Proxmox, nothing converged first.

Backfills the four in-tree reservations (Media seerr/audiobookshelf/podman-host,
monitoring podman-host). SmartHome and DevOps hold eleven more and are submodules, so
they follow separately once the schema-v1 release carries the new key.

Also fixes the stack-defaults merge for `network`, which was all-or-nothing: a member
declaring only `network.reservation` would have silently forfeited its bridge, VLAN
and address. Field-wise now, and a no-op for all three shapes that declare the block.
A default reservation is deliberately not inherited — it would hand every member in
the stack the same fixed address.

Refs #416.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisonSimtian
ChrisonSimtian marked this pull request as ready for review August 16, 2026 05:19
@ChrisonSimtian

Copy link
Copy Markdown
Collaborator Author

The blocker is cleared: Chrison.UnifiSharp 0.2.0 is on nuget.org (UnifiSharp#17 merged, tagged v0.2.0). Verified from a cleared package cache:

  • dotnet build restores 0.2.0 from nuget.org — no local shim.
  • 288 tests pass.
  • converge-unifi and unifi-reservations both run live against the real controller.

One gotcha if anyone hits NU1102 on a machine that tried to restore before the release finished indexing: Chrison.UnifiSharp.Api 10.4.57 indexed several minutes after Chrison.UnifiSharp 0.2.0 did, and NuGet caches the version index. dotnet nuget locals http-cache --clear fixes it.

@ChrisonSimtian
ChrisonSimtian merged commit b22f877 into main Aug 16, 2026
6 of 12 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/dhcp-reservations branch August 16, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request iac networking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declare DHCP reservations as IaC — converge-unifi models port-forwards and nothing else

1 participant