package osupdates import ( "log" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) type fix struct { s *Service now time.Time events []string bumps []string } func newFix(t *testing.T) *fix { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "hub.db"), log.New(os.Stderr, "", 0)) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) for _, h := range []struct{ host, cust string }{{"hp", "c-hp"}, {"n100", "c-n100"}, {"cust1", "c-1"}} { if err := st.UpsertHost(&store.Host{HostID: h.host, CustomerID: h.cust, APIKey: "k-" + h.host}); err != nil { t.Fatal(err) } } f := &fix{now: time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)} f.s = &Service{Store: st, ApproveAfter: 24 * time.Hour, NightsRequired: 1, Now: func() time.Time { return f.now }, Emit: func(_, typ, _, _, _, _ string) { f.events = append(f.events, typ) }, Bump: func(h string) { f.bumps = append(f.bumps, h) }} _ = st.SetOSRing("hp", 0) _ = st.SetOSRing("n100", 0) return f } func pk(name, ver string) Package { return Package{Name: name, Version: ver, Origin: "Debian"} } func (f *fix) report(t *testing.T, host, trigger string, healthy bool, pkgs ...Package) { t.Helper() outcome := "applied" if !healthy { outcome = "health_failed" } if err := f.s.Ingest(host, Report{RunID: host + trigger + f.now.String(), Trigger: trigger, Mode: "apply", Outcome: outcome, Healthy: healthy, Installed: pkgs, Upgraded: pkgs[:1]}); err != nil { t.Fatal(err) } } // The ruled wait: approved only after every ring-0 box ran the set healthy for ApproveAfter AND through a night run. // Red-proof: drop the age check, the healthy check or the nights check in Evaluate and a sub-step fails. func TestApproval_WaitHealthyAndOneNight(t *testing.T) { f := newFix(t) set := []Package{pk("libc6", "2.41-12+deb13u4"), pk("openssl", "3.5.7-1~deb13u3")} f.report(t, "hp", "debug", true, set...) f.report(t, "n100", "debug", true, set...) st, _ := f.s.Evaluate() if !strings.HasPrefix(st.Waiting, "healthy for") { t.Fatalf("fresh set approved or wrong reason: %+v", st) } f.now = f.now.Add(25 * time.Hour) st, _ = f.s.Evaluate() if !strings.Contains(st.Waiting, "night run") { t.Fatalf("approved without a night run: %+v", st) } f.report(t, "hp", "night", true, set...) f.report(t, "n100", "night", true, set...) if _, err := f.s.Evaluate(); err != nil { t.Fatal(err) } rel, _ := f.s.Store.LatestOSRelease() if rel == nil || rel.ApprovedBy != "auto" { t.Fatalf("not approved: %+v", rel) } if len(f.bumps) != 1 || f.bumps[0] != "cust1" { t.Fatalf("only the ring-1 box must be nudged, got %v", f.bumps) } b := f.s.DesiredBlock("cust1") if b.Ring != 1 || !b.Enabled || b.Release == nil || len(b.Release.Packages) != 2 || b.Release.Snapshot != rel.ApprovedAt.UTC().Format("20060102T150405Z") { t.Fatalf("ring-1 block = %+v", b) } if hb := f.s.DesiredBlock("hp"); hb.Ring != 0 || hb.Release != nil { t.Fatalf("a ring-0 box must not get a release (it installs everything pending): %+v", hb) } } func TestApproval_UnhealthyRunBlocks(t *testing.T) { f := newFix(t) set := []Package{pk("libc6", "2.41-12+deb13u4")} f.report(t, "hp", "debug", true, set...) f.report(t, "n100", "debug", true, set...) f.s.Evaluate() f.now = f.now.Add(25 * time.Hour) f.report(t, "hp", "night", false, set...) f.report(t, "n100", "night", true, set...) st, _ := f.s.Evaluate() if rel, _ := f.s.Store.LatestOSRelease(); rel != nil { t.Fatalf("approved although a ring-0 run was not healthy: %+v", st) } if !strings.Contains(st.Waiting, "healthy=false") { t.Fatalf("reason = %q", st.Waiting) } found := false for _, e := range f.events { found = found || e == EventHealthFailed } if !found { t.Fatalf("no %s event: %v", EventHealthFailed, f.events) } } // Ring 0 disagreeing on a package: that package is left out of the candidate (C9 — approve what ALL ring 0 runs). func TestCandidate_DisagreementLeftOut(t *testing.T) { f := newFix(t) f.report(t, "hp", "night", true, pk("libc6", "2.41-12+deb13u4"), pk("curl", "8.14.1-2+deb13u5")) f.report(t, "n100", "night", true, pk("libc6", "2.41-12+deb13u4"), pk("curl", "8.14.1-2+deb13u4")) cand, _, err := f.s.candidate([]string{"hp", "n100"}) if err != nil { t.Fatal(err) } if _, ok := cand["curl"]; ok || cand["libc6"].Version != "2.41-12+deb13u4" { t.Fatalf("candidate = %+v", cand) } } // Non-Debian origins never enter a release (the fast lane is Debian / Debian-Security only, C3). func TestCandidate_OnlyDebianOrigins(t *testing.T) { f := newFix(t) d := Package{Name: "docker-ce", Version: "5:29.8.2", Origin: "Docker"} f.report(t, "hp", "night", true, pk("libc6", "x1"), d) f.report(t, "n100", "night", true, pk("libc6", "x1"), d) cand, _, _ := f.s.candidate([]string{"hp", "n100"}) if _, ok := cand["docker-ce"]; ok { t.Fatal("a Docker package entered the candidate") } } func TestApproveNow_IsAnOperatorEvent(t *testing.T) { f := newFix(t) f.report(t, "hp", "debug", true, pk("libc6", "x1")) f.report(t, "n100", "debug", true, pk("libc6", "x1")) id, err := f.s.ApproveNow() if err != nil || id == "" { t.Fatalf("%q %v", id, err) } rel, _ := f.s.Store.LatestOSRelease() if rel.ApprovedBy != "operator" { t.Fatalf("approved_by = %q", rel.ApprovedBy) } n := 0 for _, e := range f.events { if e == EventApprovedNow { n++ } } if n != 1 { t.Fatalf("want one %s, got %v", EventApprovedNow, f.events) } } func TestSwitchAndRing(t *testing.T) { f := newFix(t) if err := f.s.SetEnabled("cust1", false); err != nil { t.Fatal(err) } if b := f.s.DesiredBlock("cust1"); b.Enabled { t.Fatal("switch OFF not delivered") } if err := f.s.SetRing("cust1", 2); err == nil { t.Fatal("ring 2 accepted") } if b := f.s.DesiredBlock("nobody-row"); b.Ring != 1 || !b.Enabled { t.Fatalf("default must be ring 1, ON: %+v", b) } } func TestIngest_AppliedIsTheHouseholdsLine(t *testing.T) { f := newFix(t) f.report(t, "cust1", "night", true, pk("libc6", "x1")) if len(f.events) != 1 || f.events[0] != EventApplied { t.Fatalf("events = %v", f.events) } }