package main import ( "net/http" "os" "path/filepath" "testing" "time" ) // The supply-chain bake window (bake.go). These tests run with the real // delays (stable 72h / beta 24h) — everything else in updater_test.go runs // zeroBake()d so the two concerns stay separate. func TestUpdatePassBakeHoldsNewStableHead(t *testing.T) { withTempOpt(t) calls := stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) _, setChannels := startFeed(t, map[string]string{"new1234": "held-binary"}) setChannels(map[string]string{"stable": "new1234"}) reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") seedInstance(t, reg, "h1.example.com", s1, "old1111") // First sighting: the clock starts and NOTHING moves — no download, no // restart. The window is measured from this box's own observation. if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatalf("updatePass: %v", err) } if v, _ := liveVersion("h1.example.com"); v != "old1111" { t.Fatalf("brand-new stable head installed immediately: live = %q", v) } if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) { t.Fatal("binary downloaded during the bake window") } if len(*calls) != 0 { t.Fatalf("restarts during the bake window: %v", *calls) } st := loadFirstSeen() seen, ok := st.Channels["stable"]["new1234"] if !ok { t.Fatal("first sighting not recorded") } if st.Jitter < 0 || st.Jitter >= 0.25 { t.Fatalf("minted jitter %v outside [0, 0.25)", st.Jitter) } // A second pass inside the window holds too, and doesn't move the clock. if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "old1111" { t.Fatalf("head installed inside the window: live = %q", v) } if again := loadFirstSeen().Channels["stable"]["new1234"]; !again.Equal(seen) { t.Fatalf("re-observation moved the clock: %v -> %v", seen, again) } // Once the window (72h plus any jitter, ≤ 90h) has passed, it installs. st.Channels["stable"]["new1234"] = time.Now().Add(-91 * time.Hour) if err := st.save(); err != nil { t.Fatal(err) } if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "new1234" { t.Fatalf("head not installed after the window: live = %q", v) } } // Pulling a channel back to a previously-seen version applies immediately — // evacuating a bad release must never itself sit in a bake window. func TestUpdatePassBakePullBackIsImmediate(t *testing.T) { withTempOpt(t) stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) _, setChannels := startFeed(t, map[string]string{"goodaaa": "good-binary", "badbbbb": "bad-binary"}) setChannels(map[string]string{"stable": "badbbbb"}) // goodaaa sat at the stable head long ago (this box saw it); badbbbb is // the fresh, about-to-be-pulled release. st := loadFirstSeen() st.Jitter = 0.1 st.Channels = map[string]map[string]time.Time{ "stable": {"goodaaa": time.Now().Add(-200 * time.Hour)}, } if err := st.save(); err != nil { t.Fatal(err) } reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") seedInstance(t, reg, "h1.example.com", s1, "old1111") // The fresh head is held... if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "old1111" { t.Fatalf("fresh head installed: live = %q", v) } // ...the operator pulls stable back to the known-good version, and the // next pass applies it with no further wait. setChannels(map[string]string{"stable": "goodaaa"}) if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "goodaaa" { t.Fatalf("pull-back target was held: live = %q", v) } } // --ignore-bake is the deliberate local override: root on the box applying a // held head NOW (there is intentionally no publisher-side equivalent). func TestUpdatePassIgnoreBakeOverrides(t *testing.T) { withTempOpt(t) stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) _, setChannels := startFeed(t, map[string]string{"new1234": "content"}) setChannels(map[string]string{"stable": "new1234"}) reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") seedInstance(t, reg, "h1.example.com", s1, "old1111") if err := updatePass(reg, "lchat@", false, true); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "new1234" { t.Fatalf("--ignore-bake didn't install: live = %q", v) } } // edge has no window: it's the operator's own deploy path (`publish && ship` // must keep converging the fleet in one pass). func TestUpdatePassEdgeHasNoBake(t *testing.T) { withTempOpt(t) stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) _, setChannels := startFeed(t, map[string]string{"edge777": "edge-binary"}) setChannels(map[string]string{"edge": "edge777"}) reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } s1 := startFakeInstance(t, "h1.example.com", http.StatusOK, updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "edge"}, "") seedInstance(t, reg, "h1.example.com", s1, "old1111") if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.example.com"); v != "edge777" { t.Fatalf("edge head was held: live = %q", v) } } // Hibernating hosts honour the window too — their repoints have no health // check, so they must never be the first to run a fresh head anyway. func TestUpdatePassBakeHoldsHibernating(t *testing.T) { withTempOpt(t) calls := stubSystemctl(t) oldData := instanceDataDir instanceDataDir = t.TempDir() t.Cleanup(func() { instanceDataDir = oldData }) _, setChannels := startFeed(t, map[string]string{"new1234": "content"}) setChannels(map[string]string{"stable": "new1234"}) reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } src := filepath.Join(t.TempDir(), "old") writeFakeBinary(t, src, "old-binary") if err := stageVersion(src, "old1111"); err != nil { t.Fatal(err) } if err := pointLive("h1.hib", "old1111"); err != nil { t.Fatal(err) } if err := reg.upsert(Route{Host: "h1.hib", Kind: "instance", Status: "ready", Socket: filepath.Join(t.TempDir(), "no.sock"), Hibernate: true, Version: "old1111"}); err != nil { t.Fatal(err) } db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db")) if err != nil { t.Fatal(err) } db.sql.Close() if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.hib"); v != "old1111" { t.Fatalf("hibernating host repointed during the bake window: live = %q", v) } if len(*calls) != 0 { t.Fatalf("systemctl touched: %v", *calls) } // After the window it repoints as usual. st := loadFirstSeen() st.Channels["stable"]["new1234"] = time.Now().Add(-91 * time.Hour) if err := st.save(); err != nil { t.Fatal(err) } if err := updatePass(reg, "lchat@", false, false); err != nil { t.Fatal(err) } if v, _ := liveVersion("h1.hib"); v != "new1234" { t.Fatalf("hibernating host not repointed after the window: live = %q", v) } } // Dry runs compute bake eligibility but never persist the clock — a dry run // must not start windows ticking. func TestUpdatePassBakeDryRunPersistsNothing(t *testing.T) { withTempOpt(t) stubSystemctl(t) _, setChannels := startFeed(t, map[string]string{"new1234": "content"}) setChannels(map[string]string{"stable": "new1234"}) reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) if err != nil { t.Fatal(err) } s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") seedInstance(t, reg, "h1.example.com", s1, "old1111") if err := updatePass(reg, "lchat@", true, false); err != nil { t.Fatal(err) } if _, err := os.Stat(firstSeenPath()); !os.IsNotExist(err) { t.Fatal("dry run persisted the bake clock") } } func TestFirstSeenObservePruneAndEligibility(t *testing.T) { withTempOpt(t) // A fresh box mints its jitter alongside the first observation. st := loadFirstSeen() now := time.Now() if !st.observe(&releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "v0"}}}, now) { t.Fatal("first observation reported no change") } if st.Jitter < 0 || st.Jitter >= 0.25 { t.Fatalf("jitter %v outside [0, 0.25)", st.Jitter) } // Re-observing the same head is a no-op. if st.observe(&releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "v0"}}}, now.Add(time.Hour)) { t.Fatal("re-observation reported a change") } // Eligibility: firstSeen + delay*(1+jitter), exactly. st.Jitter = 0.2 // 0.25 is exclusive — loadFirstSeen clamps it as corrupt seen := st.Channels["stable"]["v0"] at, ok := st.bakeEligibleAt("stable", "v0") if want := seen.Add(time.Duration(float64(72*time.Hour) * 1.2)); !ok || !at.Equal(want) { t.Fatalf("eligibleAt = %v ok=%v, want %v", at, ok, want) } if _, ok := st.bakeEligibleAt("stable", "never-seen"); ok { t.Fatal("unseen version reported eligible time") } // An unseen version is held, an old one isn't, ignore always passes. if bakeHold(st, "stable", "never-seen", false, now) == "" { t.Fatal("unseen version not held") } if bakeHold(st, "stable", "never-seen", true, now) != "" { t.Fatal("ignore-bake didn't override") } st.Channels["stable"]["ancient"] = now.Add(-100 * time.Hour) if hold := bakeHold(st, "stable", "ancient", false, now); hold != "" { t.Fatalf("aged-out version still held: %s", hold) } // History is pruned to firstSeenKeep entries, oldest first — the current // head always survives. for i := 0; i < firstSeenKeep+5; i++ { st.observe(&releaseManifest{Channels: map[string]releaseEntry{ "stable": {Version: string(rune('a' + i))}, }}, now.Add(time.Duration(i)*time.Minute)) } if n := len(st.Channels["stable"]); n != firstSeenKeep { t.Fatalf("history size = %d, want %d", n, firstSeenKeep) } if _, ok := st.Channels["stable"][string(rune('a'+firstSeenKeep+4))]; !ok { t.Fatal("newest head was pruned") } // The clock round-trips through disk. if err := st.save(); err != nil { t.Fatal(err) } got := loadFirstSeen() if got.Jitter != st.Jitter || len(got.Channels["stable"]) != firstSeenKeep { t.Fatalf("reload = jitter %v, %d entries", got.Jitter, len(got.Channels["stable"])) } }