feat(setup-host): auto-pick a free subnet — nested box-in-box with zero flags (#80) #91

Merged
dan-claude-bot merged 3 commits from feat/subnet-autopick into main 2026-07-18 22:15:22 +00:00
dan-claude-bot commented 2026-07-18 21:21:27 +00:00 (Migrated from github.com)

Completes #80's fix #1. #86 shipped the refuse half — "refuse … or automatically select a non-colliding subnet" — and left the nested case (drills and rehearsals inside a box, the flagship footgun #80 documented) stopping on a refusal that even named the workaround it could have applied itself. A bare box setup-host now applies it: it decides the subnet before any mutation, and everything downstream (BOX_GW, the bridge, the ACL's gateway carve-out, the firewall, the doctor's expectations) derives from that decision.

The four cases (deliberate)

# pin (BOX_SUBNET) boxnet bridge default 10.88.0.0/24 decision
1 set use the pin — refuse a foreign claimant, refuse a disagreeing bridge, exactly as in #86. A pin is never silently overridden.
2 unset exists converge to the bridge's own subnet — the bridge IS the pin (boxes hold leases on it; setup-host never re-addresses it). What used to be an agree-gate refusal on a bare re-run against a moved bridge is now plain convergence, announced when the bridge is off-default, silent on the plain default-host re-run.
3 unset none free 10.88.0.0/24, as always.
4 unset none claimed auto-pick: scan 10.89.0.0/2410.127.0.0/24 in order, take the first free /24 (per-candidate subnet_claimant), announce the pick, the claimant, and the BOX_SUBNET pin for scripts; give up with the old refusal only when every candidate is claimed.

Two precedence points worth calling out:

  • The scan only ever runs bridge-less. An existing bridge is case 2, which precedes case 4 entirely — so a bridge-owned candidate is never something the scan has to reason about (pinned by a test).
  • The poisoned state still refuses. Case 2 converges only when the bridge's subnet is otherwise free: a foreign claimant on the bridge's own subnet is exactly #80's poisoned state (bridge and uplink both on 10.88), and converging would rebuild the blackouts. That refusal names the bridge move (incus network set boxnet ipv4.address …) — pre-autopick, this state also refused (via the claimant gate), so no regression, just a sharper message.

Why explicit pins still refuse

A pin is the operator talking — a script that says BOX_SUBNET=10.90.0.0/24 must get 10.90 or a loud stop, never a silent substitute: every derived artifact (ACL carve-out, firewall, doctor expectations) would otherwise drift from what the script believes it built. Auto-pick is strictly the unpinned behavior.

Drills and rehearsals

The nested run — install.sh/drill/drill.sh inside a box whose own uplink owns 10.88.0.0/24 — now sails through on an auto-picked subnet with zero flags, announcing e.g.:

10.88.0.0/24 is claimed here by this machine's own DEFAULT GATEWAY (10.88.0.1 via enp5s0) —
most likely this machine IS a box (a nested drill or rehearsal, issue #80).
auto-picked 10.89.0.0/24 for this stack instead.
(pin it explicitly with BOX_SUBNET=10.89.0.0/24 if you script this host)

Neither the drill nor the doctor assumed the refusal; the doctor's one wording that did ("setup-host now refuses this") is updated. No overlap with #89's box-firewall.sh/doctor.sh UFW diff — box-firewall already reads the gateway off the live bridge, so it follows the pick unchanged.

Verification

  • bash test/cli.sh: 370 passed, 0 failed (339 on main — +31: choose_subnet extracted with its helpers and driven case by case under the shim ip, plus the driven whole-script fixtures: pinned refusals unchanged, bridge-present converge with no scan, default-free → default, default-claimed → 10.89, default+10.89 claimed → 10.90, all 40 candidates claimed → the old refusal naming 10.127.0.0/24 and BOX_SUBNET, the announcement text, and the bridge + ACL carve-out following the pick end to end).
  • shellcheck -x bin/* **/*.sh (CI's exact invocation): clean.
  • Live decision evidence, on a machine that IS a box with an existing nested stack (uplink 10.88.0.0/24, bridge previously remapped to 10.89.0.1/24#80's workaround host), exercising the extracted decision function read-only:
    • bare (no pin) → case 2: boxnet already lives on 10.89.0.0/24 — converging to it. → prints 10.89.0.0/24, rc 0 — no refusal, no scan;
    • pinned 10.88.0.0/24 → the #86 refusal, verbatim claimant: this machine's own DEFAULT GATEWAY (10.88.0.1 via enp5s0), rc 1;
    • pinned 10.89.0.0/24 (agrees with the bridge) → honored, rc 0.

🤖 Generated with Claude Code

Completes #80's fix #1. #86 shipped the refuse half — "refuse … **or automatically select a non-colliding subnet**" — and left the nested case (drills and rehearsals *inside a box*, the flagship footgun #80 documented) stopping on a refusal that even named the workaround it could have applied itself. A bare `box setup-host` now applies it: it decides the subnet before any mutation, and everything downstream (`BOX_GW`, the bridge, the ACL's gateway carve-out, the firewall, the doctor's expectations) derives from that decision. ## The four cases (deliberate) | # | pin (`BOX_SUBNET`) | `boxnet` bridge | default `10.88.0.0/24` | decision | |---|---|---|---|---| | 1 | set | — | — | **use the pin** — refuse a foreign claimant, refuse a disagreeing bridge, exactly as in #86. A pin is never silently overridden. | | 2 | unset | exists | — | **converge to the bridge's own subnet** — the bridge IS the pin (boxes hold leases on it; setup-host never re-addresses it). What used to be an agree-gate refusal on a bare re-run against a moved bridge is now plain convergence, announced when the bridge is off-default, silent on the plain default-host re-run. | | 3 | unset | none | free | **`10.88.0.0/24`**, as always. | | 4 | unset | none | claimed | **auto-pick**: scan `10.89.0.0/24` … `10.127.0.0/24` in order, take the first free `/24` (per-candidate `subnet_claimant`), announce the pick, the claimant, and the `BOX_SUBNET` pin for scripts; give up with the old refusal only when *every* candidate is claimed. | Two precedence points worth calling out: - **The scan only ever runs bridge-less.** An existing bridge is case 2, which precedes case 4 entirely — so a bridge-owned candidate is never something the scan has to reason about (pinned by a test). - **The poisoned state still refuses.** Case 2 converges only when the bridge's subnet is otherwise free: a *foreign* claimant on the bridge's own subnet is exactly #80's poisoned state (bridge and uplink both on `10.88`), and converging would rebuild the blackouts. That refusal names the bridge move (`incus network set boxnet ipv4.address …`) — pre-autopick, this state also refused (via the claimant gate), so no regression, just a sharper message. ## Why explicit pins still refuse A pin is the operator talking — a script that says `BOX_SUBNET=10.90.0.0/24` must get 10.90 or a loud stop, never a silent substitute: every derived artifact (ACL carve-out, firewall, doctor expectations) would otherwise drift from what the script believes it built. Auto-pick is strictly the *unpinned* behavior. ## Drills and rehearsals The nested run — `install.sh`/`drill/drill.sh` inside a box whose own uplink owns `10.88.0.0/24` — now sails through on an auto-picked subnet with **zero flags**, announcing e.g.: ``` 10.88.0.0/24 is claimed here by this machine's own DEFAULT GATEWAY (10.88.0.1 via enp5s0) — most likely this machine IS a box (a nested drill or rehearsal, issue #80). auto-picked 10.89.0.0/24 for this stack instead. (pin it explicitly with BOX_SUBNET=10.89.0.0/24 if you script this host) ``` Neither the drill nor the doctor assumed the refusal; the doctor's one wording that did ("setup-host now refuses this") is updated. No overlap with #89's `box-firewall.sh`/`doctor.sh` UFW diff — `box-firewall` already reads the gateway off the live bridge, so it follows the pick unchanged. ## Verification - `bash test/cli.sh`: **370 passed, 0 failed** (339 on main — +31: `choose_subnet` extracted with its helpers and driven case by case under the shim `ip`, plus the driven whole-script fixtures: pinned refusals unchanged, bridge-present converge with no scan, default-free → default, default-claimed → 10.89, default+10.89 claimed → 10.90, all 40 candidates claimed → the old refusal naming `10.127.0.0/24` and `BOX_SUBNET`, the announcement text, and the bridge + ACL carve-out following the pick end to end). - `shellcheck -x bin/* **/*.sh` (CI's exact invocation): clean. - **Live decision evidence**, on a machine that IS a box with an existing nested stack (uplink `10.88.0.0/24`, bridge previously remapped to `10.89.0.1/24` — #80's workaround host), exercising the extracted decision function read-only: - bare (no pin) → case 2: `boxnet already lives on 10.89.0.0/24 — converging to it.` → prints `10.89.0.0/24`, rc 0 — no refusal, no scan; - pinned `10.88.0.0/24` → the #86 refusal, verbatim claimant: `this machine's own DEFAULT GATEWAY (10.88.0.1 via enp5s0)`, rc 1; - pinned `10.89.0.0/24` (agrees with the bridge) → honored, rc 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-18 21:21:27 +00:00
dan-claude-bot commented 2026-07-18 21:22:39 +00:00 (Migrated from github.com)

Rebased onto current main (now containing #89) before going ready — no conflicts; full suite on the rebased head: 402/0 (main 339 + #89's 32 + this PR's 31), shellcheck clean. Live decision-path evidence on a real nested host (uplink 10.88, bridge at 10.89): bare → converge to 10.89.0.0/24 rc 0; BOX_SUBNET=10.88.0.0/24 pin → refusal rc 1.

Rebased onto current main (now containing #89) before going ready — no conflicts; full suite on the rebased head: **402/0** (main 339 + #89's 32 + this PR's 31), shellcheck clean. Live decision-path evidence on a real nested host (uplink 10.88, bridge at 10.89): bare → converge to `10.89.0.0/24` rc 0; `BOX_SUBNET=10.88.0.0/24` pin → refusal rc 1.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 21:28:32 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Completes #80 fix #1 on top of #86's refuse half. The four-case choose_subnet decision is the right shape and runs before any mutation:

  1. Pin — honored or refused, never silently overridden
  2. Bridge present — converge to the bridge's subnet (bridge is the pin); scan never runs; poisoned bridge+uplink both on 10.88 still refuses with the bridge-move repair
  3. No bridge, default free — silent 10.88.0.0/24
  4. No bridge, default claimed — scan 10.8910.127, first free wins, loud announce + BOX_SUBNET pin hint; all-claimed keeps the old refusal

Extracted pure helpers + shim-ip case matrix (pin refusals, bridge converge/no-scan, auto-pick skip, all-claimed) plus whole-script refuse-before-mutation fixtures match the contract. Doctor wording updated for auto-pick. CI green. No blockers.

**Verdict: Approve** — I agree with this as-is. Completes #80 fix #1 on top of #86's refuse half. The four-case `choose_subnet` decision is the right shape and runs before any mutation: 1. **Pin** — honored or refused, never silently overridden 2. **Bridge present** — converge to the bridge's subnet (bridge *is* the pin); scan never runs; **poisoned** bridge+uplink both on 10.88 still refuses with the bridge-move repair 3. **No bridge, default free** — silent `10.88.0.0/24` 4. **No bridge, default claimed** — scan `10.89`…`10.127`, first free wins, loud announce + `BOX_SUBNET` pin hint; all-claimed keeps the old refusal Extracted pure helpers + shim-`ip` case matrix (pin refusals, bridge converge/no-scan, auto-pick skip, all-claimed) plus whole-script refuse-before-mutation fixtures match the contract. Doctor wording updated for auto-pick. CI green. No blockers.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 21:29:05 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The subnet decision is made before mutation, preserves explicit-pin semantics, converges an existing bridge without re-addressing it, refuses the poisoned bridge-plus-foreign-claim state, and deterministically selects a free candidate for an unpinned nested host. Downstream gateway and ACL values follow the chosen subnet, the full decision matrix is tested, and all current checks pass.

Verdict: I agree with everything and have no additional feedback. The subnet decision is made before mutation, preserves explicit-pin semantics, converges an existing bridge without re-addressing it, refuses the poisoned bridge-plus-foreign-claim state, and deterministically selects a free candidate for an unpinned nested host. Downstream gateway and ACL values follow the chosen subnet, the full decision matrix is tested, and all current checks pass.
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 21:34:18 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Reviewed — I agree with all of this, no concerns.

First pass on this PR. Read choose_subnet and its four cases end to end against test/cli.sh:

  • The case-2 poisoned-state check is correct: subnet_claimant excludes the bridge itself (dev != "boxnet" on the route probe, $2 != "boxnet" on the addr scan), so a bare re-run against an existing boxnet converges rather than flagging the bridge as its own claimant — only a foreign claimant on the bridge's subnet refuses. That's the subtle bit and it's right.
  • Case 4's scan preserves the original 10.88 claimant in $hit for the announcement (the loop's subnet_claimant calls declare local hit, so the outer one survives) — the "why" line names the machine's own gateway, as the test asserts.
  • Precedence is sound: bridge-present (case 2) precedes the scan (case 4); a pin is honored-or-refused, never overridden; all-candidates-claimed refuses naming 10.127.0.0/24 and BOX_SUBNET.
  • valid_subnet's dot-terminated prefix match holds (10.8.0.0/2410.88.x), and there's an explicit test for it.

README, bin/box help, CHANGELOG and the doctor.sh message are all updated consistently with the new behavior. Nothing to add.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Reviewed — I agree with all of this, no concerns.** First pass on this PR. Read `choose_subnet` and its four cases end to end against `test/cli.sh`: - The case-2 poisoned-state check is correct: `subnet_claimant` excludes the bridge itself (`dev != "boxnet"` on the route probe, `$2 != "boxnet"` on the addr scan), so a bare re-run against an existing `boxnet` *converges* rather than flagging the bridge as its own claimant — only a **foreign** claimant on the bridge's subnet refuses. That's the subtle bit and it's right. - Case 4's scan preserves the original `10.88` claimant in `$hit` for the announcement (the loop's `subnet_claimant` calls declare `local hit`, so the outer one survives) — the "why" line names the machine's own gateway, as the test asserts. - Precedence is sound: bridge-present (case 2) precedes the scan (case 4); a pin is honored-or-refused, never overridden; all-candidates-claimed refuses naming `10.127.0.0/24` and `BOX_SUBNET`. - `valid_subnet`'s dot-terminated prefix match holds (`10.8.0.0/24` ≠ `10.88.x`), and there's an explicit test for it. README, `bin/box` help, CHANGELOG and the `doctor.sh` message are all updated consistently with the new behavior. Nothing to add. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
dan-claude-bot commented 2026-07-18 21:45:16 +00:00 (Migrated from github.com)

Round 1: unanimous (codex / grok / claude -agree, first pass, nothing to add). CI green. Requesting @danmt for final review — this is the zero-flag nested-box ergonomics you asked for: bare box setup-host inside a box now auto-picks the first free /24 and says so; explicit pins still refuse on collision; existing bridges converge.

Round 1: unanimous (codex ✅ / grok ✅ / claude ✅-agree, first pass, nothing to add). CI green. Requesting @danmt for final review — this is the zero-flag nested-box ergonomics you asked for: bare `box setup-host` inside a box now auto-picks the first free /24 and says so; explicit pins still refuse on collision; existing bridges converge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#91
No description provided.