eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "net"
11 "net/http"
12 "net/http/httptest"
13 "os"
14 "path/filepath"
15 "strings"
16 "sync"
17 "testing"
18 "time"
19)
20
21// startFakeInstance serves /api/update-prefs + /api/version on a real unix
22// socket. Its reported build follows the host's live symlink — so pointLive +
23// a stubbed systemctl restart looks exactly like a real swap to
24// waitHealthyVersion. prefsCode 404 simulates a pre-feature binary; brokenVer
25// (if set) makes that version report a wrong build, forcing the rollback path.
26func startFakeInstance(t *testing.T, host string, prefsCode int, prefs updatePrefs, brokenVer string) string {
27 t.Helper()
28 // Short base dir: unix socket paths have a ~104-char limit on darwin.
29 dir, err := os.MkdirTemp("", "lch")
30 if err != nil {
31 t.Fatal(err)
32 }
33 t.Cleanup(func() { os.RemoveAll(dir) })
34 socket := filepath.Join(dir, host+".sock")
35
36 mux := http.NewServeMux()
37 mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
38 if prefsCode != http.StatusOK {
39 http.Error(w, "no such endpoint", prefsCode)
40 return
41 }
42 writeJSON(w, http.StatusOK, map[string]any{
43 "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel, "pin": prefs.Pin, "major": prefs.Major,
44 })
45 })
46 mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
47 v, _ := liveVersion(host)
48 if brokenVer != "" && v == brokenVer {
49 v = "borked"
50 }
51 writeJSON(w, http.StatusOK, map[string]any{"build": v})
52 })
53
54 ln, err := net.Listen("unix", socket)
55 if err != nil {
56 t.Fatal(err)
57 }
58 srv := &http.Server{Handler: mux}
59 go srv.Serve(ln)
60 t.Cleanup(func() { srv.Close() })
61 return socket
62}
63
64// feedSemvers, when a test seeds it, stamps a semver onto matching versions
65// in every manifest startFeed signs (tests run serially, so a package-level
66// map is safe; entries are cleared via t.Cleanup in the tests that set them).
67var feedSemvers = map[string]string{}
68
69// startFeed signs entries into a manifest and serves it (plus each version's
70// binary bytes) from a loopback server, swapping in a test trust root.
71func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func(channels map[string]string)) {
72 t.Helper()
73 pub, priv, err := ed25519.GenerateKey(rand.Reader)
74 if err != nil {
75 t.Fatal(err)
76 }
77
78 var mu sync.Mutex
79 var wrapper []byte
80 mux := http.NewServeMux()
81 mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
82 mu.Lock()
83 defer mu.Unlock()
84 w.Write(wrapper)
85 })
86 mux.HandleFunc("/bin/", func(w http.ResponseWriter, r *http.Request) {
87 ver := strings.TrimPrefix(r.URL.Path, "/bin/")
88 w.Write([]byte(binaries[ver]))
89 })
90 // Each version's arm64 build is distinct bytes, so a test can prove the
91 // right arch was staged.
92 mux.HandleFunc("/arm/", func(w http.ResponseWriter, r *http.Request) {
93 ver := strings.TrimPrefix(r.URL.Path, "/arm/")
94 w.Write([]byte(binaries[ver] + "@arm64"))
95 })
96 ts := httptest.NewServer(mux)
97 t.Cleanup(ts.Close)
98
99 oldKey, oldURL := releasePubKeyB64, releaseManifestURL
100 releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub)
101 releaseManifestURL = ts.URL + "/manifest.json"
102 t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL })
103
104 // Pin the box arch so feed tests are deterministic on any dev machine
105 // (an arm64 Mac would otherwise resolve the arm64 variant everywhere);
106 // arch-specific tests override AFTER calling startFeed.
107 prevArch := updaterArch
108 updaterArch = "amd64"
109 t.Cleanup(func() { updaterArch = prevArch })
110
111 setChannels := func(channels map[string]string) {
112 m := &releaseManifest{Channels: map[string]releaseEntry{}}
113 for ch, ver := range channels {
114 sum := sha256.Sum256([]byte(binaries[ver]))
115 armSum := sha256.Sum256([]byte(binaries[ver] + "@arm64"))
116 m.Channels[ch] = releaseEntry{
117 Version: ver,
118 URL: ts.URL + "/bin/" + ver,
119 SHA256: hex.EncodeToString(sum[:]),
120 ARM64URL: ts.URL + "/arm/" + ver,
121 ARM64SHA256: hex.EncodeToString(armSum[:]),
122 Semver: feedSemvers[ver],
123 }
124 }
125 w, err := signReleaseManifest(priv, m)
126 if err != nil {
127 t.Fatal(err)
128 }
129 mu.Lock()
130 wrapper = w
131 mu.Unlock()
132 }
133 return ts, setChannels
134}
135
136// signManifestAt builds a signed wrapper with a caller-controlled
137// generated_at — signReleaseManifest always stamps now, but the mirror tests
138// need feeds whose relative ages are exact.
139func signManifestAt(t *testing.T, priv ed25519.PrivateKey, m *releaseManifest, at time.Time) []byte {
140 t.Helper()
141 m.Schema = 1
142 m.GeneratedAt = at.UTC().Truncate(time.Second)
143 payload, err := json.Marshal(m)
144 if err != nil {
145 t.Fatal(err)
146 }
147 b64 := base64.StdEncoding.EncodeToString(payload)
148 sig := ed25519.Sign(priv, []byte(b64))
149 w, err := json.Marshal(signedManifest{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)})
150 if err != nil {
151 t.Fatal(err)
152 }
153 return w
154}
155
156// swapFeedGlobals points the trust root, primary URL, and mirror base at test
157// servers, restoring them after.
158func swapFeedGlobals(t *testing.T, pubB64, primaryURL, mirrorBase string) {
159 t.Helper()
160 oldKey, oldURL, oldMirror := releasePubKeyB64, releaseManifestURL, releaseMirrorURL
161 releasePubKeyB64, releaseManifestURL, releaseMirrorURL = pubB64, primaryURL, mirrorBase
162 t.Cleanup(func() { releasePubKeyB64, releaseManifestURL, releaseMirrorURL = oldKey, oldURL, oldMirror })
163}
164
165// stubSystemctl records unit restarts and leaves the "restart" to the fake
166// instance's symlink-following /api/version.
167func stubSystemctl(t *testing.T) *[]string {
168 t.Helper()
169 var calls []string
170 prev := systemctl
171 systemctl = func(args ...string) error {
172 calls = append(calls, strings.Join(args, " "))
173 return nil
174 }
175 t.Cleanup(func() { systemctl = prev })
176 return &calls
177}
178
179func seedInstance(t *testing.T, reg *Registry, host, socket, ver string) {
180 t.Helper()
181 src := filepath.Join(t.TempDir(), "old")
182 writeFakeBinary(t, src, "old-binary-"+ver)
183 if err := stageVersion(src, ver); err != nil {
184 t.Fatal(err)
185 }
186 if err := pointLive(host, ver); err != nil {
187 t.Fatal(err)
188 }
189 if err := reg.upsert(Route{Host: host, Kind: "instance", Status: "ready", Socket: socket, Version: ver}); err != nil {
190 t.Fatal(err)
191 }
192}
193
194// zeroBake disables the supply-chain bake window (bake.go) for tests that
195// exercise the rest of the pipeline; bake behaviour has its own tests below.
196func zeroBake(t *testing.T) {
197 t.Helper()
198 prev := bakeDelays
199 bakeDelays = map[string]time.Duration{"edge": 0, "beta": 0, "stable": 0}
200 t.Cleanup(func() { bakeDelays = prev })
201}
202
203func TestUpdatePassAppliesPerPrefs(t *testing.T) {
204 withTempOpt(t)
205 zeroBake(t)
206 calls := stubSystemctl(t)
207 shrink := deployHealthTimeout
208 deployHealthTimeout = 2 * time.Second
209 t.Cleanup(func() { deployHealthTimeout = shrink })
210
211 _, setChannels := startFeed(t, map[string]string{
212 "new1234": "new-binary-content",
213 "beta5555": "beta-binary-content",
214 })
215 setChannels(map[string]string{"stable": "new1234", "beta": "beta5555"})
216
217 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
218 if err != nil {
219 t.Fatal(err)
220 }
221
222 // h1: defaults-by-404 (old binary) — updates to stable.
223 // h2: auto-update off — downloaded + staged, never restarted.
224 // h3: everything off, beta channel — beta's binary must not even download.
225 // h4: explicit on/on/stable — updates.
226 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
227 s2 := startFakeInstance(t, "h2.example.com", http.StatusOK, updatePrefs{AutoUpdate: false, AutoDownload: true, Channel: "stable"}, "")
228 s3 := startFakeInstance(t, "h3.example.com", http.StatusOK, updatePrefs{AutoUpdate: false, AutoDownload: false, Channel: "beta"}, "")
229 s4 := startFakeInstance(t, "h4.example.com", http.StatusOK, updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable"}, "")
230 seedInstance(t, reg, "h1.example.com", s1, "old1111")
231 seedInstance(t, reg, "h2.example.com", s2, "old1111")
232 seedInstance(t, reg, "h3.example.com", s3, "old1111")
233 seedInstance(t, reg, "h4.example.com", s4, "old1111")
234
235 if err := updatePass(reg, "lchat@", false, false); err != nil {
236 t.Fatalf("updatePass: %v", err)
237 }
238
239 assertLive := func(host, want string) {
240 t.Helper()
241 if v, _ := liveVersion(host); v != want {
242 t.Errorf("%s live version = %q, want %q", host, v, want)
243 }
244 }
245 assertLive("h1.example.com", "new1234")
246 assertLive("h2.example.com", "old1111") // staged only
247 assertLive("h3.example.com", "old1111")
248 assertLive("h4.example.com", "new1234")
249
250 // h2's chosen build is staged and byte-exact; h3's channel never downloaded.
251 staged, err := os.ReadFile(versionBinPath("new1234"))
252 if err != nil || string(staged) != "new-binary-content" {
253 t.Fatalf("staged binary = %q, %v", staged, err)
254 }
255 if _, err := os.Stat(versionBinPath("beta5555")); !os.IsNotExist(err) {
256 t.Fatal("beta5555 was downloaded despite downloads off")
257 }
258
259 // Registry recorded the moves (and only the moves).
260 rt, _ := reg.get("h1.example.com")
261 if rt.Version != "new1234" || rt.PrevVersion != "old1111" {
262 t.Fatalf("h1 registry = %s/%s", rt.Version, rt.PrevVersion)
263 }
264 rt, _ = reg.get("h2.example.com")
265 if rt.Version != "old1111" {
266 t.Fatalf("h2 registry = %s", rt.Version)
267 }
268
269 // Two instances moved (h1, h4); each restarts the instance and then its
270 // sidecars so both track the new version (restartBotSidecar +
271 // restartHooksSidecar). Six calls, no more — h2 (download-only) and h3
272 // (downloads off) never restart.
273 got := strings.Join(*calls, "; ")
274 for _, want := range []string{
275 "restart lchat@h1.example.com", "try-restart lchat-bot@h1.example.com", "try-restart lchat-hooks@h1.example.com",
276 "restart lchat@h4.example.com", "try-restart lchat-bot@h4.example.com", "try-restart lchat-hooks@h4.example.com",
277 } {
278 if !strings.Contains(got, want) {
279 t.Fatalf("missing %q in systemctl calls = %v", want, *calls)
280 }
281 }
282 if len(*calls) != 6 {
283 t.Fatalf("systemctl calls = %v", *calls)
284 }
285
286 // The pass recorded the manifest stamp for anti-replay.
287 if readManifestStamp().IsZero() {
288 t.Fatal("manifest stamp not written")
289 }
290}
291
292func TestUpdatePassRollsBackAndPoisons(t *testing.T) {
293 withTempOpt(t)
294 zeroBake(t)
295 calls := stubSystemctl(t)
296 shrink := deployHealthTimeout
297 deployHealthTimeout = 500 * time.Millisecond
298 t.Cleanup(func() { deployHealthTimeout = shrink })
299
300 _, setChannels := startFeed(t, map[string]string{"bad9999": "does-not-boot"})
301 setChannels(map[string]string{"stable": "bad9999"})
302
303 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
304 if err != nil {
305 t.Fatal(err)
306 }
307 // Both instances report "borked" while on bad9999 → health check fails.
308 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "bad9999")
309 s2 := startFakeInstance(t, "h2.example.com", http.StatusNotFound, updatePrefs{}, "bad9999")
310 seedInstance(t, reg, "h1.example.com", s1, "old1111")
311 seedInstance(t, reg, "h2.example.com", s2, "old1111")
312
313 if err := updatePass(reg, "lchat@", false, false); err != nil {
314 t.Fatalf("updatePass: %v", err)
315 }
316
317 // h1 tried, failed, rolled back; h2 was spared (the version is poisoned).
318 for _, host := range []string{"h1.example.com", "h2.example.com"} {
319 if v, _ := liveVersion(host); v != "old1111" {
320 t.Errorf("%s live version = %q, want rollback to old1111", host, v)
321 }
322 rt, _ := reg.get(host)
323 if rt.Version != "old1111" {
324 t.Errorf("%s registry version = %q", host, rt.Version)
325 }
326 }
327 // h1: restart onto bad + rollback restart = 2 calls; h2: none.
328 if len(*calls) != 2 || strings.Contains(strings.Join(*calls, ";"), "h2.example.com") {
329 t.Fatalf("systemctl calls = %v", *calls)
330 }
331}
332
333func TestUpdatePassRefusesReplayedManifest(t *testing.T) {
334 withTempOpt(t)
335 stubSystemctl(t)
336
337 _, setChannels := startFeed(t, map[string]string{"new1234": "x"})
338 setChannels(map[string]string{"stable": "new1234"})
339
340 // Pretend we've already accepted a manifest from the future.
341 if err := writeManifestStamp(time.Now().Add(time.Hour)); err != nil {
342 t.Fatal(err)
343 }
344 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
345 if err != nil {
346 t.Fatal(err)
347 }
348 if err := updatePass(reg, "lchat@", false, false); err == nil || !strings.Contains(err.Error(), "replayed") {
349 t.Fatalf("want replay refusal, got %v", err)
350 }
351}
352
353func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
354 withTempOpt(t)
355 zeroBake(t)
356 calls := stubSystemctl(t)
357
358 _, setChannels := startFeed(t, map[string]string{"new1234": "content"})
359 setChannels(map[string]string{"stable": "new1234"})
360
361 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
362 if err != nil {
363 t.Fatal(err)
364 }
365 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
366 seedInstance(t, reg, "h1.example.com", s1, "old1111")
367
368 if err := updatePass(reg, "lchat@", true, false); err != nil {
369 t.Fatal(err)
370 }
371 if v, _ := liveVersion("h1.example.com"); v != "old1111" {
372 t.Fatalf("dry-run moved the symlink to %q", v)
373 }
374 if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
375 t.Fatal("dry-run downloaded/staged a binary")
376 }
377 if len(*calls) != 0 {
378 t.Fatalf("dry-run ran systemctl: %v", *calls)
379 }
380 if !readManifestStamp().IsZero() {
381 t.Fatal("dry-run wrote the manifest stamp")
382 }
383}
384
385func TestUpdatePassStagesTheRightArch(t *testing.T) {
386 withTempOpt(t)
387 zeroBake(t)
388 stubSystemctl(t)
389
390 _, setChannels := startFeed(t, map[string]string{"new1234": "binary-content"})
391 setChannels(map[string]string{"stable": "new1234"})
392 // After startFeed (which pins amd64): this box is arm64.
393 updaterArch = "arm64"
394
395 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
396 if err != nil {
397 t.Fatal(err)
398 }
399 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
400 seedInstance(t, reg, "h1.example.com", s1, "old1111")
401
402 if err := updatePass(reg, "lchat@", false, false); err != nil {
403 t.Fatal(err)
404 }
405 staged, err := os.ReadFile(versionBinPath("new1234"))
406 if err != nil || string(staged) != "binary-content@arm64" {
407 t.Fatalf("arm64 box staged %q, %v — must be the arm64 build", staged, err)
408 }
409 if v, _ := liveVersion("h1.example.com"); v != "new1234" {
410 t.Fatalf("arm64 apply didn't land: live = %q", v)
411 }
412}
413
414func TestUpdatePassSkipsArchlessRelease(t *testing.T) {
415 withTempOpt(t)
416 zeroBake(t)
417 calls := stubSystemctl(t)
418 prevArch := updaterArch
419 updaterArch = "arm64"
420 t.Cleanup(func() { updaterArch = prevArch })
421
422 // A pre-arm64 manifest: flat amd64 fields only. An arm64 box must skip it
423 // rather than install the wrong arch.
424 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
425 feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
426 wrapper, _ := signReleaseManifest(priv, &releaseManifest{Channels: map[string]releaseEntry{
427 "stable": {Version: "new1234", URL: "https://example.com/lchat", SHA256: strings.Repeat("ab", 32)},
428 }})
429 w.Write(wrapper)
430 }))
431 defer feed.Close()
432 oldKey, oldURL := releasePubKeyB64, releaseManifestURL
433 releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub)
434 releaseManifestURL = feed.URL
435 t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL })
436
437 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
438 if err != nil {
439 t.Fatal(err)
440 }
441 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
442 seedInstance(t, reg, "h1.example.com", s1, "old1111")
443
444 if err := updatePass(reg, "lchat@", false, false); err != nil {
445 t.Fatal(err)
446 }
447 if v, _ := liveVersion("h1.example.com"); v != "old1111" {
448 t.Fatalf("arm64 box moved to an amd64-only release: %q", v)
449 }
450 if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
451 t.Fatal("amd64 binary staged on an arm64 box")
452 }
453 if len(*calls) != 0 {
454 t.Fatalf("restarts on a skipped release: %v", *calls)
455 }
456}
457
458func TestUpdatePassSkipsPinnedInstance(t *testing.T) {
459 withTempOpt(t)
460 calls := stubSystemctl(t)
461
462 _, setChannels := startFeed(t, map[string]string{"new1234": "channel-head"})
463 setChannels(map[string]string{"stable": "new1234"})
464
465 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
466 if err != nil {
467 t.Fatal(err)
468 }
469 // Pinned to a branch build no channel or registry row mentions (the canary
470 // case) — while the stable channel points somewhere newer.
471 s1 := startFakeInstance(t, "h1.example.com", http.StatusOK,
472 updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable", Pin: "pin7777"}, "")
473 seedInstance(t, reg, "h1.example.com", s1, "old1111")
474 bin := filepath.Join(t.TempDir(), "b")
475 writeFakeBinary(t, bin, "canary-build")
476 if err := stageVersion(bin, "pin7777"); err != nil {
477 t.Fatal(err)
478 }
479
480 if err := updatePass(reg, "lchat@", false, false); err != nil {
481 t.Fatal(err)
482 }
483
484 // Left alone entirely: no restart, no symlink move, no channel download.
485 if len(*calls) != 0 {
486 t.Fatalf("pinned instance was touched: %v", *calls)
487 }
488 if v, _ := liveVersion("h1.example.com"); v != "old1111" {
489 t.Fatalf("pinned instance moved to %q", v)
490 }
491 if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
492 t.Fatal("channel head downloaded for a pinned instance")
493 }
494 // And its pinned build survived the pass's GC despite being orphaned
495 // from the registry/manifest point of view.
496 if _, err := os.Stat(versionBinPath("pin7777")); err != nil {
497 t.Fatal("GC pruned the pinned version")
498 }
499}
500
501func TestGCVersionsKeepsEverythingReferenced(t *testing.T) {
502 withTempOpt(t)
503
504 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
505 if err != nil {
506 t.Fatal(err)
507 }
508 bin := filepath.Join(t.TempDir(), "b")
509 writeFakeBinary(t, bin, "x")
510 for _, v := range []string{"cur1", "prev1", "head1", "orphan1", "orphan2", "linked1", "pinned9"} {
511 if err := stageVersion(bin, v); err != nil {
512 t.Fatal(err)
513 }
514 }
515 reg.upsert(Route{Host: "a.example.com", Kind: "instance", Status: "ready", Version: "cur1", PrevVersion: "prev1"})
516 // A live symlink nothing in the registry mentions must still survive.
517 if err := pointLive("b.example.com", "linked1"); err != nil {
518 t.Fatal(err)
519 }
520 m := &releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "head1"}}}
521
522 // pinned9 is referenced only by an instance's pin (keepExtra) — a canary's
523 // branch build that no registry row or channel mentions.
524 gcVersions(reg, m, []string{"pinned9"})
525
526 for _, keep := range []string{"cur1", "prev1", "head1", "linked1", "pinned9"} {
527 if _, err := os.Stat(versionBinPath(keep)); err != nil {
528 t.Errorf("gc pruned %s (should be kept)", keep)
529 }
530 }
531 for _, gone := range []string{"orphan1", "orphan2"} {
532 if _, err := os.Stat(versionBinPath(gone)); !os.IsNotExist(err) {
533 t.Errorf("gc kept %s (should be pruned)", gone)
534 }
535 }
536 // buildVersion ("dev" under test) is always kept — the control binary's own.
537 // (No dir staged for it here; just assert gc didn't error on its absence.)
538}
539
540func TestPinHostWritesViaAdminAPI(t *testing.T) {
541 dir, err := os.MkdirTemp("", "lch")
542 if err != nil {
543 t.Fatal(err)
544 }
545 t.Cleanup(func() { os.RemoveAll(dir) })
546
547 // pinHost reads the admin token from the instance's EnvironmentFile.
548 prevDataDir := instanceDataDir
549 instanceDataDir = dir
550 t.Cleanup(func() { instanceDataDir = prevDataDir })
551 if err := os.WriteFile(filepath.Join(dir, "h1.example.com.env"), []byte("LCHAT_ADMIN_TOKEN=tok123\n"), 0o600); err != nil {
552 t.Fatal(err)
553 }
554
555 var gotAuth, gotPin string
556 mux := http.NewServeMux()
557 mux.HandleFunc("POST /api/admin/settings", func(w http.ResponseWriter, r *http.Request) {
558 gotAuth = r.Header.Get("Authorization")
559 var body struct {
560 UpdatePin *string `json:"update_pin"`
561 }
562 json.NewDecoder(r.Body).Decode(&body)
563 if body.UpdatePin != nil {
564 gotPin = *body.UpdatePin
565 }
566 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
567 })
568 // A current binary reports the stored pin back — pinHost verifies against
569 // this before claiming success.
570 mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
571 writeJSON(w, http.StatusOK, map[string]any{
572 "auto_update": true, "auto_download": true, "channel": "stable", "pin": gotPin,
573 })
574 })
575 socket := filepath.Join(dir, "h1.sock")
576 ln, err := net.Listen("unix", socket)
577 if err != nil {
578 t.Fatal(err)
579 }
580 srv := &http.Server{Handler: mux}
581 go srv.Serve(ln)
582 t.Cleanup(func() { srv.Close() })
583
584 rt := &Route{Host: "h1.example.com", Kind: "instance", Socket: socket}
585 if err := pinHost(rt, "pin7777"); err != nil {
586 t.Fatal(err)
587 }
588 if gotAuth != "Bearer tok123" || gotPin != "pin7777" {
589 t.Fatalf("auth %q pin %q", gotAuth, gotPin)
590 }
591 if err := pinHost(rt, ""); err != nil {
592 t.Fatal(err)
593 }
594 if gotPin != "" {
595 t.Fatalf("unpin sent %q", gotPin)
596 }
597}
598
599// An instance binary that predates a setting accepts the settings POST and
600// silently ignores the unknown field. The read-back turns that into a loud
601// error instead of a false success (a pin that didn't stick is how the next
602// hourly pass stomps a canary; found live with `update major` vs a pre-0.11
603// build).
604func TestPinHostRefusesSilentlyIgnoredWrite(t *testing.T) {
605 dir, err := os.MkdirTemp("", "lch")
606 if err != nil {
607 t.Fatal(err)
608 }
609 t.Cleanup(func() { os.RemoveAll(dir) })
610 prevDataDir := instanceDataDir
611 instanceDataDir = dir
612 t.Cleanup(func() { instanceDataDir = prevDataDir })
613 if err := os.WriteFile(filepath.Join(dir, "h1.example.com.env"), []byte("LCHAT_ADMIN_TOKEN=tok123\n"), 0o600); err != nil {
614 t.Fatal(err)
615 }
616
617 mux := http.NewServeMux()
618 // The old binary: 200 on the write, but the field never appears in prefs.
619 mux.HandleFunc("POST /api/admin/settings", func(w http.ResponseWriter, r *http.Request) {
620 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
621 })
622 mux.HandleFunc("/api/update-prefs", func(w http.ResponseWriter, r *http.Request) {
623 writeJSON(w, http.StatusOK, map[string]any{
624 "auto_update": true, "auto_download": true, "channel": "stable", "pin": "",
625 })
626 })
627 socket := filepath.Join(dir, "h1.sock")
628 ln, err := net.Listen("unix", socket)
629 if err != nil {
630 t.Fatal(err)
631 }
632 srv := &http.Server{Handler: mux}
633 go srv.Serve(ln)
634 t.Cleanup(func() { srv.Close() })
635
636 rt := &Route{Host: "h1.example.com", Kind: "instance", Socket: socket}
637 if err := pinHost(rt, "pin7777"); err == nil || !strings.Contains(err.Error(), "predates") {
638 t.Fatalf("silently ignored pin write reported success (err=%v)", err)
639 }
640 // Same helper, the major-pin shape: a prefs response with no "major" field
641 // decodes as "", so a wanted value must fail and a clear must pass.
642 if err := confirmHostSetting(rt, "major pin", "1", func(p updatePrefs) string { return p.Major }); err == nil {
643 t.Fatal("missing major field confirmed as set")
644 }
645 if err := confirmHostSetting(rt, "major pin", "", func(p updatePrefs) string { return p.Major }); err != nil {
646 t.Fatalf("clear should confirm against an old binary: %v", err)
647 }
648}
649
650// entryFor is a syntactically valid manifest entry (validateManifest passes)
651// whose binary URL nothing in the manifest tests ever downloads.
652func entryFor(ver string) releaseEntry {
653 return releaseEntry{
654 Version: ver,
655 URL: "https://example.com/server-" + ver + "/lchat-linux-amd64",
656 SHA256: strings.Repeat("ab", 32),
657 }
658}
659
660func TestFetchManifestFallsBackToMirrorOnFailure(t *testing.T) {
661 withTempOpt(t) // the staleness check reads the stamp from optDir
662 pub, priv, err := ed25519.GenerateKey(rand.Reader)
663 if err != nil {
664 t.Fatal(err)
665 }
666 good := signManifestAt(t, priv,
667 &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("new1234")}}, time.Now())
668
669 primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
670 http.Error(w, "gone", http.StatusNotFound) // GitHub's brand-new-asset 404 window
671 }))
672 defer primary.Close()
673 mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
674 // The mirror keeps the feed at <base>/manifest.json.
675 if r.URL.Path != "/releases/manifest.json" {
676 http.NotFound(w, r)
677 return
678 }
679 w.Write(good)
680 }))
681 defer mirror.Close()
682 swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub),
683 primary.URL+"/manifest.json", mirror.URL+"/releases")
684
685 m, err := fetchReleaseManifest(releaseManifestURL)
686 if err != nil {
687 t.Fatalf("mirror fallback failed: %v", err)
688 }
689 if m.Channels["stable"].Version != "new1234" {
690 t.Fatalf("mirror manifest channels = %+v", m.Channels)
691 }
692}
693
694func TestFetchManifestRejectsBadMirrorSignature(t *testing.T) {
695 withTempOpt(t)
696 pub, _, err := ed25519.GenerateKey(rand.Reader) // the trusted root...
697 if err != nil {
698 t.Fatal(err)
699 }
700 _, evilPriv, err := ed25519.GenerateKey(rand.Reader) // ...and an untrusted signer
701 if err != nil {
702 t.Fatal(err)
703 }
704 forged := signManifestAt(t, evilPriv,
705 &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("evil999")}}, time.Now())
706
707 primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
708 http.Error(w, "down", http.StatusServiceUnavailable)
709 }))
710 defer primary.Close()
711 mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
712 w.Write(forged)
713 }))
714 defer mirror.Close()
715 swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub),
716 primary.URL+"/manifest.json", mirror.URL+"/releases")
717
718 if _, err := fetchReleaseManifest(releaseManifestURL); err == nil {
719 t.Fatal("a mirror manifest signed by the wrong key was accepted")
720 }
721}
722
723func TestFetchManifestPrefersFresherMirrorWhenPrimaryStale(t *testing.T) {
724 withTempOpt(t)
725 pub, priv, err := ed25519.GenerateKey(rand.Reader)
726 if err != nil {
727 t.Fatal(err)
728 }
729 now := time.Now()
730 staleFeed := signManifestAt(t, priv,
731 &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("old1111")}}, now.Add(-2*time.Hour))
732 freshFeed := signManifestAt(t, priv,
733 &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("new1234")}}, now)
734 // The box last accepted a feed newer than what the primary now serves —
735 // exactly what GitHub's stale CDN looks like right after a publish.
736 if err := writeManifestStamp(now.Add(-time.Hour)); err != nil {
737 t.Fatal(err)
738 }
739
740 primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
741 w.Write(staleFeed)
742 }))
743 defer primary.Close()
744 mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
745 w.Write(freshFeed)
746 }))
747 defer mirror.Close()
748 swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub),
749 primary.URL+"/manifest.json", mirror.URL+"/releases")
750
751 m, err := fetchReleaseManifest(releaseManifestURL)
752 if err != nil {
753 t.Fatal(err)
754 }
755 if got := m.Channels["stable"].Version; got != "new1234" {
756 t.Fatalf("stale primary won over a fresher mirror: stable = %q", got)
757 }
758}
759
760func TestFetchManifestKeepsStalePrimaryWhenMirrorNoFresher(t *testing.T) {
761 withTempOpt(t)
762 pub, priv, err := ed25519.GenerateKey(rand.Reader)
763 if err != nil {
764 t.Fatal(err)
765 }
766 now := time.Now()
767 staleFeed := signManifestAt(t, priv,
768 &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("old1111")}}, now.Add(-2*time.Hour))
769 if err := writeManifestStamp(now); err != nil {
770 t.Fatal(err)
771 }
772
773 serveStale := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(staleFeed) })
774 primary := httptest.NewServer(serveStale)
775 defer primary.Close()
776 mirror := httptest.NewServer(serveStale)
777 defer mirror.Close()
778 swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub),
779 primary.URL+"/manifest.json", mirror.URL+"/releases")
780
781 // Both stale: the primary result stands, and updatePass's replay check is
782 // what refuses it — the mirror must never mask a genuinely replayed feed.
783 m, err := fetchReleaseManifest(releaseManifestURL)
784 if err != nil {
785 t.Fatal(err)
786 }
787 if !m.GeneratedAt.Before(readManifestStamp()) {
788 t.Fatal("expected the stale primary manifest back (for the replay check to refuse)")
789 }
790 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
791 if err != nil {
792 t.Fatal(err)
793 }
794 if err := updatePass(reg, "lchat@", false, false); err == nil || !strings.Contains(err.Error(), "replayed") {
795 t.Fatalf("want replay refusal, got %v", err)
796 }
797}
798
799// mirrorBinaryFeed wires a primary feed whose manifest is fine but whose
800// binary URL 404s, plus a mirror holding mirrorContent under the derived
801// server-<ver>/<name> key — the GitHub new-asset-404 shape.
802func mirrorBinaryFeed(t *testing.T, binaryContent, mirrorContent string) {
803 t.Helper()
804 pub, priv, err := ed25519.GenerateKey(rand.Reader)
805 if err != nil {
806 t.Fatal(err)
807 }
808 sum := sha256.Sum256([]byte(binaryContent))
809
810 primaryMux := http.NewServeMux() // serves only the manifest; the binary path 404s
811 primary := httptest.NewServer(primaryMux)
812 t.Cleanup(primary.Close)
813 entry := releaseEntry{
814 Version: "new1234",
815 URL: primary.URL + "/server-new1234/lchat-linux-amd64",
816 SHA256: hex.EncodeToString(sum[:]),
817 }
818 wrapper := signManifestAt(t, priv,
819 &releaseManifest{Channels: map[string]releaseEntry{"stable": entry}}, time.Now())
820 primaryMux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { w.Write(wrapper) })
821
822 mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
823 // The mirror stores binaries under the primary URL's last two segments.
824 if r.URL.Path != "/releases/server-new1234/lchat-linux-amd64" {
825 http.NotFound(w, r)
826 return
827 }
828 w.Write([]byte(mirrorContent))
829 }))
830 t.Cleanup(mirror.Close)
831 swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub),
832 primary.URL+"/manifest.json", mirror.URL+"/releases")
833
834 prevArch := updaterArch
835 updaterArch = "amd64"
836 t.Cleanup(func() { updaterArch = prevArch })
837}
838
839func TestUpdatePassFallsBackToMirrorBinary(t *testing.T) {
840 withTempOpt(t)
841 zeroBake(t)
842 stubSystemctl(t)
843 shrink := deployHealthTimeout
844 deployHealthTimeout = 2 * time.Second
845 t.Cleanup(func() { deployHealthTimeout = shrink })
846
847 content := "mirrored-binary-content"
848 mirrorBinaryFeed(t, content, content)
849
850 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
851 if err != nil {
852 t.Fatal(err)
853 }
854 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
855 seedInstance(t, reg, "h1.example.com", s1, "old1111")
856
857 if err := updatePass(reg, "lchat@", false, false); err != nil {
858 t.Fatal(err)
859 }
860 if v, _ := liveVersion("h1.example.com"); v != "new1234" {
861 t.Fatalf("mirror binary fallback didn't land: live = %q", v)
862 }
863 staged, err := os.ReadFile(versionBinPath("new1234"))
864 if err != nil || string(staged) != content {
865 t.Fatalf("staged binary = %q, %v — must be the mirror's bytes", staged, err)
866 }
867}
868
869func TestUpdatePassRejectsBadMirrorBinary(t *testing.T) {
870 withTempOpt(t)
871 zeroBake(t)
872 calls := stubSystemctl(t)
873
874 // The mirror serves bytes that don't hash to the signed sha256 — a
875 // tampered (or stale) mirror must cause a refusal, never an install.
876 mirrorBinaryFeed(t, "the-real-binary", "tampered-bytes")
877
878 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
879 if err != nil {
880 t.Fatal(err)
881 }
882 s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
883 seedInstance(t, reg, "h1.example.com", s1, "old1111")
884
885 if err := updatePass(reg, "lchat@", false, false); err != nil {
886 t.Fatal(err)
887 }
888 if v, _ := liveVersion("h1.example.com"); v != "old1111" {
889 t.Fatalf("tampered mirror binary was installed: live = %q", v)
890 }
891 if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
892 t.Fatal("tampered mirror binary was staged")
893 }
894 if len(*calls) != 0 {
895 t.Fatalf("restarts despite a refused download: %v", *calls)
896 }
897}
898
899func TestAcquireDeployLockExcludes(t *testing.T) {
900 withTempOpt(t)
901 unlock, err := acquireDeployLock()
902 if err != nil {
903 t.Fatal(err)
904 }
905 if _, err := acquireDeployLock(); err == nil {
906 t.Fatal("second acquire succeeded — deploy and update could interleave")
907 }
908 unlock()
909 unlock2, err := acquireDeployLock()
910 if err != nil {
911 t.Fatalf("re-acquire after release: %v", err)
912 }
913 unlock2()
914}
915
916// Hibernating hosts (#550): no unit to restart and never woken — the pass
917// repoints their live symlink instead, so the NEXT wake execs the new binary.
918// Prefs come from the parked DB; no DB reads as the defaults; pins hold.
919func TestUpdatePassRepointsHibernating(t *testing.T) {
920 withTempOpt(t)
921 zeroBake(t)
922 calls := stubSystemctl(t)
923
924 oldData := instanceDataDir
925 instanceDataDir = t.TempDir()
926 t.Cleanup(func() { instanceDataDir = oldData })
927
928 _, setChannels := startFeed(t, map[string]string{
929 "new1234": "new-binary-content",
930 "edge777": "edge-binary-content",
931 })
932 setChannels(map[string]string{"stable": "new1234", "edge": "edge777"})
933
934 reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
935 if err != nil {
936 t.Fatal(err)
937 }
938 seed := func(host string) {
939 t.Helper()
940 src := filepath.Join(t.TempDir(), "old")
941 writeFakeBinary(t, src, "old-binary")
942 if err := stageVersion(src, "old1111"); err != nil {
943 t.Fatal(err)
944 }
945 if err := pointLive(host, "old1111"); err != nil {
946 t.Fatal(err)
947 }
948 if err := reg.upsert(Route{Host: host, Kind: "instance", Status: "ready",
949 Socket: filepath.Join(t.TempDir(), "no.sock"), Hibernate: true, Version: "old1111"}); err != nil {
950 t.Fatal(err)
951 }
952 }
953 seedDB := func(host string, kv map[string]string) {
954 t.Helper()
955 db, err := openDB(filepath.Join(instanceDataDir, host+".db"))
956 if err != nil {
957 t.Fatal(err)
958 }
959 for k, v := range kv {
960 if err := db.setConfig(k, v); err != nil {
961 t.Fatal(err)
962 }
963 }
964 db.sql.Close()
965 }
966
967 // h1: parked DB with no update keys — defaults (stable/on/on) → repointed.
968 // h2: parked DB says edge → repointed to the edge head.
969 // h3: pinned in the parked DB → untouched.
970 // h4: downloads+updates off → untouched.
971 // h5: NO parked DB — prefs unknowable, fail-closed → untouched.
972 for _, h := range []string{"h1.hib", "h2.hib", "h3.hib", "h4.hib", "h5.hib"} {
973 seed(h)
974 }
975 seedDB("h1.hib", map[string]string{})
976 seedDB("h2.hib", map[string]string{"update_channel": "edge"})
977 seedDB("h3.hib", map[string]string{"update_pin": "old1111"})
978 seedDB("h4.hib", map[string]string{"update_auto": "0", "update_download": "0"})
979
980 if err := updatePass(reg, "lchat@", false, false); err != nil {
981 t.Fatalf("updatePass: %v", err)
982 }
983
984 assertLive := func(host, want string) {
985 t.Helper()
986 if v, _ := liveVersion(host); v != want {
987 t.Errorf("%s live version = %q, want %q", host, v, want)
988 }
989 }
990 assertLive("h1.hib", "new1234")
991 assertLive("h2.hib", "edge777")
992 assertLive("h3.hib", "old1111")
993 assertLive("h4.hib", "old1111")
994 assertLive("h5.hib", "old1111")
995
996 // The registry records the move (and the previous version, for rollback).
997 rt, _ := reg.get("h1.hib")
998 if rt.Version != "new1234" || rt.PrevVersion != "old1111" {
999 t.Errorf("h1 registry = %q prev %q", rt.Version, rt.PrevVersion)
1000 }
1001
1002 // The whole point: no unit was restarted, nothing was woken.
1003 if len(*calls) != 0 {
1004 t.Fatalf("systemctl touched for hibernating hosts: %v", *calls)
1005 }
1006
1007 // A second pass is quiet + idempotent.
1008 if err := updatePass(reg, "lchat@", false, false); err != nil {
1009 t.Fatalf("second pass: %v", err)
1010 }
1011 assertLive("h1.hib", "new1234")
1012}
1013
1014func TestStatusPrefsReadsParkedDB(t *testing.T) {
1015 oldData := instanceDataDir
1016 instanceDataDir = t.TempDir()
1017 t.Cleanup(func() { instanceDataDir = oldData })
1018 deadSocket := filepath.Join(t.TempDir(), "no.sock")
1019
1020 // A sleeping pinned canary: nothing listens on its socket, its prefs live
1021 // only in the parked DB — status must show them, not "?" (an invisible pin
1022 // is how a fleet operation stomps a canary).
1023 db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db"))
1024 if err != nil {
1025 t.Fatal(err)
1026 }
1027 for k, v := range map[string]string{"update_channel": "edge", "update_pin": "pin1234"} {
1028 if err := db.setConfig(k, v); err != nil {
1029 t.Fatal(err)
1030 }
1031 }
1032 db.sql.Close()
1033
1034 p, err := statusPrefs(Route{Host: "h1.hib", Hibernate: true, Socket: deadSocket})
1035 if err != nil {
1036 t.Fatalf("statusPrefs: %v", err)
1037 }
1038 if p.Channel != "edge" || p.Pin != "pin1234" {
1039 t.Fatalf("prefs = %+v, want edge/pin1234", p)
1040 }
1041
1042 // Fail-closed both ways: a sleeping host with no local DB stays an honest
1043 // "?", and a unit-managed host's dead socket never falls back to a DB read.
1044 if _, err := statusPrefs(Route{Host: "h2.hib", Hibernate: true, Socket: deadSocket}); err == nil {
1045 t.Fatal("expected an error for an absent parked DB")
1046 }
1047 if _, err := statusPrefs(Route{Host: "h1.hib", Hibernate: false, Socket: deadSocket}); err == nil {
1048 t.Fatal("expected an error for a dead socket on a unit-managed host")
1049 }
1050}
1051