a73x

752d49c4

feat(fleet): per-host agent upgrades from the web console

a73x   2026-07-26 11:39

Commit message
feat(fleet): per-host agent upgrades from the web console

Every binary is version-stamped (--version; ldflags via make and deploy).
The agent reports its version in Hello facts; the registry holds it
connect-owned like Sessions, never persisted. The server discovers the
latest release from the eitri.sh manifest (release_manifest_url: absent
uses the eitri.sh default, "" disables; a warm 15m retry until the first
fetch lands, then daily; bounded read) and serves
server_version/latest_version over SSE plus
agent_version/agent_update_available per host.

Upgrades are per-host clicks — the human is the rollout controller.
POST /api/v1/hosts/{id}/upgrade-agent (fleet-gated, audited) records an
in-memory offer that rides only that host's DesiredStateSnapshot
(AgentUpgrade{version,url,sha256}); a Hello reporting the target version
clears it, decommission clears it unconditionally, and a server restart
forgets it — re-clicking is idempotent. The agent self-updates: download
beside the binary, streaming sha256 verify, fsync-durable temp/.prev/dir
writes, the current binary preserved as .prev, atomic rename, re-exec.
A retry after a post-swap exec failure is idempotent (exe already at the
target sha goes straight to exec) so .prev is never clobbered. Unstamped
"dev" builds never upgrade, and an agent ahead of the manifest is left
alone — equality is the reconcile condition.

Web console: a version column and per-host Upgrade button (the SSE
stream flips the column when the agent reconnects at the new version),
plus a banner linking the manual server-upgrade flow when the server
itself is behind.

