23a8ef3de4
gates / gates (push) Successful in 18s
No automatic undo: a customer guest cannot be snapshotted (R-837, measured). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
266 lines
10 KiB
Go
266 lines
10 KiB
Go
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)
|
|
}
|
|
}
|