package main import ( "net/http" "os" "path/filepath" "testing" "time" ) // Major version pinning (update_major + releaseEntry.Semver): minors and // patches follow the channel, a head crossing the pinned major is held — // halt-and-notify, never a silent auto-upgrade across a breaking boundary. // seedSemvers stamps semvers onto feed versions for this test and cleans up. func seedSemvers(t *testing.T, m map[string]string) { t.Helper() for v, sv := range m { feedSemvers[v] = sv } t.Cleanup(func() { for v := range m { delete(feedSemvers, v) } }) } func TestSemverHelpers(t *testing.T) { if n, ok := semverMajor("2.14.3"); !ok || n != 2 { t.Fatalf("semverMajor(2.14.3) = %d, %v", n, ok) } for _, bad := range []string{"", "2", "2.1", "v2.1.3", "2.1.3-rc1", "2.1.3.4", "x.y.z"} { if _, ok := semverMajor(bad); ok { t.Errorf("semverMajor(%q) accepted", bad) } } for _, good := range []string{"", "0", "1", "12"} { if !validMajor(good) { t.Errorf("validMajor(%q) rejected", good) } } for _, bad := range []string{"-1", "1.0", "one", "99999"} { if validMajor(bad) { t.Errorf("validMajor(%q) accepted", bad) } } } func TestValidateManifestSemver(t *testing.T) { ok := entryFor("new1234") ok.Semver = "1.2.3" m := &releaseManifest{Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{"stable": ok}} if err := validateManifest(m); err != nil { t.Fatalf("valid semver rejected: %v", err) } bad := entryFor("new1234") bad.Semver = "not-a-version" m.Channels["stable"] = bad if err := validateManifest(m); err == nil { t.Fatal("garbage semver accepted") } } func TestMajorHoldSemantics(t *testing.T) { e := func(sv string) releaseEntry { return releaseEntry{Version: "abc1234", Semver: sv} } if hold := majorHold("", e("2.0.0")); hold != "" { t.Fatalf("no pin but held: %s", hold) } if hold := majorHold("1", e("1.9.9")); hold != "" { t.Fatalf("within-major held: %s", hold) } if hold := majorHold("2", e("1.0.0")); hold != "" { t.Fatalf("older major held (pull-backs must pass): %s", hold) } if hold := majorHold("1", e("2.0.0")); hold == "" { t.Fatal("crossing major not held") } // Fail closed: a pinned instance never takes an unlabelled release. if hold := majorHold("1", e("")); hold == "" { t.Fatal("semver-less release not held under a major pin") } } func TestUpdatePassMajorPinHoldsCrossing(t *testing.T) { withTempOpt(t) zeroBake(t) // bake has its own tests; this one is about the major gate calls := stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) seedSemvers(t, map[string]string{"v2head99": "2.0.0"}) _, setChannels := startFeed(t, map[string]string{"v2head99": "v2-binary"}) setChannels(map[string]string{"stable": "v2head99"}) 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: "stable", Major: "1"}, "") 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 != "old1111" { t.Fatalf("v2 installed across a major-1 pin: live = %q", v) } if _, err := os.Stat(versionBinPath("v2head99")); !os.IsNotExist(err) { t.Fatal("crossing release was downloaded") } if len(*calls) != 0 { t.Fatalf("restarts despite the major hold: %v", *calls) } } func TestUpdatePassMajorPinFollowsWithinMajor(t *testing.T) { withTempOpt(t) zeroBake(t) stubSystemctl(t) shrink := deployHealthTimeout deployHealthTimeout = 2 * time.Second t.Cleanup(func() { deployHealthTimeout = shrink }) seedSemvers(t, map[string]string{"v1patch7": "1.4.2"}) _, setChannels := startFeed(t, map[string]string{"v1patch7": "patch-binary"}) setChannels(map[string]string{"stable": "v1patch7"}) 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: "stable", Major: "1"}, "") 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 != "v1patch7" { t.Fatalf("within-major release held: live = %q", v) } } func TestUpdatePassMajorPinHoldsSemverlessHead(t *testing.T) { withTempOpt(t) zeroBake(t) stubSystemctl(t) // No seedSemvers: the head carries no semver at all — the pin fails closed. _, setChannels := startFeed(t, map[string]string{"nolabel1": "content"}) setChannels(map[string]string{"stable": "nolabel1"}) 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: "stable", Major: "1"}, "") 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 != "old1111" { t.Fatalf("semver-less head installed under a major pin: live = %q", v) } } func TestUpdatePassMajorPinHoldsHibernating(t *testing.T) { withTempOpt(t) zeroBake(t) calls := stubSystemctl(t) oldData := instanceDataDir instanceDataDir = t.TempDir() t.Cleanup(func() { instanceDataDir = oldData }) seedSemvers(t, map[string]string{"v2head99": "2.0.0"}) _, setChannels := startFeed(t, map[string]string{"v2head99": "v2-binary"}) setChannels(map[string]string{"stable": "v2head99"}) 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) } if err := db.setConfig("update_major", "1"); 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 across its major pin: live = %q", v) } if len(*calls) != 0 { t.Fatalf("systemctl touched: %v", *calls) } }