dfd5d731ee
- 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).
569 lines
19 KiB
Go
569 lines
19 KiB
Go
package guestnet
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// --- fixtures captured LIVE from guest 9201 on 2026-07-21 (probe P3, via `ssh felhom-pve`) -------
|
|
//
|
|
// These are the byte shapes the parser must survive; note the literal backslash `ip -o` emits and
|
|
// the trailing space on the route line.
|
|
|
|
const (
|
|
fxAddr = "2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0\\ valid_lft 4916sec preferred_lft 4916sec\n"
|
|
fxRoute = "default via 192.168.0.1 dev eth0 \n"
|
|
fxPgrep = "235839\n"
|
|
fxIfacesDHCP = "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
|
|
fxIfacesStat = "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet static\n\taddress 192.168.0.162/24\n\tgateway 192.168.0.1\n"
|
|
// pct exec against a guest that does not exist / is not running (rc=2, message on stderr).
|
|
fxNoGuestErr = "Configuration file 'nodes/demo-felhom/lxc/9999.conf' does not exist\n"
|
|
)
|
|
|
|
// scriptedRunner answers per probe kind and records EVERY argv. The counts are the assertions that
|
|
// matter: a watchdog that heals when it must not is worse than one that never heals.
|
|
type scriptedRunner struct {
|
|
mu sync.Mutex
|
|
out map[string]string // kind → stdout
|
|
fail map[string]error // kind → error
|
|
errs map[string]string // kind → stderr
|
|
call [][]string
|
|
// healFixes models what a successful dhclient actually does: the client is running again and
|
|
// the lease is renewed. Set false to model a guest whose network is broken beyond dhclient.
|
|
healFixes bool
|
|
}
|
|
|
|
func newRunner() *scriptedRunner {
|
|
return &scriptedRunner{
|
|
out: map[string]string{
|
|
"addr": fxAddr, "route": fxRoute, "iface": fxIfacesDHCP, "pgrep": fxPgrep, "dhclient": "",
|
|
},
|
|
fail: map[string]error{},
|
|
errs: map[string]string{},
|
|
healFixes: true,
|
|
}
|
|
}
|
|
|
|
// kind classifies a `pct exec <vmid> -- <cmd> ...` argv.
|
|
func kind(args []string) string {
|
|
if len(args) < 4 || args[0] != "exec" {
|
|
return "other"
|
|
}
|
|
rest := args[3:]
|
|
switch rest[0] {
|
|
case "ip":
|
|
if len(rest) > 1 && rest[1] == "route" {
|
|
return "route"
|
|
}
|
|
return "addr"
|
|
case "cat":
|
|
return "iface"
|
|
case "pgrep":
|
|
return "pgrep"
|
|
case "dhclient":
|
|
return "dhclient"
|
|
}
|
|
return "other"
|
|
}
|
|
|
|
func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.call = append(r.call, append([]string{name}, args...))
|
|
k := kind(args)
|
|
if k == "dhclient" && r.fail[k] == nil && r.healFixes {
|
|
// a real dhclient re-acquires the lease and stays resident
|
|
r.out["pgrep"], r.out["addr"], r.out["route"] = fxPgrep, fxAddr, fxRoute
|
|
delete(r.fail, "pgrep")
|
|
}
|
|
return []byte(r.out[k]), []byte(r.errs[k]), r.fail[k]
|
|
}
|
|
|
|
func (r *scriptedRunner) countOf(k string) int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
n := 0
|
|
for _, c := range r.call {
|
|
if len(c) > 1 && kind(c[1:]) == k {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (r *scriptedRunner) lastOf(k string) []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i := len(r.call) - 1; i >= 0; i-- {
|
|
if len(r.call[i]) > 1 && kind(r.call[i][1:]) == k {
|
|
return r.call[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// killDHClient models the exact 2026-07-20 state: lease still valid (address AND route present),
|
|
// dhclient gone. This is the fixture the whole feature exists for.
|
|
func (r *scriptedRunner) killDHClient() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.out["pgrep"] = ""
|
|
r.fail["pgrep"] = errors.New("exit status 1") // pgrep: no match, EMPTY stderr
|
|
}
|
|
|
|
func (r *scriptedRunner) reviveDHClient() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.out["pgrep"] = fxPgrep
|
|
delete(r.fail, "pgrep")
|
|
}
|
|
|
|
// expireLease models the state 80 minutes later: address and route gone too.
|
|
func (r *scriptedRunner) expireLease() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.out["addr"] = ""
|
|
r.out["route"] = ""
|
|
}
|
|
|
|
type fakeGuests struct {
|
|
guests []proxmox.Guest
|
|
err error
|
|
}
|
|
|
|
func (f *fakeGuests) Guests(context.Context) ([]proxmox.Guest, error) { return f.guests, f.err }
|
|
|
|
func running9201() *fakeGuests {
|
|
return &fakeGuests{guests: []proxmox.Guest{
|
|
{VMID: 9201, Name: "felhom-demo", Status: "running", Type: "lxc", Uptime: 7200},
|
|
}}
|
|
}
|
|
|
|
// newTestWatchdog wires a watchdog with a manual clock the test advances, and captures the log so
|
|
// the "healthy cycles are observable" contract can be asserted rather than assumed.
|
|
func newTestWatchdog(r Runner, g GuestSource) (*Watchdog, *time.Time, *bytes.Buffer) {
|
|
clock := time.Unix(1_784_000_000, 0).UTC()
|
|
buf := &bytes.Buffer{}
|
|
logger := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
w := New(r, g, time.Minute, logger)
|
|
w.now = func() time.Time { return clock }
|
|
// The agent's own settle window is measured from startedAt, which New stamped with the REAL
|
|
// clock; restamp it against the fake one, well in the past.
|
|
w.startedAt = clock.Add(-time.Hour)
|
|
return w, &clock, buf
|
|
}
|
|
|
|
// --- Scenario E: the TIMED failure, detected instantly ------------------------------------------
|
|
//
|
|
// RED-PROOF (recorded in REPORT.md): reverting classify()'s dhcp arm to IP-presence-only —
|
|
//
|
|
// case ModeDHCP:
|
|
// if p.IP == "" { return StateUnhealthy, ... }
|
|
// return StateHealthy, ...
|
|
//
|
|
// makes TestProbe_DeadDHClientWithLiveLeaseIsUnhealthy report "healthy" for the July-20 fixture, and
|
|
// TestWatchdog_HealsTheIncidentState records ZERO heals. That is the 80-minute silent window, exactly
|
|
// as it happened.
|
|
|
|
func TestProbe_DeadDHClientWithLiveLeaseIsUnhealthy(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
w, _, _ := newTestWatchdog(r, running9201())
|
|
|
|
p := w.probe(context.Background(), 9201)
|
|
if !p.Reachable {
|
|
t.Fatalf("guest must read as reachable: %+v", p)
|
|
}
|
|
if p.IP != "192.168.0.104" || !p.HasRoute {
|
|
t.Fatalf("the lease is still live in this fixture — IP/route must be present: %+v", p)
|
|
}
|
|
if p.DHCPAlive {
|
|
t.Fatalf("dhclient must read as dead: %+v", p)
|
|
}
|
|
|
|
state, detail := classify(p)
|
|
if state != StateUnhealthy {
|
|
t.Fatalf("classify = %q, want %q — waiting for the IP to vanish is the 80-minute silent "+
|
|
"window the incident proved (detail: %s)", state, StateUnhealthy, detail)
|
|
}
|
|
if !strings.Contains(detail, "dhclient") {
|
|
t.Fatalf("the reason must name the dead client, got %q", detail)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_HealsTheIncidentState(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
w, clock, logBuf := newTestWatchdog(r, running9201())
|
|
ctx := context.Background()
|
|
|
|
// Cycle 1: unhealthy, but one bad probe is not a diagnosis.
|
|
w.Tick(ctx)
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("healed after ONE bad probe (%d heals) — a single blip must never trigger a heal", n)
|
|
}
|
|
|
|
// Cycle 2: second consecutive bad probe → heal. The heal makes the client live again.
|
|
*clock = clock.Add(time.Minute)
|
|
w.Tick(ctx)
|
|
|
|
if n := r.countOf("dhclient"); n != 1 {
|
|
t.Fatalf("heal ran %d times, want exactly 1", n)
|
|
}
|
|
// The invocation must be the incident's, verbatim.
|
|
want := []string{"pct", "exec", "9201", "--", "dhclient",
|
|
"-pf", "/run/dhclient.eth0.pid", "-lf", "/var/lib/dhcp/dhclient.eth0.leases", "eth0"}
|
|
got := r.lastOf("dhclient")
|
|
if len(got) != len(want) {
|
|
t.Fatalf("heal argv = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("heal argv[%d] = %q, want %q (full: %v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
|
|
// The report must show the heal AND the verified-healthy re-probe.
|
|
snap := w.Snapshot()
|
|
if len(snap) != 1 {
|
|
t.Fatalf("snapshot = %+v, want one guest", snap)
|
|
}
|
|
g := snap[0]
|
|
if !g.Healed || !g.HealSucceeded {
|
|
t.Fatalf("report must record a successful heal: %+v", g)
|
|
}
|
|
if g.State != string(StateHealthy) || g.LastHealAt == "" || g.HealsLastHour != 1 {
|
|
t.Fatalf("post-heal report is wrong: %+v", g)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "guestnet: guest network healed") {
|
|
t.Fatalf("the heal was not logged: %s", logBuf.String())
|
|
}
|
|
}
|
|
|
|
// A healthy box must be silent about alarms but NOT silent about having looked.
|
|
func TestWatchdog_HealthyCycleProbesAndNeverHeals(t *testing.T) {
|
|
r := newRunner()
|
|
w, _, logBuf := newTestWatchdog(r, running9201())
|
|
|
|
w.Tick(context.Background())
|
|
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("a healthy guest was healed %d times, want 0", n)
|
|
}
|
|
if r.countOf("pgrep") != 1 || r.countOf("addr") != 1 {
|
|
t.Fatalf("the healthy path must still probe: %v", r.call)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "guest network healthy") {
|
|
t.Fatalf("a healthy cycle must be observable — otherwise 'no alarms' and 'never probed' "+
|
|
"are the same evidence (v0.91.2's lesson). Log: %s", logBuf.String())
|
|
}
|
|
snap := w.Snapshot()
|
|
if len(snap) != 1 || snap[0].State != string(StateHealthy) || !snap[0].DHClientAlive {
|
|
t.Fatalf("healthy snapshot wrong: %+v", snap)
|
|
}
|
|
}
|
|
|
|
// --- Scenario F: configuration and damping guards ------------------------------------------------
|
|
|
|
func TestWatchdog_StaticGuestIsNeverHealedWithDHClient(t *testing.T) {
|
|
r := newRunner()
|
|
r.out["iface"] = fxIfacesStat
|
|
r.killDHClient() // on a static guest this is NORMAL
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
|
|
for i := 0; i < 5; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("a static guest was healed with dhclient %d times, want 0", n)
|
|
}
|
|
if s := w.Snapshot(); len(s) != 1 || s[0].State != string(StateHealthy) {
|
|
t.Fatalf("a static guest with address+route is healthy, got %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_StaticGuestMissingAddressReportsButNeverHeals(t *testing.T) {
|
|
r := newRunner()
|
|
r.out["iface"] = fxIfacesStat
|
|
r.expireLease() // no address, no route on a STATIC guest → R-50 territory, not ours
|
|
w, clock, logBuf := newTestWatchdog(r, running9201())
|
|
|
|
for i := 0; i < 5; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("healed a static guest %d times, want 0 — dhclient must never fight a static config", n)
|
|
}
|
|
s := w.Snapshot()
|
|
if len(s) != 1 || s[0].State != string(StateStaticFault) {
|
|
t.Fatalf("state = %+v, want static_fault", s)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "not actionable") {
|
|
t.Fatalf("a static fault must be reported loudly: %s", logBuf.String())
|
|
}
|
|
// Loud ONCE per transition, not once per cycle.
|
|
if n := strings.Count(logBuf.String(), "not actionable"); n != 1 {
|
|
t.Fatalf("static fault logged %d times over 5 cycles, want 1 (per transition)", n)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_UnreachableGuestIsUnknownAndNeverHealed(t *testing.T) {
|
|
r := newRunner()
|
|
r.fail["addr"] = errors.New("exit status 2")
|
|
r.errs["addr"] = fxNoGuestErr
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
|
|
for i := 0; i < 4; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("healed a guest we could not probe %d times, want 0 — acting blind is how the "+
|
|
"incident happened", n)
|
|
}
|
|
if s := w.Snapshot(); len(s) != 1 || s[0].State != string(StateUnknown) {
|
|
t.Fatalf("state = %+v, want unknown", s)
|
|
}
|
|
}
|
|
|
|
// A missing pgrep (or any probe tool) must read as unknown, never as a dead client — otherwise a
|
|
// broken probe would heal forever.
|
|
func TestWatchdog_ProbeToolFailureIsUnknownNotDead(t *testing.T) {
|
|
r := newRunner()
|
|
r.fail["pgrep"] = errors.New("exit status 127")
|
|
r.errs["pgrep"] = "pgrep: command not found\n"
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
|
|
for i := 0; i < 4; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("a failed liveness probe caused %d heals, want 0", n)
|
|
}
|
|
if s := w.Snapshot(); len(s) != 1 || s[0].State != string(StateUnknown) {
|
|
t.Fatalf("state = %+v, want unknown", s)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_DampingCeilings(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
r.healFixes = false // the heal "works" but the client dies again immediately
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
ctx := context.Background()
|
|
|
|
// 10 hours of one-minute cycles against a permanently broken guest.
|
|
for i := 0; i < 600; i++ {
|
|
w.Tick(ctx)
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
|
|
heals := r.countOf("dhclient")
|
|
// Ceiling: 3 per hour AND ≥10 min apart ⇒ at most 3 in any rolling hour. Over 10 h the
|
|
// min-interval rule dominates: 6 slots/hour capped to 3/hour ⇒ ≤ 30.
|
|
if heals > 30 {
|
|
t.Fatalf("heals = %d over 10 h, want ≤ 30 (≤3/hour) — the damper is not holding", heals)
|
|
}
|
|
if heals == 0 {
|
|
t.Fatalf("heals = 0 — the damper has become a mute")
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_MinimumIntervalBetweenHeals(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
r.healFixes = false
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
ctx := context.Background()
|
|
|
|
w.Tick(ctx) // bad probe 1
|
|
*clock = clock.Add(time.Minute)
|
|
w.Tick(ctx) // bad probe 2 → heal #1
|
|
if r.countOf("dhclient") != 1 {
|
|
t.Fatalf("expected exactly one heal by now, got %d", r.countOf("dhclient"))
|
|
}
|
|
// Nine more minutes of failure: still inside the 10-minute cool-off.
|
|
for i := 0; i < 9; i++ {
|
|
*clock = clock.Add(time.Minute)
|
|
w.Tick(ctx)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 1 {
|
|
t.Fatalf("heals = %d within the 10-minute cool-off, want 1", n)
|
|
}
|
|
if s := w.Snapshot(); len(s) != 1 || !s[0].Damped {
|
|
t.Fatalf("a damped cycle must say so in the report: %+v", s)
|
|
}
|
|
// Past the cool-off, one more heal is allowed.
|
|
*clock = clock.Add(2 * time.Minute)
|
|
w.Tick(ctx)
|
|
if n := r.countOf("dhclient"); n != 2 {
|
|
t.Fatalf("heals = %d after the cool-off expired, want 2", n)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_BootRacesObserveOnly(t *testing.T) {
|
|
t.Run("young guest", func(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
g := running9201()
|
|
g.guests[0].Uptime = 40 // seconds
|
|
w, clock, _ := newTestWatchdog(r, g)
|
|
for i := 0; i < 4; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("healed a guest that booted 40 s ago %d times, want 0 — it has no lease YET", n)
|
|
}
|
|
})
|
|
|
|
t.Run("young agent", func(t *testing.T) {
|
|
r := newRunner()
|
|
r.killDHClient()
|
|
w, clock, _ := newTestWatchdog(r, running9201())
|
|
w.startedAt = *clock // the agent just started
|
|
for i := 0; i < 2; i++ {
|
|
w.Tick(context.Background())
|
|
*clock = clock.Add(time.Minute)
|
|
}
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("healed %d times within the agent's own settle window, want 0", n)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWatchdog_StoppedGuestIsNotProbed(t *testing.T) {
|
|
r := newRunner()
|
|
g := running9201()
|
|
g.guests[0].Status = "stopped"
|
|
w, _, _ := newTestWatchdog(r, g)
|
|
|
|
w.Tick(context.Background())
|
|
|
|
if len(r.call) != 0 {
|
|
t.Fatalf("a stopped guest was probed: %v", r.call)
|
|
}
|
|
}
|
|
|
|
func TestWatchdog_GuestListFailureSkipsTheSweep(t *testing.T) {
|
|
r := newRunner()
|
|
w, _, logBuf := newTestWatchdog(r, &fakeGuests{err: errors.New("pool membership read: 403")})
|
|
|
|
w.Tick(context.Background())
|
|
|
|
if len(r.call) != 0 {
|
|
t.Fatalf("acted with unproven ownership: %v", r.call)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "ownership unproven") {
|
|
t.Fatalf("the skip must be logged: %s", logBuf.String())
|
|
}
|
|
}
|
|
|
|
// A transient bad probe followed by recovery must never heal, and must log the recovery.
|
|
func TestWatchdog_SingleBlipNeverHeals(t *testing.T) {
|
|
r := newRunner()
|
|
w, clock, logBuf := newTestWatchdog(r, running9201())
|
|
ctx := context.Background()
|
|
|
|
w.Tick(ctx) // healthy
|
|
r.killDHClient()
|
|
*clock = clock.Add(time.Minute)
|
|
w.Tick(ctx) // bad probe 1
|
|
r.reviveDHClient()
|
|
*clock = clock.Add(time.Minute)
|
|
w.Tick(ctx) // healthy again
|
|
|
|
if n := r.countOf("dhclient"); n != 0 {
|
|
t.Fatalf("a single blip caused %d heals, want 0", n)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "guest network recovered") {
|
|
t.Fatalf("the recovery must be visible: %s", logBuf.String())
|
|
}
|
|
}
|
|
|
|
// --- parsers over the live P3 fixtures ------------------------------------------------------------
|
|
|
|
func TestParsers_OverLiveFixtures(t *testing.T) {
|
|
if got := parseInet(fxAddr); got != "192.168.0.104" {
|
|
t.Fatalf("parseInet = %q, want 192.168.0.104", got)
|
|
}
|
|
if got := parseInet(""); got != "" {
|
|
t.Fatalf("parseInet(empty) = %q, want empty (the post-expiry state prints nothing)", got)
|
|
}
|
|
if !hasDefaultRoute(fxRoute) {
|
|
t.Fatalf("hasDefaultRoute(%q) = false", fxRoute)
|
|
}
|
|
if hasDefaultRoute("") || hasDefaultRoute("172.17.0.0/16 dev docker0 proto kernel scope link\n") {
|
|
t.Fatal("docker bridge routes must not read as a default route (the incident's exact leftovers)")
|
|
}
|
|
if got := parseMode(fxIfacesDHCP, "eth0"); got != ModeDHCP {
|
|
t.Fatalf("parseMode(dhcp) = %q", got)
|
|
}
|
|
if got := parseMode(fxIfacesStat, "eth0"); got != ModeStatic {
|
|
t.Fatalf("parseMode(static) = %q", got)
|
|
}
|
|
if got := parseMode("auto lo\niface lo inet loopback\n", "eth0"); got != ModeUnknown {
|
|
t.Fatalf("parseMode(no eth0 stanza) = %q, want unknown", got)
|
|
}
|
|
if got := parseMode("iface eth0 inet manual\n", "eth0"); got != ModeUnknown {
|
|
t.Fatalf("parseMode(manual) = %q, want unknown", got)
|
|
}
|
|
}
|
|
|
|
func TestClassify_Table(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
p Probe
|
|
want State
|
|
}{
|
|
{"dhcp all green", Probe{Reachable: true, Mode: ModeDHCP, IP: "1.2.3.4", HasRoute: true, DHCPAlive: true}, StateHealthy},
|
|
{"dhcp dead client, live lease", Probe{Reachable: true, Mode: ModeDHCP, IP: "1.2.3.4", HasRoute: true}, StateUnhealthy},
|
|
{"dhcp no address", Probe{Reachable: true, Mode: ModeDHCP, DHCPAlive: true}, StateUnhealthy},
|
|
{"dhcp no route", Probe{Reachable: true, Mode: ModeDHCP, IP: "1.2.3.4", DHCPAlive: true}, StateUnhealthy},
|
|
{"static green", Probe{Reachable: true, Mode: ModeStatic, IP: "1.2.3.4", HasRoute: true}, StateHealthy},
|
|
{"static no client is normal", Probe{Reachable: true, Mode: ModeStatic, IP: "1.2.3.4", HasRoute: true}, StateHealthy},
|
|
{"static broken", Probe{Reachable: true, Mode: ModeStatic}, StateStaticFault},
|
|
{"unreachable", Probe{Mode: ModeDHCP}, StateUnknown},
|
|
{"unknown mode", Probe{Reachable: true, Mode: ModeUnknown, IP: "1.2.3.4", HasRoute: true}, StateUnknown},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got, _ := classify(tc.p); got != tc.want {
|
|
t.Fatalf("classify = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|