From 9cac3462bb7cddcaeb981d1b8bd16e9b86c205a3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 4 Oct 2026 11:22:03 +0200 Subject: [PATCH] =?UTF-8?q?osupdate:=20the=20health=20baseline=20is=20the?= =?UTF-8?q?=20start=20of=20the=20leg=20(inventory=20reading=20merged=20wit?= =?UTF-8?q?h=20the=20apply's=20own)=20=E2=80=94=20an=20app=20that=20stops?= =?UTF-8?q?=20during=20the=20run=20fails=20it=20(found=20live=20on=20demo-?= =?UTF-8?q?hp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- internal/osupdate/leg.go | 32 +++++++++++++++++++++++++++++++- internal/osupdate/leg_test.go | 16 ++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/osupdate/leg.go b/internal/osupdate/leg.go index 90c483a..dfe78eb 100644 --- a/internal/osupdate/leg.go +++ b/internal/osupdate/leg.go @@ -240,6 +240,32 @@ func HealthVerdict(before, after *Health) (bool, string) { return true, "" } +// MergeBaseline is the health baseline from two readings: a container counts as running (and healthy) if EITHER +// reading saw it so. nil-safe. Pinned by TestHealth_BaselineIsTheStartOfTheLeg. +func MergeBaseline(a, b *Health) *Health { + if a == nil { + return b + } + if b == nil { + return a + } + out := &Health{DockerOK: a.DockerOK && b.DockerOK, NetworkOK: a.NetworkOK && b.NetworkOK, Controller: b.Controller, + Containers: map[string]Container{}} + for _, h := range []*Health{a, b} { + for n, c := range h.Containers { + cur, seen := out.Containers[n] + if !seen || (cur.State != "running" && c.State == "running") { + out.Containers[n] = c + continue + } + if c.State == "running" && c.Health == "healthy" { + out.Containers[n] = c + } + } + } + return out +} + // call writes the plan and runs the wrapper once. func (l *Leg) call(ctx context.Context, runID, mode string, vmid int, rel hub.WireOSRelease, pkgs []Package) (WrapperReport, error) { dir := l.PlanDir @@ -374,8 +400,12 @@ func (l *Leg) Run(ctx context.Context, vmid int, trigger string) Report { } deadline := l.now().Add(wait) cur := ap.HealthAfter + // The baseline is the guest as it was at the START of the leg (the inventory's reading) merged with the + // apply's own "before": an app that stops at any point during the run counts. Found live 2026-10-04: an app + // stopped between the inventory and the apply's own reading was taken as "stopped before" and ignored. + base := MergeBaseline(inv.HealthAfter, ap.HealthBefore) for { - ok, why := HealthVerdict(ap.HealthBefore, cur) + ok, why := HealthVerdict(base, cur) rep.Healthy, rep.HealthReason = ok, why if ok || !l.now().Before(deadline) || ctx.Err() != nil { break diff --git a/internal/osupdate/leg_test.go b/internal/osupdate/leg_test.go index 8cf1922..2be35e0 100644 --- a/internal/osupdate/leg_test.go +++ b/internal/osupdate/leg_test.go @@ -263,3 +263,19 @@ func TestWrapperSuite(t *testing.T) { 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) + } +}