package main import ( "crypto/sha256" "encoding/hex" "net/http" "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "time" ) // serviceTestEnv points the service-converge seams at temp doubles: a temp // optDir holding (or not holding) the loose service binary, a recording // systemctl, a scriptable isActive, amd64 entries regardless of the dev // machine's arch, and a zero settle so unhealthy paths fail in a beat. func serviceTestEnv(t *testing.T) (calls *[]string, setActive func(func(string) bool)) { t.Helper() oldOpt, oldSysctl, oldActive := optDir, systemctl, isActive oldArch, oldSettle := updaterArch, serviceHealthSettle t.Cleanup(func() { optDir, systemctl, isActive = oldOpt, oldSysctl, oldActive updaterArch, serviceHealthSettle = oldArch, oldSettle }) optDir = t.TempDir() updaterArch = "amd64" serviceHealthSettle = 0 t.Setenv("LCHAT_CONTROL_CHANNEL", "edge") c := []string{} calls = &c systemctl = func(args ...string) error { *calls = append(*calls, joinArgs(args)) return nil } setActive = func(f func(string) bool) { isActive = f } setActive(func(string) bool { return true }) return calls, setActive } func joinArgs(args []string) string { out := "" for i, a := range args { if i > 0 { out += " " } out += a } return out } func shaOf(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } // serviceManifest builds a one-channel manifest whose edge head carries one // "connect" service binary served by the given URL, plus a bake state that // has already seen the head (edge bakes for 0, so recorded = eligible). func serviceManifest(url string, bin []byte) (*releaseManifest, *firstSeenState) { m := &releaseManifest{ Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{ "edge": { Version: "svcnew1", URL: url, // instance binary URL — unused by the service pass SHA256: shaOf([]byte("unused-instance-binary")), Services: map[string]serviceBuild{ "connect": {URL: url, SHA256: shaOf(bin)}, }, }, }, } bake := &firstSeenState{Channels: map[string]map[string]time.Time{ "edge": {"svcnew1": time.Now().Add(-time.Hour)}, }} return m, bake } func TestServiceUpdateSwapsAndRestarts(t *testing.T) { calls, _ := serviceTestEnv(t) newBin := []byte("NEW-CONNECT-BINARY") var hits atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) w.Write(newBin) })) defer ts.Close() path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, []byte("OLD-CONNECT-BINARY"), 0o755); err != nil { t.Fatal(err) } m, bake := serviceManifest(ts.URL+"/lchat-connect-linux-amd64", newBin) serviceBinariesUpdate(m, bake, nil, false, false) got, _ := os.ReadFile(path) if string(got) != string(newBin) { t.Fatalf("binary not swapped: %q", got) } prev, err := os.ReadFile(path + ".prev") if err != nil || string(prev) != "OLD-CONNECT-BINARY" { t.Fatalf("rollback copy wrong: %q err %v", prev, err) } if len(*calls) != 1 || (*calls)[0] != "try-restart lchat-connect" { t.Fatalf("systemctl calls: %v", *calls) } if _, err := os.Stat(serviceFailedPath("connect")); !os.IsNotExist(err) { t.Fatal("healthy swap must not quarantine") } if hits.Load() == 0 { t.Fatal("nothing downloaded") } } func TestServiceUpdateSkipsAbsentBinary(t *testing.T) { calls, _ := serviceTestEnv(t) var hits atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) })) defer ts.Close() m, bake := serviceManifest(ts.URL+"/x", []byte("NEW")) serviceBinariesUpdate(m, bake, nil, false, false) if hits.Load() != 0 || len(*calls) != 0 { t.Fatalf("a box without the service must be untouched (hits %d, calls %v)", hits.Load(), *calls) } } func TestServiceUpdateNoopWhenCurrent(t *testing.T) { calls, _ := serviceTestEnv(t) cur := []byte("CURRENT-BINARY") m, bake := serviceManifest("https://example.invalid/x", cur) path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, cur, 0o755); err != nil { t.Fatal(err) } serviceBinariesUpdate(m, bake, nil, false, false) if len(*calls) != 0 { t.Fatalf("sha match must be a no-op, got calls %v", *calls) } if _, err := os.Stat(path + ".prev"); !os.IsNotExist(err) { t.Fatal("no-op must not write a rollback copy") } } func TestServiceUpdateRollsBackUnhealthyAndQuarantines(t *testing.T) { calls, setActive := serviceTestEnv(t) newBin := []byte("BAD-NEW-BINARY") var hits atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) w.Write(newBin) })) defer ts.Close() // Active before the swap (so the bounce happens), dead ever after. var checks atomic.Int32 setActive(func(string) bool { return checks.Add(1) == 1 }) path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, []byte("GOOD-OLD-BINARY"), 0o755); err != nil { t.Fatal(err) } m, bake := serviceManifest(ts.URL+"/x", newBin) serviceBinariesUpdate(m, bake, nil, false, false) got, _ := os.ReadFile(path) if string(got) != "GOOD-OLD-BINARY" { t.Fatalf("unhealthy build must roll back, file is %q", got) } if readServiceFailed("connect") != "svcnew1" { t.Fatalf("expected quarantine of svcnew1, got %q", readServiceFailed("connect")) } // try-restart onto the bad build + try-restart back = 2 calls. if len(*calls) != 2 { t.Fatalf("systemctl calls: %v", *calls) } // A second pass at the same head must short-circuit on the quarantine. before := hits.Load() serviceBinariesUpdate(m, bake, nil, false, false) if hits.Load() != before { t.Fatal("quarantined head was downloaded again") } } func TestServiceUpdateInactiveUnitSwapsQuietly(t *testing.T) { calls, setActive := serviceTestEnv(t) setActive(func(string) bool { return false }) newBin := []byte("NEW-BINARY") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(newBin) })) defer ts.Close() path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } m, bake := serviceManifest(ts.URL+"/x", newBin) serviceBinariesUpdate(m, bake, nil, false, false) got, _ := os.ReadFile(path) if string(got) != string(newBin) { t.Fatalf("inactive unit still gets the file, got %q", got) } if len(*calls) != 0 { t.Fatalf("inactive unit must not be restarted: %v", *calls) } } func TestServiceUpdateFailsClosedOnUnbakedHead(t *testing.T) { calls, _ := serviceTestEnv(t) var hits atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) })) defer ts.Close() path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } m, _ := serviceManifest(ts.URL+"/x", []byte("NEW")) // A bake clock that has never seen the head: fail closed, nothing moves. empty := &firstSeenState{Channels: map[string]map[string]time.Time{}} serviceBinariesUpdate(m, empty, nil, false, false) if hits.Load() != 0 || len(*calls) != 0 { t.Fatalf("unbaked head must hold (hits %d, calls %v)", hits.Load(), *calls) } } func TestServiceUpdateIgnoresEntryWithoutServices(t *testing.T) { calls, _ := serviceTestEnv(t) path := filepath.Join(optDir, "connect") if err := os.WriteFile(path, []byte("OLD"), 0o755); err != nil { t.Fatal(err) } m := &releaseManifest{Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{ "edge": {Version: "oldver12", URL: "https://example.invalid/x", SHA256: shaOf([]byte("y"))}, }} bake := &firstSeenState{Channels: map[string]map[string]time.Time{"edge": {"oldver12": time.Now().Add(-time.Hour)}}} serviceBinariesUpdate(m, bake, nil, false, false) if len(*calls) != 0 { t.Fatalf("pre-feature manifest must be a no-op, got %v", *calls) } }