eleven / messenger Public

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>
Paul Campbell 1ca0c3d56dba269fb2a8d9f5062d1a31ec81a100 parent aa6709b
11 files changed, +396 −37
  • control.go +16 −0
  • db.go +14 −0
  • docs/instance-router.md +13 −2
  • main.go +15 −0
  • updatecheck.go +1 −0
  • updatecheck_test.go +38 −0
  • updater.go +117 −9
  • updater_test.go +102 −4
  • web/static/admin.js +34 −4
  • web/static/apps/admin.js +44 −17
  • web/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;