feat/opnsense-bootstrap-score #285
Open
stremblay
wants to merge 38 commits from
feat/opnsense-bootstrap-score into master
pull from: feat/opnsense-bootstrap-score
merge into: NationTech:master
NationTech:master
NationTech:feat/fleet-ch2-operator-recovery
NationTech:feat/fleet-device-exec-logs
NationTech:feat/zitadel-web-pkce-and-human-user
NationTech:feat/jwt-bearer-openbao-auth
NationTech:feat/fleet-ch5-graceful-deploy-upgrade
NationTech:feat/fleet-ch4-agent-upgrade
NationTech:feat/fleet-ch3-log-streaming
NationTech:feat/add-claims-for-openbao
NationTech:refactor/move-zitadel-jwt-to-module
NationTech:feat/fleet-operator-real-data
NationTech:docs/fleet-secrets-device-access
NationTech:chore/fleet-operator-prune-mock-dtos
NationTech:chore/rename-release-to-publish
NationTech:refactor/config-namespace-env-var
NationTech:feat/fleet-staging-openbao
NationTech:feat/auth-add-next-url-redirect
NationTech:pr/harmony-sso-example
NationTech:feat/unified-config-and-secrets
NationTech:ci/fleet-argo-cd
NationTech:ci/fleet-operator-release-pipeline
NationTech:feat/on-device-key-gen
NationTech:feat/install-gitea
NationTech:feat/v0-3-logs-companion
NationTech:refactor/smoke-companion-minimal
NationTech:feat/smoke-test-contract
NationTech:feat/iobench-redpanda-profile
NationTech:feat/v0-3-dashboard-role-enforcement
NationTech:feat/v0-3-init-containers
NationTech:feat/v0-3-operator-restart-baseline
NationTech:feat/fleet-e2e-x86
NationTech:feat/ceph-score
NationTech:feat/fleet-e2e
NationTech:feat/fleet-e2e-harness-and-ping
NationTech:feat/dashboard-auth
NationTech:feat/fleet-operator-web-frontend
NationTech:feat/deploy_fleet_server_side
NationTech:feat/openwebui
NationTech:feat/iot-aggregation-scale
NationTech:feat/iot-operator-helm-chart
NationTech:feat/removesideeffect
NationTech:feat/test-alert-receivers-sttest
NationTech:feat/brocade-client-add-vlans
NationTech:feat/agent-desired-state
NationTech:feat/opnsense-dns-implementation
NationTech:feat/named-config-instances
NationTech:worktree-bridge-cse_012j1jB37XfjXvDGHUjHrKSj
NationTech:chore/leftover-adr
NationTech:feat/config_e2e_zitadel_openbao
NationTech:example/vllm
NationTech:feat/config_sqlite
NationTech:chore/roadmap
NationTech:feature/kvm-module
NationTech:feat/rustfs
NationTech:feat/harmony_assets
NationTech:feat/brocade_assisted_setup
NationTech:feat/cluster_alerting_score
NationTech:e2e-tests-multicluster
NationTech:fix/refactor_alert_receivers
NationTech:feat/change-node-readiness-strategy
NationTech:feat/zitadel
NationTech:feat/improve-inventory-discovery
NationTech:fix/monitoring_abstractions_openshift
NationTech:feat/nats-jetstream
NationTech:adr-nats-creds
NationTech:feat/st_test
NationTech:feat/dockerAutoinstall
NationTech:chore/cleanup_hacluster
NationTech:doc/cert-management
NationTech:feat/certificate_management
NationTech:adr/017-staleness-failover
NationTech:fix/nats_non_root
NationTech:feat/rebuild_inventory
NationTech:fix/opnsense_update
NationTech:feat/unshedulable_control_planes
NationTech:feat/worker_okd_install
NationTech:doc-and-braindump
NationTech:fix/pxe_install
NationTech:switch-client
NationTech:okd_enable_user_workload_monitoring
NationTech:configure-switch
NationTech:fix/clippy
NationTech:feat/gen-ca-cert
NationTech:feat/okd_default_ingress_class
NationTech:fix/add_routes_to_domain
NationTech:secrets-prompt-editor
NationTech:feat/multisiteApplication
NationTech:feat/ceph-install-score
NationTech:feat/ceph-osd-score
NationTech:feat/ceph_validate_health
NationTech:better-indicatif-progress-grouped
NationTech:feat/crd-alertmanager-configs
NationTech:better-cli
NationTech:opnsense_upgrade
NationTech:feat/monitoring-application-feature
NationTech:dev/postgres
NationTech:feat/cd/localdeploymentdemo
NationTech:feat/webhook_receiver
NationTech:feat/kube-prometheus
NationTech:feat/init_k8s_tenant
NationTech:feat/discord-webhook-receiver
NationTech:feat/kube-prometheus-monitor
NationTech:feat/tenantScore
NationTech:feat/teams-integration
NationTech:feat/slack-notifs
NationTech:monitoring
NationTech:runtime-profiles
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
No description provided.
Delete Branch "feat/opnsense-bootstrap-score"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Introduces four composable OPNsense Scores so callers compose declarative pipelines instead of writing the procedural dance for fresh-firewall provisioning:
OPNsense's REST API, LAN flipped to the operator-chosen IP/subnet, and credentials persisted to SecretManager. Resolves the chicken-and-egg of Score requiring API credentials that don't exist yet by running against a separate
OPNsenseBootstrapTopology.
firmware/upgradestatus endpoint and reboot-during-upgrade transparently.
wrong cables.
OPNsenseBootstrapScore composes the firmware upgrade (default-on), the NIC-pinning step, and the API-based DHCP-range update internally. Ordering matters: pin runs before the firmware-upgrade reboot (the first reboot it has to defend against), and DHCP range is
updated via REST API before the LAN IP flip, because that flip would otherwise sever the connection mid-write.
Why
examples/opnsense_vm_integration previously did this work as ~250 lines of imperative orchestration in main.rs (login → wizard skip → SSH enable → port move → API key mint → persist → reboot loops). Per CLAUDE.md, that's procedural complexity that belongs inside
Scores. After this PR the same example is a linear Score pipeline; downstream consumers (e.g. the cameroun pico-DC example) compose the same Scores and automatically inherit NIC-name pinning + correct DHCP on the new subnet, with no per-example code.
Notable changes
editing)
Test plan
Out of scope
Solve the OPNsense bootstrap chicken-and-egg problem with a Score-shaped abstraction. Until now, every binary deploying onto a fresh OPNsense had to copy ~80 lines of procedural orchestration (login → abort wizard → SSH → port move → API key mint → LAN flip) into its own main.rs, because the bootstrap creates the very credentials that `OPNSenseFirewall` needs to construct. The trick: a separate, minimal `OPNsenseBootstrapTopology` that holds only {vanilla_ip, default_username, default_password}. The new `Score<OPNsenseBootstrapTopology>` runs the dance from `Interpret::execute`, persists `OPNSenseApiCredentials` and `OPNSenseFirewallCredentials` to `SecretManager`, and optionally rebinds the LAN. The calling binary then builds a normal `OPNSenseFirewall` from the now-stored credentials and runs `Score<OPNSenseFirewall>` composition against it — two Maestro<T> phases in sequence, SecretManager as the bridge. Idempotency is handled by a 4-boolean decision matrix (api_creds_exist, ssh_creds_exist, vanilla_reachable, target_reachable) extracted into a pure helper and table-tested. The Score is safe to re-run: NOOP when already bootstrapped, DANCE on first-run or partial resume, FAILURE with clear recovery instructions when target is up but secrets are lost (factory-reset and re-run). Output follows the precedent of `OKDAddNodeScore`: - `[OPNsenseBootstrap/{vanilla_ip}]`-prefixed log lines, one info! per state change - Runbook-shaped Outcome::success_with_details listing where the firewall now lives, where credentials were stored, and the manual reconnect step if a LAN rebind happened - Multi-sentence InterpretError messages including the recovery path Includes a new `OPNsenseBootstrap` variant on `InterpretName`. Unit tests cover Score name, serialization, the full idempotency decision matrix, and `ensure_ready` failure when the firewall is unreachable. Scope: abstraction-only. Example main.rs files keep their current procedural shape; refactoring them to compose the new Score is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The firmware-update fallback used to poll `/api/core/firmware/upgradestatus` for a "done" signal, then wait_for_https + a 10s sleep before retrying the package install. That endpoint is documented as "known to be unstable" in OPNsense 26.1.6 release notes (the WebUI itself traps its generic error popup), so the polling loop never breaks out via the success path — it just times out. wait_for_https then succeeds during the brief window before OPNsense actually starts rebooting, and the install retry gets killed mid-reboot with a `reqwest::Request` timeout. Switch to `/api/core/firmware/status`, which is the stable endpoint and returns a definitive `status_reboot` field ('1' if a reboot is required after the in-progress update/upgrade, computed from `needs_reboot` and `upgrade_needs_reboot` per FirmwareController.php). Poll until the update finishes (status == "none") or the API becomes unreachable (auto-reboot during update), then read `status_reboot` and trigger an explicit `POST /api/core/firmware/reboot` if needed. The wait-for-unreachable window after the reboot is then a tight 60s — we know the reboot just happened. No more blind multi-minute timeouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds a `Score<OPNSenseFirewall>` that brings an OPNsense firewall to the latest available firmware/package level via the REST API: POST firmware/check → refresh upstream metadata GET firmware/status → check what's actionable POST firmware/upgrade → trigger if anything is pending poll firmware/status → wait for status to return to "none" POST firmware/reboot → if status_reboot == "1" and we haven't already auto-rebooted mid-upgrade + wait unreachable → reachable → 30s settle The core logic lives in `perform_firmware_upgrade()` so it can be called from elsewhere. `OPNsenseBootstrapScore` now exposes `upgrade_firmware: bool` (default `true`) and calls the same helper after credentials are persisted, before any optional LAN rebind. The firewall thus ends bootstrap on its latest firmware, exactly the right beat operationally: no production traffic yet, operator already babysitting, all subsequent Scores run against current code. Why not extend `OPNSenseLaunchUpgrade` (the existing SSH-based Score)? It calls a shell script (`opnsense-update.sh`), has a `todo!()` Serialize impl, no idempotency check, and holds an `Arc<Config>` directly instead of reading from a topology. The new score uses the REST API end-to-end, idempotency-checks via `status_upgrade_action`, and slots cleanly into normal Score<T> composition. `OPNSenseLaunchUpgrade` stays alongside it for now; affilium2 keeps working unchanged. We can deprecate the SSH one in a follow-up once the API one has flown against real firewalls. `opnsense_vm_integration` explicitly opts out of the bootstrap-time upgrade (`upgrade_firmware: false`) — the VM image is a known firmware version, and we don't want each integration run to spend 10+ minutes pulling firmware updates. New `InterpretName::OPNsenseFirmwareUpgrade` variant. Unit tests cover score name, default api_port (9443), and serialization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The previous version did `POST firmware/check`, slept 5s, read `firmware/status`, and used a fragile "status_msg contains 'up to date'" heuristic to decide whether to upgrade. Two related bugs: 1. `firmware/check` is async — it returns immediately with a msg_uuid and runs in the background. 5s is far less than the metadata refresh takes; on a fresh boot the status still reads "requires to check for update first to provide more information" when we look. 2. That message doesn't match any of my "up to date" keywords, so my pending-check returned true and triggered `firmware/upgrade` against a system that had no actionable upgrade plan. firmware/upgrade returned immediately (also async), status stayed "none", and the helper reported success without anything having happened. Rewrite per OPNsense's actual API (verified against FirmwareController.php and firmware.volt): 1. GET firmware/info → capture initial product_version 2. Loop ≤ 5 iterations (a kernel upgrade can unlock further package updates that need their own pass): a. POST firmware/check (async) b. Poll firmware/upgradestatus until status == "done" c. POST firmware/status → read `status` enum ("none"/"update"/"upgrade"/"error") d. If "none": done. (First iteration → NOOP. Later → success.) e. If "update" / "upgrade": POST firmware/{that}, poll upgradestatus until done, handle reboot (auto-reboot or explicit firmware/reboot if status_reboot == "1"), then GET firmware/info to verify product_version changed. 3. Return UpgradeOutcome with initial_version, final_version, iterations, rebooted flag. The upgrade Score's Outcome now reports the actual version transition ("Firmware upgraded: 25.7.4 → 26.1.6 in 2 iteration(s) (rebooted: true)"). Mid-upgrade reboots are detected by `upgradestatus` going unreachable + a TCP probe confirming the API is down (vs. just a 404 from the documented-unstable endpoint). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The previous wait loop polled only `firmware/upgradestatus` for `status == "done"`, with a TCP-probe fallback to detect reboots. Two ways this got stuck against real OPNsense 26.1: 1. After the upgrade reboot completed, OPNsense had no active background task to track, so `upgradestatus` 404'd indefinitely. 2. The TCP-probe fallback could miss the brief unreachable window between two 5s-apart polls — if both polls saw the API up, we never set `rebooted=true` and never bailed. Net: the upgrade ran fine (26.1 → 26.1.8 applied), but our code waited forever for a "done" signal that never came. Now the wait loop polls THREE signals per iteration and exits on any: A. firmware/info `product_version` differs from version_before_action B. firmware/running `status` empty for 2 consecutive polls (configd reports no active task) C. firmware/upgradestatus `status == "done"` (when the endpoint works) Plus the TCP probe still detects mid-task reboots and waits for the firewall to come back — but it's no longer the sole exit path. After a reboot, signal A almost always wins on the first post-recovery poll. perform_firmware_upgrade now snapshots the version before each action and passes it as `version_before_action` so signal A has a baseline that's valid even after a previous iteration already bumped the version. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`OpnsenseClient::handle_response_typed` logged the entire response body in its WARN line on non-success status codes. OPNsense's 404 page is ~12 lines of HTML; every transient 404 (and the firmware/upgradestatus endpoint 404s constantly on 26.1, per release notes calling it "known to be unstable") dumped a multi-line block into the log. Route the body through a new truncate_for_log helper that keeps the first non-empty line, caps the result at 200 chars, and appends an ellipsis if anything was elided. JSON error responses (typically one short line) stay intact; HTML pages collapse to "<!DOCTYPE html>…". The `Error::Api { body, .. }` value passed to callers is unchanged, so code that wants to inspect the full body still can. Three unit tests cover: short-line passthrough, HTML collapsing, length-capped ellipsis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>When a firmware update has status_reboot=1, the reboot IS the final step of the install — once it completes, the task is done by definition. But my multi-signal polling loop kept trying to verify completion via signals A/B/C *after* the reboot, and all three are unreliable post-reboot: A. firmware/info `product_version` doesn't change if the update was package-only (no version bump). B. firmware/running keeps reporting the previous task as active until the next firmware/check kicks it — the operator observed "clicking 'check for updates' in the UI unstuck it", confirming OPNsense retains stale task state until a fresh check resets it. C. firmware/upgradestatus 404s (documented unstable on 26.1) when no task is registered, which is the state after a real upgrade. Net: in iteration 2 of a real upgrade run (26.1.8 → 26.1.x with 2 more packages), the wait loop was stuck silently polling for several minutes after the firewall had already rebooted and was fully operational. Now: when the TCP probe detects unreachable and wait_for_reboot_cycle returns, immediately return TaskOutcome { rebooted: true } instead of re-entering the polling loop. The outer perform_firmware_upgrade loop already calls firmware/check at the top of the next iteration (which both refreshes OPNsense's task state AND tells us if more updates are pending) and reads firmware/info after wait_for_task_or_reboot returns to verify the version moved. Those are the real post-reboot completion signals — the in-loop polling was redundant and harmful. The non-reboot path (status_reboot=0 updates, e.g. pure metadata refresh) is unchanged: signals A/B/C still run because there's no reboot to use as a definitive completion event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Replaces the boolean `OPNsenseBootstrapScore::upgrade_firmware` knob with a four-variant enum that decides per pending upgrade whether to apply it automatically, skip major-series upgrades, prompt the operator, or skip entirely. Also exposed via `OPNsenseFirmwareUpgradeScore::mode` for standalone composition. - `Auto` (default): apply every pending update and upgrade. - `AutoMinor`: apply in-series updates (status == "update"); skip major-series upgrades (status == "upgrade"). Uses OPNsense's own `status` field for the major/minor distinction — no version-string parsing. - `Prompt`: print a per-iteration summary and ask via inquire::Confirm. Errors out with `PromptRequiresTty` when run headless (no TTY) so CI contexts must pick `Auto` / `AutoMinor` / `Disabled` explicitly. - `Disabled`: skip the upgrade step entirely. The summary surfaced for Prompt (and logged in Auto/AutoMinor too) includes: - status_msg (OPNsense's "108 updates available, 349.2 MiB, reboot required" line) - whether the OPNsense product package itself is being upgraded ("Main OPNsense: 26.1 → 26.1.8") or whether the update only touches plugins/packages ("Main OPNsense: staying at <ver>") - kind (update vs upgrade) - reboot required (yes/no) Two new helpers — `extract_opnsense_version_change` and `render_upgrade_summary` — pull the version diff out of `status.all_packages` / `status.all_sets` (looking for the `opnsense` or `opnsense-update` entry) and assemble the human-readable block. Wired through: - `OPNsenseFirmwareUpgradeScore::mode` (default Auto). - `OPNsenseBootstrapScore::firmware_upgrade` (replaces `upgrade_firmware: bool`; same default behavior). - `examples/opnsense_vm_integration` opts out with `firmware_upgrade: FirmwareUpgradeMode::Disabled`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`install_package`'s poll loop only watched `firmware/info` for the positive "package installed" signal. When OPNsense's background install task failed silently (typical on stale repo metadata: pkg can't find os-haproxy that matches the firmware), the package never appeared in `firmware/info`, so the loop consumed its entire 6-minute ceiling before returning Err. The caller's fallback ("refresh metadata + retry") couldn't fire for 6+ minutes — looked like a hang. Add a second poll signal each iteration: `firmware/running` reports the name of the currently active configd task (empty when idle). When the install task vanishes (empty for 2 consecutive polls) AND the package still isn't in `firmware/info`, we know the install ended without succeeding. Fail fast with: "OPNsense install task for <pkg> ended without installing the package. The repository metadata is likely stale — try refreshing it via firmware/update, or run OPNsenseFirmwareUpgradeScore first, then retry." Typical failed install now detects within ~10s instead of 6min. The 120 × 3s ceiling stays as a safety net for "task running but never completes" pathologies. This restores the fast-fail behavior the OLD pre-refactor install_package had (via its bail-on-upgradestatus-404 path), with a proper, stable signal instead of relying on the documented-unstable endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The Err arm after the first `install_package("os-haproxy")` attempt used to POST `firmware/update` (= `pkg update`, repo-metadata refresh) and sleep 5s before retrying. That's a weaker, hand-rolled subset of what `OPNsenseFirmwareUpgradeScore` / `perform_firmware_upgrade` already does properly. Replace with a call to `perform_firmware_upgrade(..., FirmwareUpgradeMode::Auto, ...)`. That does the full canonical flow: firmware/check → firmware/status → firmware/update or upgrade → poll (with multi-signal completion + reboot tolerance) → verify the product_version moved. After it returns, the firewall is at the latest firmware AND its package index is current, so the retry of `install_package("os-haproxy")` finds the right packages and succeeds. This is what the operator asked for: "[on install failure] it should call the firmware update score." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>I added Signal B polling to install_package + wait_for_task_or_reboot checking for `status == ""` or `"none"` as the "configd idle" condition, but OPNsense's `configctl firmware running` script (core/scripts/firmware/running.sh) actually outputs `"ready"` when no firmware operation holds the lock and `"busy"` when one does. So Signal B never fired against a real OPNsense — the loop kept seeing `status: "ready"` (= idle) and treating it as "still running". For install_package this meant a doomed install still consumed the full 6-minute timeout. For wait_for_task_or_reboot it was masked by Signal A (version moved) almost always winning first, but the bug was the same. Recognize "ready" (case-insensitive) plus defensive "" / "none" as idle. Verified against the upstream script: if ${FLOCK} -n 9; then echo "ready" else echo "busy" fi Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two related changes the operator asked for, replacing the previous "firmware/running ready/busy" heuristic that felt unclean. 1. **install_package**: drop the `firmware/info` + `firmware/running` ready-idle polling. Restore the OPNsense-native pattern: poll `/api/core/firmware/upgradestatus` until `status == "done"` (same endpoint OPNsense's own WebUI uses for its install progress popup), then verify via `firmware/info` whether the package actually got installed. On failure, surface pkg's actual error from the `log` field of the upgradestatus response (last 8 non-empty lines) plus a "run OPNsenseFirmwareUpgradeScore first" hint. Tolerate transient upgradestatus errors as the 26.1.6 release notes document the endpoint as unstable; 120 × 3 s ceiling is the safety net. Now produces the clear, fast-fail message the operator remembers from before the branch, but with the actual pkg failure reason ("pkg: No packages available to install matching 'os-haproxy'", or whatever the underlying issue is) included. 2. **opnsense_vm_integration**: the post-install-failure fallback now composes `OPNsenseFirmwareUpgradeScore { mode: Auto }` into a `Vec<Box<dyn Score<OPNSenseFirewall>>>` and dispatches it via `harmony_cli::run_cli`, matching the way the rest of `run_integration` runs its Scores. Replaces the direct call to the bare `perform_firmware_upgrade()` helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Every other operational primitive in harmony has a Score wrapper between the low-level call and the user-facing composition layer. Package installation didn't — `Config::install_package` was being called naked from the integration example with a hand-rolled `match install_package() { Ok=>… Err=>compose-firmware-upgrade-Score-and-retry }` glue. That's exactly the "imperative orchestration in the caller" pattern harmony's CLAUDE.md tells us to push into Scores. This commit: - Adds `OPNsensePackageInstallScore { packages: Vec<String> }` in a new `harmony/src/modules/opnsense/package_install.rs`. The Interpret iterates packages, skips ones already installed via `is_package_installed`, calls `install_package` on the rest, surfaces newly-installed vs. already-present in `Outcome::success_with_details`. Idempotent on re-runs. - Adds the `OPNsensePackageInstall` variant to `InterpretName` + Display. - The Score deliberately has NO firmware-upgrade fallback baked in. If install fails because firmware is stale, `install_package`'s error message already points the operator at `OPNsenseFirmwareUpgradeScore`. Composition is the operator's job — same as every other Score pair relationship in harmony. - Rewrites `examples/opnsense_vm_integration::run_integration` to drop the ~40-line try/Err/retry block. The two new Scores (firmware upgrade + package install) are prepended to `build_all_scores`, so the pipeline becomes a linear vec: vec![ OPNsenseFirmwareUpgradeScore { mode: Auto, .. }, OPNsensePackageInstallScore { packages: vec!["os-haproxy"] }, webgui, lb, dhcp, … (existing config scores) ] Both `run_cli` invocations (run 1 and the idempotency run 2) exercise the new Scores. Both naturally NOOP on the second pass: upgrade because `firmware/status == "none"`, install because `is_package_installed("os-haproxy") == true`. Three unit tests in the new module cover Score name, serialization, and empty-package-list handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>In FirmwareUpgradeMode::Prompt, the summary block was being printed twice — once via the `info!("{tag} Pending firmware …:\n{summary}")` line just above the mode-gating match, and again inside the inquire::Confirm prompt's header text. The prompt now asks only the yes/no question; the operator reads the summary from the info! log line one row above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>OPNsense's `GET /api/interfaces/settings/get` returns `disablevlanhwfilter` as a `BaseListField::getNodeOptions()` select-widget structure rather than a plain string. Because the option keys are numeric strings (`"0"`/`"1"`/ `"2"`), PHP's `json_encode` collapses them into a JSON **array** — so the array index IS the wire code. The deserializer now accepts: - plain string (the `setItem` round-trip path), - object form (`{key: {value, selected}}`), - array form (index = wire code). Wire codes are also fixed to `"0"`/`"1"`/`"2"` (from the XML `value="…"` attribute, per `BaseModel::parseOptionData`), not the element names `"opt0"`/`"opt1"`/`"opt2"`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Renames OKDReapplyDhcpBindingsScore to OKDReapplyFromInventoryScore (file: reapply_from_inventory.rs) to reflect the broader scope. Per selected role, the Score now: 1. Re-writes dnsmasq Host entries via DhcpHostBindingScore (existing behavior). 2. Re-creates byMAC/01-<mac>.ipxe boot files via IPxeMacBootFileScore, rendering BootstrapIpxeTpl with the role-appropriate ignition file (bootstrap.ign / master.ign / worker.ign). Pulls installation_device + MAC from each host's PhysicalHost + HostConfig row in the inventory DB; errors loudly if either is missing rather than silently producing a half-written byMAC tree. Same constructors (interactive / for_roles / all_roles) and same inquire multi-select UX, with the prompt wording broadened from "DHCP bindings" to "firewall config". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.