v0.96.0 — R-50 island NIC: provision attaches the guest island net1
- LocalAPIConfig.island_bridge + island_guest_addr (+ IslandEnabled, Validate all-or-nothing + CIDR guard) - buildBringUpConfig attaches static net1 (island) on provision + DR when set; absent otherwise (pre-R-50 byte-for-byte). Plumbed from cfg.LocalAPI at both RunBringUp sites. Endpoint already follows listen_addr (A0: no template change). - healer stays eth0-only (A3 verify-only) — red-proof test locks the scoping - example config + firewall example rewritten for the island; REUSE updated - 3 non-hollow tests; full green. MinAgent unchanged. Coupling: host-install island config requires agent >= 0.96.0 (vouch first).
This commit is contained in:
@@ -235,6 +235,16 @@ type LocalAPIConfig struct {
|
||||
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
|
||||
// persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded).
|
||||
TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log
|
||||
// IslandBridge + IslandGuestAddr configure the R-50 host-internal control-plane bridge. When
|
||||
// BOTH are set, the provisioner attaches each guest a static net1 on IslandBridge with
|
||||
// IslandGuestAddr, so the controller reaches the agent over a fixed private address that no
|
||||
// LAN/DHCP/site move can invalidate (the F1 fix — AUDIT-vacation-remote-ops-2026-07-20). Empty
|
||||
// (the default) = LAN-only, byte-for-byte the pre-R-50 behaviour. On an island install ListenAddr
|
||||
// is the host side (169.254.253.1:8443); IslandGuestAddr is the guest side (169.254.253.2/30 — a
|
||||
// /30 is exactly host + one guest). Additive-only: it never removes a NIC, so a guest restored on
|
||||
// a non-island host (both empty) is unaffected.
|
||||
IslandBridge string `json:"island_bridge"` // e.g. "vmbr9" (portless host-internal bridge)
|
||||
IslandGuestAddr string `json:"island_guest_addr"` // guest net1 CIDR, e.g. "169.254.253.2/30"
|
||||
}
|
||||
|
||||
// Default local-API file locations (under the agent's state dir).
|
||||
@@ -249,6 +259,12 @@ func (l LocalAPIConfig) Enabled() bool {
|
||||
return l.Enable && strings.TrimSpace(l.ListenAddr) != ""
|
||||
}
|
||||
|
||||
// IslandEnabled reports whether the provisioner should attach a guest island NIC (net1). True only
|
||||
// when BOTH the bridge and the guest CIDR are set (R-50); empty = pre-R-50 LAN-only behaviour.
|
||||
func (l LocalAPIConfig) IslandEnabled() bool {
|
||||
return strings.TrimSpace(l.IslandBridge) != "" && strings.TrimSpace(l.IslandGuestAddr) != ""
|
||||
}
|
||||
|
||||
// TokenStorePath returns the configured token-store path (default applied).
|
||||
func (l LocalAPIConfig) TokenStorePath() string {
|
||||
if l.TokenStore != "" {
|
||||
@@ -283,6 +299,17 @@ func (l LocalAPIConfig) Validate() error {
|
||||
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
|
||||
return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err)
|
||||
}
|
||||
// R-50: island fields are all-or-nothing, and the guest addr must be a CIDR (the net1 ip= value).
|
||||
// A half-set island (bridge without guest addr, or vice versa) is a provisioning mistake, not a
|
||||
// silent LAN fallback — fail loudly so a botched install config is caught at load, not at day-0.
|
||||
if (strings.TrimSpace(l.IslandBridge) != "") != (strings.TrimSpace(l.IslandGuestAddr) != "") {
|
||||
return fmt.Errorf("config: local_api.island_bridge and local_api.island_guest_addr must be set together (got bridge=%q guest_addr=%q)", l.IslandBridge, l.IslandGuestAddr)
|
||||
}
|
||||
if l.IslandEnabled() {
|
||||
if _, _, err := net.ParseCIDR(strings.TrimSpace(l.IslandGuestAddr)); err != nil {
|
||||
return fmt.Errorf("config: local_api.island_guest_addr %q is not a CIDR (want e.g. 169.254.253.2/30): %w", l.IslandGuestAddr, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -171,3 +171,46 @@ func TestDeploymentModeEnvOverlay(t *testing.T) {
|
||||
t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode)
|
||||
}
|
||||
}
|
||||
|
||||
// R-50: the island NIC fields are all-or-nothing and the guest addr must be a CIDR. A half-set or
|
||||
// malformed island must fail at config load (a botched install) rather than silently fall back to
|
||||
// LAN-only, which would leave a guest with an island bind and no island NIC — the exact silent break
|
||||
// R-50 exists to kill. Covers LocalAPIConfig.Validate + IslandEnabled.
|
||||
func TestLocalAPIConfig_IslandValidation(t *testing.T) {
|
||||
base := LocalAPIConfig{Enable: true, ListenAddr: "169.254.253.1:8443"}
|
||||
|
||||
// both empty → fine (pre-R-50 default), IslandEnabled false
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Errorf("no island config must validate: %v", err)
|
||||
}
|
||||
if base.IslandEnabled() {
|
||||
t.Errorf("IslandEnabled must be false when unset")
|
||||
}
|
||||
// both set, valid CIDR → fine, IslandEnabled true
|
||||
ok := base
|
||||
ok.IslandBridge, ok.IslandGuestAddr = "vmbr9", "169.254.253.2/30"
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Errorf("valid island config must validate: %v", err)
|
||||
}
|
||||
if !ok.IslandEnabled() {
|
||||
t.Errorf("IslandEnabled must be true when both set")
|
||||
}
|
||||
// bridge only → rejected (all-or-nothing)
|
||||
half := base
|
||||
half.IslandBridge = "vmbr9"
|
||||
if err := half.Validate(); err == nil {
|
||||
t.Errorf("half-set island (bridge only) must be rejected")
|
||||
}
|
||||
// guest addr only → rejected
|
||||
half2 := base
|
||||
half2.IslandGuestAddr = "169.254.253.2/30"
|
||||
if err := half2.Validate(); err == nil {
|
||||
t.Errorf("half-set island (guest addr only) must be rejected")
|
||||
}
|
||||
// both set but guest addr is not a CIDR → rejected
|
||||
bad := base
|
||||
bad.IslandBridge, bad.IslandGuestAddr = "vmbr9", "169.254.253.2" // missing /30
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Errorf("island guest addr without a CIDR mask must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,3 +548,21 @@ func TestClassify_Table(t *testing.T) {
|
||||
}
|
||||
|
||||
var _ io.Writer = (*bytes.Buffer)(nil)
|
||||
|
||||
// A3 (R-50): the guestnet healer is eth0-only and MUST stay blind to the island NIC. A guest on an
|
||||
// island host presents eth0 DHCP (the LAN leg the healer owns) PLUS eth1 static (the island). Because
|
||||
// parseMode is interface-scoped, adding eth1 static cannot flip eth0's detected mode — so the healer
|
||||
// keeps treating eth0 as DHCP and never runs dhclient against the static island NIC (which would
|
||||
// sabotage it). This is the verify-only guarantee that let R-50 ship the island NIC without a healer
|
||||
// change. Red-proof: make parseMode scan globally instead of per-dev and the eth0 assertion fails.
|
||||
func TestParseMode_IslandStaticNICDoesNotConfuseEth0(t *testing.T) {
|
||||
interfaces := "auto lo\niface lo inet loopback\n\n" +
|
||||
"auto eth0\niface eth0 inet dhcp\n\n" +
|
||||
"auto eth1\niface eth1 inet static\n address 169.254.253.2/30\n"
|
||||
if got := parseMode(interfaces, "eth0"); got != ModeDHCP {
|
||||
t.Errorf("eth0 must classify DHCP even with an island eth1 static present, got %q", got)
|
||||
}
|
||||
if got := parseMode(interfaces, "eth1"); got != ModeStatic {
|
||||
t.Errorf("eth1 (island) must classify static when asked directly (dev-scoped), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,12 @@ type BringUpSpec struct {
|
||||
Mounts []GuestMount // additive mpN mounts (slice 7 may pass empty/test)
|
||||
KeepMAC bool // DR knob: keep the archived MAC (true) unless a source may be live
|
||||
BootTimeout time.Duration // 0 → DefaultBootTimeout; bounds the link-up liveness wait
|
||||
// IslandBridge + IslandGuestAddr (R-50): when BOTH are set, the guest gets a static net1 on the
|
||||
// host-internal island bridge, so the controller reaches the agent over a fixed private address
|
||||
// that survives any LAN/DHCP/site move (the F1 fix). Empty (default) = no net1, byte-for-byte the
|
||||
// pre-R-50 config. Set from cfg.LocalAPI (island_bridge/island_guest_addr) at both call sites.
|
||||
IslandBridge string // e.g. "vmbr9"
|
||||
IslandGuestAddr string // guest net1 CIDR, e.g. "169.254.253.2/30"
|
||||
}
|
||||
|
||||
// BringUpResult is the outcome. It reuses the restore-test's WARNINGS surface
|
||||
@@ -606,6 +612,15 @@ func buildBringUpConfig(spec BringUpSpec, cfg proxmox.GuestConfig) map[string]st
|
||||
params["net0"] = withoutHwaddr(net0) // omit hwaddr → PVE generates a fresh MAC (F1)
|
||||
}
|
||||
}
|
||||
// R-50 island NIC: attach a static net1 on the host-internal bridge so the control plane
|
||||
// (controller→agent local API) rides a fixed private address, immune to any LAN/DHCP/site move.
|
||||
// Both modes: a provisioned guest AND a DR-restored guest need to reach the island-bound agent on
|
||||
// the target host. No hwaddr → PVE mints a fresh per-guest MAC (the /30 is one guest per host, so
|
||||
// a MAC would not collide either way, but a fresh one keeps net1 symmetric with net0). Additive:
|
||||
// omitted entirely when the island is not configured, keeping non-island hosts unchanged.
|
||||
if strings.TrimSpace(spec.IslandBridge) != "" && strings.TrimSpace(spec.IslandGuestAddr) != "" {
|
||||
params["net1"] = fmt.Sprintf("name=eth1,bridge=%s,ip=%s", spec.IslandBridge, spec.IslandGuestAddr)
|
||||
}
|
||||
if spec.Mode == ModeProvision && spec.Hostname != "" {
|
||||
params["hostname"] = spec.Hostname
|
||||
}
|
||||
|
||||
@@ -165,6 +165,36 @@ func TestBuildBringUpConfig_ResourceCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// R-50: with the island configured, bring-up attaches a static net1 on the island bridge; with it
|
||||
// unset (or half-set), NO net1 is emitted — byte-for-byte the pre-R-50 config on non-island hosts.
|
||||
// Pure-function check on buildBringUpConfig (the derivation that makes fresh installs F1-immune).
|
||||
func TestBuildBringUpConfig_IslandNIC(t *testing.T) {
|
||||
// island set → net1 present, exact shape, no hwaddr (PVE mints a fresh per-guest MAC)
|
||||
island := buildBringUpConfig(BringUpSpec{
|
||||
Mode: ModeProvision, IslandBridge: "vmbr9", IslandGuestAddr: "169.254.253.2/30",
|
||||
}, scratchCfg())
|
||||
if got, want := island["net1"], "name=eth1,bridge=vmbr9,ip=169.254.253.2/30"; got != want {
|
||||
t.Errorf("island net1 mismatch:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
// DR mode too — a restored customer guest must also reach the island-bound agent on the host.
|
||||
dr := buildBringUpConfig(BringUpSpec{
|
||||
Mode: ModeDRGuestLoss, KeepMAC: true, IslandBridge: "vmbr9", IslandGuestAddr: "169.254.253.2/30",
|
||||
}, scratchCfg())
|
||||
if _, ok := dr["net1"]; !ok {
|
||||
t.Errorf("DR bring-up must also attach the island net1, got none")
|
||||
}
|
||||
// island unset → NO net1 key (non-island hosts unchanged; the pre-R-50 default)
|
||||
none := buildBringUpConfig(BringUpSpec{Mode: ModeProvision}, scratchCfg())
|
||||
if v, ok := none["net1"]; ok {
|
||||
t.Errorf("net1 must be ABSENT when the island is not configured, got %q", v)
|
||||
}
|
||||
// half-configured (bridge only) → still no net1 (all-or-nothing; config.Validate rejects the config too)
|
||||
half := buildBringUpConfig(BringUpSpec{Mode: ModeProvision, IslandBridge: "vmbr9"}, scratchCfg())
|
||||
if v, ok := half["net1"]; ok {
|
||||
t.Errorf("net1 must be ABSENT when only the bridge is set, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Both restore sites allocate the guest INTO the felhom pool (SPIKE 3b): the provision bring-up
|
||||
// threads spec.Pool, and the restore-test hardcodes DefaultPool — else a pool-scoped token 403s on
|
||||
// the created guest's config/start/destroy. Asserts via the fakeAPI's captured RestoreLXCOptions.
|
||||
|
||||
Reference in New Issue
Block a user