package osupdate import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // R-868 (v0.144.0). THE NIGHT'S SHAPE (A5, demo-hp 2026-10-05 02:57 UTC): the wrapper finished six packages, the agent // was kill -9-ed before it read the report; the hub never got it. Here: the wrapper's kept copy and the plan file are // on disk, a NEW agent process starts — it must send exactly one `applied` report and delete both files. // COMPANION RED-PROOF: drop the SendUnsent body (return 0) → "the hub got no report". func TestR868_KilledPassIsReportedAtStart(t *testing.T) { w := &fakeWrapper{t: t} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) ring := 0 kept := WrapperReport{Mode: "apply", Layer: LayerGuest, RunID: "20261005T025700Z", Trigger: "debug", Ring: &ring, VMID: 9201, ReleaseID: "ring0-20261005T025700Z", HealthBefore: guestOK(), HealthAfter: guestOK(), Upgraded: []Package{{Name: "libc6", Version: "u4"}, {Name: "openssl", Version: "u3"}}} b, _ := json.Marshal(kept) rp := reportFile(l.PlanDir, kept.RunID, LayerGuest, "apply") pp := planFile(l.PlanDir, kept.RunID, LayerGuest, "apply") must(t, os.WriteFile(rp, b, 0o600)) must(t, os.WriteFile(pp, []byte("{}"), 0o600)) if n := l.SendUnsent(context.Background()); n != 1 { t.Fatalf("sent %d, want 1", n) } if len(h.reports) != 1 { t.Fatalf("the hub got no report (or several): %+v", h.reports) } r := h.reports[0] if r.Outcome != "applied" || !r.Healthy || r.Trigger != "debug" || r.RunID != kept.RunID || r.Ring != 0 || len(r.Upgraded) != 2 || r.VMID != 9201 { t.Fatalf("report = %+v", r) } // R-875 (v0.145.0): neutral — the copy cannot tell a killed agent from an absent hub. if !strings.HasPrefix(r.HealthReason, "sent late") || strings.Contains(r.HealthReason, "stopped mid-pass") { t.Fatalf("health_reason = %q, want the neutral \"sent late …\"", r.HealthReason) } for _, p := range []string{rp, pp} { if _, err := os.Stat(p); !os.IsNotExist(err) { t.Fatalf("%s still on disk after the hub got it", filepath.Base(p)) } } // a second start sends nothing again — no duplicate report if n := l.SendUnsent(context.Background()); n != 0 || len(h.reports) != 1 { t.Fatalf("sent again: %d, reports %d", n, len(h.reports)) } } // A normal pass: the hub gets ONE report per layer and the kept copies are gone after it (nothing resent later). // COMPANION RED-PROOF: drop the os.Remove(rep.unsent) in finish → the next pass resends → "2 guest reports". func TestR868_NormalPassLeavesNoCopyAndNoDuplicate(t *testing.T) { w := &fakeWrapper{t: t, keep: true, applyRep: map[string]WrapperReport{LayerGuest: {Upgraded: []Package{{Name: "libc6", Version: "u4"}}}}} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) l.Run(context.Background(), 9201, "debug") left, _ := filepath.Glob(filepath.Join(l.PlanDir, "report-*.json")) if len(left) != 0 { t.Fatalf("kept copies left after the hub got them: %v", left) } l.Run(context.Background(), 9201, "debug") // the next pass sends kept copies first guest := 0 for _, r := range h.reports { if r.Layer == LayerGuest && r.Outcome == "applied" { guest++ } } if guest != 2 { t.Fatalf("%d guest reports for 2 passes (a duplicate or a loss)", guest) } } type failingHub struct{ n int } func (h *failingHub) PostOSReport(context.Context, []byte) error { h.n++ return errors.New("hub away") } // The hub away: the copy stays, and goes at the next chance. func TestR868_HubAwayKeepsTheCopy(t *testing.T) { w := &fakeWrapper{t: t, keep: true, applyRep: map[string]WrapperReport{LayerGuest: {Upgraded: []Package{{Name: "libc6", Version: "u4"}}}}} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true}) l.Appliance = false l.Hub = &failingHub{} l.Run(context.Background(), 9201, "night") left, _ := filepath.Glob(filepath.Join(l.PlanDir, "report-*.json")) if len(left) != 2 { // ring 0: the guest step and the Docker step each kept one t.Fatalf("the copies must stay while the hub is away: %v", left) } h := &fakeHub{} l.Hub = h if n := l.SendUnsent(context.Background()); n != 2 { t.Fatalf("sent %d: %+v", n, h.reports) } for _, r := range h.reports { if r.Trigger != "night" || r.Ring != 0 { t.Fatalf("the kept report lost its ids: %+v", r) } } } // A pass in progress holds the lock: the sender at start must not take that pass's copy (it would be sent twice). func TestR868_RunningPassKeepsTheSenderOff(t *testing.T) { w := &fakeWrapper{t: t} l, h := newLeg(t, w, nil) must(t, os.WriteFile(reportFile(l.PlanDir, "r1", LayerGuest, "apply"), []byte(`{"mode":"apply","layer":"guest"}`), 0o600)) unlock := l.lockPass(true) if n := l.SendUnsent(context.Background()); n != 0 || len(h.reports) != 0 { t.Fatalf("sent while a pass ran: %d", n) } unlock() if n := l.SendUnsent(context.Background()); n != 1 { t.Fatalf("not sent after the pass: %d", n) } } // v0.144.1 — THE LIVE SHAPE (demo-hp 2026-10-05 05:45 UTC): the restarted daemon looked at 05:45:09, the orphaned // wrapper wrote its copy at ~05:45:16. The loop must still send it. // COMPANION RED-PROOF: make SendUnsentLoop return after the first look → "the late copy was never sent". func TestR868_ACopyWrittenAfterTheStartIsSentByTheLoop(t *testing.T) { w := &fakeWrapper{t: t} l, h := newLeg(t, w, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() sent := make(chan int, 4) go l.SendUnsentLoop(ctx, 20*time.Millisecond, func(n int) { sent <- n }) time.Sleep(50 * time.Millisecond) // the start-time look found nothing must(t, os.WriteFile(reportFile(l.PlanDir, "late", LayerGuest, "apply"), []byte(`{"mode":"apply","layer":"guest","run_id":"late","trigger":"debug","ring":0,"vmid":9201,"upgraded":[{"name":"openssl","version":"u3"}]}`), 0o600)) select { case n := <-sent: if n != 1 || len(h.reports) != 1 || h.reports[0].RunID != "late" || h.reports[0].Outcome == "" { t.Fatalf("sent %d: %+v", n, h.reports) } case <-time.After(3 * time.Second): t.Fatal("the late copy was never sent") } } func must(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } }