server: version pinning — a canary declares its own version (#386)
A pinned instance (update_pin, alongside the channel) is skipped by the auto-updater entirely and its build survives GC — without this, an hourly pass would stomp any manually-deployed canary back to the channel head. Pin beats channel; the pin is instance state (its own config, reported by /api/update-prefs, shown in its admin panel), not a registry side-channel. - lchat update pin <host> [<ver>] / unpin — writes through the instance's admin API over the unix socket (token from its EnvironmentFile) - lchat deploy --pin: deploy a canary and pin it in one command; a plain --version deploy onto a still-pinned host prints a stale-pin note - update status shows AUTO=pinned; updatePass logs 'pinned to X — left alone' - both admin UIs grow a 'Pinned to <ver>' entry in the channel picker; picking a real channel clears the pin and resumes updates (one write) - setSettings validates the pin up front (same atomic-validation rule)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+396
−37
control.go+16 −0db.go+14 −0docs/instance-router.md+13 −2main.go+15 −0updatecheck.go+1 −0updatecheck_test.go+38 −0updater.go+117 −9updater_test.go+102 −4web/static/admin.js+34 −4web/static/apps/admin.js+44 −17web/static/shell.js+2 −1
diff --git a/control.go b/control.go| index a786c5c..cc5d912 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -498,6 +498,7 @@ func runDeploy(args []string) { |
| unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix") |
| all := fs.Bool("all", false, "deploy every instance, one at a time") |
| verFlag := fs.String("version", "", "point the instance(s) at this staged version before restarting (see `lchat stage`)") |
| + pinFlag := fs.Bool("pin", false, "also pin the instance(s) to --version, so the auto-updater leaves them alone (the canary flow)") |
| _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| ver := strings.TrimSpace(*verFlag) |
| @@ -509,6 +510,9 @@ func runDeploy(args []string) { |
| die("deploy: version %q is not staged — run `lchat stage --file <binary>` first", ver) |
| } |
| } |
| + if *pinFlag && ver == "" { |
| + die("deploy: --pin requires --version (a pin names one exact build)") |
| + } |
| unlock, err := acquireDeployLock() |
| if err != nil { |
| @@ -560,6 +564,18 @@ func runDeploy(args []string) { |
| } else { |
| fmt.Printf("deployed %s (already on %s)\n", host, ver) |
| } |
| + if *pinFlag { |
| + // The deploy stands even if the pin write fails — say so precisely. |
| + if err := pinHost(rt, ver); err != nil { |
| + die("deploy %s: deployed @ %s but PINNING FAILED (%v) — the next update pass may move it; retry with `lchat update pin %s %s`", host, ver, err, host, ver) |
| + } |
| + fmt.Printf("pinned %s to %s (auto-updates paused)\n", host, ver) |
| + } else if prefs, err := fetchUpdatePrefs(rt.Socket); err == nil && prefs.Pin != "" && prefs.Pin != ver { |
| + // A stale pin silently freezes the instance on what was just |
| + // deployed — surface it so the operator decides. |
| + fmt.Printf("note: %s is still pinned to %s — auto-updates stay paused; `lchat update pin %s %s` to repin here, or `lchat update unpin %s` to resume its channel\n", |
| + host, prefs.Pin, host, ver, host) |
| + } |
| } |
| } |
diff --git a/db.go b/db.go| index 9a9f686..ebd5d79 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -133,6 +133,20 @@ func (db *DB) updateChannel() string { |
| } |
| func (db *DB) setUpdateChannel(c string) error { return db.setConfig("update_channel", c) } |
| +// updatePin, when set, freezes this instance on one exact version — the |
| +// auto-updater leaves a pinned instance alone entirely (pin beats channel). |
| +// This is how a canary declares "I'm deliberately on this build": without it, |
| +// an hourly pass would stomp any manually-deployed version back to the channel |
| +// head. Empty = not pinned; an unparseable stored value reads as unpinned. |
| +func (db *DB) updatePin() string { |
| + v, _, _ := db.getConfig("update_pin") |
| + if !validVersion(v) { |
| + return "" |
| + } |
| + return v |
| +} |
| +func (db *DB) setUpdatePin(v string) error { return db.setConfig("update_pin", v) } |
| + |
| // DB wraps the SQLite connection. Note: the server never stores message |
| // plaintext or any encryption key. Messages live here only as opaque |
| // ciphertext blobs produced by the browser. |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 2004cc9..4234d72 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -422,8 +422,19 @@ described in `docs/server-releases.md`: |
| - **Prefs are per-instance admin settings** (`update_auto`, `update_download`, |
| `update_channel` in the instance's own config table, set from the admin |
| consoles). Download-only means "staged, apply manually with |
| - `lchat deploy <host> --version <ver>`". **A pinned canary gets stomped like |
| - `deploy --all` would** — flip that instance's auto-update off first. |
| + `lchat deploy <host> --version <ver>`". |
| +- **Pinning (the canary story).** In addition to a channel, an instance can be |
| + pinned to one exact version (`update_pin`) — the updater skips a pinned |
| + instance entirely and its build survives GC, so an hourly pass never stomps |
| + a deliberately-deployed canary back to the channel head. Pin beats channel. |
| + The canary flow is one command: `lchat deploy test.example.com --version |
| + <ver> --pin` (or `lchat update pin <host> [<ver>]`, defaulting to what it |
| + runs now). The pin is *instance state* — written through the instance's own |
| + admin API (token from its EnvironmentFile), reported by `/api/update-prefs`, |
| + and shown in its admin panel as a "Pinned to <ver>" entry in the channel |
| + picker; the admin resumes updates by picking a channel (one write that sets |
| + the channel and clears the pin), or the operator runs `lchat update unpin |
| + <host>`. |
| - **A deploy flock** (`/opt/lchat/.deploy.lock`) serialises `deploy`, |
| `rollback`, and `update run` across processes; the loser errors immediately |
| ("another deploy/rollback/update is running") instead of interleaving the |
diff --git a/main.go b/main.go| index 7a215ee..59e29f6 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -2475,6 +2475,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| "update_auto": s.db.updateAuto(), |
| "update_download": s.db.updateDownload(), |
| "update_channel": s.db.updateChannel(), |
| + "update_pin": s.db.updatePin(), |
| "update": s.updateInfo(), |
| }) |
| } |
| @@ -2497,6 +2498,10 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| UpdateAuto *bool `json:"update_auto"` |
| UpdateDownload *bool `json:"update_download"` |
| UpdateChannel *string `json:"update_channel"` |
| + // Pin to one exact version ("" clears): the updater skips a pinned |
| + // instance entirely. Set by `lchat update pin` / `deploy --pin`; cleared |
| + // from the admin UI by picking a channel. |
| + UpdatePin *string `json:"update_pin"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -2508,6 +2513,10 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "unknown channel (edge, beta, or stable)") |
| return |
| } |
| + if body.UpdatePin != nil && *body.UpdatePin != "" && !validVersion(*body.UpdatePin) { |
| + httpError(w, http.StatusBadRequest, "invalid pin version") |
| + return |
| + } |
| if body.UserInvites != nil { |
| if err := s.db.setUsersCanInvite(*body.UserInvites); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| @@ -2544,6 +2553,12 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.UpdatePin != nil { |
| + if err := s.db.setUpdatePin(*body.UpdatePin); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_invites": s.db.usersCanInvite()}) |
| } |
diff --git a/updatecheck.go b/updatecheck.go| index 3b61e7b..e6a1e6c 100644 |
| --- a/updatecheck.go |
| +++ b/updatecheck.go |
| @@ -33,6 +33,7 @@ func (s *Server) getUpdatePrefs(w http.ResponseWriter, r *http.Request) { |
| "auto_update": s.db.updateAuto(), |
| "auto_download": s.db.updateDownload(), |
| "channel": s.db.updateChannel(), |
| + "pin": s.db.updatePin(), |
| }) |
| } |
diff --git a/updatecheck_test.go b/updatecheck_test.go| index 6b28610..4b7d661 100644 |
| --- a/updatecheck_test.go |
| +++ b/updatecheck_test.go |
| @@ -12,6 +12,44 @@ import ( |
| "time" |
| ) |
| +func TestUpdatePinSettings(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + |
| + // A pin must be a plausible version id (it becomes a store dir name). |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_pin":"../evil"}`); resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("bad pin: want 400, got %d", resp.StatusCode) |
| + } |
| + |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_pin":"abc1234"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("set pin: %d", resp.StatusCode) |
| + } |
| + var prefs struct { |
| + Channel string `json:"channel"` |
| + Pin string `json:"pin"` |
| + } |
| + json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) |
| + if prefs.Pin != "abc1234" { |
| + t.Fatalf("prefs pin = %q", prefs.Pin) |
| + } |
| + var settings struct { |
| + UpdatePin string `json:"update_pin"` |
| + } |
| + json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) |
| + if settings.UpdatePin != "abc1234" { |
| + t.Fatalf("settings pin = %q", settings.UpdatePin) |
| + } |
| + |
| + // The admin UI resumes updates by picking a channel — one combined write |
| + // that sets the channel and clears the pin. |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_channel":"beta","update_pin":""}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("clear pin: %d", resp.StatusCode) |
| + } |
| + json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) |
| + if prefs.Pin != "" || prefs.Channel != "beta" { |
| + t.Fatalf("after clear = %+v, want no pin on beta", prefs) |
| + } |
| +} |
| + |
| func TestUpdatePrefsDefaultsAndSettings(t *testing.T) { |
| ts, _ := newTestServer(t) |
diff --git a/updater.go b/updater.go| index 20ce644..8bcc39a 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -1,6 +1,7 @@ |
| package main |
| import ( |
| + "bytes" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| @@ -78,6 +79,9 @@ type updatePrefs struct { |
| AutoUpdate bool `json:"auto_update"` |
| AutoDownload bool `json:"auto_download"` |
| Channel string `json:"channel"` |
| + // Pin, when set, freezes the instance on that exact version — the updater |
| + // skips it entirely (pin beats channel). How a canary declares itself. |
| + Pin string `json:"pin"` |
| } |
| // fetchUpdatePrefs reads an instance's prefs over its unix socket. A binary |
| @@ -104,6 +108,9 @@ func fetchUpdatePrefs(socket string) (updatePrefs, error) { |
| if !validChannel(p.Channel) { |
| p.Channel = "stable" |
| } |
| + if !validVersion(p.Pin) { |
| + p.Pin = "" // a garbled pin must not freeze anything silently |
| + } |
| return p, nil |
| } |
| @@ -184,6 +191,9 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error { |
| // A version that failed its health check on one host isn't tried on the |
| // next this pass — one bad build shouldn't bounce every instance in turn. |
| poisoned := map[string]bool{} |
| + // Versions instances are pinned to must survive GC even when no channel or |
| + // registry row mentions them (a canary's branch build). |
| + var pinnedVers []string |
| for _, rt := range routes { |
| if rt.Kind != "instance" { |
| continue |
| @@ -197,6 +207,14 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error { |
| fmt.Printf(" %s: unreachable (%v) — skipped\n", rt.Host, err) |
| continue |
| } |
| + if prefs.Pin != "" { |
| + // Pinned: the instance declared its own version — leave it alone |
| + // entirely (this is what keeps a canary from being stomped back to |
| + // the channel head an hour after it's deployed). |
| + pinnedVers = append(pinnedVers, prefs.Pin) |
| + fmt.Printf(" %s: pinned to %s — left alone\n", rt.Host, prefs.Pin) |
| + continue |
| + } |
| e, ok := m.Channels[prefs.Channel] |
| if !ok { |
| fmt.Printf(" %s: channel %s has no release yet\n", rt.Host, prefs.Channel) |
| @@ -239,19 +257,23 @@ func updatePass(reg *Registry, unitTmpl string, dryRun bool) error { |
| } |
| } |
| if !dryRun { |
| - gcVersions(reg, m) |
| + gcVersions(reg, m, pinnedVers) |
| } |
| return nil |
| } |
| // gcVersions prunes version-store dirs nothing references anymore: kept are |
| // every route's current + previous version, every channel head, the control |
| -// binary's own build, and whatever any live symlink points at (belt and |
| -// braces — the symlinks are what units actually exec). Without this, hourly |
| -// edge updates would grow /opt/lchat/versions without bound on a box that |
| -// enforces a free-disk floor. Leftover crashed downloads are swept too. |
| -func gcVersions(reg *Registry, m *releaseManifest) { |
| +// binary's own build, every pinned version (keepExtra), and whatever any live |
| +// symlink points at (belt and braces — the symlinks are what units actually |
| +// exec). Without this, hourly edge updates would grow /opt/lchat/versions |
| +// without bound on a box that enforces a free-disk floor. Leftover crashed |
| +// downloads are swept too. |
| +func gcVersions(reg *Registry, m *releaseManifest, keepExtra []string) { |
| keep := map[string]bool{buildVersion: true} |
| + for _, v := range keepExtra { |
| + keep[v] = true |
| + } |
| routes, err := reg.list() |
| if err != nil { |
| return |
| @@ -294,18 +316,102 @@ func gcVersions(reg *Registry, m *releaseManifest) { |
| } |
| } |
| -// runUpdate: `lchat update run|status` — the auto-updater CLI. |
| +// runUpdate: `lchat update run|status|pin|unpin` — the auto-updater CLI. |
| func runUpdate(args []string) { |
| if len(args) == 0 { |
| - die("update: subcommand required: run | status") |
| + die("update: subcommand required: run | status | pin | unpin") |
| } |
| switch args[0] { |
| case "run": |
| runUpdateRun(args[1:]) |
| case "status": |
| runUpdateStatus(args[1:]) |
| + case "pin": |
| + runUpdatePin(args[1:], true) |
| + case "unpin": |
| + runUpdatePin(args[1:], false) |
| default: |
| - die("update: unknown subcommand %q (run | status)", args[0]) |
| + die("update: unknown subcommand %q (run | status | pin | unpin)", args[0]) |
| + } |
| +} |
| + |
| +// pinHost writes update_pin into the instance's own settings via its admin API |
| +// over the unix socket — the pin is instance state (the same value the admin |
| +// panel shows and /api/update-prefs reports), not a registry side-channel. The |
| +// admin token comes from the instance's EnvironmentFile, like `admin-link`. |
| +func pinHost(rt *Route, pin string) error { |
| + env, err := readEnvFile(filepath.Join(instanceDataDir, rt.Host+".env")) |
| + if err != nil { |
| + return err |
| + } |
| + tok := env["LCHAT_ADMIN_TOKEN"] |
| + if tok == "" { |
| + return fmt.Errorf("no LCHAT_ADMIN_TOKEN in %s/%s.env", instanceDataDir, rt.Host) |
| + } |
| + body, err := json.Marshal(map[string]string{"update_pin": pin}) |
| + if err != nil { |
| + return err |
| + } |
| + req, err := http.NewRequest("POST", "http://lchat/api/admin/settings", bytes.NewReader(body)) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := unixHTTPClient(rt.Socket).Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return fmt.Errorf("settings write: %s", resp.Status) |
| + } |
| + return nil |
| +} |
| + |
| +// runUpdatePin: `lchat update pin <host> [<ver>]` freezes an instance on a |
| +// version (default: whatever it's running now); `unpin` resumes its channel. |
| +func runUpdatePin(args []string, pin bool) { |
| + cmd := "unpin" |
| + if pin { |
| + cmd = "pin" |
| + } |
| + fs := flag.NewFlagSet("update "+cmd, flag.ExitOnError) |
| + registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + |
| + host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| + if !validHost(host) { |
| + die("update %s: invalid host %q", cmd, host) |
| + } |
| + reg, err := openRegistry(*registryPath) |
| + if err != nil { |
| + die("update %s: open registry: %v", cmd, err) |
| + } |
| + rt, err := reg.get(host) |
| + if err != nil || rt == nil || rt.Kind != "instance" { |
| + die("update %s: %s is not a managed instance", cmd, host) |
| + } |
| + |
| + ver := "" |
| + if pin { |
| + ver = strings.TrimSpace(fs.Arg(1)) |
| + if ver == "" { |
| + // Default to what the instance is running — "freeze me here". |
| + if ver, _ = liveVersion(host); ver == "" { |
| + die("update pin: %s has no live version — pass one explicitly", host) |
| + } |
| + } |
| + if !validVersion(ver) { |
| + die("update pin: invalid version %q", ver) |
| + } |
| + } |
| + if err := pinHost(rt, ver); err != nil { |
| + die("update %s %s: %v", cmd, host, err) |
| + } |
| + if pin { |
| + 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) |
| + } else { |
| + fmt.Printf("unpinned %s (back on its channel from the next update pass)\n", host) |
| } |
| } |
| @@ -356,6 +462,8 @@ func runUpdateStatus(args []string) { |
| if prefs, err := fetchUpdatePrefs(rt.Socket); err == nil { |
| channel = prefs.Channel |
| switch { |
| + case prefs.Pin != "": |
| + auto = "pinned" // pin beats channel; the updater skips this host |
| case prefs.AutoDownload && prefs.AutoUpdate: |
| auto = "dl+upd" |
| case prefs.AutoDownload: |
diff --git a/updater_test.go b/updater_test.go| index 0ec1c16..14edd4a 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/hex" |
| + "encoding/json" |
| "net" |
| "net/http" |
| "net/http/httptest" |
| @@ -39,7 +40,7 @@ func startFakeInstance(t *testing.T, host string, prefsCode int, prefs updatePre |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel, |
| + "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel, "pin": prefs.Pin, |
| }) |
| }) |
| mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { |
| @@ -307,6 +308,49 @@ func TestUpdatePassDryRunTouchesNothing(t *testing.T) { |
| } |
| } |
| +func TestUpdatePassSkipsPinnedInstance(t *testing.T) { |
| + withTempOpt(t) |
| + calls := stubSystemctl(t) |
| + |
| + _, setChannels := startFeed(t, map[string]string{"new1234": "channel-head"}) |
| + setChannels(map[string]string{"stable": "new1234"}) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Pinned to a branch build no channel or registry row mentions (the canary |
| + // case) — while the stable channel points somewhere newer. |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusOK, |
| + updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable", Pin: "pin7777"}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + bin := filepath.Join(t.TempDir(), "b") |
| + writeFakeBinary(t, bin, "canary-build") |
| + if err := stageVersion(bin, "pin7777"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := updatePass(reg, "lchat@", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Left alone entirely: no restart, no symlink move, no channel download. |
| + if len(*calls) != 0 { |
| + t.Fatalf("pinned instance was touched: %v", *calls) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "old1111" { |
| + t.Fatalf("pinned instance moved to %q", v) |
| + } |
| + if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) { |
| + t.Fatal("channel head downloaded for a pinned instance") |
| + } |
| + // And its pinned build survived the pass's GC despite being orphaned |
| + // from the registry/manifest point of view. |
| + if _, err := os.Stat(versionBinPath("pin7777")); err != nil { |
| + t.Fatal("GC pruned the pinned version") |
| + } |
| +} |
| + |
| func TestGCVersionsKeepsEverythingReferenced(t *testing.T) { |
| withTempOpt(t) |
| @@ -316,7 +360,7 @@ func TestGCVersionsKeepsEverythingReferenced(t *testing.T) { |
| } |
| bin := filepath.Join(t.TempDir(), "b") |
| writeFakeBinary(t, bin, "x") |
| - for _, v := range []string{"cur1", "prev1", "head1", "orphan1", "orphan2", "linked1"} { |
| + for _, v := range []string{"cur1", "prev1", "head1", "orphan1", "orphan2", "linked1", "pinned9"} { |
| if err := stageVersion(bin, v); err != nil { |
| t.Fatal(err) |
| } |
| @@ -328,9 +372,11 @@ func TestGCVersionsKeepsEverythingReferenced(t *testing.T) { |
| } |
| m := &releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: "head1"}}} |
| - gcVersions(reg, m) |
| + // pinned9 is referenced only by an instance's pin (keepExtra) — a canary's |
| + // branch build that no registry row or channel mentions. |
| + gcVersions(reg, m, []string{"pinned9"}) |
| - for _, keep := range []string{"cur1", "prev1", "head1", "linked1"} { |
| + for _, keep := range []string{"cur1", "prev1", "head1", "linked1", "pinned9"} { |
| if _, err := os.Stat(versionBinPath(keep)); err != nil { |
| t.Errorf("gc pruned %s (should be kept)", keep) |
| } |
| @@ -344,6 +390,58 @@ func TestGCVersionsKeepsEverythingReferenced(t *testing.T) { |
| // (No dir staged for it here; just assert gc didn't error on its absence.) |
| } |
| +func TestPinHostWritesViaAdminAPI(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "lch") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + |
| + // pinHost reads the admin token from the instance's EnvironmentFile. |
| + prevDataDir := instanceDataDir |
| + instanceDataDir = dir |
| + t.Cleanup(func() { instanceDataDir = prevDataDir }) |
| + if err := os.WriteFile(filepath.Join(dir, "h1.example.com.env"), []byte("LCHAT_ADMIN_TOKEN=tok123\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var gotAuth, gotPin string |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/admin/settings", func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + var body struct { |
| + UpdatePin *string `json:"update_pin"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&body) |
| + if body.UpdatePin != nil { |
| + gotPin = *body.UpdatePin |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + }) |
| + socket := filepath.Join(dir, "h1.sock") |
| + ln, err := net.Listen("unix", socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := &http.Server{Handler: mux} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + rt := &Route{Host: "h1.example.com", Kind: "instance", Socket: socket} |
| + if err := pinHost(rt, "pin7777"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gotAuth != "Bearer tok123" || gotPin != "pin7777" { |
| + t.Fatalf("auth %q pin %q", gotAuth, gotPin) |
| + } |
| + if err := pinHost(rt, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gotPin != "" { |
| + t.Fatalf("unpin sent %q", gotPin) |
| + } |
| +} |
| + |
| func TestAcquireDeployLockExcludes(t *testing.T) { |
| withTempOpt(t) |
| unlock, err := acquireDeployLock() |
diff --git a/web/static/admin.js b/web/static/admin.js| index 6433e22..502ceeb 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -88,7 +88,9 @@ async function wireUpdates() { |
| const renderStatus = (s) => { |
| const u = s.update || {}; |
| let line = `Running build ${u.build || "unknown"}`; |
| - if (u.available_version && u.available_version !== u.build) { |
| + if (s.update_pin) { |
| + line += ` — pinned to ${s.update_pin}; automatic updates are paused (pick a channel to resume)`; |
| + } else if (u.available_version && u.available_version !== u.build) { |
| line += ` — ${u.available_version} is available on ${s.update_channel}`; |
| } else if (u.checked_at) { |
| line += ` — up to date on ${s.update_channel}`; |
| @@ -98,12 +100,31 @@ async function wireUpdates() { |
| status.textContent = line + "."; |
| }; |
| + // A pin shows as an extra selected option in the channel picker; choosing a |
| + // real channel clears it (one combined write) and the option disappears. |
| + const PINNED = "__pinned__"; |
| + let pinnedOpt = null; |
| + const showPinned = (pin) => { |
| + if (pinnedOpt) { |
| + pinnedOpt.remove(); |
| + pinnedOpt = null; |
| + } |
| + if (pin) { |
| + pinnedOpt = document.createElement("option"); |
| + pinnedOpt.value = PINNED; |
| + pinnedOpt.textContent = `Pinned to ${pin}`; |
| + chan.appendChild(pinnedOpt); |
| + chan.value = PINNED; |
| + } |
| + }; |
| + |
| let prevChannel = "stable"; |
| try { |
| const s = await (await api("GET", "/api/admin/settings")).json(); |
| auto.checked = !!s.update_auto; |
| dl.checked = !!s.update_download; |
| chan.value = prevChannel = s.update_channel || "stable"; |
| + showPinned(s.update_pin); |
| renderStatus(s); |
| } catch {} |
| @@ -111,18 +132,27 @@ async function wireUpdates() { |
| const res = await api("POST", "/api/admin/settings", body); |
| if (!res.ok) { |
| revert(); |
| - return flash("Couldn't save that", true); |
| + flash("Couldn't save that", true); |
| + return false; |
| } |
| - prevChannel = chan.value; |
| // Re-read so the status line reflects the (possibly new) channel at once. |
| try { |
| renderStatus(await (await api("GET", "/api/admin/settings")).json()); |
| } catch {} |
| flash("Saved"); |
| + return true; |
| }; |
| auto.onchange = () => save({ update_auto: auto.checked }, () => (auto.checked = !auto.checked)); |
| dl.onchange = () => save({ update_download: dl.checked }, () => (dl.checked = !dl.checked)); |
| - chan.onchange = () => save({ update_channel: chan.value }, () => (chan.value = prevChannel)); |
| + chan.onchange = async () => { |
| + if (chan.value === PINNED) return; // pinning happens via deploy tooling, not here |
| + const revert = () => (chan.value = pinnedOpt ? PINNED : prevChannel); |
| + // Picking a channel also clears any pin — that's how updates resume. |
| + if (await save({ update_channel: chan.value, update_pin: "" }, revert)) { |
| + prevChannel = chan.value; |
| + showPinned(""); |
| + } |
| + }; |
| } |
| // ---- this space (name + optional administrator identity) ---------------- |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index 7c32725..a938e54 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -274,10 +274,12 @@ export const app = defineApp({ |
| updSec.appendChild(chanRow); |
| const updStatus = statusLine(updSec); |
| - const paintUpdateInfo = (update, channel) => { |
| + const paintUpdateInfo = (update, channel, pin) => { |
| const u = update || {}; |
| let line = `Running build ${u.build || "unknown"}`; |
| - if (u.available_version && u.available_version !== u.build) { |
| + if (pin) { |
| + line += ` — pinned to ${pin}; automatic updates are paused (pick a channel to resume)`; |
| + } else if (u.available_version && u.available_version !== u.build) { |
| line += ` — ${u.available_version} is available on ${channel}`; |
| } else if (u.checked_at) { |
| line += ` — up to date on ${channel}`; |
| @@ -287,14 +289,33 @@ export const app = defineApp({ |
| updInfo.textContent = line + "."; |
| }; |
| - const updState = { update: {}, channel: "stable" }; |
| + // A pin shows as an extra selected option in the channel picker; choosing |
| + // a real channel clears it (one combined write) and the option disappears. |
| + const PINNED = "__pinned__"; |
| + let pinnedOpt = null; |
| + const showPinned = (pin) => { |
| + if (pinnedOpt) { |
| + pinnedOpt.remove(); |
| + pinnedOpt = null; |
| + } |
| + if (pin) { |
| + pinnedOpt = document.createElement("option"); |
| + pinnedOpt.value = PINNED; |
| + pinnedOpt.textContent = `Pinned to ${pin}`; |
| + chanSelect.appendChild(pinnedOpt); |
| + chanSelect.value = PINNED; |
| + } |
| + }; |
| + |
| + const updState = { update: {}, channel: "stable", pin: "" }; |
| const saveUpdatePref = async (patch, revert, saved) => { |
| if (await bridge.setUpdatePrefs(patch)) { |
| flash(updStatus, saved); |
| - } else { |
| - revert(); |
| - flash(updStatus, "Couldn't save — try again."); |
| + return true; |
| } |
| + revert(); |
| + flash(updStatus, "Couldn't save — try again."); |
| + return false; |
| }; |
| autoCheck.onchange = () => |
| saveUpdatePref( |
| @@ -308,18 +329,22 @@ export const app = defineApp({ |
| () => (dlCheck.checked = !dlCheck.checked), |
| dlCheck.checked ? "New versions will download automatically." : "New versions won't download automatically.", |
| ); |
| - chanSelect.onchange = () => { |
| + chanSelect.onchange = async () => { |
| + if (chanSelect.value === PINNED) return; // pinning happens via deploy tooling, not here |
| const prev = updState.channel; |
| - saveUpdatePref( |
| - { update_channel: chanSelect.value }, |
| - () => (chanSelect.value = prev), |
| + const revert = () => (chanSelect.value = pinnedOpt ? PINNED : prev); |
| + // Picking a channel also clears any pin — that's how updates resume. |
| + const ok = await saveUpdatePref( |
| + { update_channel: chanSelect.value, update_pin: "" }, |
| + revert, |
| `Now on the ${chanSelect.value} channel.`, |
| - ).then(() => { |
| - if (chanSelect.value !== prev) { |
| - updState.channel = chanSelect.value; |
| - paintUpdateInfo(updState.update, updState.channel); |
| - } |
| - }); |
| + ); |
| + if (ok) { |
| + updState.channel = chanSelect.value; |
| + updState.pin = ""; |
| + showPinned(""); |
| + paintUpdateInfo(updState.update, updState.channel, ""); |
| + } |
| }; |
| // ---- Photo ---------------------------------------------------------------- |
| @@ -383,8 +408,10 @@ export const app = defineApp({ |
| dlCheck.checked = !!info.updateDownload; |
| updState.channel = info.updateChannel || "stable"; |
| updState.update = info.update || {}; |
| + updState.pin = info.updatePin || ""; |
| chanSelect.value = updState.channel; |
| - paintUpdateInfo(updState.update, updState.channel); |
| + showPinned(updState.pin); |
| + paintUpdateInfo(updState.update, updState.channel, updState.pin); |
| photoState.alt = info.avatarAlt || ""; |
| photoImg.alt = photoState.alt; |
| altInput.value = photoState.alt; |
diff --git a/web/static/shell.js b/web/static/shell.js| index ac2fcd0..6b64660 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3049,6 +3049,7 @@ setSetupBridge({ |
| updateAuto: !!s.update_auto, |
| updateDownload: !!s.update_download, |
| updateChannel: s.update_channel || "stable", |
| + updatePin: s.update_pin || "", |
| update: s.update || {}, |
| }; |
| } catch { |
| @@ -3056,7 +3057,7 @@ setSetupBridge({ |
| } |
| }, |
| // Auto-update prefs — a partial update; pass only the changed field |
| - // (update_auto / update_download / update_channel). |
| + // (update_auto / update_download / update_channel / update_pin). |
| setUpdatePrefs: async (patch) => { |
| try { |
| return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify(patch) })).ok; |