package osupdate import ( "context" "encoding/json" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // fakeWrapper plays /usr/local/sbin/felhom-os-apply: it reads the plan the leg wrote and answers per mode. type fakeWrapper struct { t *testing.T pending []Pending applyRep WrapperReport healthSeq []*Health // answers to successive "health" calls plans []map[string]any } func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { if name != WrapperPath || len(args) != 2 || args[0] != "--plan" { f.t.Fatalf("unexpected command %s %v", name, args) } b, err := os.ReadFile(args[1]) if err != nil { f.t.Fatal(err) } var plan map[string]any json.Unmarshal(b, &plan) f.plans = append(f.plans, plan) var rep WrapperReport healthy := &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{ "felhom-controller": {State: "running", Health: "healthy"}, "app": {State: "running", Health: "healthy"}}} switch plan["mode"] { case "inventory": rep = WrapperReport{Mode: "inventory", Pending: f.pending, HealthAfter: healthy, Installed: []Package{{Name: "libc6", Version: "u3", Origin: "Debian"}}} case "apply": rep = f.applyRep rep.Mode = "apply" if rep.HealthBefore == nil { rep.HealthBefore = healthy } if rep.HealthAfter == nil { rep.HealthAfter = healthy } case "health": if len(f.healthSeq) > 0 { rep.Health, f.healthSeq = f.healthSeq[0], f.healthSeq[1:] } else { rep.Health = healthy } } out, _ := json.Marshal(rep) return []byte("OSAPPLY-REPORT " + string(out) + "\n"), []byte("os-apply: DONE rc=0\n"), nil } func (f *fakeWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return f.Run(ctx, name, args...) } type fakeHub struct{ reports []Report } func (h *fakeHub) PostOSReport(_ context.Context, body []byte) error { var r Report json.Unmarshal(body, &r) h.reports = append(h.reports, r) return nil } func newLeg(t *testing.T, w *fakeWrapper, blk *hub.WireOSUpdate) (*Leg, *fakeHub) { h := &fakeHub{} now := time.Date(2026, 10, 4, 4, 0, 0, 0, time.UTC) l := &Leg{Runner: w, Hub: h, PlanDir: t.TempDir(), StatePath: filepath.Join(t.TempDir(), "last"), HealthWait: time.Minute, HealthPoll: 10 * time.Second, Now: func() time.Time { return now }, Sleep: func(_ context.Context, d time.Duration) { now = now.Add(d) }} if blk != nil { l.SetBlock(blk) } return l, h } var pend = []Pending{ {Name: "libc6", From: "u3", To: "u4", Origin: []string{"Debian"}}, {Name: "openssl", From: "u1", To: "u3", Origin: []string{"Debian-Security", "Debian"}}, {Name: "docker-ce", From: "29.7", To: "29.8", Origin: []string{"Docker CE"}}, } func modes(w *fakeWrapper) string { var m []string for _, p := range w.plans { m = append(m, p["mode"].(string)) } return strings.Join(m, ",") } // Ring 0 plans every pending FAST-LANE update (Docker excluded, `11` C3) and reports what it now runs. func TestRing0_PlansTheFastLaneOnly(t *testing.T) { w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6", Version: "u4"}, {Name: "openssl", Version: "u3"}}, Pending: pend[2:]}} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "applied" || !rep.Healthy { t.Fatalf("rep = %+v", rep) } pk := w.plans[1]["packages"].([]any) if len(pk) != 2 { t.Fatalf("ring-0 plan = %v, want libc6 + openssl only", pk) } if o := pk[1].(map[string]any)["origin"]; o != "Debian-Security" { t.Fatalf("openssl origin = %v", o) } if w.plans[1]["snapshot"] != "" { t.Fatalf("ring 0 installs from live sources, snapshot = %v", w.plans[1]["snapshot"]) } if len(rep.NotCovered) != 1 || rep.NotCovered[0] != "docker-ce" { t.Fatalf("not covered = %v", rep.NotCovered) } if len(h.reports) != 1 || h.reports[0].Outcome != "applied" { t.Fatalf("hub got %+v", h.reports) } } // Ring 1 installs EXACTLY the approved release (its versions, its snapshot), nothing it computed itself. func TestRing1_InstallsExactlyTheRelease(t *testing.T) { w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6", Version: "u4-approved"}}, Pending: pend[1:]}} rel := &hub.WireOSRelease{ID: "os-1", Snapshot: "20261004T080000Z", Packages: []hub.WireOSPackage{{Name: "libc6", Version: "u4-approved", Origin: "Debian"}}} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true, Release: rel}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "applied" || rep.ReleaseID != "os-1" { t.Fatalf("rep = %+v", rep) } ap := w.plans[1] pk := ap["packages"].([]any) if len(pk) != 1 || pk[0].(map[string]any)["version"] != "u4-approved" || ap["snapshot"] != "20261004T080000Z" || ap["release_id"] != "os-1" { t.Fatalf("ring-1 plan = %v", ap) } // openssl is pending and fast-lane but NOT in the release → not covered; docker-ce is never covered. if strings.Join(rep.NotCovered, ",") != "openssl,docker-ce" { t.Fatalf("not covered = %v", rep.NotCovered) } } func TestRing1_NoReleaseInstallsNothing(t *testing.T) { w := &fakeWrapper{t: t, pending: pend} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "nothing" || modes(w) != "inventory" { t.Fatalf("rep=%+v modes=%s", rep, modes(w)) } } // No block from the hub (an older hub): ring 1, ON, no release → reports, installs nothing. func TestNoBlock_IsRing1Nothing(t *testing.T) { w := &fakeWrapper{t: t, pending: pend} l, _ := newLeg(t, w, nil) if rep := l.Run(context.Background(), 9201, "night"); rep.Outcome != "nothing" || rep.Ring != 1 || modes(w) != "inventory" { t.Fatalf("rep=%+v modes=%s", rep, modes(w)) } } // Switched OFF: the box reports but installs nothing (the brief, Part D 2). func TestSwitchOff_ReportsOnly(t *testing.T) { w := &fakeWrapper{t: t, pending: pend} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: false}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "inventory" || modes(w) != "inventory" || len(h.reports) != 1 || len(rep.Pending) != 3 { t.Fatalf("rep=%+v modes=%s", rep, modes(w)) } } // Unhealthy after the run, and still unhealthy at the end of the wait → health_failed (the hub mails the operator). func TestHealth_FailsAfterTheWait(t *testing.T) { bad := &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{ "felhom-controller": {State: "running", Health: "healthy"}, "app": {State: "exited"}}} w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6"}}, HealthAfter: bad}, healthSeq: []*Health{bad, bad, bad, bad, bad, bad, bad, bad}} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "health_failed" || rep.Healthy || !strings.Contains(rep.HealthReason, "app was running") { t.Fatalf("rep = %+v", rep) } if h.reports[0].Outcome != "health_failed" { t.Fatal("the hub was not told") } } // A service that takes a moment to come back is not a failure: the poll sees it recover inside the wait. func TestHealth_RecoversInsideTheWait(t *testing.T) { starting := &Health{DockerOK: true, NetworkOK: true, Controller: "starting"} w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6"}}, HealthAfter: starting}, healthSeq: []*Health{starting}} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) if rep := l.Run(context.Background(), 9201, "night"); rep.Outcome != "applied" || !rep.Healthy { t.Fatalf("rep = %+v", rep) } } func TestHealthVerdict(t *testing.T) { ok := &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{"a": {State: "running", Health: "healthy"}}} cases := []struct { name string before *Health after *Health want bool }{ {"all good", ok, ok, true}, {"no reading", ok, nil, false}, {"docker down", ok, &Health{NetworkOK: true, Controller: "healthy"}, false}, {"no network", ok, &Health{DockerOK: true, Controller: "healthy"}, false}, {"controller starting", ok, &Health{DockerOK: true, NetworkOK: true, Controller: "starting"}, false}, {"only the controller differs", ok, &Health{DockerOK: true, NetworkOK: true, Controller: "unhealthy", Containers: map[string]Container{"a": {State: "running", Health: "healthy"}}}, false}, {"app gone", ok, &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{}}, false}, {"app unhealthy", ok, &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{"a": {State: "running", Health: "unhealthy"}}}, false}, {"stopped before stays stopped", &Health{Containers: map[string]Container{"x": {State: "exited"}}}, &Health{DockerOK: true, NetworkOK: true, Controller: "healthy"}, true}, } for _, c := range cases { if got, why := HealthVerdict(c.before, c.after); got != c.want { t.Errorf("%s: got %v (%s), want %v", c.name, got, why, c.want) } } } func TestOncePerNight(t *testing.T) { w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6"}}}} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) l.Run(context.Background(), 9201, "night") n := len(w.plans) if rep := l.Run(context.Background(), 9201, "night"); rep.Outcome != "skipped" || len(w.plans) != n { t.Fatalf("a second night run in the same night ran: %+v", rep) } if rep := l.Run(context.Background(), 9201, "debug"); rep.Outcome == "skipped" { t.Fatal("the debug action must not be throttled") } } func TestRefusedIsReported(t *testing.T) { w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Refused: json.RawMessage(`{"code":"R6","reason":"x"}`)}} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) if rep := l.Run(context.Background(), 9201, "night"); rep.Outcome != "refused" || h.reports[0].Outcome != "refused" { t.Fatalf("rep = %+v", rep) } } // The root wrapper's own suite (configs/test_felhom_os_apply.py) runs with `go test ./...` so CI covers it. func TestWrapperSuite(t *testing.T) { py, err := exec.LookPath("python3") if err != nil { t.Skip("python3 not available") } cmd := exec.Command(py, "../../configs/test_felhom_os_apply.py") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("wrapper suite failed: %v\n%s", err, out) } if !strings.Contains(string(out), "OK") { t.Fatalf("wrapper suite did not report OK:\n%s", out) } } // An app that stops BETWEEN the start of the leg and the apply's own "before" reading still fails the run. // Measured live 2026-10-04 on demo-hp (privatebin stopped 1 s after the apply plan was written: the old rule // passed). Red-proof: use ap.HealthBefore alone as the baseline and this fails. func TestHealth_BaselineIsTheStartOfTheLeg(t *testing.T) { stoppedEarly := &Health{DockerOK: true, NetworkOK: true, Controller: "healthy", Containers: map[string]Container{ "felhom-controller": {State: "running", Health: "healthy"}, "app": {State: "exited"}}} w := &fakeWrapper{t: t, pending: pend, applyRep: WrapperReport{Upgraded: []Package{{Name: "libc6"}}, HealthBefore: stoppedEarly, HealthAfter: stoppedEarly}, healthSeq: []*Health{stoppedEarly, stoppedEarly, stoppedEarly, stoppedEarly, stoppedEarly, stoppedEarly, stoppedEarly}} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) rep := l.Run(context.Background(), 9201, "night") if rep.Outcome != "health_failed" || !strings.Contains(rep.HealthReason, "app was running") { t.Fatalf("an app that stopped during the run passed: %+v", rep) } }