56ef1d6655
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
153 lines
6.1 KiB
Go
153 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|