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 "bytes"
5 "context"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/hex"
9 "encoding/json"
10 "flag"
11 "fmt"
12 "io"
13 "log"
14 "net/http"
15 "os"
16 "path/filepath"
17 "runtime"
18 "strings"
19 "syscall"
20 "time"
21)
22
23// The box-side auto-updater: `lchat update run`, fired hourly by
24// lchat-update.timer (root — it stages binaries and restarts units, like
25// lchat-control). One pass reads the signed channel feed (release.go), asks
26// each instance what its admin chose (GET /api/update-prefs over the unix
27// socket — instances are sandboxed and can't update themselves), then
28// downloads/stages/applies per those prefs, reusing the exact deploy dance
29// (deployHostVersion: repoint → restart → health-check → auto-rollback).
30// It shares the deploy flock with `deploy`/`rollback`, so a timer firing
31// mid-operator-deploy waits its turn (fails fast, next tick retries).
32//
33// Trust + anti-replay: the manifest is Ed25519-verified, binaries are hashed
34// against the signed sha256, and the last accepted generated_at is persisted —
35// a replayed stale feed is refused, so nobody can downgrade the fleet by
36// re-serving an old (validly signed) manifest. See docs/server-releases.md.
37
38func incomingDir() string { return filepath.Join(optDir, "incoming") }
39
40// manifestStampPath persists the newest generated_at ever accepted.
41func manifestStampPath() string { return filepath.Join(optDir, "manifest-stamp") }
42
43func readManifestStamp() time.Time {
44 raw, err := os.ReadFile(manifestStampPath())
45 if err != nil {
46 return time.Time{}
47 }
48 t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(raw)))
49 if err != nil {
50 return time.Time{}
51 }
52 return t
53}
54
55func writeManifestStamp(t time.Time) error {
56 return os.WriteFile(manifestStampPath(), []byte(t.Format(time.RFC3339)+"\n"), 0o644)
57}
58
59// fetchReleaseManifestFrom downloads and signature-verifies the channel feed
60// from one URL.
61func fetchReleaseManifestFrom(url string) (*releaseManifest, error) {
62 pub, err := releasePubKey()
63 if err != nil {
64 return nil, err
65 }
66 client := &http.Client{Timeout: 2 * time.Minute}
67 resp, err := client.Get(url)
68 if err != nil {
69 return nil, err
70 }
71 defer resp.Body.Close()
72 if resp.StatusCode != http.StatusOK {
73 return nil, fmt.Errorf("fetch manifest: %s", resp.Status)
74 }
75 raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
76 if err != nil {
77 return nil, err
78 }
79 return verifyReleaseManifest(pub, raw)
80}
81
82// fetchReleaseManifest reads the channel feed, falling back to the S3 mirror
83// (LCHAT_RELEASE_MIRROR_URL) when the primary fetch fails — or when it
84// verifies but is STALE: GitHub's CDN has served a pre-publish manifest for
85// minutes after a clobber, which errors nowhere. Stale here means a
86// generated_at strictly older than the newest feed this box ever accepted
87// (the anti-replay stamp); a feed equal to the stamp is indistinguishable
88// from "no new release", so it never consults the mirror — the hourly pass
89// stays a single request in the steady state. Signature verification is
90// identical on both paths (the mirror adds availability, never trust), and
91// every fallback logs loudly. Shared with the instance-side availability
92// checker (updatecheck.go), where no stamp file exists and so only outright
93// primary failure falls back.
94func fetchReleaseManifest(url string) (*releaseManifest, error) {
95 m, err := fetchReleaseManifestFrom(url)
96 base := releaseMirrorURL
97 if base == "" {
98 return m, err
99 }
100 stamp := readManifestStamp()
101 stale := err == nil && m.GeneratedAt.Before(stamp)
102 if err == nil && !stale {
103 return m, nil
104 }
105 murl := mirrorManifestURL(base)
106 mm, merr := fetchReleaseManifestFrom(murl)
107 switch {
108 case err != nil && merr != nil:
109 log.Printf("update: primary feed failed (%v); mirror %s failed too (%v)", err, murl, merr)
110 return nil, err
111 case err != nil:
112 log.Printf("update: primary feed failed (%v) — using mirror %s (signature verified)", err, murl)
113 return mm, nil
114 case merr != nil:
115 log.Printf("update: primary feed is stale (generated_at %s < last accepted %s) and mirror %s failed (%v) — keeping the primary result",
116 m.GeneratedAt.Format(time.RFC3339), stamp.Format(time.RFC3339), murl, merr)
117 return m, nil
118 case mm.GeneratedAt.After(m.GeneratedAt):
119 log.Printf("update: primary feed is stale (generated_at %s < last accepted %s) — using fresher mirror %s (generated_at %s)",
120 m.GeneratedAt.Format(time.RFC3339), stamp.Format(time.RFC3339), murl, mm.GeneratedAt.Format(time.RFC3339))
121 return mm, nil
122 }
123 // The mirror is no fresher: the stale primary stands, and updatePass's
124 // replay check refuses it — the mirror never masks a replayed feed.
125 return m, nil
126}
127
128// updatePrefs is what an instance's admin chose (GET /api/update-prefs).
129type updatePrefs struct {
130 AutoUpdate bool `json:"auto_update"`
131 AutoDownload bool `json:"auto_download"`
132 Channel string `json:"channel"`
133 // Pin, when set, freezes the instance on that exact version — the updater
134 // skips it entirely (pin beats channel). How a canary declares itself.
135 Pin string `json:"pin"`
136 // Major, when set, keeps the instance on that major version: minors and
137 // patches follow the channel, a head whose semver crosses the major is
138 // held (majorHold, release.go).
139 Major string `json:"major"`
140}
141
142// fetchUpdatePrefs reads an instance's prefs over its unix socket. A binary
143// too old to have the endpoint 404s — that means the defaults (on/on/stable),
144// which is exactly how a pre-feature fleet adopts auto-updating.
145func fetchUpdatePrefs(socket string) (updatePrefs, error) {
146 defaults := updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable"}
147 client := unixHTTPClient(socket)
148 resp, err := client.Get("http://lchat/api/update-prefs")
149 if err != nil {
150 return defaults, err
151 }
152 defer resp.Body.Close()
153 if resp.StatusCode == http.StatusNotFound {
154 return defaults, nil
155 }
156 if resp.StatusCode != http.StatusOK {
157 return defaults, fmt.Errorf("update-prefs: %s", resp.Status)
158 }
159 var p updatePrefs
160 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&p); err != nil {
161 return defaults, err
162 }
163 if !validChannel(p.Channel) {
164 p.Channel = "stable"
165 }
166 if !validVersion(p.Pin) {
167 p.Pin = "" // a garbled pin must not freeze anything silently
168 }
169 if !validMajor(p.Major) {
170 p.Major = "" // same rule as the pin
171 }
172 return p, nil
173}
174
175// hibernatingPrefs reads an instance's update prefs straight from its parked
176// DB — an activator-managed host can't answer the socket fetch without being
177// woken, and waking the fleet hourly would defeat hibernation. Read-only, so
178// it can't disturb an awake child's WAL. FAIL-CLOSED: an absent DB (never
179// woken here; state parked only in S3) or any read trouble returns an error
180// and the caller skips the host — the admin's pin or updates-off may live
181// only in the unreadable copy, and "the one host that must not move, moves"
182// is the failure that matters. A skipped host converges on the pass after
183// its first wake leaves a warm local DB.
184func hibernatingPrefs(host string) (updatePrefs, error) {
185 p := updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable"}
186 path := filepath.Join(instanceDataDir, host+".db")
187 if _, err := os.Stat(path); err != nil {
188 return p, err
189 }
190 // Even a mode=ro open CREATES <db>-wal/-shm when they're absent (a cleanly
191 // slept instance has neither) — and this runs as root, so without repair
192 // they'd be root-owned and the instance (service user) would EACCES on its
193 // next wake: space down until a manual chown. Fix ownership on the way out.
194 defer repairDBSidecarOwnership(path)
195 conn, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)")
196 if err != nil {
197 return p, err
198 }
199 defer conn.Close()
200 conn.SetMaxOpenConns(1)
201 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
202 defer cancel()
203 get := func(key string) (string, bool, error) {
204 var v string
205 err := conn.QueryRowContext(ctx, "SELECT value FROM config WHERE key = ?", key).Scan(&v)
206 if err == sql.ErrNoRows {
207 return "", false, nil
208 }
209 if err != nil {
210 return "", false, err
211 }
212 return v, true, nil
213 }
214 // Same semantics as the instance's own readers: booleans default ON when
215 // absent, an unrecognised channel falls back to stable, a garbled pin
216 // must not freeze anything silently.
217 if v, ok, err := get("update_auto"); err != nil {
218 return p, err
219 } else if ok {
220 p.AutoUpdate = v == "1"
221 }
222 if v, ok, err := get("update_download"); err != nil {
223 return p, err
224 } else if ok {
225 p.AutoDownload = v == "1"
226 }
227 if v, ok, err := get("update_channel"); err != nil {
228 return p, err
229 } else if ok && validChannel(v) {
230 p.Channel = v
231 }
232 if v, ok, err := get("update_pin"); err != nil {
233 return p, err
234 } else if ok && validVersion(v) {
235 p.Pin = v
236 }
237 if v, ok, err := get("update_major"); err != nil {
238 return p, err
239 } else if ok && validMajor(v) {
240 p.Major = v
241 }
242 return p, nil
243}
244
245// repairDBSidecarOwnership chowns <db>-wal/-shm to the DB file's own uid/gid.
246// A root-run read-only open of a checkpointed WAL DB creates those two files
247// root-owned (mode=ro guards the DB's content, not the WAL control files),
248// and the instance — running as the service user — then can't open its own
249// DB: the add-bot root-owned-dir incident as a library. Best-effort: non-root
250// callers can't chown and can't create root-owned files either.
251func repairDBSidecarOwnership(dbPath string) {
252 st, err := os.Stat(dbPath)
253 if err != nil {
254 return
255 }
256 sys, ok := st.Sys().(*syscall.Stat_t)
257 if !ok {
258 return
259 }
260 for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} {
261 if _, err := os.Stat(p); err == nil {
262 _ = os.Chown(p, int(sys.Uid), int(sys.Gid))
263 }
264 }
265}
266
267// updaterArch is the architecture this box runs — a var so tests can exercise
268// the arm64 resolution without faking runtime.GOARCH.
269var updaterArch = runtime.GOARCH
270
271// downloadBinaryToTemp streams one URL into a temp file in the incoming dir
272// and refuses bytes whose sha256 doesn't match the signed manifest entry.
273func downloadBinaryToTemp(url, sha, ver string) (string, error) {
274 if err := os.MkdirAll(incomingDir(), 0o755); err != nil {
275 return "", err
276 }
277 f, err := os.CreateTemp(incomingDir(), "dl-")
278 if err != nil {
279 return "", err
280 }
281 defer f.Close()
282 fail := func(err error) (string, error) {
283 os.Remove(f.Name())
284 return "", err
285 }
286 client := &http.Client{Timeout: 10 * time.Minute}
287 resp, err := client.Get(url)
288 if err != nil {
289 return fail(err)
290 }
291 defer resp.Body.Close()
292 if resp.StatusCode != http.StatusOK {
293 return fail(fmt.Errorf("download %s: %s", ver, resp.Status))
294 }
295 h := sha256.New()
296 if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
297 return fail(err)
298 }
299 if got := hex.EncodeToString(h.Sum(nil)); got != sha {
300 return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver))
301 }
302 return f.Name(), nil
303}
304
305// downloadReleaseBinary fetches this box's arch of a release into the incoming
306// dir and refuses anything whose sha256 doesn't match the signed manifest
307// entry. A failed primary download (GitHub 404s brand-new assets for minutes
308// after a publish) falls back to the S3 mirror, loudly; the sha256 gate is
309// identical on both paths, so a mirror serving the wrong bytes only ever
310// causes a refusal. Returns the temp path; the caller stages it and removes
311// the temp.
312func downloadReleaseBinary(e releaseEntry) (string, error) {
313 url, sha, ok := e.forArch(updaterArch)
314 if !ok {
315 // An arm64 box facing a pre-arm64 release: skip, never install the
316 // wrong arch. The next release publishes both and the box catches up.
317 return "", fmt.Errorf("release %s has no %s build — skipping", e.Version, updaterArch)
318 }
319 path, err := downloadBinaryToTemp(url, sha, e.Version)
320 if err == nil {
321 return path, nil
322 }
323 murl := ""
324 if releaseMirrorURL != "" {
325 murl = mirrorBinaryURL(releaseMirrorURL, url)
326 }
327 if murl == "" {
328 return "", err
329 }
330 log.Printf("update: primary download of %s failed (%v) — trying mirror %s", e.Version, err, murl)
331 path, merr := downloadBinaryToTemp(murl, sha, e.Version)
332 if merr != nil {
333 return "", fmt.Errorf("download %s: primary failed (%v); mirror failed too (%v)", e.Version, err, merr)
334 }
335 return path, nil
336}
337
338// ensureVersionStaged makes a manifest entry's binary available in the version
339// store, downloading it if needed. Idempotent across hosts in one pass.
340func ensureVersionStaged(e releaseEntry) error {
341 if _, err := os.Stat(versionBinPath(e.Version)); err == nil {
342 return nil
343 }
344 tmp, err := downloadReleaseBinary(e)
345 if err != nil {
346 return err
347 }
348 defer os.Remove(tmp)
349 return stageVersion(tmp, e.Version)
350}
351
352// updatePass is one full check-and-apply sweep, split from runUpdateRun so
353// tests drive it directly (die()-free: per-host problems log and continue —
354// an unreachable instance must not strand the rest of the fleet unchecked).
355// ignoreBake is the local, root-only override of the supply-chain bake window
356// (bake.go) — deliberately never a publisher-side flag.
357func updatePass(reg *Registry, unitTmpl string, dryRun, ignoreBake bool) error {
358 m, err := fetchReleaseManifest(releaseManifestURL)
359 if err != nil {
360 // No feed is a normal state (the releases repo may not exist yet, or
361 // the box is offline) — never an error exit the timer would flap on.
362 fmt.Printf("update: no usable manifest (%v) — nothing to do\n", err)
363 return nil
364 }
365 if last := readManifestStamp(); m.GeneratedAt.Before(last) {
366 return fmt.Errorf("manifest generated_at %s is older than the last accepted %s — refusing a replayed feed",
367 m.GeneratedAt.Format(time.RFC3339), last.Format(time.RFC3339))
368 }
369 if !dryRun {
370 if err := writeManifestStamp(m.GeneratedAt); err != nil {
371 return fmt.Errorf("record manifest stamp: %v", err)
372 }
373 }
374 // Tick the bake clock: record every channel head this box hasn't seen at
375 // that head before, so the supply-chain window is measured from the box's
376 // own first sighting, never a feed timestamp (bake.go). Dry runs compute
377 // eligibility but persist nothing.
378 bake := loadFirstSeen()
379 if bake.observe(m, time.Now()) && !dryRun {
380 if err := bake.save(); err != nil {
381 return fmt.Errorf("record bake clock: %v", err)
382 }
383 }
384
385 routes, err := reg.list()
386 if err != nil {
387 return err
388 }
389 // A version that failed its health check on one host isn't tried on the
390 // next this pass — one bad build shouldn't bounce every instance in turn.
391 poisoned := map[string]bool{}
392 // Versions instances are pinned to must survive GC even when no channel or
393 // registry row mentions them (a canary's branch build).
394 var pinnedVers []string
395 // Hibernating hosts, deferred to a second phase (see the branch below).
396 var hibernating []Route
397 for _, rt := range routes {
398 if rt.Kind != "instance" {
399 continue
400 }
401 if rt.Status != "ready" {
402 fmt.Printf(" %s: skipped (status %s)\n", rt.Host, rt.Status)
403 continue
404 }
405 if rt.Hibernate {
406 // Processed in a second phase below, AFTER every unit-managed host:
407 // repoints have no health check, so let an awake host discover a
408 // boot-broken build (and poison it) before any sleeper is moved.
409 hibernating = append(hibernating, rt)
410 continue
411 }
412 prefs, err := fetchUpdatePrefs(rt.Socket)
413 if err != nil {
414 fmt.Printf(" %s: unreachable (%v) — skipped\n", rt.Host, err)
415 continue
416 }
417 if prefs.Pin != "" {
418 // Pinned: the instance declared its own version — leave it alone
419 // entirely (this is what keeps a canary from being stomped back to
420 // the channel head an hour after it's deployed).
421 pinnedVers = append(pinnedVers, prefs.Pin)
422 fmt.Printf(" %s: pinned to %s — left alone\n", rt.Host, prefs.Pin)
423 continue
424 }
425 e, ok := m.Channels[prefs.Channel]
426 if !ok {
427 fmt.Printf(" %s: channel %s has no release yet\n", rt.Host, prefs.Channel)
428 continue
429 }
430 cur, _ := liveVersion(rt.Host)
431 if cur == e.Version {
432 continue // up to date — stay quiet on the hourly tick
433 }
434 if hold := majorHold(prefs.Major, e); hold != "" {
435 fmt.Printf(" %s: %s\n", rt.Host, hold)
436 continue
437 }
438 if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" {
439 fmt.Printf(" %s: %s (%s) is %s — apply now: lchat deploy %s --version %s\n",
440 rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version)
441 continue
442 }
443 if !prefs.AutoDownload && !prefs.AutoUpdate {
444 fmt.Printf(" %s: %s available on %s (downloads off — admin's call)\n", rt.Host, e.Version, prefs.Channel)
445 continue
446 }
447 if dryRun {
448 fmt.Printf(" %s: would update %s -> %s (%s)\n", rt.Host, cur, e.Version, prefs.Channel)
449 continue
450 }
451 if err := ensureVersionStaged(e); err != nil {
452 fmt.Printf(" %s: %v\n", rt.Host, err)
453 continue
454 }
455 if !prefs.AutoUpdate {
456 fmt.Printf(" %s: %s downloaded and staged (auto-update off — apply with `lchat deploy %s --version %s`)\n",
457 rt.Host, e.Version, rt.Host, e.Version)
458 continue
459 }
460 if poisoned[e.Version] {
461 fmt.Printf(" %s: skipping %s (failed health check on another instance this pass)\n", rt.Host, e.Version)
462 continue
463 }
464 rtCopy := rt
465 changed, err := deployHostVersion(reg, &rtCopy, unitTmpl+rt.Host, e.Version)
466 if err != nil {
467 poisoned[e.Version] = true
468 fmt.Printf(" %s: update to %s failed: %v\n", rt.Host, e.Version, err)
469 continue
470 }
471 if changed {
472 fmt.Printf(" %s: updated %s -> %s (%s)\n", rt.Host, cur, e.Version, prefs.Channel)
473 }
474 }
475
476 // Second phase: hibernating hosts. An activator-managed host has no unit
477 // to restart and must not be woken just to update (an hourly wake would
478 // defeat hibernation). Instead: repoint its live symlink — the NEXT wake
479 // execs the new binary, since the router's spawn resolves
480 // /opt/lchat/live/<host> at exec time. Prefs come from the parked DB
481 // (read-only, fail-closed). Two stated trades vs the unit path, both in
482 // docs/server-releases.md: no health check (nothing runs to probe — a bad
483 // build surfaces at wake; recover with `lchat rollback <host>`, which
484 // repoints back), and a currently-AWAKE child keeps its old binary until
485 // it next sleeps (a running process doesn't follow the symlink).
486 for _, rt := range hibernating {
487 prefs, err := hibernatingPrefs(rt.Host)
488 if err != nil {
489 // The admin's pin or opt-out may live only in the copy we couldn't
490 // read — never move a host whose wishes are unknown. It converges
491 // on the pass after its first wake leaves a warm local DB.
492 fmt.Printf(" %s: hibernating, parked prefs unreadable (%v) — skipped\n", rt.Host, err)
493 continue
494 }
495 if prefs.Pin != "" {
496 pinnedVers = append(pinnedVers, prefs.Pin)
497 fmt.Printf(" %s: pinned to %s — left alone\n", rt.Host, prefs.Pin)
498 continue
499 }
500 e, ok := m.Channels[prefs.Channel]
501 if !ok {
502 fmt.Printf(" %s: channel %s has no release yet\n", rt.Host, prefs.Channel)
503 continue
504 }
505 cur, _ := liveVersion(rt.Host)
506 if cur == e.Version {
507 continue // up to date — stay quiet on the hourly tick
508 }
509 if hold := majorHold(prefs.Major, e); hold != "" {
510 fmt.Printf(" %s: %s\n", rt.Host, hold)
511 continue
512 }
513 if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" {
514 fmt.Printf(" %s: %s (%s) is %s — apply now: lchat deploy %s --version %s\n",
515 rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version)
516 continue
517 }
518 if !prefs.AutoDownload && !prefs.AutoUpdate {
519 fmt.Printf(" %s: %s available on %s (downloads off — admin's call)\n", rt.Host, e.Version, prefs.Channel)
520 continue
521 }
522 if dryRun {
523 fmt.Printf(" %s: would repoint %s -> %s (%s; hibernating)\n", rt.Host, cur, e.Version, prefs.Channel)
524 continue
525 }
526 if err := ensureVersionStaged(e); err != nil {
527 fmt.Printf(" %s: %v\n", rt.Host, err)
528 continue
529 }
530 if !prefs.AutoUpdate {
531 fmt.Printf(" %s: %s downloaded and staged (auto-update off — apply with `lchat deploy %s --version %s`)\n",
532 rt.Host, e.Version, rt.Host, e.Version)
533 continue
534 }
535 if poisoned[e.Version] {
536 fmt.Printf(" %s: skipping %s (failed health check on another instance this pass)\n", rt.Host, e.Version)
537 continue
538 }
539 if err := pointLive(rt.Host, e.Version); err != nil {
540 fmt.Printf(" %s: repoint failed: %v\n", rt.Host, err)
541 continue
542 }
543 if err := reg.setVersions(rt.Host, e.Version, cur); err != nil {
544 fmt.Printf(" %s: repointed but recording the version failed: %v\n", rt.Host, err)
545 continue
546 }
547 fmt.Printf(" %s: repointed %s -> %s (%s; hibernating — runs it on next wake)\n", rt.Host, cur, e.Version, prefs.Channel)
548 }
549
550 if !dryRun {
551 gcVersions(reg, m, pinnedVers)
552 }
553 // Last, the box's own trust plane: the control binary rides the same
554 // signed feed (see controlSelfUpdate) — after instances, so a build that
555 // poisons a health check this pass never also becomes the control plane.
556 // A self-update failure is already logged loudly inside (it rolls itself
557 // back); surface the summary here but never fail the whole pass on it —
558 // the instances above already converged. Passing `poisoned` makes the
559 // "a build that poisons a health check this pass never also becomes the
560 // control plane" promise real: a head that just failed on an instance is
561 // skipped for the control binary too.
562 if err := controlSelfUpdate(m, bake, poisoned, dryRun, ignoreBake); err != nil {
563 fmt.Printf("update: control self-update: %v\n", err)
564 }
565 // The sibling services (connect/push/home) converge from the same signed
566 // entry, after the control binary — see updaterservices.go.
567 serviceBinariesUpdate(m, bake, poisoned, dryRun, ignoreBake)
568 return nil
569}
570
571// controlSelfUpdate keeps the CONTROL binary — /opt/lchat/lchat, the path
572// every privileged operation execs through (router, controld, cloud, this
573// very updater) — on its own release channel, from the same signed feed the
574// instance pass just verified. Before the operator-allowlist hardening this
575// was a manual root `cp`; with root SSH gone (docs/instance-router.md § Box
576// access), the already-root update pass is the one place the swap can live —
577// and it adds no trust the box didn't have: same signature, same sha, same
578// bake window as every instance binary.
579//
580// LCHAT_CONTROL_CHANNEL (in the unit's /data/update.env) picks the channel;
581// "stable" is the default — the trust plane should be the LAST thing on a
582// box to take a new build — and "off" disables self-update entirely. The
583// swap is an atomic same-filesystem rename over the existing path (running
584// services keep their old inode until restarted; sudo never sees a missing
585// path), with the outgoing build kept beside it as .prev-<build>.
586//
587// The swap is NOT committed blind: after bouncing lchat-control/lchat-cloud
588// onto the new binary we confirm the control plane reports the new build. The
589// strong gate is the control daemon's live /healthz (waitControlHealthy); when
590// lchat-control isn't running to answer it, we fall back to the weaker but
591// controld-independent `<self> version` integrity check (controlBinaryReports)
592// rather than roll back a binary the probe simply couldn't reach. If neither
593// gate passes we restore .prev and confirm the box recovered — a boot-broken
594// control plane rolling itself back is the whole point of this function, since
595// with root SSH gone an operator can't reach in to fix a bricked box by hand.
596// lchat-control and lchat-cloud are the only units bounced (backend sockets,
597// invisible to members); the ROUTER is deliberately NOT restarted here — its
598// front door should never blip on a timer's schedule, and it has no /healthz to
599// probe, so it runs the old build until an operator restarts it, noted below.
600//
601// A rolled-back head is QUARANTINED (writeFailedControlVersion, persisted under
602// optDir) so the hourly pass doesn't reinstall + roll it back forever; the
603// quarantine clears when a good build commits or the channel moves past it. A
604// head that poisoned an instance's health check THIS pass is skipped up front.
605//
606// Returns an error only when something actually went wrong (a pre-swap failure,
607// or a swap that was rolled back); every no-op path returns nil. updatePass
608// logs the summary but never fails the pass on it — the instances already
609// converged before this runs.
610//
611// RESIDUAL RISK (deliberately not addressed here): the swap → health-check
612// window is not crash-atomic. A reboot or kill in that window can leave the
613// loose /opt/lchat/lchat as the unproven NEW build with no process left to run
614// the rollback — so nothing auto-recovers until a human renames .prev back.
615// Closing it means a boot-time recovery (an ExecStartPre on lchat-router /
616// lchat-control that verifies the loose binary and reverts to .prev on doubt);
617// that's deferred because it touches the ingress start path, which we don't
618// want a timer-adjacent change to destabilise.
619func controlSelfUpdate(m *releaseManifest, bake *firstSeenState, poisoned map[string]bool, dryRun, ignoreBake bool) error {
620 if buildVersion == "dev" {
621 // An unstamped binary (local build, test binary) has no place in the
622 // channel ordering — self-updating it would clobber whatever the
623 // operator is hand-running. Only release-stamped builds converge.
624 return nil
625 }
626 channel := envOr("LCHAT_CONTROL_CHANNEL", "stable")
627 if channel == "off" {
628 return nil
629 }
630 e, ok := m.Channels[channel]
631 if !ok || e.Version == buildVersion {
632 return nil
633 }
634 // The channel moved past a previously-quarantined head — forget the
635 // quarantine and let the new head be tried.
636 if failed := readFailedControlVersion(); failed != "" && failed != e.Version {
637 clearFailedControlVersion()
638 }
639 // A head we already swapped in, health-checked, and rolled back stays
640 // quarantined until a human pins/patches the channel or it moves on — never
641 // reinstalled + rolled back every hour.
642 if e.Version == readFailedControlVersion() {
643 fmt.Printf("update: control build %s is quarantined (failed its health check; pin or patch the channel to move)\n", e.Version)
644 return nil
645 }
646 // A build that failed a health check on an instance THIS pass must never
647 // then become the control plane (updatePass runs us after the instances).
648 if poisoned[e.Version] {
649 fmt.Printf("update: skipping control update to %s — it failed a health check on an instance this pass\n", e.Version)
650 return nil
651 }
652 if hold := bakeHold(bake, channel, e.Version, ignoreBake, time.Now()); hold != "" {
653 fmt.Printf("update: control binary %s -> %s: %s\n", buildVersion, e.Version, hold)
654 return nil
655 }
656 if dryRun {
657 fmt.Printf("update: control binary would update %s -> %s (%s; dry run)\n", buildVersion, e.Version, channel)
658 return nil
659 }
660 self, err := selfPath()
661 if err != nil {
662 return err
663 }
664 tmp, err := downloadReleaseBinary(e)
665 if err != nil {
666 return err
667 }
668 defer os.Remove(tmp)
669 if err := os.Chmod(tmp, 0o755); err != nil {
670 return fmt.Errorf("chmod: %v", err)
671 }
672 // Stage the verified build into the version store BEFORE swapping it live.
673 // gcVersions keeps every channel head, so this protects the good target from
674 // the pruner and makes it reachable to `lchat rollback`/re-deploy — the swap
675 // alone would leave the new build only as the loose /opt/lchat/lchat, a file
676 // the version store has never heard of.
677 if err := stageVersion(tmp, e.Version); err != nil {
678 return fmt.Errorf("stage %s: %v", e.Version, err)
679 }
680 // One .prev at a time: the previous previous build is already two behind
681 // and reachable from the version store anyway.
682 if old, _ := filepath.Glob(self + ".prev-*"); len(old) > 0 {
683 for _, p := range old {
684 os.Remove(p)
685 }
686 }
687 // .prev is written HERE, from the still-running (known-good) binary, and only
688 // ever just before a swap — so it always points at the last build that passed
689 // its health check. A rollback below restores it and returns immediately,
690 // never advancing .prev past an unproven build; the invariant is that the
691 // loose /opt/lchat/lchat is always either health-confirmed or being actively
692 // rolled back to one.
693 prev := self + ".prev-" + buildVersion
694 if err := copyFileAtomic(self, prev, 0o755); err != nil {
695 return fmt.Errorf("keep rollback copy: %v", err)
696 }
697 // downloadReleaseBinary lands in incomingDir() — same filesystem as the
698 // control binary, so this rename atomically replaces the path.
699 if err := os.Rename(tmp, self); err != nil {
700 return fmt.Errorf("install: %v", err)
701 }
702 // The binary is swapped; prove it comes up before walking away. Bounce the
703 // backend units onto it, then confirm the control plane reports the new build.
704 restartControlUnits()
705 if herr := waitControlHealthy(e.Version, deployHealthTimeout); herr != nil {
706 // The live probe failed — but it's only AUTHORITATIVE when lchat-control
707 // is actually running to answer it. try-restart is a no-op on a box where
708 // the unit is inactive (never enabled, or intentionally stopped), so a
709 // failed probe there means "nobody answered", not "the binary is bad".
710 if isActive(controlUnit) {
711 return rollbackControl(self, prev, e.Version, herr) // authoritative failure
712 }
713 // controld isn't up to probe. Fall back to the weaker, controld-independent
714 // gate: run `<self> version` and require it names the target build. That
715 // proves the new binary loads and self-reports the right build/arch (a
716 // corrupt or wrong-arch binary fails), NOT that the service boots — hence
717 // "weaker", used only because the strong gate is unavailable. controld
718 // starts fresh from this binary when next needed.
719 if verr := controlBinaryReports(self, e.Version); verr != nil {
720 return rollbackControl(self, prev, e.Version, fmt.Errorf("lchat-control inactive and the new binary failed its integrity check: %v", verr))
721 }
722 log.Printf("update: lchat-control inactive — committing control update to %s on the `version` integrity check (weaker gate; live probe unavailable)", e.Version)
723 // fall through to commit
724 }
725 // Committed. Any stale quarantine no longer applies.
726 clearFailedControlVersion()
727 // F5: cloud rode the same swap+restart but isn't the health gate (the router
728 // and chat are unaffected by it) — so don't roll back on it, just flag it.
729 if !isActive(cloudUnit) {
730 log.Printf("update: WARN lchat-cloud not active after control update to %s — check the provisioner", e.Version)
731 }
732 fmt.Printf("update: control binary %s -> %s (%s; healthy; rollback at %s)\n", buildVersion, e.Version, channel, prev)
733 fmt.Printf("update: lchat-router still runs %s — restart it when convenient to converge\n", buildVersion)
734 return nil
735}
736
737// rollbackControl restores the last-known-good control binary (.prev) after a
738// swapped-in build failed its health check, confirms the box came back, and
739// quarantines the bad build so the hourly pass doesn't reinstall + roll it back
740// forever. It returns a non-nil error describing the rollback — or a screaming,
741// non-looping CONTROL PLANE BRICKED error if recovery itself fails.
742func rollbackControl(self, prev, newVer string, healthErr error) error {
743 log.Printf("update: control binary %s -> %s is UNHEALTHY (%v) — rolling back to %s", buildVersion, newVer, healthErr, buildVersion)
744 if rberr := os.Rename(prev, self); rberr != nil {
745 return fmt.Errorf("CONTROL PLANE BRICKED: %s failed its health check AND restoring %s failed: %v (health error: %v)", newVer, prev, rberr, healthErr)
746 }
747 restartControlUnits()
748 // Confirm the restored binary with the same strong/weak gate: the live probe
749 // when controld runs, else the `version` integrity check.
750 var recerr error
751 if isActive(controlUnit) {
752 recerr = waitControlHealthy(buildVersion, deployHealthTimeout)
753 } else {
754 recerr = controlBinaryReports(self, buildVersion)
755 }
756 if recerr != nil {
757 // Genuine brick: neither the new build nor the restored old one is
758 // healthy. Scream, but do NOT loop — a human has to look now.
759 log.Printf("update: CONTROL PLANE BRICKED — rolled back to %s but IT is unhealthy too (%v); box needs a human NOW", buildVersion, recerr)
760 return fmt.Errorf("CONTROL PLANE BRICKED: %s unhealthy, rolled back to %s but it is unhealthy too (%v) — box needs a human NOW (original health error: %v)", newVer, buildVersion, recerr, healthErr)
761 }
762 // Persist the quarantine so the next hourly pass short-circuits this head
763 // instead of reinstalling + rolling it back again.
764 if err := writeFailedControlVersion(newVer); err != nil {
765 log.Printf("update: warning: couldn't persist control quarantine for %s: %v", newVer, err)
766 }
767 log.Printf("update: rolled back to %s and it is healthy again — %s is quarantined until a good build ships or you pin/patch the channel", buildVersion, newVer)
768 return fmt.Errorf("control binary %s failed its health check and was rolled back to %s: %v", newVer, buildVersion, healthErr)
769}
770
771// controlUnit / cloudUnit / linksUnit are the systemd units that exec the
772// control binary (/opt/lchat/lchat) directly, rather than a per-instance
773// versioned live symlink — so they keep running the OLD inode until something
774// restarts them.
775const (
776 controlUnit = "lchat-control.service"
777 cloudUnit = "lchat-cloud.service"
778 linksUnit = "lchat-links.service"
779)
780
781// controlBinaryUnits is the one list of them, so the updater and the tests that
782// assert "one restart each, no rollback round" can't disagree about how many
783// that is. Adding a unit here is all it takes to put it on the update path.
784var controlBinaryUnits = []string{controlUnit, cloudUnit, linksUnit}
785
786// restartControlUnits bounces the backend units that exec the control binary —
787// lchat-control, lchat-cloud, and lchat-links — onto whatever /opt/lchat/lchat
788// now is. The ROUTER is deliberately excluded: it's the fleet's only ingress,
789// has no /healthz to probe, and must never blip on the updater's schedule (it
790// keeps its old inode until an operator restarts it). Restart failures only
791// log — the health probe that follows is the real gate on whether the new
792// build is up.
793//
794// links belongs here for the same reason cloud does, and is the cheapest of
795// the three to bounce: it holds no database and no connections, just an
796// in-memory verification cache that re-populates lazily. Without it the
797// redirector would be the one service that never picks up a new build at all —
798// try-restart is a no-op on a box where it isn't running, so boxes that route
799// no short links are unaffected.
800func restartControlUnits() {
801 for _, unit := range controlBinaryUnits {
802 if err := systemctl("try-restart", unit); err != nil {
803 fmt.Printf("update: %s restart: %v (new build applies on its next start)\n", unit, err)
804 }
805 }
806}
807
808// isActive reports whether a systemd unit is currently active (`systemctl
809// is-active --quiet` exits 0 only when active). A var so tests can fake the
810// box's unit state — it decides whether the control /healthz probe is even
811// answerable, so a failed probe on a box that simply isn't running controld
812// doesn't roll back a perfectly good binary.
813var isActive = func(unit string) bool {
814 return systemctlQuery("is-active", "--quiet", unit) == nil
815}
816
817// controlBinaryReports is the controld-independent integrity gate: run
818// `<path> version` and require its output names wantBuild. It proves the binary
819// execs and self-reports the expected build/arch, which is weaker than a live
820// /healthz (it does NOT prove the service boots) but is all we have when
821// lchat-control isn't running to answer a probe. A var so tests can fake the
822// exec without a real binary on disk.
823var controlBinaryReports = func(path, wantBuild string) error {
824 got, err := readBinaryVersion(path)
825 if err != nil {
826 return fmt.Errorf("run %s version: %v", path, err)
827 }
828 if !strings.Contains(got, wantBuild) {
829 return fmt.Errorf("%s reported build %q, want %q", path, got, wantBuild)
830 }
831 return nil
832}
833
834// controlFailedVersionPath persists the last control head that failed its health
835// check and was rolled back (mirrors manifestStampPath). It lives under optDir
836// so it survives the updater process and is read on the next hourly pass.
837func controlFailedVersionPath() string { return filepath.Join(optDir, ".control-failed-version") }
838
839// readFailedControlVersion returns the quarantined control build, or "" if none.
840func readFailedControlVersion() string {
841 raw, err := os.ReadFile(controlFailedVersionPath())
842 if err != nil {
843 return ""
844 }
845 return strings.TrimSpace(string(raw))
846}
847
848// writeFailedControlVersion records ver as quarantined.
849func writeFailedControlVersion(ver string) error {
850 return os.WriteFile(controlFailedVersionPath(), []byte(ver+"\n"), 0o644)
851}
852
853// clearFailedControlVersion lifts the quarantine (a good build committed, or the
854// channel moved on). Absent file is fine — clearing "nothing" is a no-op.
855func clearFailedControlVersion() {
856 if err := os.Remove(controlFailedVersionPath()); err != nil && !os.IsNotExist(err) {
857 log.Printf("update: warning: couldn't clear control quarantine file: %v", err)
858 }
859}
860
861// waitControlHealthy polls the control daemon's /healthz until it reports
862// wantBuild, or timeout elapses — the gate on the box's self-update. After the
863// control binary is swapped and lchat-control restarted, this confirms the NEW
864// build came up (wantBuild = the new version); on the rollback path it confirms
865// the OLD build came back (wantBuild = buildVersion, this updater process's own
866// still-running id).
867//
868// lchat-control runs Restart=always (RestartSec=1), so a crash-looping build
869// flaps between activating and active: the socket comes and goes and dials fail
870// with "connection refused" in between. A transient refusal is therefore
871// neither success nor failure — keep retrying across it until either the build
872// matches (healthy, return early) or the whole timeout expires (unhealthy). It
873// mirrors waitHealthyVersion, but against the control socket and /healthz.
874func waitControlHealthy(wantBuild string, timeout time.Duration) error {
875 deadline := time.Now().Add(timeout)
876 client := unixHTTPClient(controlSocketPath)
877 var last error
878 for time.Now().Before(deadline) {
879 resp, err := client.Get("http://lchat/healthz")
880 if err == nil {
881 var body struct {
882 Build string `json:"build"`
883 }
884 derr := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body)
885 resp.Body.Close()
886 if derr == nil && body.Build == wantBuild {
887 return nil
888 }
889 if derr != nil {
890 last = fmt.Errorf("read /healthz: %v", derr)
891 } else {
892 last = fmt.Errorf("control build %q != expected %q", body.Build, wantBuild)
893 }
894 } else {
895 last = err // often "connection refused" mid-restart — retried, not fatal
896 }
897 time.Sleep(150 * time.Millisecond)
898 }
899 return fmt.Errorf("control daemon didn't report build %q within %s: %v", wantBuild, timeout, last)
900}
901
902// gcVersions prunes version-store dirs nothing references anymore: kept are
903// every route's current + previous version, every channel head, the control
904// binary's own build, every pinned version (keepExtra), and whatever any live
905// symlink points at (belt and braces — the symlinks are what units actually
906// exec). Without this, hourly edge updates would grow /opt/lchat/versions
907// without bound on a box that enforces a free-disk floor. Leftover crashed
908// downloads are swept too.
909func gcVersions(reg *Registry, m *releaseManifest, keepExtra []string) {
910 keep := map[string]bool{buildVersion: true}
911 for _, v := range keepExtra {
912 keep[v] = true
913 }
914 routes, err := reg.list()
915 if err != nil {
916 return
917 }
918 for _, rt := range routes {
919 keep[rt.Version], keep[rt.PrevVersion] = true, true
920 }
921 if m != nil {
922 for _, e := range m.Channels {
923 keep[e.Version] = true
924 }
925 }
926 if links, err := os.ReadDir(liveDir()); err == nil {
927 for _, l := range links {
928 if v, err := liveVersion(l.Name()); err == nil {
929 keep[v] = true
930 }
931 }
932 }
933 entries, err := os.ReadDir(versionsDir())
934 if err != nil {
935 return
936 }
937 for _, en := range entries {
938 if !en.IsDir() || keep[en.Name()] {
939 continue
940 }
941 if err := os.RemoveAll(filepath.Join(versionsDir(), en.Name())); err == nil {
942 fmt.Printf(" pruned unused version %s\n", en.Name())
943 }
944 }
945 // Our downloads are temp-then-removed; a surviving dl-* file is a crashed
946 // pass. (Operator-scp'd files in incoming/ are left alone.)
947 if files, err := os.ReadDir(incomingDir()); err == nil {
948 for _, f := range files {
949 if strings.HasPrefix(f.Name(), "dl-") {
950 _ = os.Remove(filepath.Join(incomingDir(), f.Name()))
951 }
952 }
953 }
954}
955
956// runUpdate: `lchat update run|status|pin|unpin|major` — the auto-updater CLI.
957func runUpdate(args []string) {
958 if len(args) == 0 {
959 die("update: subcommand required: run | status | pin | unpin | major")
960 }
961 switch args[0] {
962 case "run":
963 runUpdateRun(args[1:])
964 case "status":
965 runUpdateStatus(args[1:])
966 case "pin":
967 runUpdatePin(args[1:], true)
968 case "unpin":
969 runUpdatePin(args[1:], false)
970 case "major":
971 runUpdateMajor(args[1:])
972 default:
973 die("update: unknown subcommand %q (run | status | pin | unpin | major)", args[0])
974 }
975}
976
977// pinHost writes update_pin into the instance's own settings via its admin API
978// over the unix socket — the pin is instance state (the same value the admin
979// panel shows and /api/update-prefs reports), not a registry side-channel. The
980// admin token comes from the instance's EnvironmentFile, like `admin-link`.
981// The write is read back before reporting success: a pin the operator
982// believes is set but isn't is exactly how the next hourly pass stomps a
983// canary.
984func pinHost(rt *Route, pin string) error {
985 if err := writeHostSetting(rt, "update_pin", pin); err != nil {
986 return err
987 }
988 return confirmHostSetting(rt, "pin", pin, func(p updatePrefs) string { return p.Pin })
989}
990
991// confirmHostSetting re-reads /api/update-prefs after a settings write and
992// errors when the value didn't stick. An instance binary that predates a
993// setting ACCEPTS the POST and silently ignores the unknown field — found
994// live: `update major` against a pre-0.11.0 build reported success while
995// storing nothing.
996func confirmHostSetting(rt *Route, what, want string, read func(updatePrefs) string) error {
997 p, err := fetchUpdatePrefs(rt.Socket)
998 if err != nil {
999 return fmt.Errorf("wrote the %s but couldn't read it back (%v) — verify with `lchat update status`", what, err)
1000 }
1001 if got := read(p); got != want {
1002 return fmt.Errorf("%s accepted the %s write but reports %q — its binary likely predates the setting; update the instance first, then retry", rt.Host, what, got)
1003 }
1004 return nil
1005}
1006
1007// writeHostSetting posts one settings key into the instance's own admin API —
1008// the shared plumbing behind `update pin`/`update major`.
1009func writeHostSetting(rt *Route, key, value string) error {
1010 env, err := readEnvFile(filepath.Join(instanceDataDir, rt.Host+".env"))
1011 if err != nil {
1012 return err
1013 }
1014 tok := env["LCHAT_ADMIN_TOKEN"]
1015 if tok == "" {
1016 return fmt.Errorf("no LCHAT_ADMIN_TOKEN in %s/%s.env", instanceDataDir, rt.Host)
1017 }
1018 body, err := json.Marshal(map[string]string{key: value})
1019 if err != nil {
1020 return err
1021 }
1022 req, err := http.NewRequest("POST", "http://lchat/api/admin/settings", bytes.NewReader(body))
1023 if err != nil {
1024 return err
1025 }
1026 req.Header.Set("Authorization", "Bearer "+tok)
1027 resp, err := unixHTTPClient(rt.Socket).Do(req)
1028 if err != nil {
1029 return err
1030 }
1031 defer resp.Body.Close()
1032 if resp.StatusCode != http.StatusOK {
1033 return fmt.Errorf("settings write: %s", resp.Status)
1034 }
1035 return nil
1036}
1037
1038// runUpdatePin: `lchat update pin <host> [<ver>]` freezes an instance on a
1039// version (default: whatever it's running now); `unpin` resumes its channel.
1040func runUpdatePin(args []string, pin bool) {
1041 cmd := "unpin"
1042 if pin {
1043 cmd = "pin"
1044 }
1045 fs := flag.NewFlagSet("update "+cmd, flag.ExitOnError)
1046 registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
1047 _ = fs.Parse(reorderFlagsFirst(fs, args))
1048
1049 host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
1050 if !validHost(host) {
1051 die("update %s: invalid host %q", cmd, host)
1052 }
1053 reg, err := openRegistry(*registryPath)
1054 if err != nil {
1055 die("update %s: open registry: %v", cmd, err)
1056 }
1057 rt, err := reg.get(host)
1058 if err != nil || rt == nil || rt.Kind != "instance" {
1059 die("update %s: %s is not a managed instance", cmd, host)
1060 }
1061
1062 ver := ""
1063 if pin {
1064 ver = strings.TrimSpace(fs.Arg(1))
1065 if ver == "" {
1066 // Default to what the instance is running — "freeze me here".
1067 if ver, _ = liveVersion(host); ver == "" {
1068 die("update pin: %s has no live version — pass one explicitly", host)
1069 }
1070 }
1071 if !validVersion(ver) {
1072 die("update pin: invalid version %q", ver)
1073 }
1074 }
1075 if err := pinHost(rt, ver); err != nil {
1076 die("update %s %s: %v", cmd, host, err)
1077 }
1078 if pin {
1079 fmt.Printf("pinned %s to %s (auto-updates paused; `lchat update unpin %s` or pick a channel in its admin panel to resume)\n", host, ver, host)
1080 } else {
1081 fmt.Printf("unpinned %s (back on its channel from the next update pass)\n", host)
1082 }
1083}
1084
1085// runUpdateMajor: `lchat update major <host> <n>|clear` keeps an instance on
1086// one major version — minors/patches keep flowing, a major bump holds until
1087// the admin raises or clears it. Unlike `pin` there is no default: staying on
1088// a major is a statement about the future, so the operator says which one.
1089func runUpdateMajor(args []string) {
1090 fs := flag.NewFlagSet("update major", flag.ExitOnError)
1091 registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
1092 _ = fs.Parse(reorderFlagsFirst(fs, args))
1093
1094 host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
1095 if !validHost(host) {
1096 die("update major: invalid host %q", host)
1097 }
1098 major := strings.TrimSpace(fs.Arg(1))
1099 if major == "clear" {
1100 major = ""
1101 } else if major == "" || !validMajor(major) {
1102 die("update major: pass a major version number, or `clear` to follow everything")
1103 }
1104 reg, err := openRegistry(*registryPath)
1105 if err != nil {
1106 die("update major: open registry: %v", err)
1107 }
1108 rt, err := reg.get(host)
1109 if err != nil || rt == nil || rt.Kind != "instance" {
1110 die("update major: %s is not a managed instance", host)
1111 }
1112 if err := writeHostSetting(rt, "update_major", major); err != nil {
1113 die("update major %s: %v", host, err)
1114 }
1115 if err := confirmHostSetting(rt, "major pin", major, func(p updatePrefs) string { return p.Major }); err != nil {
1116 die("update major: %v", err)
1117 }
1118 if major == "" {
1119 fmt.Printf("cleared %s's major pin (follows its channel fully from the next update pass)\n", host)
1120 } else {
1121 fmt.Printf("%s stays on major %s (minors and patches keep flowing; a release crossing major %s is held until this is raised or cleared)\n", host, major, major)
1122 }
1123}
1124
1125func runUpdateRun(args []string) {
1126 fs := flag.NewFlagSet("update run", flag.ExitOnError)
1127 registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
1128 unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
1129 dryRun := fs.Bool("dry-run", false, "report what would change without staging or restarting anything")
1130 ignoreBake := fs.Bool("ignore-bake", false, "install channel heads still inside their supply-chain bake window (the deliberate local override)")
1131 _ = fs.Parse(reorderFlagsFirst(fs, args))
1132
1133 unlock, err := acquireDeployLock()
1134 if err != nil {
1135 die("update: %v", err)
1136 }
1137 defer unlock()
1138
1139 reg, err := openRegistry(*registryPath)
1140 if err != nil {
1141 die("update: open registry: %v", err)
1142 }
1143 if err := updatePass(reg, *unitTmpl, *dryRun, *ignoreBake); err != nil {
1144 die("update: %v", err)
1145 }
1146}
1147
1148// statusPrefs resolves the prefs a status row shows: the live socket when the
1149// host answers; a hibernating host's parked DB (the same fail-closed read the
1150// update pass trusts) when it doesn't. The error keeps the caller's honest
1151// "?" for a host unreadable either way — status must never guess what an
1152// admin chose.
1153func statusPrefs(rt Route) (updatePrefs, error) {
1154 p, err := fetchUpdatePrefs(rt.Socket)
1155 if err != nil && rt.Hibernate {
1156 return hibernatingPrefs(rt.Host)
1157 }
1158 return p, err
1159}
1160
1161// runUpdateStatus prints where every instance stands relative to its channel.
1162func runUpdateStatus(args []string) {
1163 fs := flag.NewFlagSet("update status", flag.ExitOnError)
1164 registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
1165 probe := fs.Bool("probe", false, "also read each instance's socket /api/version — the build it's ACTUALLY serving, which an awake hibernating instance can lag behind its symlink (docs/awake-instance-convergence.md)")
1166 _ = fs.Parse(reorderFlagsFirst(fs, args))
1167
1168 reg, err := openRegistry(*registryPath)
1169 if err != nil {
1170 die("update: open registry: %v", err)
1171 }
1172 routes, err := reg.list()
1173 if err != nil {
1174 die("update: %v", err)
1175 }
1176 m, merr := fetchReleaseManifest(releaseManifestURL)
1177 bake := loadFirstSeen()
1178
1179 if *probe {
1180 fmt.Printf("%-34s %-8s %-8s %-12s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "SYMLINK", "SERVING", "AVAILABLE")
1181 } else {
1182 fmt.Printf("%-34s %-8s %-8s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "CURRENT", "AVAILABLE")
1183 }
1184 staleServing := false
1185 for _, rt := range routes {
1186 if rt.Kind != "instance" {
1187 continue
1188 }
1189 channel, auto, major := "?", "?", ""
1190 if prefs, err := statusPrefs(rt); err == nil {
1191 channel, major = prefs.Channel, prefs.Major
1192 switch {
1193 case prefs.Pin != "":
1194 auto = "pinned" // pin beats channel; the updater skips this host
1195 case prefs.AutoDownload && prefs.AutoUpdate:
1196 auto = "dl+upd"
1197 case prefs.AutoDownload:
1198 auto = "dl"
1199 case prefs.AutoUpdate:
1200 auto = "upd"
1201 default:
1202 auto = "off"
1203 }
1204 }
1205 cur, _ := liveVersion(rt.Host)
1206 if cur == "" {
1207 cur = "-"
1208 }
1209 avail := "-"
1210 if m != nil {
1211 if e, ok := m.Channels[channel]; ok {
1212 avail = e.Version
1213 switch {
1214 case e.Version == cur:
1215 avail += " (current)"
1216 case majorHold(major, e) != "":
1217 avail += " (held by major pin " + major + ")"
1218 case bakeDelays[channel] > 0:
1219 // Status reads the bake clock without ticking it — only an
1220 // update pass observes. An unticked head shows as held too.
1221 if at, ok := bake.bakeEligibleAt(channel, e.Version); !ok {
1222 avail += " (held — bake starts on the next update pass)"
1223 } else if time.Now().Before(at) {
1224 avail += " (held until " + at.UTC().Format("2006-01-02 15:04 MST") + ")"
1225 }
1226 }
1227 }
1228 }
1229 if *probe {
1230 // The build the process is actually serving (vs. the symlink `cur`).
1231 serving, ok := probeVersion(rt.Socket)
1232 if !ok {
1233 serving = "asleep" // socket didn't answer — hibernating, converges on wake
1234 } else if serving != cur {
1235 serving += " ⚠" // awake but stale: symlink moved, process didn't
1236 staleServing = true
1237 }
1238 fmt.Printf("%-34s %-8s %-8s %-12s %-12s %s\n", rt.Host, channel, auto, cur, serving, avail)
1239 } else {
1240 fmt.Printf("%-34s %-8s %-8s %-12s %s\n", rt.Host, channel, auto, cur, avail)
1241 }
1242 }
1243 if staleServing {
1244 fmt.Println("\n⚠ = awake but serving an older build than its symlink names (a running process doesn't follow the")
1245 fmt.Println(" symlink). Converges on next sleep/wake, or cycle now with: lchat hibernate <host> --off && … --on")
1246 fmt.Println(" (docs/awake-instance-convergence.md).")
1247 }
1248 if merr != nil {
1249 fmt.Printf("(no manifest: %v)\n", merr)
1250 }
1251}
1252