752d49c4
feat(fleet): per-host agent upgrades from the web console
a73x 2026-07-26 11:39
Commit message
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 { |