server: update status reads sleeping hosts' prefs from the parked DB (#774)
After #778, update run honors a hibernating host's parked-DB prefs but update status still probed only the socket — every sleeping host showed '?' for CHANNEL/AUTO, hiding exactly the pin that fleet operations must not stomp. statusPrefs falls back to hibernatingPrefs (read-only, fail-closed, WAL-ownership repair included) for hibernate-flagged hosts whose socket doesn't answer; an unreadable DB keeps the honest '?'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+56
−2
docs/server-releases.md+4 −1updater.go+14 −1updater_test.go+38 −0
diff --git a/docs/server-releases.md b/docs/server-releases.md| index 2aeda4c..1f37462 100644 |
| --- a/docs/server-releases.md |
| +++ b/docs/server-releases.md |
| @@ -168,7 +168,10 @@ recover with `lchat rollback <host>`, which repoints back without waking; |
| `lchat deploy <host> --version <ver>` likewise repoints), and a |
| currently-awake child keeps its old binary until it next sleeps. A |
| sidecar-bearing space that never sleeps therefore still needs the flip-off |
| -flow (`lchat hibernate <host> --off`, update, `--on`). |
| +flow (`lchat hibernate <host> --off`, update, `--on`). `lchat update status` |
| +reads a sleeping host's prefs the same parked-DB way, so its CHANNEL/AUTO |
| +columns (including a pin) show real values instead of `?` — an invisible pin |
| +is how a fleet operation stomps a canary. |
| Same rule as deploys and TestFlight: **publish from landed `main`**, never a |
| feature branch (the script refuses a dirty tree). Publishing is manual for now; |
diff --git a/updater.go b/updater.go| index 64851df..74bb0cc 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -686,6 +686,19 @@ func runUpdateRun(args []string) { |
| } |
| } |
| +// statusPrefs resolves the prefs a status row shows: the live socket when the |
| +// host answers; a hibernating host's parked DB (the same fail-closed read the |
| +// update pass trusts) when it doesn't. The error keeps the caller's honest |
| +// "?" for a host unreadable either way — status must never guess what an |
| +// admin chose. |
| +func statusPrefs(rt Route) (updatePrefs, error) { |
| + p, err := fetchUpdatePrefs(rt.Socket) |
| + if err != nil && rt.Hibernate { |
| + return hibernatingPrefs(rt.Host) |
| + } |
| + return p, err |
| +} |
| + |
| // runUpdateStatus prints where every instance stands relative to its channel. |
| func runUpdateStatus(args []string) { |
| fs := flag.NewFlagSet("update status", flag.ExitOnError) |
| @@ -708,7 +721,7 @@ func runUpdateStatus(args []string) { |
| continue |
| } |
| channel, auto := "?", "?" |
| - if prefs, err := fetchUpdatePrefs(rt.Socket); err == nil { |
| + if prefs, err := statusPrefs(rt); err == nil { |
| channel = prefs.Channel |
| switch { |
| case prefs.Pin != "": |
diff --git a/updater_test.go b/updater_test.go| index 5897281..0b6fde1 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -928,3 +928,41 @@ func TestUpdatePassRepointsHibernating(t *testing.T) { |
| } |
| assertLive("h1.hib", "new1234") |
| } |
| + |
| +func TestStatusPrefsReadsParkedDB(t *testing.T) { |
| + oldData := instanceDataDir |
| + instanceDataDir = t.TempDir() |
| + t.Cleanup(func() { instanceDataDir = oldData }) |
| + deadSocket := filepath.Join(t.TempDir(), "no.sock") |
| + |
| + // A sleeping pinned canary: nothing listens on its socket, its prefs live |
| + // only in the parked DB — status must show them, not "?" (an invisible pin |
| + // is how a fleet operation stomps a canary). |
| + db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for k, v := range map[string]string{"update_channel": "edge", "update_pin": "pin1234"} { |
| + if err := db.setConfig(k, v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + db.sql.Close() |
| + |
| + p, err := statusPrefs(Route{Host: "h1.hib", Hibernate: true, Socket: deadSocket}) |
| + if err != nil { |
| + t.Fatalf("statusPrefs: %v", err) |
| + } |
| + if p.Channel != "edge" || p.Pin != "pin1234" { |
| + t.Fatalf("prefs = %+v, want edge/pin1234", p) |
| + } |
| + |
| + // Fail-closed both ways: a sleeping host with no local DB stays an honest |
| + // "?", and a unit-managed host's dead socket never falls back to a DB read. |
| + if _, err := statusPrefs(Route{Host: "h2.hib", Hibernate: true, Socket: deadSocket}); err == nil { |
| + t.Fatal("expected an error for an absent parked DB") |
| + } |
| + if _, err := statusPrefs(Route{Host: "h1.hib", Hibernate: false, Socket: deadSocket}); err == nil { |
| + t.Fatal("expected an error for a dead socket on a unit-managed host") |
| + } |
| +} |