Makefile
Old New
@@ -29,11 +29,15 @@ web:
29 find $(WEB_DIST) -mindepth 1 ! -name .gitkeep -delete 29 find $(WEB_DIST) -mindepth 1 ! -name .gitkeep -delete
30 cp -r web/build/. $(WEB_DIST)/ 30 cp -r web/build/. $(WEB_DIST)/
31 31
32 # Version stamp baked into every binary (internal/version.Version).
33 VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
34 GO_LDFLAGS := -ldflags "-X github.com/a73x/eitri/internal/version.Version=$(VERSION)"
35
32 build: web 36 build: web
33 go build -o $(BIN)/eitri-server ./cmd/eitri-server 37 go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server
34 go build -o $(BIN)/eitri-agent ./cmd/eitri-agent 38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent
35 go build -o $(BIN)/eitri-mcp ./cmd/eitri-mcp 39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
36 go build -o $(BIN)/eitri-smoke ./cmd/eitri-smoke 40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke
37 41
38 test: 42 test:
39 go test -race ./... 43 go test -race ./...
README.md
Old New
@@ -25,7 +25,9 @@ eitri is built around a single desired-state loop, the same shape as a kubelet:
25 goroutine that creates, converges, or tears down that one guest, so a slow 25 goroutine that creates, converges, or tears down that one guest, so a slow
26 operation on one VM never stalls the others or the host's heartbeat. It reports 26 operation on one VM never stalls the others or the host's heartbeat. It reports
27 the actual state back on the same stream; that report doubles as the heartbeat, 27 the actual state back on the same stream; that report doubles as the heartbeat,
28 and the control plane marks a host offline after ~30s of silence. 28 and the control plane marks a host offline after ~30s of silence. Each agent
29 reports its binary version and can be upgraded per host from the fleet
30 console, which also signals when a newer eitri release is available.
29 - **State is desired-state, not RPC.** The loop is level-triggered: a failed step 31 - **State is desired-state, not RPC.** The loop is level-triggered: a failed step
30 is retried on the next tick, and a host that reconnects re-derives everything 32 is retried on the next tick, and a host that reconnects re-derives everything
31 from persisted records plus what it observes on the box. 33 from persisted records plus what it observes on the box.
cmd/eitri-agent/main.go
Old New
@@ -26,6 +26,7 @@ import (
26 "github.com/a73x/eitri/internal/agent/syncclient" 26 "github.com/a73x/eitri/internal/agent/syncclient"
27 "github.com/a73x/eitri/internal/covsnap" 27 "github.com/a73x/eitri/internal/covsnap"
28 "github.com/a73x/eitri/internal/joinblob" 28 "github.com/a73x/eitri/internal/joinblob"
29 "github.com/a73x/eitri/internal/version"
29 ) 30 )
30 31
31 // agentConfig carries runAgent's wiring, replacing a long positional list. 32 // agentConfig carries runAgent's wiring, replacing a long positional list.
@@ -43,6 +44,10 @@ type agentConfig struct {
43 } 44 }
44 45
45 func main() { 46 func main() {
47 if len(os.Args) > 1 && os.Args[1] == "--version" {
48 fmt.Println(version.Version)
49 return
50 }
46 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 51 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
47 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") 52 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
48 firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") 53 firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
cmd/eitri-mcp/main.go
Old New
@@ -21,11 +21,16 @@ import (
21 "github.com/a73x/eitri/internal/gateclient" 21 "github.com/a73x/eitri/internal/gateclient"
22 "github.com/a73x/eitri/internal/mcpserver" 22 "github.com/a73x/eitri/internal/mcpserver"
23 "github.com/a73x/eitri/internal/server/api/client" 23 "github.com/a73x/eitri/internal/server/api/client"
24 "github.com/a73x/eitri/internal/version"
24 "github.com/modelcontextprotocol/go-sdk/mcp" 25 "github.com/modelcontextprotocol/go-sdk/mcp"
25 "golang.org/x/crypto/ssh" 26 "golang.org/x/crypto/ssh"
26 ) 27 )
27 28
28 func main() { 29 func main() {
30 if len(os.Args) > 1 && os.Args[1] == "--version" {
31 fmt.Println(version.Version)
32 return
33 }
29 defaultCfg := "~/.config/eitri-mcp/config.json" 34 defaultCfg := "~/.config/eitri-mcp/config.json"
30 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" { 35 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" {
31 defaultCfg = env 36 defaultCfg = env
cmd/eitri-server/main.go
Old New
@@ -7,6 +7,7 @@ import (
7 "encoding/json" 7 "encoding/json"
8 "errors" 8 "errors"
9 "flag" 9 "flag"
10 "fmt"
10 "log/slog" 11 "log/slog"
11 "net/http" 12 "net/http"
12 "os" 13 "os"
@@ -22,10 +23,12 @@ import (
22 "github.com/a73x/eitri/internal/server/health" 23 "github.com/a73x/eitri/internal/server/health"
23 "github.com/a73x/eitri/internal/server/hub" 24 "github.com/a73x/eitri/internal/server/hub"
24 "github.com/a73x/eitri/internal/server/registry" 25 "github.com/a73x/eitri/internal/server/registry"
26 "github.com/a73x/eitri/internal/server/release"
25 "github.com/a73x/eitri/internal/server/store" 27 "github.com/a73x/eitri/internal/server/store"
26 "github.com/a73x/eitri/internal/server/syncsvc" 28 "github.com/a73x/eitri/internal/server/syncsvc"
27 "github.com/a73x/eitri/internal/server/web" 29 "github.com/a73x/eitri/internal/server/web"
28 "github.com/a73x/eitri/internal/transport" 30 "github.com/a73x/eitri/internal/transport"
31 "github.com/a73x/eitri/internal/version"
29 "github.com/quic-go/quic-go" 32 "github.com/quic-go/quic-go"
30 ) 33 )
31 34
@@ -61,6 +64,10 @@ func parseDurationCfg(name, raw string, def time.Duration, valid func(time.Durat
61 type config = serverconfig.Config 64 type config = serverconfig.Config
62 65
63 func main() { 66 func main() {
67 if len(os.Args) > 1 && os.Args[1] == "--version" {
68 fmt.Println(version.Version)
69 return
70 }
64 cfgPath := flag.String("config", "/etc/eitri/server.json", "config file") 71 cfgPath := flag.String("config", "/etc/eitri/server.json", "config file")
65 flag.Parse() 72 flag.Parse()
66 raw, err := os.ReadFile(*cfgPath) 73 raw, err := os.ReadFile(*cfgPath)
@@ -235,6 +242,20 @@ func main() {
235 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 242 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
236 defer stop() 243 defer stop()
237 244
245 // Release discovery: absent field ⇒ eitri.sh default; explicit "" disables.
246 manifestURL := "https://eitri.sh/dl/latest/manifest.json"
247 if cfg.ReleaseManifestURL != nil {
248 manifestURL = *cfg.ReleaseManifestURL
249 }
250 if manifestURL != "" {
251 rel := release.New(manifestURL)
252 go rel.Poll(ctx, 24*time.Hour, func(err error) {
253 slog.Warn("release manifest refresh failed", "err", err)
254 })
255 a.SetReleaseSource(rel)
256 }
257 a.SetAgentUpgrader(svc)
258
238 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with 259 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with
239 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage 260 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage
240 // from the live server without bouncing the process. 261 // from the live server without bouncing the process.
cmd/eitri-smoke/main.go
Old New
@@ -14,9 +14,14 @@ import (
14 "time" 14 "time"
15 15
16 "github.com/a73x/eitri/internal/server/api/client" 16 "github.com/a73x/eitri/internal/server/api/client"
17 "github.com/a73x/eitri/internal/version"
17 ) 18 )
18 19
19 func main() { 20 func main() {
21 if len(os.Args) > 1 && os.Args[1] == "--version" {
22 fmt.Println(version.Version)
23 return
24 }
20 if err := run(); err != nil { 25 if err := run(); err != nil {
21 fmt.Fprintln(os.Stderr, "eitri-smoke:", err) 26 fmt.Fprintln(os.Stderr, "eitri-smoke:", err)
22 os.Exit(1) 27 os.Exit(1)
docs/openapi.json
Old New
@@ -151,6 +151,12 @@
151 }, 151 },
152 "Host": { 152 "Host": {
153 "properties": { 153 "properties": {
154 "agent_update_available": {
155 "type": "boolean"
156 },
157 "agent_version": {
158 "type": "string"
159 },
154 "allocated": { 160 "allocated": {
155 "$ref": "#/components/schemas/Capacity" 161 "$ref": "#/components/schemas/Capacity"
156 }, 162 },
@@ -234,6 +240,8 @@
234 } 240 }
235 }, 241 },
236 "required": [ 242 "required": [
243 "agent_update_available",
244 "agent_version",
237 "allocated", 245 "allocated",
238 "arch", 246 "arch",
239 "bridge_cidr", 247 "bridge_cidr",
@@ -359,6 +367,12 @@
359 }, 367 },
360 "type": "array" 368 "type": "array"
361 }, 369 },
370 "latest_version": {
371 "type": "string"
372 },
373 "server_version": {
374 "type": "string"
375 },
362 "vms": { 376 "vms": {
363 "items": { 377 "items": {
364 "$ref": "#/components/schemas/VM" 378 "$ref": "#/components/schemas/VM"
@@ -368,6 +382,8 @@
368 }, 382 },
369 "required": [ 383 "required": [
370 "hosts", 384 "hosts",
385 "latest_version",
386 "server_version",
371 "vms" 387 "vms"
372 ], 388 ],
373 "type": "object" 389 "type": "object"
@@ -776,6 +792,41 @@
776 "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled." 792 "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled."
777 } 793 }
778 }, 794 },
795 "/api/v1/hosts/{id}/upgrade-agent": {
796 "post": {
797 "parameters": [
798 {
799 "in": "path",
800 "name": "id",
801 "required": true,
802 "schema": {
803 "type": "string"
804 }
805 }
806 ],
807 "responses": {
808 "202": {
809 "description": "success"
810 },
811 "default": {
812 "content": {
813 "text/plain": {
814 "schema": {
815 "type": "string"
816 }
817 }
818 },
819 "description": "error (plain text)"
820 }
821 },
822 "security": [
823 {
824 "adminToken": []
825 }
826 ],
827 "summary": "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout)."
828 }
829 },
779 "/api/v1/ssh-ca": { 830 "/api/v1/ssh-ca": {
780 "get": { 831 "get": {
781 "responses": { 832 "responses": {
docs/shape.html
Old New
@@ -67,7 +67,8 @@
67 "internal/agent/state", 67 "internal/agent/state",
68 "internal/agent/syncclient", 68 "internal/agent/syncclient",
69 "internal/covsnap", 69 "internal/covsnap",
70 "internal/joinblob" 70 "internal/joinblob",
71 "internal/version"
71 ] 72 ]
72 }, 73 },
73 { 74 {
@@ -85,7 +86,8 @@
85 "imports": [ 86 "imports": [
86 "internal/gateclient", 87 "internal/gateclient",
87 "internal/mcpserver", 88 "internal/mcpserver",
88 "internal/server/api/client" 89 "internal/server/api/client",
90 "internal/version"
89 ] 91 ]
90 }, 92 },
91 { 93 {
@@ -100,12 +102,14 @@
100 "internal/server/health", 102 "internal/server/health",
101 "internal/server/hub", 103 "internal/server/hub",
102 "internal/server/registry", 104 "internal/server/registry",
105 "internal/server/release",
103 "internal/server/sshca", 106 "internal/server/sshca",
104 "internal/server/sshgate", 107 "internal/server/sshgate",
105 "internal/server/store", 108 "internal/server/store",
106 "internal/server/syncsvc", 109 "internal/server/syncsvc",
107 "internal/server/web", 110 "internal/server/web",
108 "internal/transport" 111 "internal/transport",
112 "internal/version"
109 ] 113 ]
110 }, 114 },
111 { 115 {
@@ -122,7 +126,8 @@
122 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", 126 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.",
123 "imports": [ 127 "imports": [
124 "internal/gateclient", 128 "internal/gateclient",
125 "internal/server/api/client" 129 "internal/server/api/client",
130 "internal/version"
126 ] 131 ]
127 }, 132 },
128 { 133 {
@@ -204,6 +209,12 @@
204 "imports": [] 209 "imports": []
205 }, 210 },
206 { 211 {
212 "importPath": "internal/agent/selfupdate",
213 "plane": "data",
214 "synopsis": "Package selfupdate replaces the running agent binary with a server-instructed release and re-execs.",
215 "imports": []
216 },
217 {
207 "importPath": "internal/agent/serialpump", 218 "importPath": "internal/agent/serialpump",
208 "plane": "data", 219 "plane": "data",
209 "synopsis": "Package serialpump owns the durability of VM serial consoles.", 220 "synopsis": "Package serialpump owns the durability of VM serial consoles.",
@@ -223,9 +234,11 @@
223 "internal/agent/exec", 234 "internal/agent/exec",
224 "internal/agent/hostinfo", 235 "internal/agent/hostinfo",
225 "internal/agent/reconcile", 236 "internal/agent/reconcile",
237 "internal/agent/selfupdate",
226 "internal/agent/state", 238 "internal/agent/state",
227 "internal/pb", 239 "internal/pb",
228 "internal/transport" 240 "internal/transport",
241 "internal/version"
229 ] 242 ]
230 }, 243 },
231 { 244 {
@@ -299,8 +312,10 @@
299 "internal/server/hosttoken", 312 "internal/server/hosttoken",
300 "internal/server/hub", 313 "internal/server/hub",
301 "internal/server/registry", 314 "internal/server/registry",
315 "internal/server/release",
302 "internal/server/sshca", 316 "internal/server/sshca",
303 "internal/server/store" 317 "internal/server/store",
318 "internal/version"
304 ] 319 ]
305 }, 320 },
306 { 321 {
@@ -357,6 +372,12 @@
357 "imports": [] 372 "imports": []
358 }, 373 },
359 { 374 {
375 "importPath": "internal/server/release",
376 "plane": "control",
377 "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.",
378 "imports": []
379 },
380 {
360 "importPath": "internal/server/sshca", 381 "importPath": "internal/server/sshca",
361 "plane": "control", 382 "plane": "control",
362 "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", 383 "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.",
@@ -407,6 +428,12 @@
407 "plane": "wire", 428 "plane": "wire",
408 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 429 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
409 "imports": [] 430 "imports": []
431 },
432 {
433 "importPath": "internal/version",
434 "plane": "wire",
435 "synopsis": "Package version carries the build-stamped eitri version, set via -ldflags \"-X github.com/a73x/eitri/internal/version.Version=v0.0.2\".",
436 "imports": []
410 } 437 }
411 ] 438 ]
412 } 439 }
docs/shape.json
Old New
@@ -16,7 +16,8 @@
16 "internal/agent/state", 16 "internal/agent/state",
17 "internal/agent/syncclient", 17 "internal/agent/syncclient",
18 "internal/covsnap", 18 "internal/covsnap",
19 "internal/joinblob" 19 "internal/joinblob",
20 "internal/version"
20 ] 21 ]
21 }, 22 },
22 { 23 {
@@ -34,7 +35,8 @@
34 "imports": [ 35 "imports": [
35 "internal/gateclient", 36 "internal/gateclient",
36 "internal/mcpserver", 37 "internal/mcpserver",
37 "internal/server/api/client" 38 "internal/server/api/client",
39 "internal/version"
38 ] 40 ]
39 }, 41 },
40 { 42 {
@@ -49,12 +51,14 @@
49 "internal/server/health", 51 "internal/server/health",
50 "internal/server/hub", 52 "internal/server/hub",
51 "internal/server/registry", 53 "internal/server/registry",
54 "internal/server/release",
52 "internal/server/sshca", 55 "internal/server/sshca",
53 "internal/server/sshgate", 56 "internal/server/sshgate",
54 "internal/server/store", 57 "internal/server/store",
55 "internal/server/syncsvc", 58 "internal/server/syncsvc",
56 "internal/server/web", 59 "internal/server/web",
57 "internal/transport" 60 "internal/transport",
61 "internal/version"
58 ] 62 ]
59 }, 63 },
60 { 64 {
@@ -71,7 +75,8 @@
71 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", 75 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.",
72 "imports": [ 76 "imports": [
73 "internal/gateclient", 77 "internal/gateclient",
74 "internal/server/api/client" 78 "internal/server/api/client",
79 "internal/version"
75 ] 80 ]
76 }, 81 },
77 { 82 {
@@ -153,6 +158,12 @@
153 "imports": [] 158 "imports": []
154 }, 159 },
155 { 160 {
161 "importPath": "internal/agent/selfupdate",
162 "plane": "data",
163 "synopsis": "Package selfupdate replaces the running agent binary with a server-instructed release and re-execs.",
164 "imports": []
165 },
166 {
156 "importPath": "internal/agent/serialpump", 167 "importPath": "internal/agent/serialpump",
157 "plane": "data", 168 "plane": "data",
158 "synopsis": "Package serialpump owns the durability of VM serial consoles.", 169 "synopsis": "Package serialpump owns the durability of VM serial consoles.",
@@ -172,9 +183,11 @@
172 "internal/agent/exec", 183 "internal/agent/exec",
173 "internal/agent/hostinfo", 184 "internal/agent/hostinfo",
174 "internal/agent/reconcile", 185 "internal/agent/reconcile",
186 "internal/agent/selfupdate",
175 "internal/agent/state", 187 "internal/agent/state",
176 "internal/pb", 188 "internal/pb",
177 "internal/transport" 189 "internal/transport",
190 "internal/version"
178 ] 191 ]
179 }, 192 },
180 { 193 {
@@ -248,8 +261,10 @@
248 "internal/server/hosttoken", 261 "internal/server/hosttoken",
249 "internal/server/hub", 262 "internal/server/hub",
250 "internal/server/registry", 263 "internal/server/registry",
264 "internal/server/release",
251 "internal/server/sshca", 265 "internal/server/sshca",
252 "internal/server/store" 266 "internal/server/store",
267 "internal/version"
253 ] 268 ]
254 }, 269 },
255 { 270 {
@@ -306,6 +321,12 @@
306 "imports": [] 321 "imports": []
307 }, 322 },
308 { 323 {
324 "importPath": "internal/server/release",
325 "plane": "control",
326 "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.",
327 "imports": []
328 },
329 {
309 "importPath": "internal/server/sshca", 330 "importPath": "internal/server/sshca",
310 "plane": "control", 331 "plane": "control",
311 "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", 332 "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.",
@@ -356,6 +377,12 @@
356 "plane": "wire", 377 "plane": "wire",
357 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 378 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
358 "imports": [] 379 "imports": []
380 },
381 {
382 "importPath": "internal/version",
383 "plane": "wire",
384 "synopsis": "Package version carries the build-stamped eitri version, set via -ldflags \"-X github.com/a73x/eitri/internal/version.Version=v0.0.2\".",
385 "imports": []
359 } 386 }
360 ] 387 ]
361 } 388 }
internal/agent/selfupdate/selfupdate.go
Old New
@@ -0,0 +1,201 @@
1 // Package selfupdate replaces the running agent binary with a
2 // server-instructed release and re-execs. Every step is level-triggered
3 // retry-safe: a failure leaves the current binary running and untouched, and
4 // the next snapshot carrying the offer tries again. The previous binary is
5 // kept beside the new one as "<exe>.prev" for manual recovery.
6 package selfupdate
7
8 import (
9 "context"
10 "crypto/sha256"
11 "encoding/hex"
12 "fmt"
13 "io"
14 "net/http"
15 "os"
16 "path/filepath"
17 "syscall"
18 "time"
19 )
20
21 // Update names the target binary: its version (for logging), artifact URL,
22 // and expected sha256 (hex).
23 type Update struct {
24 Version, URL, SHA256 string
25 }
26
27 // Applier performs the swap. Zero value works in production; tests inject the
28 // seams.
29 type Applier struct {
30 // HTTP is the download client (nil ⇒ a 10-minute-timeout default,
31 // mirroring the image fetch bound).
32 HTTP *http.Client
33 // Exec replaces the process image (nil ⇒ syscall.Exec). Tests capture it.
34 Exec func(argv0 string, argv, env []string) error
35 // ExePath resolves the running binary's path (nil ⇒ os.Executable).
36 ExePath func() (string, error)
37 }
38
39 func (a *Applier) httpClient() *http.Client {
40 if a.HTTP != nil {
41 return a.HTTP
42 }
43 return &http.Client{Timeout: 10 * time.Minute}
44 }
45
46 // Apply downloads, verifies, swaps, and re-execs. On any error before the
47 // final rename the running binary is untouched; after the rename the process
48 // re-execs (or returns the exec error — at that point <exe> is already the
49 // new binary and <exe>.prev the old one).
50 //
51 // ctx governs the download only (via http.NewRequestWithContext); if it is
52 // ever cancelled mid-download, the download aborts, Apply returns an error,
53 // the binary is untouched, and the next attempt starts over — a killed
54 // download is never resumed. Whether that happens on every reconnect or only
55 // on full process shutdown is the CALLER's choice of which ctx to pass;
56 // syncclient.Client.maybeUpgrade deliberately uses a ctx that outlives one
57 // QUIC session so a reconnect blip doesn't abandon a half-finished download.
58 func (a *Applier) Apply(ctx context.Context, u Update) error {
59 exePath := os.Executable
60 if a.ExePath != nil {
61 exePath = a.ExePath
62 }
63 exe, err := exePath()
64 if err != nil {
65 return fmt.Errorf("resolve executable: %w", err)
66 }
67 dir := filepath.Dir(exe)
68
69 // Sweep stale temps from a crashed prior attempt: the deferred os.Remove
70 // below never runs if THAT process was killed mid-download, so litter can
71 // accumulate across restarts. Best-effort; a removal failure here is not
72 // fatal to this attempt.
73 if stale, globErr := filepath.Glob(filepath.Join(dir, ".eitri-agent-upgrade-*")); globErr == nil {
74 for _, f := range stale {
75 _ = os.Remove(f)
76 }
77 }
78
79 // Idempotency: if <exe> ALREADY has the target sha, the swap already
80 // happened — most likely syscall.Exec failed on a previous Apply AFTER
81 // the rename succeeded, so the running process is still the old version
82 // and the server keeps re-offering the same upgrade. Skip straight to
83 // exec: re-running download+copy+rename here would overwrite <exe>.prev
84 // with the NEW binary (this exe IS the new binary already), destroying
85 // the actual previous-version recovery copy for no reason.
86 if u.SHA256 != "" {
87 if sum, sumErr := sha256File(exe); sumErr == nil && sum == u.SHA256 {
88 execFn := a.Exec
89 if execFn == nil {
90 execFn = syscall.Exec
91 }
92 return execFn(exe, os.Args, os.Environ())
93 }
94 }
95
96 // The temp file lives NEXT TO the binary (same filesystem) so the final
97 // os.Rename is atomic — the state dir may be a different mount.
98 tmp, err := os.CreateTemp(dir, ".eitri-agent-upgrade-*")
99 if err != nil {
100 return fmt.Errorf("create temp beside binary: %w", err)
101 }
102 defer os.Remove(tmp.Name()) // no-op after the successful rename
103
104 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.URL, nil)
105 if err != nil {
106 tmp.Close()
107 return err
108 }
109 resp, err := a.httpClient().Do(req)
110 if err != nil {
111 tmp.Close()
112 return fmt.Errorf("download %s: %w", u.URL, err)
113 }
114 defer resp.Body.Close()
115 if resp.StatusCode/100 != 2 {
116 tmp.Close()
117 return fmt.Errorf("download %s: HTTP %d", u.URL, resp.StatusCode)
118 }
119 h := sha256.New()
120 if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil {
121 tmp.Close()
122 return fmt.Errorf("download %s: %w", u.URL, err)
123 }
124 // Durability: fsync the downloaded bytes before Close. Unlike imagecache
125 // (content-addressed, self-healing on re-fetch), a torn agent binary
126 // can't heal itself — these fsyncs (here, in copyFile, and on the parent
127 // dir after the rename below) earn the "failure leaves a runnable
128 // binary" invariant this package's doc comment claims.
129 if err := tmp.Sync(); err != nil {
130 tmp.Close()
131 return fmt.Errorf("sync downloaded temp: %w", err)
132 }
133 if err := tmp.Close(); err != nil {
134 return err
135 }
136 if got := hex.EncodeToString(h.Sum(nil)); got != u.SHA256 {
137 return fmt.Errorf("sha256 mismatch for %s: got %s want %s", u.URL, got, u.SHA256)
138 }
139 if err := os.Chmod(tmp.Name(), 0o755); err != nil {
140 return err
141 }
142 // Copy (not rename) the current binary to .prev: a crash between the two
143 // steps must leave <exe> present and runnable.
144 if err := copyFile(exe, exe+".prev"); err != nil {
145 return fmt.Errorf("preserve .prev: %w", err)
146 }
147 if err := os.Rename(tmp.Name(), exe); err != nil {
148 return fmt.Errorf("swap binary: %w", err)
149 }
150 // Durability: fsync the directory entry itself, not just the file data —
151 // on many filesystems a rename's directory-entry update is not durable
152 // until the containing directory is fsynced. Best-effort: the swap has
153 // already happened either way, so a failure here doesn't unwind it.
154 if df, derr := os.Open(dir); derr == nil {
155 _ = df.Sync()
156 _ = df.Close()
157 }
158 execFn := a.Exec
159 if execFn == nil {
160 execFn = syscall.Exec
161 }
162 return execFn(exe, os.Args, os.Environ())
163 }
164
165 // sha256File hashes the file at path, hex-encoded.
166 func sha256File(path string) (string, error) {
167 f, err := os.Open(path)
168 if err != nil {
169 return "", err
170 }
171 defer f.Close()
172 h := sha256.New()
173 if _, err := io.Copy(h, f); err != nil {
174 return "", err
175 }
176 return hex.EncodeToString(h.Sum(nil)), nil
177 }
178
179 // copyFile copies src to dst (0755), truncating dst.
180 func copyFile(src, dst string) error {
181 in, err := os.Open(src)
182 if err != nil {
183 return err
184 }
185 defer in.Close()
186 out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
187 if err != nil {
188 return err
189 }
190 if _, err := io.Copy(out, in); err != nil {
191 out.Close()
192 return err
193 }
194 // Durability: see the fsync note on the downloaded temp above — the same
195 // power-loss hole applies to the .prev recovery copy.
196 if err := out.Sync(); err != nil {
197 out.Close()
198 return err
199 }
200 return out.Close()
201 }
internal/agent/selfupdate/selfupdate_test.go
Old New
@@ -0,0 +1,159 @@
1 package selfupdate
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "testing"
12 )
13
14 func serveBinary(t *testing.T, body []byte) (url, sha string) {
15 t.Helper()
16 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17 w.Write(body)
18 }))
19 t.Cleanup(srv.Close)
20 sum := sha256.Sum256(body)
21 return srv.URL, hex.EncodeToString(sum[:])
22 }
23
24 func TestApplySwapsAndExecs(t *testing.T) {
25 dir := t.TempDir()
26 exe := filepath.Join(dir, "eitri-agent")
27 if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
28 t.Fatal(err)
29 }
30 url, sha := serveBinary(t, []byte("new-binary"))
31
32 var gotArgv0 string
33 var gotArgv []string
34 a := &Applier{
35 ExePath: func() (string, error) { return exe, nil },
36 Exec: func(argv0 string, argv, env []string) error {
37 gotArgv0, gotArgv = argv0, argv
38 return nil
39 },
40 }
41 if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil {
42 t.Fatal(err)
43 }
44 if b, _ := os.ReadFile(exe); string(b) != "new-binary" {
45 t.Fatalf("binary not swapped: %q", b)
46 }
47 if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
48 t.Fatalf(".prev not preserved: %q", b)
49 }
50 if gotArgv0 != exe || len(gotArgv) == 0 || gotArgv[0] != os.Args[0] {
51 t.Fatalf("exec argv0=%q argv=%v", gotArgv0, gotArgv)
52 }
53 fi, _ := os.Stat(exe)
54 if fi.Mode().Perm()&0o111 == 0 {
55 t.Fatal("swapped binary not executable")
56 }
57 }
58
59 func TestApplyShaMismatchLeavesBinary(t *testing.T) {
60 dir := t.TempDir()
61 exe := filepath.Join(dir, "eitri-agent")
62 os.WriteFile(exe, []byte("old"), 0o755)
63 url, _ := serveBinary(t, []byte("evil"))
64
65 // A stale temp from some earlier, crashed attempt: Apply must sweep this
66 // before doing anything else, and it must be gone afterward too.
67 stale := filepath.Join(dir, ".eitri-agent-upgrade-stale")
68 if err := os.WriteFile(stale, []byte("litter"), 0o644); err != nil {
69 t.Fatal(err)
70 }
71
72 execCalled := false
73 a := &Applier{
74 ExePath: func() (string, error) { return exe, nil },
75 Exec: func(string, []string, []string) error { execCalled = true; return nil },
76 }
77 err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: "00"})
78 if err == nil || execCalled {
79 t.Fatalf("want sha error without exec; err=%v execCalled=%v", err, execCalled)
80 }
81 if b, _ := os.ReadFile(exe); string(b) != "old" {
82 t.Fatal("binary must be untouched on sha mismatch")
83 }
84 if _, err := os.Stat(exe + ".prev"); err == nil {
85 t.Fatal("no .prev should exist on sha mismatch")
86 }
87 if _, err := os.Stat(stale); err == nil {
88 t.Fatal("stale pre-existing temp must be swept")
89 }
90 // No temp litter (this attempt's own temp is cleaned up too).
91 entries, _ := os.ReadDir(dir)
92 if len(entries) != 1 {
93 t.Fatalf("temp file leaked: %v", entries)
94 }
95 }
96
97 // TestApplyResumesAfterSwappedExec covers the case where a PRIOR Apply already
98 // renamed the new binary into place but then syscall.Exec failed (e.g. ENOMEM,
99 // or the new binary is not actually executable on this kernel): the running
100 // process is still the old one, the server keeps re-offering the same
101 // upgrade, and this retry must not re-download or re-copy — that copyFile
102 // would overwrite .prev (the true previous version) with the binary that is
103 // already at <exe>, destroying the recovery copy for nothing.
104 func TestApplyResumesAfterSwappedExec(t *testing.T) {
105 dir := t.TempDir()
106 exe := filepath.Join(dir, "eitri-agent")
107 swapped := []byte("already-swapped-binary")
108 sum := sha256.Sum256(swapped)
109 sha := hex.EncodeToString(sum[:])
110 if err := os.WriteFile(exe, swapped, 0o755); err != nil {
111 t.Fatal(err)
112 }
113 if err := os.WriteFile(exe+".prev", []byte("old"), 0o755); err != nil {
114 t.Fatal(err)
115 }
116
117 var requests int
118 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119 requests++
120 w.Write([]byte("should not be fetched"))
121 }))
122 defer srv.Close()
123
124 var gotArgv0 string
125 a := &Applier{
126 ExePath: func() (string, error) { return exe, nil },
127 Exec: func(argv0 string, argv, env []string) error {
128 gotArgv0 = argv0
129 return nil
130 },
131 }
132 if err := a.Apply(context.Background(), Update{Version: "v9", URL: srv.URL, SHA256: sha}); err != nil {
133 t.Fatal(err)
134 }
135 if requests != 0 {
136 t.Fatalf("want zero HTTP requests on idempotent resume, got %d", requests)
137 }
138 if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
139 t.Fatalf(".prev must survive an exec-failure retry: %q", b)
140 }
141 if gotArgv0 != exe {
142 t.Fatalf("exec argv0=%q want %q", gotArgv0, exe)
143 }
144 }
145
146 func TestApplyDownloadError(t *testing.T) {
147 dir := t.TempDir()
148 exe := filepath.Join(dir, "eitri-agent")
149 os.WriteFile(exe, []byte("old"), 0o755)
150 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151 w.WriteHeader(http.StatusNotFound)
152 }))
153 defer srv.Close()
154 a := &Applier{ExePath: func() (string, error) { return exe, nil },
155 Exec: func(string, []string, []string) error { return nil }}
156 if err := a.Apply(context.Background(), Update{URL: srv.URL, SHA256: "00"}); err == nil {
157 t.Fatal("want download error")
158 }
159 }
internal/agent/syncclient/client.go
Old New
@@ -12,15 +12,18 @@ import (
12 "runtime" 12 "runtime"
13 "strings" 13 "strings"
14 "sync" 14 "sync"
15 "sync/atomic"
15 "syscall" 16 "syscall"
16 "time" 17 "time"
17 18
18 agentexec "github.com/a73x/eitri/internal/agent/exec" 19 agentexec "github.com/a73x/eitri/internal/agent/exec"
19 "github.com/a73x/eitri/internal/agent/hostinfo" 20 "github.com/a73x/eitri/internal/agent/hostinfo"
20 "github.com/a73x/eitri/internal/agent/reconcile" 21 "github.com/a73x/eitri/internal/agent/reconcile"
22 "github.com/a73x/eitri/internal/agent/selfupdate"
21 "github.com/a73x/eitri/internal/agent/state" 23 "github.com/a73x/eitri/internal/agent/state"
22 "github.com/a73x/eitri/internal/pb" 24 "github.com/a73x/eitri/internal/pb"
23 "github.com/a73x/eitri/internal/transport" 25 "github.com/a73x/eitri/internal/transport"
26 "github.com/a73x/eitri/internal/version"
24 "github.com/quic-go/quic-go" 27 "github.com/quic-go/quic-go"
25 ) 28 )
26 29
@@ -104,6 +107,12 @@ type Client struct {
104 // the tunnel to an arbitrary port. 107 // the tunnel to an arbitrary port.
105 dialGuest func(ip string) (net.Conn, error) 108 dialGuest func(ip string) (net.Conn, error)
106 109
110 // applyUpgrade performs the self-update (overridable in tests). nil uses
111 // a default selfupdate.Applier; on success it never returns (re-exec).
112 applyUpgrade func(ctx context.Context, u selfupdate.Update) error
113 // upgrading guards one self-update attempt in flight across snapshots.
114 upgrading atomic.Bool
115
107 // TickInterval is the period of the fallback ticker that drives a reconcile 116 // TickInterval is the period of the fallback ticker that drives a reconcile
108 // step even when no new snapshot has arrived (e.g. for periodic health 117 // step even when no new snapshot has arrived (e.g. for periodic health
109 // reports). Zero uses DefaultTickInterval (coupled to registry.OnlineWindow). 118 // reports). Zero uses DefaultTickInterval (coupled to registry.OnlineWindow).
@@ -171,6 +180,46 @@ func (c *Client) advertisedCapacity(stateDir string) *pb.Capacity {
171 return clampCapacity(raw, c.MaxVCPUs, c.MaxMemMB, c.MaxDiskGB) 180 return clampCapacity(raw, c.MaxVCPUs, c.MaxMemMB, c.MaxDiskGB)
172 } 181 }
173 182
183 // maybeUpgrade launches the agent self-update named by a snapshot's
184 // AgentUpgrade. No-op when the target equals the running version (the offer
185 // has converged) or an attempt is already in flight. On success the process
186 // re-execs and never returns; on failure the flight is released and the next
187 // snapshot carrying the offer retries — level-triggered like everything else.
188 //
189 // ctx here is deliberately session()'s OUTER parameter, not the per-session
190 // sessCtx: the download must survive a mere reconnect (a QUIC blip must not
191 // abandon a half-downloaded artifact only to restart it from scratch next
192 // session), and c.upgrading is a Client-level field so the single-flight
193 // guard already spans sessions. The download is only ever aborted by the
194 // caller of Run cancelling the agent's whole lifetime; short of that, it
195 // runs to completion (or its own HTTP/sha error) in the background.
196 //
197 // A successful upgrade re-execs the process, abandoning any reconcile step
198 // mid-flight on this session's worker goroutine — safe because VMs are
199 // external processes unaffected by the agent's own exit, and engine state
200 // writes are atomic temp-and-rename, so whatever the abandoned step hadn't
201 // finished is simply re-derived, level-triggered, once the new binary
202 // reconnects and reads the next snapshot.
203 func (c *Client) maybeUpgrade(ctx context.Context, up *pb.AgentUpgrade) {
204 if up.GetVersion() == "" || up.GetVersion() == version.Version {
205 return
206 }
207 if !c.upgrading.CompareAndSwap(false, true) {
208 return
209 }
210 apply := c.applyUpgrade
211 if apply == nil {
212 apply = (&selfupdate.Applier{}).Apply
213 }
214 go func() {
215 slog.Info("agent self-upgrade starting", "from", version.Version, "to", up.GetVersion(), "url", up.GetUrl())
216 if err := apply(ctx, selfupdate.Update{Version: up.GetVersion(), URL: up.GetUrl(), SHA256: up.GetSha256()}); err != nil {
217 slog.Error("agent self-upgrade failed; will retry on next snapshot", "to", up.GetVersion(), "err", err)
218 c.upgrading.Store(false)
219 }
220 }()
221 }
222
174 // errPermanentAuth marks a credential rejection so Run() backs off long instead 223 // errPermanentAuth marks a credential rejection so Run() backs off long instead
175 // of tight-looping a dead credential. 224 // of tight-looping a dead credential.
176 var errPermanentAuth = errors.New("auth rejected (permanent)") 225 var errPermanentAuth = errors.New("auth rejected (permanent)")
@@ -241,6 +290,14 @@ func sleep(ctx context.Context, d time.Duration) bool {
241 // session has read at least one snapshot, so Run() can reset its failure count. 290 // session has read at least one snapshot, so Run() can reset its failure count.
242 var errSessionConnected = errors.New("session was connected") 291 var errSessionConnected = errors.New("session was connected")
243 292
293 // helloFacts gathers host facts and stamps the agent's own version onto them —
294 // the one fact the agent knows about itself rather than the host.
295 func helloFacts(ctx context.Context, run agentexec.Runner) *pb.HostFacts {
296 f := hostinfo.Facts(ctx, run)
297 f.AgentVersion = version.Version
298 return f
299 }
300
244 // session opens one QUIC connection, runs the dual-stream loop, and returns 301 // session opens one QUIC connection, runs the dual-stream loop, and returns
245 // when the session ends (for any reason). The caller retries. 302 // when the session ends (for any reason). The caller retries.
246 func (c *Client) session(ctx context.Context) error { 303 func (c *Client) session(ctx context.Context) error {
@@ -267,7 +324,7 @@ func (c *Client) session(ctx context.Context) error {
267 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, 324 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH,
268 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR, 325 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR,
269 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), 326 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir),
270 Facts: hostinfo.Facts(ctx, c.Runner), 327 Facts: helloFacts(ctx, c.Runner),
271 Credential: c.Identity.Credential, 328 Credential: c.Identity.Credential,
272 }}} 329 }}}
273 if err := transport.WriteMsg(up, hello); err != nil { 330 if err := transport.WriteMsg(up, hello); err != nil {
@@ -343,6 +400,9 @@ func (c *Client) session(ctx context.Context) error {
343 case stepSignal <- struct{}{}: 400 case stepSignal <- struct{}{}:
344 default: 401 default:
345 } 402 }
403 if up := snap.GetAgentUpgrade(); up != nil {
404 c.maybeUpgrade(ctx, up)
405 }
346 } 406 }
347 } 407 }
348 }() 408 }()
internal/agent/syncclient/client_test.go
Old New
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/a73x/eitri/internal/agent/reconcile" 12 "github.com/a73x/eitri/internal/agent/reconcile"
13 "github.com/a73x/eitri/internal/agent/seed" 13 "github.com/a73x/eitri/internal/agent/seed"
14 "github.com/a73x/eitri/internal/agent/selfupdate"
14 "github.com/a73x/eitri/internal/agent/state" 15 "github.com/a73x/eitri/internal/agent/state"
15 "github.com/a73x/eitri/internal/pb" 16 "github.com/a73x/eitri/internal/pb"
16 "github.com/a73x/eitri/internal/server/hosttoken" 17 "github.com/a73x/eitri/internal/server/hosttoken"
@@ -19,6 +20,7 @@ import (
19 "github.com/a73x/eitri/internal/server/store" 20 "github.com/a73x/eitri/internal/server/store"
20 "github.com/a73x/eitri/internal/server/syncsvc" 21 "github.com/a73x/eitri/internal/server/syncsvc"
21 "github.com/a73x/eitri/internal/transport" 22 "github.com/a73x/eitri/internal/transport"
23 "github.com/a73x/eitri/internal/version"
22 "github.com/quic-go/quic-go" 24 "github.com/quic-go/quic-go"
23 "github.com/stretchr/testify/assert" 25 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require" 26 "github.com/stretchr/testify/require"
@@ -46,6 +48,16 @@ func (noopNet) ReserveIP(string) (string, error) {
46 // reconnect/drop cases don't wait out the production SyncMaxIdleTimeout. 48 // reconnect/drop cases don't wait out the production SyncMaxIdleTimeout.
47 const testQUICIdle = 5 * time.Second 49 const testQUICIdle = 5 * time.Second
48 50
51 // TestHelloFactsCarryAgentVersion proves helloFacts stamps the running
52 // binary's version onto the host facts it sends in Hello — the one fact
53 // the agent knows about itself rather than the host.
54 func TestHelloFactsCarryAgentVersion(t *testing.T) {
55 f := helloFacts(context.Background(), nil)
56 if got, want := f.GetAgentVersion(), version.Version; got != want {
57 t.Fatalf("agent_version = %q, want %q", got, want)
58 }
59 }
60
49 // TestAdvertisedCapacityComputesOnce proves the Statfs+Sysinfo capacity probe 61 // TestAdvertisedCapacityComputesOnce proves the Statfs+Sysinfo capacity probe
50 // runs a single time per client (host totals are fixed for the session) while 62 // runs a single time per client (host totals are fixed for the session) while
51 // the cheap cap clamp still applies on every call. 63 // the cheap cap clamp still applies on every call.
@@ -388,3 +400,37 @@ func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness
388 t.Cleanup(func() { cancel(); h.lis.Close() }) 400 t.Cleanup(func() { cancel(); h.lis.Close() })
389 return h 401 return h
390 } 402 }
403
404 func TestMaybeUpgradeSkipsOwnVersionAndSingleFlights(t *testing.T) {
405 c := &Client{}
406 applied := make(chan selfupdate.Update, 2)
407 block := make(chan struct{})
408 c.applyUpgrade = func(ctx context.Context, u selfupdate.Update) error {
409 applied <- u
410 <-block // hold the flight open
411 return nil
412 }
413
414 // Same version: no-op.
415 c.maybeUpgrade(context.Background(), &pb.AgentUpgrade{Version: version.Version})
416 select {
417 case <-applied:
418 t.Fatal("must not apply own version")
419 default:
420 }
421
422 // New version: applies once; a second call while in flight is dropped.
423 up := &pb.AgentUpgrade{Version: "v99.0.0", Url: "u", Sha256: "s"}
424 c.maybeUpgrade(context.Background(), up)
425 c.maybeUpgrade(context.Background(), up)
426 got := <-applied
427 if got.Version != "v99.0.0" {
428 t.Fatalf("applied %+v", got)
429 }
430 select {
431 case <-applied:
432 t.Fatal("second in-flight apply must be dropped")
433 default:
434 }
435 close(block)
436 }
internal/pb/sync.pb.go
Old New
@@ -413,12 +413,13 @@ func (x *Capacity) GetDiskGb() int64 {
413 // Every field is empty when its source can't be read. 413 // Every field is empty when its source can't be read.
414 type HostFacts struct { 414 type HostFacts struct {
415 state protoimpl.MessageState `protogen:"open.v1"` 415 state protoimpl.MessageState `protogen:"open.v1"`
416 OsId string `protobuf:"bytes,1,opt,name=os_id,json=osId,proto3" json:"os_id,omitempty"` // /etc/os-release ID, e.g. "debian" 416 OsId string `protobuf:"bytes,1,opt,name=os_id,json=osId,proto3" json:"os_id,omitempty"` // /etc/os-release ID, e.g. "debian"
417 OsPretty string `protobuf:"bytes,2,opt,name=os_pretty,json=osPretty,proto3" json:"os_pretty,omitempty"` // PRETTY_NAME, e.g. "Debian GNU/Linux 12 (bookworm)" 417 OsPretty string `protobuf:"bytes,2,opt,name=os_pretty,json=osPretty,proto3" json:"os_pretty,omitempty"` // PRETTY_NAME, e.g. "Debian GNU/Linux 12 (bookworm)"
418 OsVersion string `protobuf:"bytes,3,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"` // VERSION_ID, e.g. "12" 418 OsVersion string `protobuf:"bytes,3,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"` // VERSION_ID, e.g. "12"
419 Kernel string `protobuf:"bytes,4,opt,name=kernel,proto3" json:"kernel,omitempty"` // kernel release, e.g. "6.1.0-18-amd64" 419 Kernel string `protobuf:"bytes,4,opt,name=kernel,proto3" json:"kernel,omitempty"` // kernel release, e.g. "6.1.0-18-amd64"
420 CpuModel string `protobuf:"bytes,5,opt,name=cpu_model,json=cpuModel,proto3" json:"cpu_model,omitempty"` // /proc/cpuinfo model name 420 CpuModel string `protobuf:"bytes,5,opt,name=cpu_model,json=cpuModel,proto3" json:"cpu_model,omitempty"` // /proc/cpuinfo model name
421 Virt string `protobuf:"bytes,6,opt,name=virt,proto3" json:"virt,omitempty"` // systemd-detect-virt: "kvm" | "none" | "" (unknown) 421 Virt string `protobuf:"bytes,6,opt,name=virt,proto3" json:"virt,omitempty"` // systemd-detect-virt: "kvm" | "none" | "" (unknown)
422 AgentVersion string `protobuf:"bytes,7,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` // the agent binary's stamped version ("dev" unstamped)
422 unknownFields protoimpl.UnknownFields 423 unknownFields protoimpl.UnknownFields
423 sizeCache protoimpl.SizeCache 424 sizeCache protoimpl.SizeCache
424 } 425 }
@@ -495,6 +496,13 @@ func (x *HostFacts) GetVirt() string {
495 return "" 496 return ""
496 } 497 }
497 498
499 func (x *HostFacts) GetAgentVersion() string {
500 if x != nil {
501 return x.AgentVersion
502 }
503 return ""
504 }
505
498 // HostMetrics is live measured host utilization, refreshed each report. It is 506 // HostMetrics is live measured host utilization, refreshed each report. It is
499 // NOT persisted — it lives only in the registry while the host is online. 507 // NOT persisted — it lives only in the registry while the host is online.
500 type HostMetrics struct { 508 type HostMetrics struct {
@@ -993,8 +1001,9 @@ func (x *VMDesired) GetSshUserCaAuthorizedKeys() []string {
993 1001
994 type DesiredStateSnapshot struct { 1002 type DesiredStateSnapshot struct {
995 state protoimpl.MessageState `protogen:"open.v1"` 1003 state protoimpl.MessageState `protogen:"open.v1"`
996 Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen 1004 Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen
997 Vms []*VMDesired `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned 1005 Vms []*VMDesired `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned
1006 AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade
998 unknownFields protoimpl.UnknownFields 1007 unknownFields protoimpl.UnknownFields
999 sizeCache protoimpl.SizeCache 1008 sizeCache protoimpl.SizeCache
1000 } 1009 }
@@ -1043,6 +1052,77 @@ func (x *DesiredStateSnapshot) GetVms() []*VMDesired {
1043 return nil 1052 return nil
1044 } 1053 }
1045 1054
1055 func (x *DesiredStateSnapshot) GetAgentUpgrade() *AgentUpgrade {
1056 if x != nil {
1057 return x.AgentUpgrade
1058 }
1059 return nil
1060 }
1061
1062 // AgentUpgrade asks the agent to replace its own binary: download url, verify
1063 // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an
1064 // operator explicitly clicked; absent otherwise. An agent already running
1065 // version treats it as a no-op.
1066 type AgentUpgrade struct {
1067 state protoimpl.MessageState `protogen:"open.v1"`
1068 Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // target version (matches Hello facts.agent_version when done)
1069 Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` // artifact URL (https, from the release manifest)
1070 Sha256 string `protobuf:"bytes,3,opt,name=sha256,proto3" json:"sha256,omitempty"` // artifact sha256 (hex)
1071 unknownFields protoimpl.UnknownFields
1072 sizeCache protoimpl.SizeCache
1073 }
1074
1075 func (x *AgentUpgrade) Reset() {
1076 *x = AgentUpgrade{}
1077 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11]
1078 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1079 ms.StoreMessageInfo(mi)
1080 }
1081
1082 func (x *AgentUpgrade) String() string {
1083 return protoimpl.X.MessageStringOf(x)
1084 }
1085
1086 func (*AgentUpgrade) ProtoMessage() {}
1087
1088 func (x *AgentUpgrade) ProtoReflect() protoreflect.Message {
1089 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11]
1090 if x != nil {
1091 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1092 if ms.LoadMessageInfo() == nil {
1093 ms.StoreMessageInfo(mi)
1094 }
1095 return ms
1096 }
1097 return mi.MessageOf(x)
1098 }
1099
1100 // Deprecated: Use AgentUpgrade.ProtoReflect.Descriptor instead.
1101 func (*AgentUpgrade) Descriptor() ([]byte, []int) {
1102 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{11}
1103 }
1104
1105 func (x *AgentUpgrade) GetVersion() string {
1106 if x != nil {
1107 return x.Version
1108 }
1109 return ""
1110 }
1111
1112 func (x *AgentUpgrade) GetUrl() string {
1113 if x != nil {
1114 return x.Url
1115 }
1116 return ""
1117 }
1118
1119 func (x *AgentUpgrade) GetSha256() string {
1120 if x != nil {
1121 return x.Sha256
1122 }
1123 return ""
1124 }
1125
1046 // ConsoleOpen is the first frame on a server-initiated console stream: it names 1126 // ConsoleOpen is the first frame on a server-initiated console stream: it names
1047 // the VM whose serial console the stream should bridge. After the agent's 1127 // the VM whose serial console the stream should bridge. After the agent's
1048 // ConsoleOpened reply, the stream carries RAW serial bytes (no framing). 1128 // ConsoleOpened reply, the stream carries RAW serial bytes (no framing).
@@ -1055,7 +1135,7 @@ type ConsoleOpen struct {
1055 1135
1056 func (x *ConsoleOpen) Reset() { 1136 func (x *ConsoleOpen) Reset() {
1057 *x = ConsoleOpen{} 1137 *x = ConsoleOpen{}
1058 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] 1138 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12]
1059 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1139 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1060 ms.StoreMessageInfo(mi) 1140 ms.StoreMessageInfo(mi)
1061 } 1141 }
@@ -1067,7 +1147,7 @@ func (x *ConsoleOpen) String() string {
1067 func (*ConsoleOpen) ProtoMessage() {} 1147 func (*ConsoleOpen) ProtoMessage() {}
1068 1148
1069 func (x *ConsoleOpen) ProtoReflect() protoreflect.Message { 1149 func (x *ConsoleOpen) ProtoReflect() protoreflect.Message {
1070 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] 1150 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12]
1071 if x != nil { 1151 if x != nil {
1072 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1152 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1073 if ms.LoadMessageInfo() == nil { 1153 if ms.LoadMessageInfo() == nil {
@@ -1080,7 +1160,7 @@ func (x *ConsoleOpen) ProtoReflect() protoreflect.Message {
1080 1160
1081 // Deprecated: Use ConsoleOpen.ProtoReflect.Descriptor instead. 1161 // Deprecated: Use ConsoleOpen.ProtoReflect.Descriptor instead.
1082 func (*ConsoleOpen) Descriptor() ([]byte, []int) { 1162 func (*ConsoleOpen) Descriptor() ([]byte, []int) {
1083 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{11} 1163 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{12}
1084 } 1164 }
1085 1165
1086 func (x *ConsoleOpen) GetVmId() string { 1166 func (x *ConsoleOpen) GetVmId() string {
@@ -1103,7 +1183,7 @@ type ConsoleOpened struct {
1103 1183
1104 func (x *ConsoleOpened) Reset() { 1184 func (x *ConsoleOpened) Reset() {
1105 *x = ConsoleOpened{} 1185 *x = ConsoleOpened{}
1106 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] 1186 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13]
1107 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1187 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1108 ms.StoreMessageInfo(mi) 1188 ms.StoreMessageInfo(mi)
1109 } 1189 }
@@ -1115,7 +1195,7 @@ func (x *ConsoleOpened) String() string {
1115 func (*ConsoleOpened) ProtoMessage() {} 1195 func (*ConsoleOpened) ProtoMessage() {}
1116 1196
1117 func (x *ConsoleOpened) ProtoReflect() protoreflect.Message { 1197 func (x *ConsoleOpened) ProtoReflect() protoreflect.Message {
1118 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] 1198 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13]
1119 if x != nil { 1199 if x != nil {
1120 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1200 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1121 if ms.LoadMessageInfo() == nil { 1201 if ms.LoadMessageInfo() == nil {
@@ -1128,7 +1208,7 @@ func (x *ConsoleOpened) ProtoReflect() protoreflect.Message {
1128 1208
1129 // Deprecated: Use ConsoleOpened.ProtoReflect.Descriptor instead. 1209 // Deprecated: Use ConsoleOpened.ProtoReflect.Descriptor instead.
1130 func (*ConsoleOpened) Descriptor() ([]byte, []int) { 1210 func (*ConsoleOpened) Descriptor() ([]byte, []int) {
1131 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{12} 1211 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{13}
1132 } 1212 }
1133 1213
1134 func (x *ConsoleOpened) GetOk() bool { 1214 func (x *ConsoleOpened) GetOk() bool {
@@ -1158,7 +1238,7 @@ type TCPOpen struct {
1158 1238
1159 func (x *TCPOpen) Reset() { 1239 func (x *TCPOpen) Reset() {
1160 *x = TCPOpen{} 1240 *x = TCPOpen{}
1161 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13] 1241 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14]
1162 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1242 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1163 ms.StoreMessageInfo(mi) 1243 ms.StoreMessageInfo(mi)
1164 } 1244 }
@@ -1170,7 +1250,7 @@ func (x *TCPOpen) String() string {
1170 func (*TCPOpen) ProtoMessage() {} 1250 func (*TCPOpen) ProtoMessage() {}
1171 1251
1172 func (x *TCPOpen) ProtoReflect() protoreflect.Message { 1252 func (x *TCPOpen) ProtoReflect() protoreflect.Message {
1173 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13] 1253 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14]
1174 if x != nil { 1254 if x != nil {
1175 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1255 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1176 if ms.LoadMessageInfo() == nil { 1256 if ms.LoadMessageInfo() == nil {
@@ -1183,7 +1263,7 @@ func (x *TCPOpen) ProtoReflect() protoreflect.Message {
1183 1263
1184 // Deprecated: Use TCPOpen.ProtoReflect.Descriptor instead. 1264 // Deprecated: Use TCPOpen.ProtoReflect.Descriptor instead.
1185 func (*TCPOpen) Descriptor() ([]byte, []int) { 1265 func (*TCPOpen) Descriptor() ([]byte, []int) {
1186 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{13} 1266 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{14}
1187 } 1267 }
1188 1268
1189 func (x *TCPOpen) GetVmId() string { 1269 func (x *TCPOpen) GetVmId() string {
@@ -1213,7 +1293,7 @@ type TCPOpened struct {
1213 1293
1214 func (x *TCPOpened) Reset() { 1294 func (x *TCPOpened) Reset() {
1215 *x = TCPOpened{} 1295 *x = TCPOpened{}
1216 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14] 1296 mi := &file_proto_eitri_v1_sync_proto_msgTypes[15]
1217 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1297 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1218 ms.StoreMessageInfo(mi) 1298 ms.StoreMessageInfo(mi)
1219 } 1299 }
@@ -1225,7 +1305,7 @@ func (x *TCPOpened) String() string {
1225 func (*TCPOpened) ProtoMessage() {} 1305 func (*TCPOpened) ProtoMessage() {}
1226 1306
1227 func (x *TCPOpened) ProtoReflect() protoreflect.Message { 1307 func (x *TCPOpened) ProtoReflect() protoreflect.Message {
1228 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14] 1308 mi := &file_proto_eitri_v1_sync_proto_msgTypes[15]
1229 if x != nil { 1309 if x != nil {
1230 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1310 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1231 if ms.LoadMessageInfo() == nil { 1311 if ms.LoadMessageInfo() == nil {
@@ -1238,7 +1318,7 @@ func (x *TCPOpened) ProtoReflect() protoreflect.Message {
1238 1318
1239 // Deprecated: Use TCPOpened.ProtoReflect.Descriptor instead. 1319 // Deprecated: Use TCPOpened.ProtoReflect.Descriptor instead.
1240 func (*TCPOpened) Descriptor() ([]byte, []int) { 1320 func (*TCPOpened) Descriptor() ([]byte, []int) {
1241 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{14} 1321 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{15}
1242 } 1322 }
1243 1323
1244 func (x *TCPOpened) GetOk() bool { 1324 func (x *TCPOpened) GetOk() bool {
@@ -1290,7 +1370,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1290 "\bCapacity\x12\x14\n" + 1370 "\bCapacity\x12\x14\n" +
1291 "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" + 1371 "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" +
1292 "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" + 1372 "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" +
1293 "\adisk_gb\x18\x03 \x01(\x03R\x06diskGb\"\xa5\x01\n" + 1373 "\adisk_gb\x18\x03 \x01(\x03R\x06diskGb\"\xca\x01\n" +
1294 "\tHostFacts\x12\x13\n" + 1374 "\tHostFacts\x12\x13\n" +
1295 "\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1b\n" + 1375 "\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1b\n" +
1296 "\tos_pretty\x18\x02 \x01(\tR\bosPretty\x12\x1d\n" + 1376 "\tos_pretty\x18\x02 \x01(\tR\bosPretty\x12\x1d\n" +
@@ -1298,7 +1378,8 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1298 "os_version\x18\x03 \x01(\tR\tosVersion\x12\x16\n" + 1378 "os_version\x18\x03 \x01(\tR\tosVersion\x12\x16\n" +
1299 "\x06kernel\x18\x04 \x01(\tR\x06kernel\x12\x1b\n" + 1379 "\x06kernel\x18\x04 \x01(\tR\x06kernel\x12\x1b\n" +
1300 "\tcpu_model\x18\x05 \x01(\tR\bcpuModel\x12\x12\n" + 1380 "\tcpu_model\x18\x05 \x01(\tR\bcpuModel\x12\x12\n" +
1301 "\x04virt\x18\x06 \x01(\tR\x04virt\"\xfa\x01\n" + 1381 "\x04virt\x18\x06 \x01(\tR\x04virt\x12#\n" +
1382 "\ragent_version\x18\a \x01(\tR\fagentVersion\"\xfa\x01\n" +
1302 "\vHostMetrics\x12\x19\n" + 1383 "\vHostMetrics\x12\x19\n" +
1303 "\buptime_s\x18\x01 \x01(\x03R\auptimeS\x12\x1e\n" + 1384 "\buptime_s\x18\x01 \x01(\x03R\auptimeS\x12\x1e\n" +
1304 "\vmem_used_mb\x18\x02 \x01(\x03R\tmemUsedMb\x12(\n" + 1385 "\vmem_used_mb\x18\x02 \x01(\x03R\tmemUsedMb\x12(\n" +
@@ -1353,10 +1434,15 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1353 "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" + 1434 "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" +
1354 "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + 1435 "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" +
1355 "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + 1436 "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" +
1356 "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"S\n" + 1437 "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"\x90\x01\n" +
1357 "\x14DesiredStateSnapshot\x12\x14\n" + 1438 "\x14DesiredStateSnapshot\x12\x14\n" +
1358 "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + 1439 "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" +
1359 "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\"\"\n" + 1440 "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\x12;\n" +
1441 "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\"R\n" +
1442 "\fAgentUpgrade\x12\x18\n" +
1443 "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" +
1444 "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" +
1445 "\x06sha256\x18\x03 \x01(\tR\x06sha256\"\"\n" +
1360 "\vConsoleOpen\x12\x13\n" + 1446 "\vConsoleOpen\x12\x13\n" +
1361 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\"5\n" + 1447 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\"5\n" +
1362 "\rConsoleOpened\x12\x0e\n" + 1448 "\rConsoleOpened\x12\x0e\n" +
@@ -1381,7 +1467,7 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte {
1381 return file_proto_eitri_v1_sync_proto_rawDescData 1467 return file_proto_eitri_v1_sync_proto_rawDescData
1382 } 1468 }
1383 1469
1384 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 15) 1470 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
1385 var file_proto_eitri_v1_sync_proto_goTypes = []any{ 1471 var file_proto_eitri_v1_sync_proto_goTypes = []any{
1386 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage 1472 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage
1387 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage 1473 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage
@@ -1394,19 +1480,20 @@ var file_proto_eitri_v1_sync_proto_goTypes = []any{
1394 (*ActualStateReport)(nil), // 8: eitri.v1.ActualStateReport 1480 (*ActualStateReport)(nil), // 8: eitri.v1.ActualStateReport
1395 (*VMDesired)(nil), // 9: eitri.v1.VMDesired 1481 (*VMDesired)(nil), // 9: eitri.v1.VMDesired
1396 (*DesiredStateSnapshot)(nil), // 10: eitri.v1.DesiredStateSnapshot 1482 (*DesiredStateSnapshot)(nil), // 10: eitri.v1.DesiredStateSnapshot
1397 (*ConsoleOpen)(nil), // 11: eitri.v1.ConsoleOpen 1483 (*AgentUpgrade)(nil), // 11: eitri.v1.AgentUpgrade
1398 (*ConsoleOpened)(nil), // 12: eitri.v1.ConsoleOpened 1484 (*ConsoleOpen)(nil), // 12: eitri.v1.ConsoleOpen
1399 (*TCPOpen)(nil), // 13: eitri.v1.TCPOpen 1485 (*ConsoleOpened)(nil), // 13: eitri.v1.ConsoleOpened
1400 (*TCPOpened)(nil), // 14: eitri.v1.TCPOpened 1486 (*TCPOpen)(nil), // 14: eitri.v1.TCPOpen
1487 (*TCPOpened)(nil), // 15: eitri.v1.TCPOpened
1401 } 1488 }
1402 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ 1489 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
1403 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello 1490 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello
1404 8, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport 1491 8, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport
1405 12, // 2: eitri.v1.AgentMessage.console_opened:type_name -> eitri.v1.ConsoleOpened 1492 13, // 2: eitri.v1.AgentMessage.console_opened:type_name -> eitri.v1.ConsoleOpened
1406 14, // 3: eitri.v1.AgentMessage.tcp_opened:type_name -> eitri.v1.TCPOpened 1493 15, // 3: eitri.v1.AgentMessage.tcp_opened:type_name -> eitri.v1.TCPOpened
1407 10, // 4: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot 1494 10, // 4: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot
1408 11, // 5: eitri.v1.ServerMessage.console_open:type_name -> eitri.v1.ConsoleOpen 1495 12, // 5: eitri.v1.ServerMessage.console_open:type_name -> eitri.v1.ConsoleOpen
1409 13, // 6: eitri.v1.ServerMessage.tcp_open:type_name -> eitri.v1.TCPOpen 1496 14, // 6: eitri.v1.ServerMessage.tcp_open:type_name -> eitri.v1.TCPOpen
1410 3, // 7: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity 1497 3, // 7: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity
1411 4, // 8: eitri.v1.Hello.facts:type_name -> eitri.v1.HostFacts 1498 4, // 8: eitri.v1.Hello.facts:type_name -> eitri.v1.HostFacts
1412 6, // 9: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM 1499 6, // 9: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM
@@ -1414,11 +1501,12 @@ var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
1414 3, // 11: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity 1501 3, // 11: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity
1415 5, // 12: eitri.v1.ActualStateReport.metrics:type_name -> eitri.v1.HostMetrics 1502 5, // 12: eitri.v1.ActualStateReport.metrics:type_name -> eitri.v1.HostMetrics
1416 9, // 13: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired 1503 9, // 13: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired
1417 14, // [14:14] is the sub-list for method output_type 1504 11, // 14: eitri.v1.DesiredStateSnapshot.agent_upgrade:type_name -> eitri.v1.AgentUpgrade
1418 14, // [14:14] is the sub-list for method input_type 1505 15, // [15:15] is the sub-list for method output_type
1419 14, // [14:14] is the sub-list for extension type_name 1506 15, // [15:15] is the sub-list for method input_type
1420 14, // [14:14] is the sub-list for extension extendee 1507 15, // [15:15] is the sub-list for extension type_name
1421 0, // [0:14] is the sub-list for field type_name 1508 15, // [15:15] is the sub-list for extension extendee
1509 0, // [0:15] is the sub-list for field type_name
1422 } 1510 }
1423 1511
1424 func init() { file_proto_eitri_v1_sync_proto_init() } 1512 func init() { file_proto_eitri_v1_sync_proto_init() }
@@ -1443,7 +1531,7 @@ func file_proto_eitri_v1_sync_proto_init() {
1443 GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 1531 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
1444 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), 1532 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)),
1445 NumEnums: 0, 1533 NumEnums: 0,
1446 NumMessages: 15, 1534 NumMessages: 16,
1447 NumExtensions: 0, 1535 NumExtensions: 0,
1448 NumServices: 0, 1536 NumServices: 0,
1449 }, 1537 },
internal/server/api/api.go
Old New
@@ -23,6 +23,7 @@ import (
23 "github.com/a73x/eitri/internal/server/hosttoken" 23 "github.com/a73x/eitri/internal/server/hosttoken"
24 "github.com/a73x/eitri/internal/server/hub" 24 "github.com/a73x/eitri/internal/server/hub"
25 "github.com/a73x/eitri/internal/server/registry" 25 "github.com/a73x/eitri/internal/server/registry"
26 "github.com/a73x/eitri/internal/server/release"
26 "github.com/a73x/eitri/internal/server/store" 27 "github.com/a73x/eitri/internal/server/store"
27 ) 28 )
28 29
@@ -47,6 +48,23 @@ type Config struct {
47 AdvertiseQUIC string // QUIC host:port agents use to reach this server 48 AdvertiseQUIC string // QUIC host:port agents use to reach this server
48 } 49 }
49 50
51 // ReleaseSource exposes the latest known release. *release.Client satisfies
52 // it; the API only ever reads the cached manifest — Refresh is the poller's
53 // job (main.go), not a request-path concern — so this interface stays a
54 // single method.
55 type ReleaseSource interface {
56 Latest() (release.Manifest, bool)
57 }
58
59 // AgentUpgrader records a pending per-host agent self-upgrade.
60 // *syncsvc.Service satisfies it.
61 type AgentUpgrader interface {
62 OfferAgentUpgrade(hostID, version, url, sha256 string)
63 // ClearAgentUpgrade drops any pending offer for hostID — called when the
64 // host leaves the fleet so a decommission cannot strand a stale offer.
65 ClearAgentUpgrade(hostID string)
66 }
67
50 // API is the HTTP handler container. 68 // API is the HTTP handler container.
51 type API struct { 69 type API struct {
52 cfg Config 70 cfg Config
@@ -60,6 +78,26 @@ type API struct {
60 console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) 78 console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer)
61 hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off 79 hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off
62 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off 80 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off
81 release ReleaseSource // nil ⇒ release discovery disabled
82 upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader)
83 }
84
85 // SetReleaseSource wires release discovery (nil leaves it disabled).
86 func (a *API) SetReleaseSource(rs ReleaseSource) { a.release = rs }
87
88 // SetAgentUpgrader wires the per-host upgrade offer sink.
89 func (a *API) SetAgentUpgrader(u AgentUpgrader) { a.upgrader = u }
90
91 // latestVersion returns the latest known release version ("" when discovery
92 // is disabled or the manifest hasn't been fetched).
93 func (a *API) latestVersion() string {
94 if a.release == nil {
95 return ""
96 }
97 if m, ok := a.release.Latest(); ok {
98 return m.Version
99 }
100 return ""
63 } 101 }
64 102
65 // New constructs an API. It starts the central SSE snapshot hub (one goroutine 103 // New constructs an API. It starts the central SSE snapshot hub (one goroutine
@@ -295,6 +333,7 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al
295 hr.Online = st.Online 333 hr.Online = st.Online
296 hr.Stale = st.Stale 334 hr.Stale = st.Stale
297 hr.Sessions = st.Sessions 335 hr.Sessions = st.Sessions
336 hr.AgentVersion = st.AgentVersion
298 // LastSeen unset ⇒ connected but never reported: leave the age fields 337 // LastSeen unset ⇒ connected but never reported: leave the age fields
299 // null rather than emit a bogus "last seen at the zero time". 338 // null rather than emit a bogus "last seen at the zero time".
300 if !st.LastSeen.IsZero() { 339 if !st.LastSeen.IsZero() {
@@ -390,10 +429,13 @@ func vmHostIDs(vms []store.VM) []string {
390 // buildHostResponses merges durable host rows with live registry state, indexing 429 // buildHostResponses merges durable host rows with live registry state, indexing
391 // the pre-fetched states map (one reg.Get per host). 430 // the pre-fetched states map (one reg.Get per host).
392 func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Alloc, states map[string]regState) []types.Host { 431 func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Alloc, states map[string]regState) []types.Host {
432 latest := a.latestVersion()
393 out := make([]types.Host, len(hosts)) 433 out := make([]types.Host, len(hosts))
394 for i, h := range hosts { 434 for i, h := range hosts {
395 rs := states[h.ID] 435 rs := states[h.ID]
396 out[i] = toHostResponse(h, rs.st, rs.ok, alloc[h.ID]) 436 out[i] = toHostResponse(h, rs.st, rs.ok, alloc[h.ID])
437 out[i].AgentUpdateAvailable = latest != "" && out[i].Online &&
438 out[i].AgentVersion != "" && release.Less(out[i].AgentVersion, latest)
397 } 439 }
398 return out 440 return out
399 } 441 }
internal/server/api/events.go
Old New
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/a73x/eitri/internal/server/api/types" 12 "github.com/a73x/eitri/internal/server/api/types"
13 "github.com/a73x/eitri/internal/server/store" 13 "github.com/a73x/eitri/internal/server/store"
14 "github.com/a73x/eitri/internal/version"
14 ) 15 )
15 16
16 // handleDecommissionHost begins graceful host decommission: its VMs are 17 // handleDecommissionHost begins graceful host decommission: its VMs are
@@ -43,6 +44,9 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
43 "host_id": id, "remote": clientIP(r), 44 "host_id": id, "remote": clientIP(r),
44 "force": "true", "vms_purged": strconv.Itoa(purged), 45 "force": "true", "vms_purged": strconv.Itoa(purged),
45 }) 46 })
47 if a.upgrader != nil {
48 a.upgrader.ClearAgentUpgrade(id)
49 }
46 a.hub.Poke(id) 50 a.hub.Poke(id)
47 a.notif.notify() 51 a.notif.notify()
48 w.WriteHeader(http.StatusOK) 52 w.WriteHeader(http.StatusOK)
@@ -58,6 +62,9 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
58 return 62 return
59 } 63 }
60 a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)}) 64 a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)})
65 if a.upgrader != nil {
66 a.upgrader.ClearAgentUpgrade(id)
67 }
61 a.hub.Poke(id) 68 a.hub.Poke(id)
62 a.notif.notify() 69 a.notif.notify()
63 w.WriteHeader(http.StatusAccepted) 70 w.WriteHeader(http.StatusAccepted)
@@ -254,7 +261,9 @@ func (a *API) marshalSnapshot() ([]byte, error) {
254 vms = filterVMs(fleet, vms) 261 vms = filterVMs(fleet, vms)
255 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) 262 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms))
256 return json.Marshal(types.StateSnapshot{ 263 return json.Marshal(types.StateSnapshot{
257 Hosts: a.buildHostResponses(hosts, alloc, states), 264 Hosts: a.buildHostResponses(hosts, alloc, states),
258 VMs: a.buildVMResponses(vms, states), 265 VMs: a.buildVMResponses(vms, states),
266 ServerVersion: version.Version,
267 LatestVersion: a.latestVersion(),
259 }) 268 })
260 } 269 }
internal/server/api/routes.go
Old New
@@ -143,6 +143,15 @@ var routeTable = []Route{
143 handler: (*API).handleRevokeCredential, 143 handler: (*API).handleRevokeCredential,
144 }, 144 },
145 { 145 {
146 Method: "POST",
147 Path: "/api/v1/hosts/{id}/upgrade-agent",
148 Auth: AuthAdmin,
149 Kind: KindJSON,
150 Success: http.StatusAccepted,
151 Doc: "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout).",
152 handler: (*API).handleUpgradeAgent,
153 },
154 {
146 Method: "GET", 155 Method: "GET",
147 Path: "/api/v1/audit", 156 Path: "/api/v1/audit",
148 Auth: AuthAdmin, 157 Auth: AuthAdmin,
internal/server/api/routes_test.go
Old New
@@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
34 // `required` array for request schemas, so a type serving both roles would 34 // `required` array for request schemas, so a type serving both roles would
35 // get the wrong treatment on one of them). 35 // get the wrong treatment on one of them).
36 func TestRouteTable(t *testing.T) { 36 func TestRouteTable(t *testing.T) {
37 const wantRoutes = 21 37 const wantRoutes = 22
38 if len(routeTable) != wantRoutes { 38 if len(routeTable) != wantRoutes {
39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) 39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes)
40 } 40 }
internal/server/api/testdata/host.golden.json
Old New
@@ -22,6 +22,8 @@
22 "mem_mb": 8192, 22 "mem_mb": 8192,
23 "disk_gb": 100 23 "disk_gb": 100
24 }, 24 },
25 "agent_version": "v0.0.1-agent",
26 "agent_update_available": true,
25 "os_id": "arch", 27 "os_id": "arch",
26 "os_pretty": "Arch Linux", 28 "os_pretty": "Arch Linux",
27 "os_version": "rolling", 29 "os_version": "rolling",
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -24,6 +24,8 @@
24 "mem_mb": 8192, 24 "mem_mb": 8192,
25 "disk_gb": 100 25 "disk_gb": 100
26 }, 26 },
27 "agent_version": "v0.0.1-agent",
28 "agent_update_available": true,
27 "os_id": "arch", 29 "os_id": "arch",
28 "os_pretty": "Arch Linux", 30 "os_pretty": "Arch Linux",
29 "os_version": "rolling", 31 "os_version": "rolling",
@@ -63,5 +65,7 @@
63 "destroy_at": 1785153600, 65 "destroy_at": 1785153600,
64 "lifecycle": "deleting" 66 "lifecycle": "deleting"
65 } 67 }
66 ] 68 ],
69 "server_version": "v0.0.1-test",
70 "latest_version": "v0.0.2-test"
67 } 71 }
internal/server/api/types/types.go
Old New
@@ -57,6 +57,12 @@ type Host struct {
57 Sessions int `json:"sessions"` 57 Sessions int `json:"sessions"`
58 Capacity Capacity `json:"capacity"` // host TOTALS (when online) 58 Capacity Capacity `json:"capacity"` // host TOTALS (when online)
59 Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed) 59 Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed)
60 // AgentVersion is the agent binary's stamped version from its Hello
61 // ("" until a version-reporting agent connects). AgentUpdateAvailable is
62 // server-computed: a newer release exists for this agent AND the host is
63 // online (offerable).
64 AgentVersion string `json:"agent_version"`
65 AgentUpdateAvailable bool `json:"agent_update_available"`
60 // Host OS facts (persisted; refreshed from each Hello). 66 // Host OS facts (persisted; refreshed from each Hello).
61 OSID string `json:"os_id"` 67 OSID string `json:"os_id"`
62 OSPretty string `json:"os_pretty"` 68 OSPretty string `json:"os_pretty"`
@@ -108,8 +114,10 @@ type VM struct {
108 // StateSnapshot is the full fleet state pushed as each `event: state` frame 114 // StateSnapshot is the full fleet state pushed as each `event: state` frame
109 // over the SSE stream (GET /api/v1/events). 115 // over the SSE stream (GET /api/v1/events).
110 type StateSnapshot struct { 116 type StateSnapshot struct {
111 Hosts []Host `json:"hosts"` 117 Hosts []Host `json:"hosts"`
112 VMs []VM `json:"vms"` 118 VMs []VM `json:"vms"`
119 ServerVersion string `json:"server_version"`
120 LatestVersion string `json:"latest_version"` // "" until the manifest is known
113 } 121 }
114 122
115 // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an 123 // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an
internal/server/api/upgrade.go
Old New
@@ -0,0 +1,58 @@
1 package api
2
3 import (
4 "database/sql"
5 "errors"
6 "net/http"
7
8 "github.com/a73x/eitri/internal/server/release"
9 )
10
11 // handleUpgradeAgent records a pending self-upgrade offer for one host's agent
12 // and pokes its snapshot stream. The human is the rollout controller: nothing
13 // upgrades without this per-host click, so a bad release stops at one host.
14 func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) {
15 if !a.requireFleet(w, r) {
16 return
17 }
18 if a.release == nil || a.upgrader == nil {
19 http.Error(w, "release discovery not configured", http.StatusServiceUnavailable)
20 return
21 }
22 m, ok := a.release.Latest()
23 if !ok {
24 http.Error(w, "release manifest not fetched yet", http.StatusServiceUnavailable)
25 return
26 }
27 id := r.PathValue("id")
28 h, err := a.st.GetHost(id)
29 switch {
30 case errors.Is(err, sql.ErrNoRows):
31 http.Error(w, "host not found", http.StatusNotFound)
32 return
33 case err != nil:
34 http.Error(w, "internal error", http.StatusInternalServerError)
35 return
36 }
37 st, okReg := a.reg.Get(id)
38 if !okReg || !st.Online {
39 http.Error(w, "host is offline", http.StatusConflict)
40 return
41 }
42 if st.AgentVersion == "" || !release.Less(st.AgentVersion, m.Version) {
43 http.Error(w, "agent is not behind the latest release", http.StatusConflict)
44 return
45 }
46 art, ok := m.Artifacts["eitri-agent"][h.OS+"/"+h.Arch]
47 if !ok {
48 http.Error(w, "no eitri-agent artifact for "+h.OS+"/"+h.Arch, http.StatusConflict)
49 return
50 }
51 a.upgrader.OfferAgentUpgrade(id, m.Version, art.URL, art.SHA256)
52 a.hub.Poke(id)
53 a.audit("host.agent.upgrade", map[string]string{
54 "host_id": id, "remote": clientIP(r),
55 "from": st.AgentVersion, "to": m.Version,
56 })
57 w.WriteHeader(http.StatusAccepted)
58 }
internal/server/api/upgrade_test.go
Old New
@@ -0,0 +1,226 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "testing"
7
8 "github.com/a73x/eitri/internal/server/api/types"
9 "github.com/a73x/eitri/internal/server/registry"
10 "github.com/a73x/eitri/internal/server/release"
11 "github.com/a73x/eitri/internal/version"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 )
15
16 // fakeRelease is a ReleaseSource test double that always returns the same
17 // manifest (or ok=false when the manifest hasn't been "fetched").
18 type fakeRelease struct {
19 m release.Manifest
20 ok bool
21 }
22
23 func (f fakeRelease) Latest() (release.Manifest, bool) { return f.m, f.ok }
24
25 // fakeUpgrader is an AgentUpgrader test double that captures the last offer.
26 type fakeUpgrader struct{ host, version, url, sha string }
27
28 func (f *fakeUpgrader) OfferAgentUpgrade(hostID, version, url, sha256 string) {
29 f.host, f.version, f.url, f.sha = hostID, version, url, sha256
30 }
31
32 func (f *fakeUpgrader) ClearAgentUpgrade(hostID string) {
33 if f.host == hostID {
34 f.host, f.version, f.url, f.sha = "", "", "", ""
35 }
36 }
37
38 // upgradeManifest is the happy-path manifest: a newer release with an
39 // eitri-agent artifact for linux/amd64 (matching the enrolled test host).
40 func upgradeManifest() release.Manifest {
41 return release.Manifest{Version: "v0.0.2", Artifacts: map[string]map[string]release.Artifact{
42 "eitri-agent": {"linux/amd64": {URL: "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", SHA256: "abcd"}},
43 }}
44 }
45
46 func TestUpgradeAgentEndpoint(t *testing.T) {
47 t.Run("happy path", func(t *testing.T) {
48 ts, _, _, reg, a := newServer(t)
49 out := enroll(t, ts)
50 hostID := out["host_id"]
51 reg.SetAgentVersion(hostID, "v0.0.1")
52 reg.UpdateReport(hostID, registry.Report{})
53
54 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
55 up := &fakeUpgrader{}
56 a.SetAgentUpgrader(up)
57
58 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
59 require.Equal(t, http.StatusAccepted, resp.StatusCode)
60
61 assert.Equal(t, hostID, up.host)
62 assert.Equal(t, "v0.0.2", up.version)
63 assert.Equal(t, "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", up.url)
64 assert.Equal(t, "abcd", up.sha)
65
66 auditResp := do(t, "GET", ts.URL+"/api/v1/audit", "admintok", nil)
67 require.Equal(t, http.StatusOK, auditResp.StatusCode)
68 var rows []types.AuditEvent
69 require.NoError(t, json.NewDecoder(auditResp.Body).Decode(&rows))
70 var found bool
71 for _, row := range rows {
72 if row.Action == "host.agent.upgrade" {
73 found = true
74 assert.Contains(t, string(row.Detail), hostID)
75 }
76 }
77 assert.True(t, found, "expected a host.agent.upgrade audit row")
78 })
79
80 t.Run("release source not wired", func(t *testing.T) {
81 ts, _, _, reg, a := newServer(t)
82 out := enroll(t, ts)
83 hostID := out["host_id"]
84 reg.SetAgentVersion(hostID, "v0.0.1")
85 reg.UpdateReport(hostID, registry.Report{})
86 a.SetAgentUpgrader(&fakeUpgrader{})
87 // a.release left nil.
88
89 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
90 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
91 })
92
93 t.Run("manifest not fetched", func(t *testing.T) {
94 ts, _, _, reg, a := newServer(t)
95 out := enroll(t, ts)
96 hostID := out["host_id"]
97 reg.SetAgentVersion(hostID, "v0.0.1")
98 reg.UpdateReport(hostID, registry.Report{})
99 a.SetReleaseSource(fakeRelease{ok: false})
100 a.SetAgentUpgrader(&fakeUpgrader{})
101
102 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
103 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
104 })
105
106 t.Run("unknown host id", func(t *testing.T) {
107 ts, _, _, _, a := newServer(t)
108 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
109 a.SetAgentUpgrader(&fakeUpgrader{})
110
111 resp := do(t, "POST", ts.URL+"/api/v1/hosts/does-not-exist/upgrade-agent", "admintok", nil)
112 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113 })
114
115 t.Run("host offline", func(t *testing.T) {
116 ts, _, _, _, a := newServer(t)
117 out := enroll(t, ts)
118 hostID := out["host_id"]
119 // No registry report at all: never connected, so offline.
120 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
121 a.SetAgentUpgrader(&fakeUpgrader{})
122
123 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
124 assert.Equal(t, http.StatusConflict, resp.StatusCode)
125 })
126
127 t.Run("agent already at latest", func(t *testing.T) {
128 ts, _, _, reg, a := newServer(t)
129 out := enroll(t, ts)
130 hostID := out["host_id"]
131 reg.SetAgentVersion(hostID, "v0.0.2")
132 reg.UpdateReport(hostID, registry.Report{})
133 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
134 a.SetAgentUpgrader(&fakeUpgrader{})
135
136 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
137 assert.Equal(t, http.StatusConflict, resp.StatusCode)
138 })
139
140 t.Run("no artifact for host os/arch", func(t *testing.T) {
141 ts, _, _, reg, a := newServer(t)
142 out := enroll(t, ts)
143 hostID := out["host_id"]
144 reg.SetAgentVersion(hostID, "v0.0.1")
145 reg.UpdateReport(hostID, registry.Report{})
146 a.SetReleaseSource(fakeRelease{ok: true, m: release.Manifest{
147 Version: "v0.0.2",
148 Artifacts: map[string]map[string]release.Artifact{},
149 }})
150 a.SetAgentUpgrader(&fakeUpgrader{})
151
152 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil)
153 assert.Equal(t, http.StatusConflict, resp.StatusCode)
154 })
155 }
156
157 // TestHostResponseAgentUpdateAvailable pins the read-side computation in
158 // buildHostResponses: an online host whose reported AgentVersion trails the
159 // latest known release surfaces agent_version and a true
160 // agent_update_available; with no release source wired (or an unfetched
161 // manifest) the flag stays false even though the version still trails.
162 func TestHostResponseAgentUpdateAvailable(t *testing.T) {
163 t.Run("online + behind + release known -> true", func(t *testing.T) {
164 ts, _, _, reg, a := newServer(t)
165 out := enroll(t, ts)
166 hostID := out["host_id"]
167 reg.SetAgentVersion(hostID, "v0.0.1")
168 reg.UpdateReport(hostID, registry.Report{})
169 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
170
171 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)
172 require.Equal(t, http.StatusOK, resp.StatusCode)
173 items := decodeJSONKeys(t, resp)
174 require.Len(t, items, 1)
175 assert.Equal(t, "v0.0.1", items[0]["agent_version"])
176 assert.Equal(t, true, items[0]["agent_update_available"])
177 })
178
179 t.Run("release source unwired -> false", func(t *testing.T) {
180 ts, _, _, reg, _ := newServer(t)
181 out := enroll(t, ts)
182 hostID := out["host_id"]
183 reg.SetAgentVersion(hostID, "v0.0.1")
184 reg.UpdateReport(hostID, registry.Report{})
185 // a.release left nil.
186
187 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)
188 require.Equal(t, http.StatusOK, resp.StatusCode)
189 items := decodeJSONKeys(t, resp)
190 require.Len(t, items, 1)
191 assert.Equal(t, "v0.0.1", items[0]["agent_version"])
192 assert.Equal(t, false, items[0]["agent_update_available"])
193 })
194
195 t.Run("manifest not fetched -> false", func(t *testing.T) {
196 ts, _, _, reg, a := newServer(t)
197 out := enroll(t, ts)
198 hostID := out["host_id"]
199 reg.SetAgentVersion(hostID, "v0.0.1")
200 reg.UpdateReport(hostID, registry.Report{})
201 a.SetReleaseSource(fakeRelease{ok: false})
202
203 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)
204 require.Equal(t, http.StatusOK, resp.StatusCode)
205 items := decodeJSONKeys(t, resp)
206 require.Len(t, items, 1)
207 assert.Equal(t, false, items[0]["agent_update_available"])
208 })
209 }
210
211 // TestMarshalSnapshotCarriesVersions pins that the SSE snapshot payload
212 // itself (not just GET /hosts) carries the server's own build version and the
213 // latest known release version. marshalSnapshot is called directly (in-package)
214 // rather than driving the SSE endpoint, per the plan's guidance.
215 func TestMarshalSnapshotCarriesVersions(t *testing.T) {
216 _, _, _, _, a := newServer(t)
217 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
218
219 raw, err := a.marshalSnapshot()
220 require.NoError(t, err)
221
222 var snap types.StateSnapshot
223 require.NoError(t, json.Unmarshal(raw, &snap))
224 assert.Equal(t, version.Version, snap.ServerVersion)
225 assert.Equal(t, "v0.0.2", snap.LatestVersion)
226 }
internal/server/api/wire_golden_test.go
Old New
@@ -50,23 +50,25 @@ func TestWireGolden(t *testing.T) {
50 base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) 50 base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
51 51
52 host := types.Host{ 52 host := types.Host{
53 ID: "h-1234", 53 ID: "h-1234",
54 Name: "mewtwo", 54 Name: "mewtwo",
55 OS: "linux", 55 OS: "linux",
56 Arch: "amd64", 56 Arch: "amd64",
57 Provisioner: "cloudhypervisor", 57 Provisioner: "cloudhypervisor",
58 BridgeCIDR: "10.77.1.0/24", 58 BridgeCIDR: "10.77.1.0/24",
59 Status: "active", 59 Status: "active",
60 EnrolledAt: base, 60 EnrolledAt: base,
61 Online: true, 61 Online: true,
62 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512}, 62 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512},
63 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100}, 63 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100},
64 OSID: "arch", 64 AgentVersion: "v0.0.1-agent",
65 OSPretty: "Arch Linux", 65 AgentUpdateAvailable: true,
66 OSVersion: "rolling", 66 OSID: "arch",
67 Kernel: "6.15.4-arch1-1", 67 OSPretty: "Arch Linux",
68 CPUModel: "AMD Ryzen 9 7950X", 68 OSVersion: "rolling",
69 Virt: "kvm", 69 Kernel: "6.15.4-arch1-1",
70 CPUModel: "AMD Ryzen 9 7950X",
71 Virt: "kvm",
70 Metrics: &types.Metrics{ 72 Metrics: &types.Metrics{
71 UptimeS: 86400, 73 UptimeS: 86400,
72 MemUsedMB: 12000, 74 MemUsedMB: 12000,
@@ -103,8 +105,10 @@ func TestWireGolden(t *testing.T) {
103 goldenCheck(t, "vm", vm) 105 goldenCheck(t, "vm", vm)
104 106
105 goldenCheck(t, "snapshot", types.StateSnapshot{ 107 goldenCheck(t, "snapshot", types.StateSnapshot{
106 Hosts: []types.Host{host}, 108 Hosts: []types.Host{host},
107 VMs: []types.VM{vm}, 109 VMs: []types.VM{vm},
110 ServerVersion: "v0.0.1-test",
111 LatestVersion: "v0.0.2-test",
108 }) 112 })
109 113
110 goldenCheck(t, "enroll-request", types.EnrollRequest{ 114 goldenCheck(t, "enroll-request", types.EnrollRequest{
internal/server/config/config.go
Old New
@@ -39,4 +39,9 @@ type Config struct {
39 // "localhost". It must match the host in EITRI_GATE so `@cert-authority` 39 // "localhost". It must match the host in EITRI_GATE so `@cert-authority`
40 // verification accepts the presented host cert. 40 // verification accepts the presented host cert.
41 SSHGateDomain string `json:"ssh_gate_domain"` 41 SSHGateDomain string `json:"ssh_gate_domain"`
42 // ReleaseManifestURL is where the server discovers the latest eitri
43 // release (default https://eitri.sh/dl/latest/manifest.json when the
44 // field is absent — applied by cmd, not here). Empty string in an
45 // explicit config disables release discovery and every upgrade surface.
46 ReleaseManifestURL *string `json:"release_manifest_url"`
42 } 47 }
internal/server/registry/registry.go
Old New
@@ -62,6 +62,10 @@ type HostState struct {
62 // value with a live LastSeen means the agent is churning/flapping its QUIC 62 // value with a live LastSeen means the agent is churning/flapping its QUIC
63 // session even though it looks online; a flat value means a stable link. 63 // session even though it looks online; a flat value means a stable link.
64 Sessions int 64 Sessions int
65 // AgentVersion is the agent binary's stamped version, set from each Hello
66 // (like Sessions, owned by the connect path, preserved across reports).
67 // Empty until a version-reporting agent connects.
68 AgentVersion string
65 // The following are derived on each Get from LastSeen and the clock; they 69 // The following are derived on each Get from LastSeen and the clock; they
66 // are not stored. 70 // are not stored.
67 Online bool 71 Online bool
@@ -89,7 +93,8 @@ func (r *Registry) UpdateReport(hostID string, rep Report) {
89 // Preserve the session counter across reports: UpdateReport replaces the 93 // Preserve the session counter across reports: UpdateReport replaces the
90 // whole HostState, and Sessions is owned by RecordConnect, not the report. 94 // whole HostState, and Sessions is owned by RecordConnect, not the report.
91 sessions := r.m[hostID].Sessions 95 sessions := r.m[hostID].Sessions
92 r.m[hostID] = HostState{Report: rep, LastSeen: r.now(), Sessions: sessions} 96 agentVersion := r.m[hostID].AgentVersion
97 r.m[hostID] = HostState{Report: rep, LastSeen: r.now(), Sessions: sessions, AgentVersion: agentVersion}
93 } 98 }
94 99
95 // RecordConnect increments the host's session counter, marking one agent 100 // RecordConnect increments the host's session counter, marking one agent
@@ -103,6 +108,16 @@ func (r *Registry) RecordConnect(hostID string) {
103 r.m[hostID] = st 108 r.m[hostID] = st
104 } 109 }
105 110
111 // SetAgentVersion records the host's agent binary version from its Hello.
112 // Preserves existing report state, like RecordConnect.
113 func (r *Registry) SetAgentVersion(hostID, v string) {
114 r.mu.Lock()
115 defer r.mu.Unlock()
116 st := r.m[hostID]
117 st.AgentVersion = v
118 r.m[hostID] = st
119 }
120
106 func (r *Registry) Get(hostID string) (HostState, bool) { 121 func (r *Registry) Get(hostID string) (HostState, bool) {
107 r.mu.RLock() 122 r.mu.RLock()
108 defer r.mu.RUnlock() 123 defer r.mu.RUnlock()
internal/server/registry/registry_test.go
Old New
@@ -120,3 +120,14 @@ func TestSessionsCountAndReportPreservation(t *testing.T) {
120 assert.Equal(t, 2, st.Sessions) 120 assert.Equal(t, 2, st.Sessions)
121 assert.Equal(t, int64(4), st.Capacity.VCPUs, "reconnect must not blank live state") 121 assert.Equal(t, int64(4), st.Capacity.VCPUs, "reconnect must not blank live state")
122 } 122 }
123
124 func TestAgentVersionSurvivesReports(t *testing.T) {
125 now := time.Now()
126 r := New(func() time.Time { return now })
127 r.SetAgentVersion("h1", "v0.0.2")
128 r.UpdateReport("h1", Report{})
129 st, ok := r.Get("h1")
130 if !ok || st.AgentVersion != "v0.0.2" {
131 t.Fatalf("AgentVersion = %q ok=%v, want v0.0.2 true", st.AgentVersion, ok)
132 }
133 }
internal/server/release/release.go
Old New
@@ -0,0 +1,155 @@
1 // Package release discovers the latest eitri release from a manifest URL
2 // (eitri.sh) and orders versions. The manifest is the bootstrap contract:
3 // stable URLs + sha256 per artifact, fetchable by tooling and humans alike.
4 package release
5
6 import (
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16 )
17
18 // Artifact is one downloadable binary build.
19 type Artifact struct {
20 URL string `json:"url"`
21 SHA256 string `json:"sha256"`
22 }
23
24 // Manifest is the eitri.sh release manifest: a version plus per-binary,
25 // per-platform artifacts (keys like "eitri-agent" → "linux/amd64").
26 type Manifest struct {
27 Version string `json:"version"`
28 Artifacts map[string]map[string]Artifact `json:"artifacts"`
29 }
30
31 // Client fetches and caches the latest manifest. Construct with New.
32 type Client struct {
33 url string
34 hc *http.Client
35
36 mu sync.RWMutex
37 latest *Manifest
38 }
39
40 // New builds a Client for the given manifest URL.
41 func New(url string) *Client {
42 return &Client{url: url, hc: &http.Client{Timeout: 30 * time.Second}}
43 }
44
45 // Latest returns the most recently fetched manifest; ok=false before the
46 // first successful Refresh. The returned Manifest's Artifacts map aliases
47 // cached state and must be treated read-only — callers never mutate it, and
48 // Refresh only ever swaps the whole manifest (never writes in place), so the
49 // borrow stays valid without copying.
50 func (c *Client) Latest() (Manifest, bool) {
51 c.mu.RLock()
52 defer c.mu.RUnlock()
53 if c.latest == nil {
54 return Manifest{}, false
55 }
56 return *c.latest, true
57 }
58
59 // Refresh fetches the manifest once. A failure leaves the previous manifest
60 // in place (stale beats absent for a signal-only feature).
61 func (c *Client) Refresh(ctx context.Context) error {
62 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
63 if err != nil {
64 return err
65 }
66 resp, err := c.hc.Do(req)
67 if err != nil {
68 return fmt.Errorf("fetch release manifest: %w", err)
69 }
70 defer resp.Body.Close()
71 if resp.StatusCode/100 != 2 {
72 return fmt.Errorf("fetch release manifest: HTTP %d", resp.StatusCode)
73 }
74 var m Manifest
75 // A manifest is a few KB; bound the read so a hostile or misconfigured
76 // endpoint can't balloon memory.
77 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
78 return fmt.Errorf("decode release manifest: %w", err)
79 }
80 if m.Version == "" {
81 return fmt.Errorf("release manifest has no version")
82 }
83 c.mu.Lock()
84 c.latest = &m
85 c.mu.Unlock()
86 return nil
87 }
88
89 // warmRetry is how often Poll retries while NO manifest has ever been fetched
90 // successfully — deliberately short so a transient network blip at server
91 // boot doesn't leave release discovery dark for a full `every` interval
92 // (typically 24h). Overridable in tests, like sshgate.handshakeGrace.
93 var warmRetry = 15 * time.Minute
94
95 // Poll refreshes now and then on a schedule until ctx is cancelled. Before the
96 // first successful fetch, a failed refresh is retried every warmRetry (fast)
97 // rather than waiting the full `every` interval; once a manifest has been
98 // fetched at least once, Poll settles into ticking at `every`. Refresh errors
99 // are reported through onErr (nil ⇒ ignored); a failure keeps the last good
100 // manifest.
101 func (c *Client) Poll(ctx context.Context, every time.Duration, onErr func(error)) {
102 refresh := func() {
103 if err := c.Refresh(ctx); err != nil && onErr != nil {
104 onErr(err)
105 }
106 }
107 refresh()
108 for {
109 interval := every
110 if _, ok := c.Latest(); !ok {
111 interval = warmRetry
112 }
113 t := time.NewTimer(interval)
114 select {
115 case <-ctx.Done():
116 t.Stop()
117 return
118 case <-t.C:
119 refresh()
120 }
121 }
122 }
123
124 // Less reports whether version a orders strictly before b. Versions are
125 // eitri's own tags ("vX.Y.Z"); anything unparsable (e.g. "dev") never orders
126 // before anything — an unstamped build never sees an upgrade.
127 func Less(a, b string) bool {
128 pa, oka := parse(a)
129 pb, okb := parse(b)
130 if !oka || !okb {
131 return false
132 }
133 for i := range 3 {
134 if pa[i] != pb[i] {
135 return pa[i] < pb[i]
136 }
137 }
138 return false
139 }
140
141 func parse(v string) ([3]int, bool) {
142 var out [3]int
143 parts := strings.SplitN(strings.TrimPrefix(v, "v"), ".", 3)
144 if len(parts) != 3 {
145 return out, false
146 }
147 for i, p := range parts {
148 n, err := strconv.Atoi(p)
149 if err != nil || n < 0 {
150 return out, false
151 }
152 out[i] = n
153 }
154 return out, true
155 }
internal/server/release/release_test.go
Old New
@@ -0,0 +1,204 @@
1 package release
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11 )
12
13 func TestLess(t *testing.T) {
14 cases := []struct {
15 a, b string
16 want bool
17 }{
18 {"v0.0.1", "v0.0.2", true},
19 {"v0.0.2", "v0.0.1", false},
20 {"v0.0.2", "v0.0.2", false},
21 {"v0.9.0", "v0.10.0", true}, // numeric, not lexicographic
22 {"dev", "v0.0.2", false}, // unparsable never upgrades
23 {"v0.0.1", "dev", false},
24 }
25 for _, c := range cases {
26 if got := Less(c.a, c.b); got != c.want {
27 t.Errorf("Less(%q,%q) = %v, want %v", c.a, c.b, got, c.want)
28 }
29 }
30 }
31
32 func TestRefreshParsesManifest(t *testing.T) {
33 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 w.Write([]byte(`{"version":"v0.0.2","artifacts":{"eitri-agent":{"linux/amd64":{"url":"https://eitri.sh/dl/v0.0.2/a.tar.gz","sha256":"ab"}}}}`))
35 }))
36 defer srv.Close()
37 c := New(srv.URL)
38 if err := c.Refresh(context.Background()); err != nil {
39 t.Fatal(err)
40 }
41 m, ok := c.Latest()
42 if !ok || m.Version != "v0.0.2" {
43 t.Fatalf("Latest = %+v ok=%v", m, ok)
44 }
45 if m.Artifacts["eitri-agent"]["linux/amd64"].SHA256 != "ab" {
46 t.Fatal("artifact not parsed")
47 }
48 }
49
50 func TestRefreshErrors(t *testing.T) {
51 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 w.WriteHeader(http.StatusInternalServerError)
53 }))
54 defer srv.Close()
55 c := New(srv.URL)
56 if err := c.Refresh(context.Background()); err == nil {
57 t.Fatal("want error on non-2xx")
58 }
59 if _, ok := c.Latest(); ok {
60 t.Fatal("failed refresh must not populate Latest")
61 }
62 }
63
64 // TestRefreshStaleBeatsAbsent verifies that once a manifest has been fetched
65 // successfully, a later failed Refresh leaves the previous manifest in place
66 // rather than clearing it.
67 func TestRefreshStaleBeatsAbsent(t *testing.T) {
68 fail := false
69 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70 if fail {
71 w.WriteHeader(http.StatusInternalServerError)
72 return
73 }
74 w.Write([]byte(`{"version":"v0.0.3","artifacts":{}}`))
75 }))
76 defer srv.Close()
77 c := New(srv.URL)
78 if err := c.Refresh(context.Background()); err != nil {
79 t.Fatal(err)
80 }
81 fail = true
82 if err := c.Refresh(context.Background()); err == nil {
83 t.Fatal("want error on second refresh")
84 }
85 m, ok := c.Latest()
86 if !ok || m.Version != "v0.0.3" {
87 t.Fatalf("Latest after failed refresh = %+v ok=%v, want stale v0.0.3", m, ok)
88 }
89 }
90
91 // TestRefreshRejectsEmptyVersion verifies a manifest with an empty version
92 // string is treated as invalid and does not populate Latest.
93 func TestRefreshRejectsEmptyVersion(t *testing.T) {
94 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 w.Write([]byte(`{"version":"","artifacts":{}}`))
96 }))
97 defer srv.Close()
98 c := New(srv.URL)
99 if err := c.Refresh(context.Background()); err == nil {
100 t.Fatal("want error on empty version")
101 }
102 if _, ok := c.Latest(); ok {
103 t.Fatal("empty-version refresh must not populate Latest")
104 }
105 }
106
107 // TestRefreshRejectsMalformedJSON verifies a non-JSON body is a decode error
108 // and never populates Latest.
109 func TestRefreshRejectsMalformedJSON(t *testing.T) {
110 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111 w.Write([]byte(`{not json`))
112 }))
113 defer srv.Close()
114 c := New(srv.URL)
115 if err := c.Refresh(context.Background()); err == nil {
116 t.Fatal("want error on malformed JSON")
117 }
118 if _, ok := c.Latest(); ok {
119 t.Fatal("malformed-JSON refresh must not populate Latest")
120 }
121 }
122
123 // TestPollWarmStartRetriesFastUntilFirstSuccess pins the warm-start behavior:
124 // while no manifest has ever been fetched, Poll retries on the fast warmRetry
125 // cadence (not the caller's `every`), so a boot-time blip doesn't leave
126 // release discovery dark for a full day. Once the first fetch succeeds, Poll
127 // settles onto `every` — no further calls arrive on the fast cadence.
128 func TestPollWarmStartRetriesFastUntilFirstSuccess(t *testing.T) {
129 orig := warmRetry
130 warmRetry = 20 * time.Millisecond
131 t.Cleanup(func() { warmRetry = orig })
132
133 var mu sync.Mutex
134 calls := 0
135 callCh := make(chan int, 10)
136 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137 mu.Lock()
138 calls++
139 n := calls
140 mu.Unlock()
141 if n < 3 {
142 w.WriteHeader(http.StatusInternalServerError)
143 callCh <- n
144 return
145 }
146 w.Write([]byte(`{"version":"v0.0.2","artifacts":{}}`))
147 callCh <- n
148 }))
149 defer srv.Close()
150
151 c := New(srv.URL)
152 ctx, cancel := context.WithCancel(context.Background())
153
154 var errCount int32
155 done := make(chan struct{})
156 go func() {
157 c.Poll(ctx, time.Hour, func(err error) { atomic.AddInt32(&errCount, 1) })
158 close(done)
159 }()
160
161 // The first two calls fail (n=1,2); the fast warmRetry cadence (not the
162 // 1h `every`) is what makes them arrive quickly.
163 for want := 1; want <= 3; want++ {
164 select {
165 case n := <-callCh:
166 if n != want {
167 t.Fatalf("call order: got %d want %d", n, want)
168 }
169 case <-time.After(2 * time.Second):
170 t.Fatalf("timed out waiting for call %d (warm retry not firing?)", want)
171 }
172 }
173 if atomic.LoadInt32(&errCount) < 2 {
174 t.Fatalf("errCount = %d, want >= 2 (the two failed warm-retry attempts)", errCount)
175 }
176
177 // Poll away from the handler; give the third (successful) response time to
178 // be parsed and stored before asserting on it.
179 deadline := time.Now().Add(1 * time.Second)
180 for {
181 if _, ok := c.Latest(); ok {
182 break
183 }
184 if time.Now().After(deadline) {
185 t.Fatal("manifest never became available after the successful fetch")
186 }
187 time.Sleep(time.Millisecond)
188 }
189 m, ok := c.Latest()
190 if !ok || m.Version != "v0.0.2" {
191 t.Fatalf("Latest = %+v ok=%v, want v0.0.2", m, ok)
192 }
193
194 // Once fetched, Poll must settle onto `every` (1h): no further call
195 // arrives within a fast-cadence-sized window.
196 select {
197 case n := <-callCh:
198 t.Fatalf("unexpected extra call %d after first success — should have settled onto `every`", n)
199 case <-time.After(5 * warmRetry):
200 }
201
202 cancel()
203 <-done
204 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -58,6 +58,12 @@ type Service struct {
58 // console broker opens per-session streams on it. 58 // console broker opens per-session streams on it.
59 consoleMu sync.Mutex 59 consoleMu sync.Mutex
60 conns map[string]quic.Connection 60 conns map[string]quic.Connection
61 // offersMu guards offers: pending per-host agent self-upgrades, set by the
62 // API (operator click), carried in that host's snapshots, cleared when a
63 // Hello reports the target version. In-memory only — a restart forgets
64 // pending offers and the operator clicks again (idempotent).
65 offersMu sync.Mutex
66 offers map[string]*pb.AgentUpgrade
61 } 67 }
62 68
63 // New constructs a Service with the production-default down-stream write 69 // New constructs a Service with the production-default down-stream write
@@ -74,7 +80,8 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se
74 writeTimeout = defaultWriteTimeout 80 writeTimeout = defaultWriteTimeout
75 } 81 }
76 return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, 82 return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout,
77 conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker()} 83 conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(),
84 offers: map[string]*pb.AgentUpgrade{}}
78 } 85 }
79 86
80 // Serve accepts QUIC connections until ctx is cancelled. 87 // Serve accepts QUIC connections until ctx is cancelled.
@@ -148,6 +155,8 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
148 return 155 return
149 } 156 }
150 s.reg.RecordConnect(hostID) 157 s.reg.RecordConnect(hostID)
158 s.reg.SetAgentVersion(hostID, h.GetFacts().GetAgentVersion())
159 s.clearOfferIfDone(hostID, h.GetFacts().GetAgentVersion())
151 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch()) 160 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch())
152 161
153 // Best-effort: refresh the host's OS facts from this Hello. A failed write 162 // Best-effort: refresh the host's OS facts from this Hello. A failed write
@@ -245,6 +254,7 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error {
245 return fmt.Errorf("desired for host: %w", err) 254 return fmt.Errorf("desired for host: %w", err)
246 } 255 }
247 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))} 256 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))}
257 snap.AgentUpgrade = s.offerFor(hostID)
248 caCache := map[string][]string{} // tenant -> canonical CA lines 258 caCache := map[string][]string{} // tenant -> canonical CA lines
249 for _, v := range vms { 259 for _, v := range vms {
250 cas, ok := caCache[v.Tenant] 260 cas, ok := caCache[v.Tenant]
@@ -279,6 +289,40 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error {
279 return transport.WriteMsg(down, &pb.ServerMessage{Msg: &pb.ServerMessage_Snapshot{Snapshot: snap}}) 289 return transport.WriteMsg(down, &pb.ServerMessage{Msg: &pb.ServerMessage_Snapshot{Snapshot: snap}})
280 } 290 }
281 291
292 // OfferAgentUpgrade records a pending agent self-upgrade for hostID; the
293 // host's next snapshot carries it (callers poke the host via the hub).
294 func (s *Service) OfferAgentUpgrade(hostID, version, url, sha256 string) {
295 s.offersMu.Lock()
296 defer s.offersMu.Unlock()
297 s.offers[hostID] = &pb.AgentUpgrade{Version: version, Url: url, Sha256: sha256}
298 }
299
300 // offerFor returns hostID's pending upgrade (nil when none).
301 func (s *Service) offerFor(hostID string) *pb.AgentUpgrade {
302 s.offersMu.Lock()
303 defer s.offersMu.Unlock()
304 return s.offers[hostID]
305 }
306
307 // ClearAgentUpgrade drops any pending offer for hostID unconditionally —
308 // called when the host leaves the fleet (decommission/purge) so a stale offer
309 // cannot outlive its host.
310 func (s *Service) ClearAgentUpgrade(hostID string) {
311 s.offersMu.Lock()
312 defer s.offersMu.Unlock()
313 delete(s.offers, hostID)
314 }
315
316 // clearOfferIfDone drops hostID's pending offer once the agent reports the
317 // target version (from its Hello) — the offer has converged.
318 func (s *Service) clearOfferIfDone(hostID, reportedVersion string) {
319 s.offersMu.Lock()
320 defer s.offersMu.Unlock()
321 if up, ok := s.offers[hostID]; ok && up.Version == reportedVersion {
322 delete(s.offers, hostID)
323 }
324 }
325
282 // failWrite handles a failed down-stream write by closing the connection. A 326 // failWrite handles a failed down-stream write by closing the connection. A
283 // canceled hub subscription does NOT unblock an in-flight Write, and the read 327 // canceled hub subscription does NOT unblock an in-flight Write, and the read
284 // loop is parked in ReadMsg(up); closing the connection unblocks that ReadMsg so 328 // loop is parked in ReadMsg(up); closing the connection unblocks that ReadMsg so
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -733,3 +733,34 @@ func TestReportMetricsLandInRegistry(t *testing.T) {
733 assert.Equal(t, int64(2048), st.Metrics.MemUsedMB) 733 assert.Equal(t, int64(2048), st.Metrics.MemUsedMB)
734 assert.Equal(t, int64(80), st.Metrics.DiskFreeGB) 734 assert.Equal(t, int64(80), st.Metrics.DiskFreeGB)
735 } 735 }
736
737 func TestUpgradeOffers(t *testing.T) {
738 s := &Service{offers: map[string]*pb.AgentUpgrade{}}
739
740 s.OfferAgentUpgrade("h1", "v0.0.2", "https://eitri.sh/dl/v0.0.2/a.tar.gz", "ab")
741 up := s.offerFor("h1")
742 if up == nil || up.Version != "v0.0.2" {
743 t.Fatalf("offerFor = %v", up)
744 }
745 if s.offerFor("h2") != nil {
746 t.Fatal("offer must be per-host")
747 }
748
749 // A Hello reporting a DIFFERENT version keeps the offer.
750 s.clearOfferIfDone("h1", "v0.0.1")
751 if s.offerFor("h1") == nil {
752 t.Fatal("offer cleared too early")
753 }
754 // A Hello reporting the target clears it.
755 s.clearOfferIfDone("h1", "v0.0.2")
756 if s.offerFor("h1") != nil {
757 t.Fatal("offer not cleared at target version")
758 }
759
760 // Decommission clears unconditionally — a stale offer must not outlive its host.
761 s.OfferAgentUpgrade("h3", "v0.0.2", "u", "s")
762 s.ClearAgentUpgrade("h3")
763 if s.offerFor("h3") != nil {
764 t.Fatal("ClearAgentUpgrade must drop the offer")
765 }
766 }
internal/shape/classify.go
Old New
@@ -34,7 +34,8 @@ func classify(rel string) Plane {
34 strings.HasPrefix(rel, "internal/joinblob"), 34 strings.HasPrefix(rel, "internal/joinblob"),
35 strings.HasPrefix(rel, "internal/cloudinit"), 35 strings.HasPrefix(rel, "internal/cloudinit"),
36 strings.HasPrefix(rel, "internal/names"), 36 strings.HasPrefix(rel, "internal/names"),
37 strings.HasPrefix(rel, "internal/random"): 37 strings.HasPrefix(rel, "internal/random"),
38 strings.HasPrefix(rel, "internal/version"):
38 return PlaneWire 39 return PlaneWire
39 case strings.HasPrefix(rel, "cmd/"): 40 case strings.HasPrefix(rel, "cmd/"):
40 return PlaneBinaries 41 return PlaneBinaries
internal/version/version.go
Old New
@@ -0,0 +1,7 @@
1 // Package version carries the build-stamped eitri version, set via
2 // -ldflags "-X github.com/a73x/eitri/internal/version.Version=v0.0.2".
3 // Unstamped builds report "dev" and never consider themselves upgradable.
4 package version
5
6 // Version is the running binary's version ("dev" when built without stamping).
7 var Version = "dev"
proto/eitri/v1/sync.proto
Old New
@@ -47,6 +47,7 @@ message HostFacts {
47 string kernel = 4; // kernel release, e.g. "6.1.0-18-amd64" 47 string kernel = 4; // kernel release, e.g. "6.1.0-18-amd64"
48 string cpu_model = 5; // /proc/cpuinfo model name 48 string cpu_model = 5; // /proc/cpuinfo model name
49 string virt = 6; // systemd-detect-virt: "kvm" | "none" | "" (unknown) 49 string virt = 6; // systemd-detect-virt: "kvm" | "none" | "" (unknown)
50 string agent_version = 7; // the agent binary's stamped version ("dev" unstamped)
50 } 51 }
51 52
52 // HostMetrics is live measured host utilization, refreshed each report. It is 53 // HostMetrics is live measured host utilization, refreshed each report. It is
@@ -112,6 +113,17 @@ message VMDesired {
112 message DesiredStateSnapshot { 113 message DesiredStateSnapshot {
113 uint64 epoch = 1; // agents refuse epoch < highest seen 114 uint64 epoch = 1; // agents refuse epoch < highest seen
114 repeated VMDesired vms = 2; // FULL set for this host, including tombstoned 115 repeated VMDesired vms = 2; // FULL set for this host, including tombstoned
116 AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade
117 }
118
119 // AgentUpgrade asks the agent to replace its own binary: download url, verify
120 // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an
121 // operator explicitly clicked; absent otherwise. An agent already running
122 // version treats it as a no-op.
123 message AgentUpgrade {
124 string version = 1; // target version (matches Hello facts.agent_version when done)
125 string url = 2; // artifact URL (https, from the release manifest)
126 string sha256 = 3; // artifact sha256 (hex)
115 } 127 }
116 128
117 // ConsoleOpen is the first frame on a server-initiated console stream: it names 129 // ConsoleOpen is the first frame on a server-initiated console stream: it names
scripts/coverage.sh
Old New
@@ -29,6 +29,8 @@ declare -A FLOOR=(
29 [internal/server/api/spec]=81 29 [internal/server/api/spec]=81
30 [internal/server/store]=73 30 [internal/server/store]=73
31 [internal/server/registry]=95 31 [internal/server/registry]=95
32 [internal/server/release]=90
33 [internal/agent/selfupdate]=65
32 [internal/server/hosttoken]=95 34 [internal/server/hosttoken]=95
33 [internal/server/hub]=90 35 [internal/server/hub]=90
34 [internal/server/syncsvc]=72 36 [internal/server/syncsvc]=72
scripts/deploy.sh
Old New
@@ -56,6 +56,13 @@ SHA="$(git rev-parse --short HEAD)"
56 git diff --quiet || SHA="$SHA-dirty" 56 git diff --quiet || SHA="$SHA-dirty"
57 bold "Deploying HEAD $SHA to fleet" 57 bold "Deploying HEAD $SHA to fleet"
58 58
59 # Version stamp baked into every binary (internal/version.Version), matching
60 # the Makefile's build target so a deploy.sh build and a `make build` binary
61 # report the same string for the same tree. An array (not a plain string) so
62 # the "-X ...=..." value survives intact as ONE argument to go build.
63 VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
64 GO_LDFLAGS=(-ldflags "-X github.com/a73x/eitri/internal/version.Version=$VERSION")
65
59 # ── 0. Build ────────────────────────────────────────────────────────────────── 66 # ── 0. Build ──────────────────────────────────────────────────────────────────
60 # The fleet binaries are ALWAYS built with coverage instrumentation. -coverpkg 67 # The fleet binaries are ALWAYS built with coverage instrumentation. -coverpkg
61 # spans the whole module so internal/* (not just main) is measured through the 68 # spans the whole module so internal/* (not just main) is measured through the
@@ -67,9 +74,9 @@ bold "Deploying HEAD $SHA to fleet"
67 # normally. 74 # normally.
68 bold "Building binaries (coverage-instrumented server+agent)" 75 bold "Building binaries (coverage-instrumented server+agent)"
69 make web 76 make web
70 go build -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-server ./cmd/eitri-server 77 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-server ./cmd/eitri-server
71 go build -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-agent ./cmd/eitri-agent 78 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-agent ./cmd/eitri-agent
72 go build -o bin/eitri-smoke ./cmd/eitri-smoke 79 go build "${GO_LDFLAGS[@]}" -o bin/eitri-smoke ./cmd/eitri-smoke
73 80
74 # ── 1. Control plane (local eitri-server) ───────────────────────────────────── 81 # ── 1. Control plane (local eitri-server) ─────────────────────────────────────
75 bold "Rolling server -> $SERVER_BIN" 82 bold "Rolling server -> $SERVER_BIN"
web/src/lib/api-types.ts
Old New
@@ -329,6 +329,51 @@ export interface paths {
329 patch?: never; 329 patch?: never;
330 trace?: never; 330 trace?: never;
331 }; 331 };
332 "/api/v1/hosts/{id}/upgrade-agent": {
333 parameters: {
334 query?: never;
335 header?: never;
336 path?: never;
337 cookie?: never;
338 };
339 get?: never;
340 put?: never;
341 /** Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout). */
342 post: {
343 parameters: {
344 query?: never;
345 header?: never;
346 path: {
347 id: string;
348 };
349 cookie?: never;
350 };
351 requestBody?: never;
352 responses: {
353 /** @description success */
354 202: {
355 headers: {
356 [name: string]: unknown;
357 };
358 content?: never;
359 };
360 /** @description error (plain text) */
361 default: {
362 headers: {
363 [name: string]: unknown;
364 };
365 content: {
366 "text/plain": string;
367 };
368 };
369 };
370 };
371 delete?: never;
372 options?: never;
373 head?: never;
374 patch?: never;
375 trace?: never;
376 };
332 "/api/v1/ssh-ca": { 377 "/api/v1/ssh-ca": {
333 parameters: { 378 parameters: {
334 query?: never; 379 query?: never;
@@ -975,6 +1020,8 @@ export interface components {
975 token: string; 1020 token: string;
976 }; 1021 };
977 Host: { 1022 Host: {
1023 agent_update_available: boolean;
1024 agent_version: string;
978 allocated: components["schemas"]["Capacity"]; 1025 allocated: components["schemas"]["Capacity"];
979 arch: string; 1026 arch: string;
980 bridge_cidr: string; 1027 bridge_cidr: string;
@@ -1029,6 +1076,8 @@ export interface components {
1029 }; 1076 };
1030 StateSnapshot: { 1077 StateSnapshot: {
1031 hosts: components["schemas"]["Host"][]; 1078 hosts: components["schemas"]["Host"][];
1079 latest_version: string;
1080 server_version: string;
1032 vms: components["schemas"]["VM"][]; 1081 vms: components["schemas"]["VM"][];
1033 }; 1082 };
1034 StreamTicketResponse: { 1083 StreamTicketResponse: {
web/src/lib/fleet.svelte.ts
Old New
@@ -34,7 +34,9 @@ export const fleet = $state({
34 vms: [] as VM[], 34 vms: [] as VM[],
35 userCAs: [] as UserCA[], 35 userCAs: [] as UserCA[],
36 connected: false, 36 connected: false,
37 error: '' 37 error: '',
38 server_version: '',
39 latest_version: ''
38 }); 40 });
39 41
40 // clock is a single shared ticking wall-clock (unix seconds). Countdowns read 42 // clock is a single shared ticking wall-clock (unix seconds). Countdowns read
@@ -136,6 +138,8 @@ export async function connect() {
136 const snap = JSON.parse((e as MessageEvent).data); 138 const snap = JSON.parse((e as MessageEvent).data);
137 fleet.hosts = snap.hosts ?? []; 139 fleet.hosts = snap.hosts ?? [];
138 fleet.vms = snap.vms ?? []; 140 fleet.vms = snap.vms ?? [];
141 fleet.server_version = snap.server_version ?? '';
142 fleet.latest_version = snap.latest_version ?? '';
139 fleet.connected = true; 143 fleet.connected = true;
140 // Deliberately does NOT clear action errors: state events arrive 144 // Deliberately does NOT clear action errors: state events arrive
141 // ~1/s, so auto-clearing here made action failures flash for under 145 // ~1/s, so auto-clearing here made action failures flash for under
@@ -260,6 +264,13 @@ export async function decommissionHost(id: string) {
260 await req('DELETE', `/api/v1/hosts/${id}`); 264 await req('DELETE', `/api/v1/hosts/${id}`);
261 } 265 }
262 266
267 /** upgradeAgent asks the given host's agent to self-upgrade and re-exec. req
268 * throws on non-2xx (409/503 plain-text body), so a busy/unavailable host
269 * surfaces to the caller like any other action. */
270 export async function upgradeAgent(id: string) {
271 await req('POST', `/api/v1/hosts/${id}/upgrade-agent`);
272 }
273
263 export async function createJoinBlob(): Promise<string> { 274 export async function createJoinBlob(): Promise<string> {
264 const r = await (await req('POST', '/api/v1/enroll-tokens')).json(); 275 const r = await (await req('POST', '/api/v1/enroll-tokens')).json();
265 return r.join; 276 return r.join;
web/src/routes/+page.svelte
Old New
@@ -16,6 +16,7 @@
16 uploadUserCA, 16 uploadUserCA,
17 deleteUserCA, 17 deleteUserCA,
18 refreshUserCAs, 18 refreshUserCAs,
19 upgradeAgent,
19 type CreateVMRequest 20 type CreateVMRequest
20 } from '$lib/fleet.svelte'; 21 } from '$lib/fleet.svelte';
21 22
@@ -151,6 +152,11 @@
151 await action(() => decommissionHost(id)); 152 await action(() => decommissionHost(id));
152 } 153 }
153 154
155 async function upgrade(h: (typeof fleet.hosts)[number]) {
156 if (!confirm(`Upgrade ${h.name} to ${fleet.latest_version}?`)) return;
157 await action(() => upgradeAgent(h.id));
158 }
159
154 async function addHost() { 160 async function addHost() {
155 await action(async () => { 161 await action(async () => {
156 joinBlob = await createJoinBlob(); 162 joinBlob = await createJoinBlob();
@@ -158,6 +164,13 @@
158 } 164 }
159 </script> 165 </script>
160 166
167 {#if fleet.latest_version && fleet.server_version && fleet.latest_version !== fleet.server_version}
168 <div class="update-banner">
169 eitri {fleet.latest_version} is available (server running {fleet.server_version}) —
170 <a href="https://eitri.sh/docs/upgrade" target="_blank" rel="noreferrer">upgrade guide</a>
171 </div>
172 {/if}
173
161 <div class="row"> 174 <div class="row">
162 <input 175 <input
163 id="fleet-filter" 176 id="fleet-filter"
@@ -189,7 +202,7 @@
189 {:else} 202 {:else}
190 <table> 203 <table>
191 <thead> 204 <thead>
192 <tr><th>Name</th><th>Status</th><th>OS</th><th>Load</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr> 205 <tr><th>Name</th><th>Status</th><th>OS</th><th>Version</th><th>Load</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr>
193 </thead> 206 </thead>
194 <tbody> 207 <tbody>
195 {#each shownHosts as h (h.id)} 208 {#each shownHosts as h (h.id)}
@@ -202,6 +215,12 @@
202 </td> 215 </td>
203 <td title={osTitle(h)}>{osLabel(h)}</td> 216 <td title={osTitle(h)}>{osLabel(h)}</td>
204 <td> 217 <td>
218 {h.agent_version || '—'}
219 {#if h.agent_update_available}
220 <button class="ghost upgrade" onclick={() => upgrade(h)}>↑ {fleet.latest_version}</button>
221 {/if}
222 </td>
223 <td>
205 {#if load !== null} 224 {#if load !== null}
206 <span class="loadcell" title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU"> 225 <span class="loadcell" title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU">
207 <span 226 <span
@@ -425,6 +444,19 @@
425 padding: 0.5rem; 444 padding: 0.5rem;
426 margin: 0.5rem 0; 445 margin: 0.5rem 0;
427 } 446 }
447 .update-banner {
448 background: #15171c;
449 border: 1px solid #2a2e37;
450 border-radius: 4px;
451 padding: 0.5rem 0.8rem;
452 margin: 0.8rem 0 0;
453 color: #9aa0aa;
454 }
455 .upgrade {
456 margin-left: 0.4rem;
457 padding: 0.1rem 0.45rem;
458 font-size: 12px;
459 }
428 .enroll code { 460 .enroll code {
429 display: block; 461 display: block;
430 margin-top: 0.3rem; 462 margin-top: 0.3rem;
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -4,6 +4,7 @@
4 fleet, 4 fleet,
5 action, 5 action,
6 decommissionHost, 6 decommissionHost,
7 upgradeAgent,
7 vmsForHost, 8 vmsForHost,
8 vmPhase, 9 vmPhase,
9 vmPower, 10 vmPower,
@@ -30,6 +31,12 @@
30 const hostId = host.id; 31 const hostId = host.id;
31 await action(() => decommissionHost(hostId)); 32 await action(() => decommissionHost(hostId));
32 } 33 }
34
35 async function upgrade() {
36 if (!host) return;
37 if (!confirm(`Upgrade ${host.name} to ${fleet.latest_version}?`)) return;
38 await action(() => upgradeAgent(host.id));
39 }
33 </script> 40 </script>
34 41
35 <p><a href="/">← fleet</a></p> 42 <p><a href="/">← fleet</a></p>
@@ -43,6 +50,15 @@
43 <tr><th>ID</th><td>{host.id}</td></tr> 50 <tr><th>ID</th><td>{host.id}</td></tr>
44 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr> 51 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr>
45 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr> 52 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
53 <tr>
54 <th>Agent</th>
55 <td>
56 {host.agent_version || '—'}
57 {#if host.agent_update_available}
58 <button class="ghost upgrade" onclick={upgrade}>↑ {fleet.latest_version}</button>
59 {/if}
60 </td>
61 </tr>
46 <tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr> 62 <tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
47 <tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr> 63 <tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
48 <tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr> 64 <tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
@@ -124,6 +140,11 @@
124 color: #8b919c; 140 color: #8b919c;
125 width: 140px; 141 width: 140px;
126 } 142 }
143 .upgrade {
144 margin-left: 0.4rem;
145 padding: 0.1rem 0.45rem;
146 font-size: 12px;
147 }
127 /* base .dot rule and .on/.off colors are global (+layout.svelte); this 148 /* base .dot rule and .on/.off colors are global (+layout.svelte); this
128 page's dot just wants a bit more breathing room before the text. */ 149 page's dot just wants a bit more breathing room before the text. */
129 .dot { 150 .dot {