a73x

c2b35d97

feat: Eitri — BYO-hardware VM fleet control plane

a73x   2026-07-25 08:12

Commit message
feat: Eitri — BYO-hardware VM fleet control plane

.gitignore
Old New
@@ -0,0 +1,19 @@
1 /eitri-server
2 /eitri-agent
3 /eitri-devstack
4 /eitri-sandbox
5 /eitri-smoketest
6 *.db
7 *.db-wal
8 *.db-shm
9 node_modules/
10 web/.svelte-kit/
11 web/build/
12
13 # Built SPA copied into the embed dir; only .gitkeep is tracked.
14 /internal/server/web/dist/*
15 !/internal/server/web/dist/.gitkeep
16
17 # Claude Code worktrees/session dirs
18 .claude/
19 /bin/
Makefile
Old New
@@ -0,0 +1,70 @@
1 BIN := bin
2 WEB_DIST := internal/server/web/dist
3
4 .PHONY: build web test vet proto api api-check smoke smoke-go devstack sandbox clean
5
6 # Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node.
7 # `go build` works without this (the server serves a "UI not built" notice until
8 # the SPA is staged here).
9 web:
10 cd web && npm ci && npm run build
11 find $(WEB_DIST) -mindepth 1 ! -name .gitkeep -delete
12 cp -r web/build/. $(WEB_DIST)/
13
14 build: web
15 go build -o $(BIN)/eitri-server ./cmd/eitri-server
16 go build -o $(BIN)/eitri-agent ./cmd/eitri-agent
17 go build -o $(BIN)/eitri-devstack ./cmd/eitri-devstack
18 go build -o $(BIN)/eitri-sandbox ./cmd/eitri-sandbox
19
20 test:
21 go test -race ./...
22
23 vet:
24 go vet ./...
25
26 proto:
27 protoc --go_out=. --go_opt=module=github.com/a73x/eitri \
28 proto/eitri/v1/sync.proto
29
30 # Regenerate the API contract artifacts: docs/openapi.json from the route
31 # table, and the TypeScript types from the spec (needs the web toolchain,
32 # like `make web`).
33 api:
34 go run ./cmd/eitri-apispec
35 @if [ -x web/node_modules/.bin/openapi-typescript ]; then \
36 cd web && npm run gen:api; \
37 else \
38 echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \
39 fi
40
41 # Merge gate: the committed spec and TS types must match the route table.
42 api-check:
43 @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \
44 { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; }
45
46 # Build as the invoking user, then run the smoke script, which elevates
47 # (sudo) ONLY for the agent — the one process that needs CAP_NET_ADMIN.
48 smoke: build
49 ./scripts/smoke.sh
50
51 # Go real-VM harness: boots actual cloud-hypervisor VMs through the real stack
52 # and asserts on the full control loop. Skips cleanly without KVM/CH/firmware.
53 # Prime sudo first (`sudo -v`) — the agent runs under sudo for CAP_NET_ADMIN.
54 smoke-go: build
55 sudo -v
56 go test -tags=smoke -timeout=20m -count=1 ./internal/integration -run TestSmoke -v
57
58 # Bring up the real stack interactively for feature development; Ctrl-C to stop.
59 devstack: build
60 sudo -v
61 ./$(BIN)/eitri-devstack
62
63 # Nested-VM sandbox: runs the whole eitri stack — including real guest boots —
64 # inside disposable QEMU VMs, never touching the host. Needs qemu + nested KVM.
65 # No sudo required (qemu uses /dev/kvm directly). Heavy: downloads guest images.
66 sandbox:
67 go test -tags=sandbox -timeout=40m -count=1 ./internal/integration/sandbox -run TestSandbox -v
68
69 clean:
70 rm -rf $(BIN)
README.md
Old New
@@ -0,0 +1,132 @@
1 # eitri
2
3 A control plane for running virtual machines on your own hardware.
4
5 eitri turns a pool of Linux hosts into a small VM cloud. You describe the guests
6 you want; each host runs an agent that makes reality match that description and
7 reports back. Guests boot as real [cloud-hypervisor](https://www.cloudhypervisor.org/)
8 VMs under UEFI, own their own kernel, and get a sticky IP on a per-host bridge.
9
10 ## How it works
11
12 eitri is built around a single desired-state loop, the same shape as a kubelet:
13
14 ```
15 eitri-server ──DesiredStateSnapshot──▶ eitri-agent ──▶ cloud-hypervisor guests
16 (control plane) (one per host)
17 ▲ │
18 └──────────ActualStateReport─────────────┘ (also the heartbeat)
19 ```
20
21 - **The control plane (`eitri-server`)** holds the desired fleet — which VMs
22 should exist, on which host, with what resources — and streams it to each host
23 over a persistent QUIC connection.
24 - **The agent (`eitri-agent`)** reconciles: it gives every VM its own worker
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
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.
29 - **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
31 from persisted records plus what it observes on the box.
32
33 The agent owns everything host-local: resource admission (vCPU / memory / disk /
34 address are admitted through one serialized gate), IP allocation (an embedded
35 DHCP server hands each VM a sticky, deterministic address and reserves it at
36 create), and a content-addressed image cache (each base image is downloaded and
37 `qemu-img`-converted once, then reflink-copied per guest).
38
39 ## Components
40
41 | Binary | Role |
42 | --- | --- |
43 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. |
44 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives cloud-hypervisor. |
45 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. |
46 | `hack/eitri-ssh` | Client that signs an ephemeral cert with a tenant CA and reaches a guest through the gate. |
47
48 Development tooling lives alongside them: `eitri-devstack` (bring the whole stack
49 up locally), `eitri-sandbox` (run it — real guest boots included — inside a
50 disposable nested-KVM VM), `eitri-shape` (regenerate the architecture graph), and
51 `eitri-smoketest`.
52
53 ## Access model
54
55 eitri is multi-tenant. A **fleet** of hosts is partitioned into **tenants**, each
56 its own isolated namespace with its own SSH user CA — eitri holds no tenant user
57 signing key. You reach a guest by name:
58
59 ```
60 eitri-ssh <tenant>.<vm-name>
61 ```
62
63 `eitri-ssh` self-signs a short-lived certificate with your tenant's user CA and
64 jumps through the server's gate, which authorizes the connection against the
65 tenant derived from the signing CA. Guest host certificates are namespaced the
66 same way, so names never collide across tenants.
67
68 ## Getting started
69
70 Build everything:
71
72 ```sh
73 make build # binaries into ./bin
74 ```
75
76 Run the full stack locally for development (Ctrl-C to stop):
77
78 ```sh
79 make devstack # needs sudo for CAP_NET_ADMIN; boots real guests
80 ```
81
82 Or exercise the whole system — including real guest boots — inside a throwaway
83 nested-KVM VM that never touches your host:
84
85 ```sh
86 make sandbox # needs qemu + nested KVM
87 ```
88
89 **Enrolling a real host.** The server mints a single-paste join blob; on the host,
90 `eitri-agent join <blob>` enrolls it (posting to `/api/v1/enroll`), pins the
91 server certificate from the blob, and persists its identity. From then on the
92 agent runs the reconcile + sync loop against the fleet.
93
94 Guests boot from cloud images (the default is Ubuntu resolute) via UEFI firmware
95 (`CLOUDHV.fd`) shipped to each host, so the guest owns its kernel and any
96 disk-only image boots unmodified.
97
98 ## Repository layout
99
100 ```
101 cmd/ entrypoints (eitri-server, eitri-agent, eitri-mcp, tooling)
102 internal/
103 agent/ reconcile loop, cloud-hypervisor driver, DHCP, image cache, netenv
104 server/ API, QUIC sync service, SSH gate/CA, store, registry, hub
105 transport/ QUIC transport shared by both sides
106 pb/ generated protobuf (proto/eitri/v1)
107 web/ SvelteKit fleet console, embedded into eitri-server
108 proto/ the wire contract
109 docs/shape.* the generated, explorable architecture graph
110 ```
111
112 ## Development
113
114 `make ci` is the gate — everything a change must pass before it lands:
115
116 ```sh
117 make ci
118 ```
119
120 It runs `go vet`, the build, the architecture fitness functions, `golangci-lint`,
121 the test suite under `-race`, per-package coverage floors, `go mod tidy` and proto
122 checks, and the shape-graph check. A pre-push hook runs it and blocks a red push
123 to `main`.
124
125 Common loops:
126
127 ```sh
128 make test # go test -race
129 make smoke # scripted end-to-end smoke
130 make deploy # roll HEAD to the live fleet (local server + remote agents)
131 make shape # regenerate docs/shape.{json,html} after a package change
132 ```
cmd/eitri-agent/main.go
Old New
@@ -0,0 +1,241 @@
1 // eitri-agent: BYO-hardware agent. Enrolls the host and then runs the
2 // reconcile + sync loop indefinitely.
3 package main
4
5 import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "errors"
10 "flag"
11 "fmt"
12 "io"
13 "log/slog"
14 "net/http"
15 "os"
16 "os/exec"
17 "os/signal"
18 "runtime"
19 "strings"
20 "syscall"
21 "time"
22
23 "github.com/a73x/eitri/internal/agent/cloudhv"
24 "github.com/a73x/eitri/internal/agent/imagecache"
25 "github.com/a73x/eitri/internal/agent/netenv"
26 "github.com/a73x/eitri/internal/agent/overlay"
27 "github.com/a73x/eitri/internal/agent/reconcile"
28 "github.com/a73x/eitri/internal/agent/seed"
29 "github.com/a73x/eitri/internal/agent/state"
30 "github.com/a73x/eitri/internal/agent/syncclient"
31 )
32
33 func main() {
34 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
35 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
36 firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw")
37 server := flag.String("server", "", "HTTP server URL (e.g. http://localhost:8080)")
38 quicAddr := flag.String("quic-addr", "", "server QUIC address (e.g. localhost:8443)")
39 token := flag.String("token", "", "enrollment token")
40 overlayKind := flag.String("overlay", "tailscale", "overlay kind: tailscale or none")
41 overlayAuthkey := flag.String("overlay-authkey", "", "overlay auth key (optional, for initial enroll on dedicated hosts — requires --manage-overlay)")
42 manageOverlay := flag.Bool("manage-overlay", false, "allow agent to additively modify overlay route advertisement (opt-in; for dedicated hosts)")
43 noMasqIfaces := flag.String("no-masquerade-ifaces", "", "comma-separated interfaces to exclude from NAT masquerade (e.g. wg0)")
44 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
45 vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone")
46 flag.Parse()
47
48 st, err := state.Open(*stateDir)
49 if err != nil {
50 slog.Error("open state dir", "err", err)
51 os.Exit(1)
52 }
53
54 if flag.Arg(0) == "enroll" {
55 runEnroll(st, *server, *quicAddr, *token, *stateDir, *overlayKind)
56 return
57 }
58
59 runAgent(st, *stateDir, *chBin, *firmware, *overlayKind, *overlayAuthkey, *noMasqIfaces, *manageOverlay, *tombstoneGrace, *vanishGrace)
60 }
61
62 // runEnroll handles the "enroll" subcommand.
63 func runEnroll(st *state.Store, server, quicAddr, token, stateDir, overlayKind string) {
64 if server == "" || quicAddr == "" || token == "" {
65 fmt.Fprintln(os.Stderr, "enroll requires --server, --quic-addr, and --token")
66 os.Exit(1)
67 }
68
69 hostname, err := os.Hostname()
70 if err != nil {
71 hostname = "unknown"
72 }
73
74 body, err := json.Marshal(map[string]string{
75 "token": token,
76 "name": hostname,
77 "os": runtime.GOOS,
78 "arch": runtime.GOARCH,
79 "provisioner": "cloudhv",
80 "overlay": overlayKind,
81 })
82 if err != nil {
83 slog.Error("marshal enroll request", "err", err)
84 os.Exit(1)
85 }
86
87 resp, err := http.Post(server+"/api/v1/enroll", "application/json", bytes.NewReader(body))
88 if err != nil {
89 slog.Error("enroll request", "err", err)
90 os.Exit(1)
91 }
92 defer resp.Body.Close()
93
94 respBody, _ := io.ReadAll(resp.Body)
95 if resp.StatusCode != http.StatusCreated {
96 fmt.Fprintf(os.Stderr, "enroll failed (HTTP %d): %s\n", resp.StatusCode, respBody)
97 os.Exit(1)
98 }
99
100 var result struct {
101 HostID string `json:"host_id"`
102 Credential string `json:"credential"`
103 BridgeCIDR string `json:"bridge_cidr"`
104 ServerCertSHA256 string `json:"server_cert_sha256"`
105 }
106 if err := json.Unmarshal(respBody, &result); err != nil {
107 slog.Error("parse enroll response", "err", err)
108 os.Exit(1)
109 }
110
111 id := state.Identity{
112 HostID: result.HostID,
113 Credential: result.Credential,
114 BridgeCIDR: result.BridgeCIDR,
115 ServerQUICAddr: quicAddr,
116 ServerCertSHA256: result.ServerCertSHA256,
117 }
118 if err := st.SaveIdentity(id); err != nil {
119 slog.Error("save identity", "err", err)
120 os.Exit(1)
121 }
122
123 fmt.Printf("Enrolled: host_id=%s bridge_cidr=%s\n", result.HostID, result.BridgeCIDR)
124 }
125
126 // realRunner creates a subprocess and returns its combined output.
127 func realRunner(ctx context.Context, name string, args ...string) (string, error) {
128 cmd := exec.CommandContext(ctx, name, args...)
129 out, err := cmd.CombinedOutput()
130 return string(out), err
131 }
132
133 // splitComma splits a comma-separated string, returning nil for empty input.
134 func splitComma(s string) []string {
135 if s == "" {
136 return nil
137 }
138 parts := strings.Split(s, ",")
139 result := make([]string, 0, len(parts))
140 for _, p := range parts {
141 if t := strings.TrimSpace(p); t != "" {
142 result = append(result, t)
143 }
144 }
145 return result
146 }
147
148 // runAgent handles the normal (no subcommand) run mode.
149 func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAuthkey, noMasqIfacesStr string, manageOverlay bool, tombstoneGrace, vanishGrace time.Duration) {
150 id, ok := st.Identity()
151 if !ok {
152 fmt.Fprintln(os.Stderr, "not enrolled — run with 'enroll' subcommand first")
153 os.Exit(1)
154 }
155
156 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
157 defer cancel()
158
159 net, err := netenv.New(realRunner, id.BridgeCIDR)
160 if err != nil {
161 slog.Error("netenv init", "err", err)
162 os.Exit(1)
163 }
164
165 extraNoMasq := splitComma(noMasqIfacesStr)
166 ov, err := overlay.New(overlayKind, id.BridgeCIDR, overlayAuthkey, realRunner, extraNoMasq)
167 if err != nil {
168 slog.Error("overlay init", "err", err)
169 os.Exit(1)
170 }
171
172 if err := net.EnsureBridge(ctx, ov.NoMasqueradeIfaces()); err != nil {
173 slog.Error("ensure bridge", "err", err)
174 os.Exit(1)
175 }
176
177 // EnsureRoute implements the overlay ownership model (spec:
178 // "Networking — pluggable overlay").
179 // Default (--manage-overlay=false): observe-and-instruct — never mutates
180 // overlay state; logs an action-required warning with the exact command
181 // for the operator and keeps running (VMs work locally; reachability
182 // pending).
183 // Opt-in (--manage-overlay): additive only — unions our bridge CIDR into
184 // the host's existing advertised routes; never replaces or removes others'.
185 if err := ov.EnsureRoute(ctx, manageOverlay); err != nil {
186 if errors.Is(err, overlay.ErrActionRequired) {
187 slog.Warn("overlay action required — VMs reachable locally; network reachability pending operator action",
188 "instruction", err.Error())
189 // Non-fatal: keep running. VMs work on the bridge; network connectivity
190 // is blocked until the operator follows the instruction.
191 } else {
192 // Config or consent error (e.g. authkey without --manage-overlay).
193 slog.Error("overlay setup failed", "err", err)
194 os.Exit(1)
195 }
196 }
197
198 if err := ov.VerifyRoute(ctx); err != nil {
199 if errors.Is(err, overlay.ErrUnverifiable) {
200 slog.Info("route verification unavailable for this overlay — unverifiable by design")
201 } else {
202 slog.Warn("overlay route not yet verified — VMs may be unreachable from the network", "err", err)
203 }
204 // Non-fatal: the bridge still works for local routing.
205 }
206
207 // Start a background goroutine that periodically re-checks both
208 // EnsureRoute and VerifyRoute, logging only on state transitions
209 // (level-triggered, not one-shot).
210 go overlay.Watch(ctx, ov, manageOverlay, 60*time.Second, nil)
211
212 prov := cloudhv.New(st, chBin, firmware, realRunner)
213 cache := imagecache.New(st.ImagesDir(), realRunner)
214
215 engine := &reconcile.Engine{
216 St: st,
217 Prov: prov,
218 Net: net,
219 Images: cache.Ensure,
220 Seed: seed.Build,
221 BootID: syncclient.HostBootID,
222 Now: time.Now,
223 TombstoneGrace: tombstoneGrace,
224 VanishGrace: vanishGrace,
225 MaxCreateAttempts: 3,
226 }
227
228 // Compile-time interface satisfaction checks.
229 var _ reconcile.Provisioner = prov
230 var _ reconcile.NetEnv = net
231
232 client := &syncclient.Client{
233 Engine: engine,
234 St: st,
235 Identity: id,
236 StateDir: stateDir,
237 }
238
239 slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR)
240 client.Run(ctx)
241 }
cmd/eitri-apispec/main.go
Old New
@@ -0,0 +1,20 @@
1 // Command eitri-apispec regenerates docs/openapi.json from the api route
2 // table. `make api` runs it; `api-check` fails ci when the artifact is stale.
3 package main
4
5 import (
6 "log"
7 "os"
8
9 "github.com/a73x/eitri/internal/server/api/spec"
10 )
11
12 func main() {
13 out, err := spec.Generate()
14 if err != nil {
15 log.Fatalf("eitri-apispec: %v", err)
16 }
17 if err := os.WriteFile("docs/openapi.json", out, 0o644); err != nil {
18 log.Fatalf("eitri-apispec: write docs/openapi.json: %v", err)
19 }
20 }
cmd/eitri-server/main.go
Old New
@@ -0,0 +1,111 @@
1 // eitri-server: single-node control plane (Phase 1: static admin token, no TLS
2 // termination here — front with a reverse proxy for TLS).
3 package main
4
5 import (
6 "context"
7 "encoding/json"
8 "flag"
9 "log/slog"
10 "net/http"
11 "os"
12 "time"
13
14 "github.com/a73x/eitri/internal/server/api"
15 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry"
17 "github.com/a73x/eitri/internal/server/store"
18 "github.com/a73x/eitri/internal/server/syncsvc"
19 "github.com/a73x/eitri/internal/server/web"
20 "github.com/a73x/eitri/internal/transport"
21 "github.com/quic-go/quic-go"
22 )
23
24 type config struct {
25 HTTPListen string `json:"http_listen"`
26 QUICListen string `json:"quic_listen"`
27 DBPath string `json:"db_path"`
28 AdminToken string `json:"admin_token"`
29 HostSecret string `json:"host_secret"`
30 CIDRPool string `json:"cidr_pool"`
31 DefaultImageURL string `json:"default_image_url"`
32 DefaultImageSHA string `json:"default_image_sha256"`
33 }
34
35 func main() {
36 cfgPath := flag.String("config", "/etc/eitri/server.json", "config file")
37 flag.Parse()
38 raw, err := os.ReadFile(*cfgPath)
39 if err != nil {
40 slog.Error("read config", "err", err)
41 os.Exit(1)
42 }
43 var cfg config
44 if err := json.Unmarshal(raw, &cfg); err != nil {
45 slog.Error("parse config", "err", err)
46 os.Exit(1)
47 }
48 if cfg.AdminToken == "" || cfg.HostSecret == "" {
49 slog.Error("admin_token and host_secret are required")
50 os.Exit(1)
51 }
52
53 st, err := store.Open(cfg.DBPath, cfg.CIDRPool)
54 if err != nil {
55 slog.Error("open store", "err", err)
56 os.Exit(1)
57 }
58
59 certPEM, certFP, err := st.ServerCert()
60 if err != nil {
61 slog.Error("server cert", "err", err)
62 os.Exit(1)
63 }
64 keyPEM, err := st.ServerKeyPEM()
65 if err != nil {
66 slog.Error("server key", "err", err)
67 os.Exit(1)
68 }
69
70 reg := registry.New(time.Now)
71 h := hub.New()
72
73 a := api.New(api.Config{AdminToken: cfg.AdminToken, HostSecret: []byte(cfg.HostSecret),
74 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA},
75 ServerCertSHA256: certFP},
76 st, reg, h)
77
78 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
79 if err != nil {
80 slog.Error("server tls", "err", err)
81 os.Exit(1)
82 }
83 lis, err := quic.ListenAddr(cfg.QUICListen, tlsConf,
84 &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second})
85 if err != nil {
86 slog.Error("quic listen", "err", err)
87 os.Exit(1)
88 }
89 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret))
90 go func() {
91 slog.Info("quic listening", "addr", cfg.QUICListen)
92 if err := svc.Serve(context.Background(), lis); err != nil {
93 slog.Error("quic serve", "err", err)
94 os.Exit(1)
95 }
96 }()
97
98 // Background: finalize drained decommissioning hosts.
99 go a.StartBackground(context.Background())
100
101 // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else.
102 root := http.NewServeMux()
103 root.Handle("/api/", a.Handler())
104 root.Handle("/", web.Handler())
105
106 slog.Info("http listening", "addr", cfg.HTTPListen)
107 if err := http.ListenAndServe(cfg.HTTPListen, root); err != nil {
108 slog.Error("http serve", "err", err)
109 os.Exit(1)
110 }
111 }
docs/openapi.json
Old New
@@ -0,0 +1,639 @@
1 {
2 "components": {
3 "schemas": {
4 "Capacity": {
5 "properties": {
6 "disk_gb": {
7 "type": "integer"
8 },
9 "mem_mb": {
10 "type": "integer"
11 },
12 "vcpus": {
13 "type": "integer"
14 }
15 },
16 "required": [
17 "disk_gb",
18 "mem_mb",
19 "vcpus"
20 ],
21 "type": "object"
22 },
23 "CreateVMRequest": {
24 "properties": {
25 "cloud_init": {
26 "type": "string"
27 },
28 "disk_gb": {
29 "type": "integer"
30 },
31 "host_id": {
32 "type": "string"
33 },
34 "image_sha256": {
35 "type": "string"
36 },
37 "image_url": {
38 "type": "string"
39 },
40 "mem_mb": {
41 "type": "integer"
42 },
43 "name": {
44 "type": "string"
45 },
46 "persistent": {
47 "type": "boolean"
48 },
49 "power_state": {
50 "type": "string"
51 },
52 "ssh_authorized_key": {
53 "type": "string"
54 },
55 "vcpus": {
56 "type": "integer"
57 }
58 },
59 "type": "object"
60 },
61 "CreateVMResponse": {
62 "properties": {
63 "id": {
64 "type": "string"
65 },
66 "name": {
67 "type": "string"
68 }
69 },
70 "required": [
71 "id",
72 "name"
73 ],
74 "type": "object"
75 },
76 "EnrollRequest": {
77 "properties": {
78 "arch": {
79 "type": "string"
80 },
81 "name": {
82 "type": "string"
83 },
84 "os": {
85 "type": "string"
86 },
87 "overlay": {
88 "type": "string"
89 },
90 "provisioner": {
91 "type": "string"
92 },
93 "token": {
94 "type": "string"
95 }
96 },
97 "type": "object"
98 },
99 "EnrollResponse": {
100 "properties": {
101 "bridge_cidr": {
102 "type": "string"
103 },
104 "credential": {
105 "type": "string"
106 },
107 "host_id": {
108 "type": "string"
109 },
110 "overlay": {
111 "type": "string"
112 },
113 "server_cert_sha256": {
114 "type": "string"
115 }
116 },
117 "required": [
118 "bridge_cidr",
119 "credential",
120 "host_id",
121 "overlay",
122 "server_cert_sha256"
123 ],
124 "type": "object"
125 },
126 "EnrollTokenResponse": {
127 "properties": {
128 "token": {
129 "type": "string"
130 }
131 },
132 "required": [
133 "token"
134 ],
135 "type": "object"
136 },
137 "Host": {
138 "properties": {
139 "allocated": {
140 "$ref": "#/components/schemas/Capacity"
141 },
142 "arch": {
143 "type": "string"
144 },
145 "bridge_cidr": {
146 "type": "string"
147 },
148 "capacity": {
149 "$ref": "#/components/schemas/Capacity"
150 },
151 "enrolled_at": {
152 "format": "date-time",
153 "type": "string"
154 },
155 "id": {
156 "type": "string"
157 },
158 "name": {
159 "type": "string"
160 },
161 "online": {
162 "type": "boolean"
163 },
164 "os": {
165 "type": "string"
166 },
167 "overlay": {
168 "type": "string"
169 },
170 "provisioner": {
171 "type": "string"
172 },
173 "status": {
174 "type": "string"
175 }
176 },
177 "required": [
178 "allocated",
179 "arch",
180 "bridge_cidr",
181 "capacity",
182 "enrolled_at",
183 "id",
184 "name",
185 "online",
186 "os",
187 "overlay",
188 "provisioner",
189 "status"
190 ],
191 "type": "object"
192 },
193 "PatchVMRequest": {
194 "properties": {
195 "power_state": {
196 "type": "string"
197 }
198 },
199 "type": "object"
200 },
201 "StateSnapshot": {
202 "properties": {
203 "hosts": {
204 "items": {
205 "$ref": "#/components/schemas/Host"
206 },
207 "type": "array"
208 },
209 "vms": {
210 "items": {
211 "$ref": "#/components/schemas/VM"
212 },
213 "type": "array"
214 }
215 },
216 "required": [
217 "hosts",
218 "vms"
219 ],
220 "type": "object"
221 },
222 "VM": {
223 "properties": {
224 "actual_power": {
225 "type": "string"
226 },
227 "assigned_ip": {
228 "type": "string"
229 },
230 "created_at": {
231 "format": "date-time",
232 "type": "string"
233 },
234 "deleted": {
235 "type": "boolean"
236 },
237 "disk_gb": {
238 "type": "integer"
239 },
240 "host_id": {
241 "type": "string"
242 },
243 "id": {
244 "type": "string"
245 },
246 "image_url": {
247 "type": "string"
248 },
249 "last_error": {
250 "type": "string"
251 },
252 "mem_mb": {
253 "type": "integer"
254 },
255 "name": {
256 "type": "string"
257 },
258 "persistent": {
259 "type": "boolean"
260 },
261 "phase": {
262 "type": "string"
263 },
264 "power_state": {
265 "type": "string"
266 },
267 "status": {
268 "type": "string"
269 },
270 "vcpus": {
271 "type": "integer"
272 }
273 },
274 "required": [
275 "actual_power",
276 "assigned_ip",
277 "created_at",
278 "deleted",
279 "disk_gb",
280 "host_id",
281 "id",
282 "image_url",
283 "last_error",
284 "mem_mb",
285 "name",
286 "persistent",
287 "phase",
288 "power_state",
289 "status",
290 "vcpus"
291 ],
292 "type": "object"
293 }
294 },
295 "securitySchemes": {
296 "adminToken": {
297 "scheme": "bearer",
298 "type": "http"
299 }
300 }
301 },
302 "info": {
303 "title": "eitri server API",
304 "version": "v1"
305 },
306 "openapi": "3.1.0",
307 "paths": {
308 "/api/v1/enroll": {
309 "post": {
310 "requestBody": {
311 "content": {
312 "application/json": {
313 "schema": {
314 "$ref": "#/components/schemas/EnrollRequest"
315 }
316 }
317 },
318 "required": true
319 },
320 "responses": {
321 "201": {
322 "content": {
323 "application/json": {
324 "schema": {
325 "$ref": "#/components/schemas/EnrollResponse"
326 }
327 }
328 },
329 "description": "success"
330 },
331 "default": {
332 "content": {
333 "text/plain": {
334 "schema": {
335 "type": "string"
336 }
337 }
338 },
339 "description": "error (plain text)"
340 }
341 },
342 "summary": "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof."
343 }
344 },
345 "/api/v1/enroll-tokens": {
346 "post": {
347 "responses": {
348 "201": {
349 "content": {
350 "application/json": {
351 "schema": {
352 "$ref": "#/components/schemas/EnrollTokenResponse"
353 }
354 }
355 },
356 "description": "success"
357 },
358 "default": {
359 "content": {
360 "text/plain": {
361 "schema": {
362 "type": "string"
363 }
364 }
365 },
366 "description": "error (plain text)"
367 }
368 },
369 "security": [
370 {
371 "adminToken": []
372 }
373 ],
374 "summary": "Mint a one-time host enrollment token."
375 }
376 },
377 "/api/v1/events": {
378 "get": {
379 "parameters": [
380 {
381 "description": "admin token",
382 "in": "query",
383 "name": "token",
384 "required": false,
385 "schema": {
386 "type": "string"
387 }
388 }
389 ],
390 "responses": {
391 "200": {
392 "content": {
393 "text/event-stream": {
394 "schema": {
395 "$ref": "#/components/schemas/StateSnapshot"
396 }
397 }
398 },
399 "description": "success"
400 },
401 "default": {
402 "content": {
403 "text/plain": {
404 "schema": {
405 "type": "string"
406 }
407 }
408 },
409 "description": "error (plain text)"
410 }
411 },
412 "summary": "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot."
413 }
414 },
415 "/api/v1/hosts": {
416 "get": {
417 "responses": {
418 "200": {
419 "content": {
420 "application/json": {
421 "schema": {
422 "items": {
423 "$ref": "#/components/schemas/Host"
424 },
425 "type": "array"
426 }
427 }
428 },
429 "description": "success"
430 },
431 "default": {
432 "content": {
433 "text/plain": {
434 "schema": {
435 "type": "string"
436 }
437 }
438 },
439 "description": "error (plain text)"
440 }
441 },
442 "security": [
443 {
444 "adminToken": []
445 }
446 ],
447 "summary": "List fleet hosts: durable rows merged with live agent state and allocation."
448 }
449 },
450 "/api/v1/hosts/{id}": {
451 "delete": {
452 "parameters": [
453 {
454 "in": "path",
455 "name": "id",
456 "required": true,
457 "schema": {
458 "type": "string"
459 }
460 }
461 ],
462 "responses": {
463 "202": {
464 "description": "success"
465 },
466 "default": {
467 "content": {
468 "text/plain": {
469 "schema": {
470 "type": "string"
471 }
472 }
473 },
474 "description": "error (plain text)"
475 }
476 },
477 "security": [
478 {
479 "adminToken": []
480 }
481 ],
482 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202)."
483 }
484 },
485 "/api/v1/vms": {
486 "get": {
487 "responses": {
488 "200": {
489 "content": {
490 "application/json": {
491 "schema": {
492 "items": {
493 "$ref": "#/components/schemas/VM"
494 },
495 "type": "array"
496 }
497 }
498 },
499 "description": "success"
500 },
501 "default": {
502 "content": {
503 "text/plain": {
504 "schema": {
505 "type": "string"
506 }
507 }
508 },
509 "description": "error (plain text)"
510 }
511 },
512 "security": [
513 {
514 "adminToken": []
515 }
516 ],
517 "summary": "List VMs: durable rows merged with live agent-reported actual state."
518 },
519 "post": {
520 "requestBody": {
521 "content": {
522 "application/json": {
523 "schema": {
524 "$ref": "#/components/schemas/CreateVMRequest"
525 }
526 }
527 },
528 "required": true
529 },
530 "responses": {
531 "201": {
532 "content": {
533 "application/json": {
534 "schema": {
535 "$ref": "#/components/schemas/CreateVMResponse"
536 }
537 }
538 },
539 "description": "success"
540 },
541 "default": {
542 "content": {
543 "text/plain": {
544 "schema": {
545 "type": "string"
546 }
547 }
548 },
549 "description": "error (plain text)"
550 }
551 },
552 "security": [
553 {
554 "adminToken": []
555 }
556 ],
557 "summary": "Create a VM on a host. Omitted fields get one-click defaults."
558 }
559 },
560 "/api/v1/vms/{id}": {
561 "delete": {
562 "parameters": [
563 {
564 "in": "path",
565 "name": "id",
566 "required": true,
567 "schema": {
568 "type": "string"
569 }
570 }
571 ],
572 "responses": {
573 "204": {
574 "description": "success"
575 },
576 "default": {
577 "content": {
578 "text/plain": {
579 "schema": {
580 "type": "string"
581 }
582 }
583 },
584 "description": "error (plain text)"
585 }
586 },
587 "security": [
588 {
589 "adminToken": []
590 }
591 ],
592 "summary": "Tombstone a VM for teardown."
593 },
594 "patch": {
595 "parameters": [
596 {
597 "in": "path",
598 "name": "id",
599 "required": true,
600 "schema": {
601 "type": "string"
602 }
603 }
604 ],
605 "requestBody": {
606 "content": {
607 "application/json": {
608 "schema": {
609 "$ref": "#/components/schemas/PatchVMRequest"
610 }
611 }
612 },
613 "required": true
614 },
615 "responses": {
616 "204": {
617 "description": "success"
618 },
619 "default": {
620 "content": {
621 "text/plain": {
622 "schema": {
623 "type": "string"
624 }
625 }
626 },
627 "description": "error (plain text)"
628 }
629 },
630 "security": [
631 {
632 "adminToken": []
633 }
634 ],
635 "summary": "Set a VM's desired power state (running or stopped)."
636 }
637 }
638 }
639 }
go.mod
Old New
@@ -0,0 +1,46 @@
1 module github.com/a73x/eitri
2
3 go 1.26.4
4
5 require (
6 github.com/diskfs/go-diskfs v1.9.3
7 github.com/quic-go/quic-go v0.48.2
8 github.com/stretchr/testify v1.11.1
9 google.golang.org/protobuf v1.36.11
10 modernc.org/sqlite v1.52.0
11 )
12
13 require (
14 github.com/anchore/go-lzo v0.1.0 // indirect
15 github.com/davecgh/go-spew v1.1.1 // indirect
16 github.com/djherbis/times v1.6.0 // indirect
17 github.com/dustin/go-humanize v1.0.1 // indirect
18 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
19 github.com/go-logr/logr v1.4.3 // indirect
20 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
21 github.com/golang/protobuf v1.5.4 // indirect
22 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
23 github.com/google/uuid v1.6.0 // indirect
24 github.com/klauspost/compress v1.18.5 // indirect
25 github.com/mattn/go-isatty v0.0.20 // indirect
26 github.com/ncruces/go-strftime v1.0.0 // indirect
27 github.com/onsi/ginkgo/v2 v2.9.5 // indirect
28 github.com/pierrec/lz4/v4 v4.1.26 // indirect
29 github.com/pkg/xattr v0.4.12 // indirect
30 github.com/pmezard/go-difflib v1.0.0 // indirect
31 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
32 github.com/sirupsen/logrus v1.9.4 // indirect
33 github.com/ulikunitz/xz v0.5.15 // indirect
34 go.uber.org/mock v0.4.0 // indirect
35 golang.org/x/crypto v0.48.0 // indirect
36 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
37 golang.org/x/mod v0.33.0 // indirect
38 golang.org/x/net v0.51.0 // indirect
39 golang.org/x/sync v0.20.0 // indirect
40 golang.org/x/sys v0.43.0 // indirect
41 golang.org/x/tools v0.42.0 // indirect
42 gopkg.in/yaml.v3 v3.0.1 // indirect
43 modernc.org/libc v1.72.3 // indirect
44 modernc.org/mathutil v1.7.1 // indirect
45 modernc.org/memory v1.11.0 // indirect
46 )
go.sum
Old New
@@ -0,0 +1,115 @@
1 github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
2 github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
7 github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
8 github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
9 github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
10 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
11 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
12 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
13 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
14 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
15 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
16 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
17 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
18 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
19 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
20 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
21 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
22 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
23 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
24 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
25 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
26 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
27 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
28 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
29 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
30 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
31 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
32 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
33 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
34 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
35 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
36 github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
37 github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
38 github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
39 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
40 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
41 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
42 github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
43 github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
44 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
45 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
46 github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
47 github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
48 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
49 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
50 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
51 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
52 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
53 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
54 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
55 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
56 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
57 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
58 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
59 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
60 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
61 golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
62 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
63 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
64 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
65 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
66 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
67 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
68 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
69 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
70 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
71 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
72 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73 golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
74 golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
75 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
76 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
77 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
78 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
79 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
80 golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
81 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
82 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
83 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
84 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
85 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
86 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
87 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
88 modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
89 modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
90 modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
91 modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
92 modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
93 modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
94 modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
95 modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
96 modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
97 modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
98 modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
99 modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
100 modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
101 modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
102 modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
103 modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
104 modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
105 modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
106 modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
107 modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
108 modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
109 modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
110 modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
111 modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
112 modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
113 modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
114 modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
115 modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
internal/agent/cloudhv/cloudhv.go
Old New
@@ -0,0 +1,227 @@
1 // Package cloudhv manages one cloud-hypervisor process per VM.
2 // It handles disk preparation (reflink copy + resize), argument assembly,
3 // process lifecycle (boot / running / shutdown / kill), and the
4 // cloud-hypervisor HTTP API over a Unix socket.
5 package cloudhv
6
7 import (
8 "context"
9 "crypto/sha256"
10 "fmt"
11 "net"
12 "net/http"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "strconv"
17 "strings"
18 "syscall"
19 "time"
20
21 agentexec "github.com/a73x/eitri/internal/agent/exec"
22 "github.com/a73x/eitri/internal/agent/state"
23 )
24
25 // chLogMode is the file mode for the cloud-hypervisor diagnostic log.
26 const chLogMode = 0o600
27
28 // MAC returns a deterministic, locally-administered MAC address for vmID.
29 // It uses the QEMU/KVM OUI prefix 52:54:00 and derives the last three
30 // octets from SHA-256(vmID).
31 func MAC(vmID string) string {
32 h := sha256.Sum256([]byte(vmID))
33 return fmt.Sprintf("52:54:00:%02x:%02x:%02x", h[0], h[1], h[2])
34 }
35
36 // Provisioner manages cloud-hypervisor processes for all VMs on this host.
37 type Provisioner struct {
38 st *state.Store
39 chBin string // path to cloud-hypervisor binary
40 firmware string // path to hypervisor-fw (EFI firmware)
41 run agentexec.Runner
42 }
43
44 // New constructs a Provisioner. run may be nil when only pure methods
45 // (buildArgs, MAC) are needed.
46 func New(st *state.Store, chBin, firmware string, run agentexec.Runner) *Provisioner {
47 return &Provisioner{st: st, chBin: chBin, firmware: firmware, run: run}
48 }
49
50 // buildArgs returns the cloud-hypervisor command-line arguments for spec.
51 // The result is deterministic given the same spec so it can be unit-tested
52 // without spawning a process.
53 func (p *Provisioner) buildArgs(spec state.VMSpec) []string {
54 vmID := spec.VMID
55 tap := state.TapName(vmID)
56 mac := MAC(vmID)
57 serialLog := filepath.Join(p.st.VMDir(vmID), "serial.log")
58
59 return []string{
60 "--api-socket", p.st.SocketPath(vmID),
61 "--kernel", p.firmware,
62 "--cpus", fmt.Sprintf("boot=%d", spec.VCPUs),
63 "--memory", fmt.Sprintf("size=%dM", spec.MemMB),
64 "--disk",
65 fmt.Sprintf("path=%s", p.st.DiskPath(vmID)),
66 fmt.Sprintf("path=%s,readonly=on", p.st.SeedPath(vmID)),
67 "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac),
68 "--serial", fmt.Sprintf("file=%s", serialLog),
69 "--console", "off",
70 }
71 }
72
73 // PrepareDisk creates the VM disk by making a reflink copy of basePath
74 // (instant on XFS/btrfs; silent full-copy fallback on ext4) and then
75 // truncating it to spec.DiskGB gigabytes.
76 func (p *Provisioner) PrepareDisk(ctx context.Context, spec state.VMSpec, basePath string) error {
77 diskPath := p.st.DiskPath(spec.VMID)
78 // Ensure VM directory exists.
79 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil {
80 return fmt.Errorf("mkdir %s: %w", p.st.VMDir(spec.VMID), err)
81 }
82 if _, err := p.run(ctx, "cp", "--reflink=auto", basePath, diskPath); err != nil {
83 return fmt.Errorf("cp --reflink=auto %s %s: %w", basePath, diskPath, err)
84 }
85 sizeArg := fmt.Sprintf("%dG", spec.DiskGB)
86 if _, err := p.run(ctx, "truncate", "-s", sizeArg, diskPath); err != nil {
87 return fmt.Errorf("truncate -s %s %s: %w", sizeArg, diskPath, err)
88 }
89 return nil
90 }
91
92 // pidPath returns the path to the PID file for vmID.
93 func (p *Provisioner) pidPath(vmID string) string {
94 return filepath.Join(p.st.VMDir(vmID), "ch.pid")
95 }
96
97 // Boot spawns a cloud-hypervisor process for spec. The process is placed in
98 // its own session (Setsid) so it survives an agent restart. A goroutine calls
99 // cmd.Wait to reap the child when it exits.
100 func (p *Provisioner) Boot(ctx context.Context, vmID string, spec state.VMSpec) error {
101 // Remove stale socket from a previous run.
102 _ = os.Remove(p.st.SocketPath(vmID))
103
104 args := p.buildArgs(spec)
105 cmd := exec.CommandContext(ctx, p.chBin, args...)
106 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
107
108 // Guest console output (serial) goes to serial.log (via --serial file=…).
109 // CH's own diagnostic output (startup errors, API logs) goes to ch.log.
110 chLogPath := filepath.Join(p.st.VMDir(vmID), "ch.log")
111 chLog, err := os.OpenFile(chLogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, chLogMode)
112 if err != nil {
113 return fmt.Errorf("open ch.log %s: %w", vmID, err)
114 }
115 cmd.Stdout = chLog
116 cmd.Stderr = chLog
117
118 if err := cmd.Start(); err != nil {
119 _ = chLog.Close()
120 return fmt.Errorf("cloud-hypervisor start %s: %w", vmID, err)
121 }
122 // Close the log fd in the parent; the child has its own copy.
123 _ = chLog.Close()
124
125 // Write PID file so Running/Shutdown/Kill can find the process later.
126 pidData := []byte(strconv.Itoa(cmd.Process.Pid))
127 if err := os.WriteFile(p.pidPath(vmID), pidData, 0o600); err != nil {
128 // Best effort — kill the orphan if we can't track it.
129 _ = cmd.Process.Kill()
130 return fmt.Errorf("write pidfile %s: %w", vmID, err)
131 }
132
133 // Reap child asynchronously; ignore exit error (VM may be killed intentionally).
134 go func() { _ = cmd.Wait() }()
135
136 return nil
137 }
138
139 // readPID reads the PID file for vmID and returns the PID, or 0 on error.
140 func (p *Provisioner) readPID(vmID string) int {
141 raw, err := os.ReadFile(p.pidPath(vmID))
142 if err != nil {
143 return 0
144 }
145 pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
146 if err != nil {
147 return 0
148 }
149 return pid
150 }
151
152 // Running reports whether the cloud-hypervisor process for vmID is still alive.
153 // It reads the PID file and sends signal 0 (existence check).
154 //
155 // PID-liveness only; a recycled PID after host reboot can false-positive.
156 // The reconcile engine's boot-ID check (lost = boot ID changed) is the
157 // authoritative reboot guard — do not trust Running() standalone across reboots.
158 func (p *Provisioner) Running(vmID string) bool {
159 pid := p.readPID(vmID)
160 if pid == 0 {
161 return false
162 }
163 return syscall.Kill(pid, 0) == nil
164 }
165
166 // socketClient returns an *http.Client whose transport dials over the VM's
167 // Unix socket.
168 func (p *Provisioner) socketClient(vmID string) *http.Client {
169 sockPath := p.st.SocketPath(vmID)
170 return &http.Client{
171 Timeout: 5 * time.Second,
172 Transport: &http.Transport{
173 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
174 return (&net.Dialer{}).DialContext(ctx, "unix", sockPath)
175 },
176 },
177 }
178 }
179
180 // Shutdown requests a clean shutdown via the cloud-hypervisor power-button API.
181 // Falls back to SIGTERM via the PID file if the API call fails or returns a
182 // non-2xx status (e.g. 404/500 when CH is unhealthy or the VM is not running).
183 func (p *Provisioner) Shutdown(ctx context.Context, vmID string) error {
184 client := p.socketClient(vmID)
185 req, err := http.NewRequestWithContext(ctx, http.MethodPut,
186 "http://localhost/api/v1/vm.power-button", nil)
187 if err != nil {
188 return p.sigterm(vmID)
189 }
190 resp, err := client.Do(req)
191 if err != nil {
192 return p.sigterm(vmID)
193 }
194 resp.Body.Close()
195 // Treat any non-2xx response as a failure and fall through to SIGTERM.
196 // A 404 means the VM is not in a running state; a 500 means CH is unhealthy.
197 // Either way the power-button did not trigger a shutdown.
198 if resp.StatusCode >= 300 {
199 return p.sigterm(vmID)
200 }
201 return nil
202 }
203
204 // sigterm sends SIGTERM to the process identified by vmID's PID file.
205 func (p *Provisioner) sigterm(vmID string) error {
206 pid := p.readPID(vmID)
207 if pid == 0 {
208 return nil // already gone
209 }
210 if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH {
211 return fmt.Errorf("SIGTERM %s (pid %d): %w", vmID, pid, err)
212 }
213 return nil
214 }
215
216 // Kill sends SIGKILL to the cloud-hypervisor process for vmID and removes
217 // the PID file.
218 func (p *Provisioner) Kill(ctx context.Context, vmID string) error {
219 pid := p.readPID(vmID)
220 if pid != 0 {
221 if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
222 return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err)
223 }
224 }
225 _ = os.Remove(p.pidPath(vmID))
226 return nil
227 }
internal/agent/cloudhv/cloudhv_test.go
Old New
@@ -0,0 +1,118 @@
1 package cloudhv
2
3 import (
4 "context"
5 "net"
6 "net/http"
7 "strings"
8 "sync/atomic"
9 "testing"
10
11 "github.com/a73x/eitri/internal/agent/state"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 )
15
16 func TestMACDeterministicAndLocallyAdministered(t *testing.T) {
17 m1, m2 := MAC("vm-abc"), MAC("vm-abc")
18 assert.Equal(t, m1, m2)
19 assert.NotEqual(t, m1, MAC("vm-def"))
20 assert.True(t, strings.HasPrefix(m1, "52:54:00:"), "QEMU/KVM locally-administered OUI")
21 }
22
23 func TestBuildArgs(t *testing.T) {
24 st, _ := state.Open(t.TempDir())
25 p := New(st, "/usr/bin/cloud-hypervisor", "/usr/share/ch/hypervisor-fw", nil)
26 spec := state.VMSpec{VMID: "vm1", VCPUs: 2, MemMB: 2048}
27 args := p.buildArgs(spec)
28 joined := strings.Join(args, " ")
29 assert.Contains(t, joined, "--api-socket "+st.SocketPath("vm1"))
30 assert.Contains(t, joined, "--kernel /usr/share/ch/hypervisor-fw")
31 assert.Contains(t, joined, "boot=2")
32 assert.Contains(t, joined, "size=2048M")
33 assert.Contains(t, joined, st.DiskPath("vm1"))
34 assert.Contains(t, joined, st.SeedPath("vm1"))
35 assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1"))
36 }
37
38 func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) {
39 var cmds []string
40 run := func(ctx context.Context, name string, args ...string) (string, error) {
41 cmds = append(cmds, name+" "+strings.Join(args, " "))
42 return "", nil
43 }
44 st, _ := state.Open(t.TempDir())
45 p := New(st, "ch", "fw", run)
46 require.NoError(t, p.PrepareDisk(context.Background(),
47 state.VMSpec{VMID: "vm1", DiskGB: 10}, "/cache/abc.raw"))
48 joined := strings.Join(cmds, "\n")
49 // reflink=auto: instant on XFS/btrfs, silent full-copy fallback on ext4 (spec)
50 assert.Contains(t, joined, "cp --reflink=auto /cache/abc.raw "+st.DiskPath("vm1"))
51 assert.Contains(t, joined, "truncate -s 10G "+st.DiskPath("vm1"))
52 }
53
54 // TestShutdownFallsBackToSIGTERMOn500 verifies fix 3: a non-2xx HTTP response
55 // from the cloud-hypervisor socket is treated as failure and the SIGTERM
56 // fallback path is taken.
57 //
58 // Setup: a unix-socket HTTP server serving 500 is bound at the VM's SocketPath.
59 // No PID file exists, so sigterm() is a no-op returning nil.
60 // Expected: Shutdown returns nil (fallback succeeded) AND the server observed
61 // the incoming request (proving the API was actually called before falling back).
62 func TestShutdownFallsBackToSIGTERMOn500(t *testing.T) {
63 st, err := state.Open(t.TempDir())
64 require.NoError(t, err)
65
66 vmID := "vm-shutdown-test"
67 // Ensure VM directory exists (SocketPath lives inside it).
68 require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: vmID}}))
69
70 sockPath := st.SocketPath(vmID)
71
72 // Bind a Unix socket serving HTTP 500 at the VM's socket path.
73 ln, err := net.Listen("unix", sockPath)
74 require.NoError(t, err)
75 defer ln.Close()
76
77 var requestSeen atomic.Bool
78 srv := &http.Server{
79 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80 requestSeen.Store(true)
81 w.WriteHeader(http.StatusInternalServerError)
82 }),
83 }
84 go srv.Serve(ln) //nolint:errcheck
85 defer srv.Close()
86
87 p := New(st, "ch", "fw", nil)
88 // No pidfile → sigterm fallback is a no-op returning nil.
89 err = p.Shutdown(context.Background(), vmID)
90 assert.NoError(t, err, "Shutdown must not error when SIGTERM fallback has no pidfile")
91 assert.True(t, requestSeen.Load(), "Shutdown must attempt the CH API before falling back")
92 }
93
94 // TestShutdownSucceedsOn204 verifies that a 2xx response is treated as success
95 // (no fallback to SIGTERM).
96 func TestShutdownSucceedsOn204(t *testing.T) {
97 st, err := state.Open(t.TempDir())
98 require.NoError(t, err)
99
100 vmID := "vm-shutdown-ok"
101 require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: vmID}}))
102
103 sockPath := st.SocketPath(vmID)
104 ln, err := net.Listen("unix", sockPath)
105 require.NoError(t, err)
106 defer ln.Close()
107
108 srv := &http.Server{
109 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110 w.WriteHeader(http.StatusNoContent)
111 }),
112 }
113 go srv.Serve(ln) //nolint:errcheck
114 defer srv.Close()
115
116 p := New(st, "ch", "fw", nil)
117 assert.NoError(t, p.Shutdown(context.Background(), vmID))
118 }
internal/agent/exec/exec.go
Old New
@@ -0,0 +1,10 @@
1 // Package exec defines the single command-runner type shared by the host-touching
2 // agent packages (cloudhv, imagecache, netenv, overlay). Injecting a Runner keeps
3 // those packages testable without touching the kernel.
4 package exec
5
6 import "context"
7
8 // Runner executes a system command and returns its combined stdout/stderr and
9 // any error.
10 type Runner func(ctx context.Context, name string, args ...string) (string, error)
internal/agent/imagecache/imagecache.go
Old New
@@ -0,0 +1,94 @@
1 // Package imagecache downloads, verifies, and raw-converts base images.
2 // Layout: <dir>/<sha256>.raw — keyed by checksum (spec). LRU eviction is
3 // deferred (Phase 1 hosts pin one or two images).
4 package imagecache
5
6 import (
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "fmt"
11 "io"
12 "net/http"
13 "os"
14 "path/filepath"
15 "regexp"
16 "time"
17
18 "github.com/a73x/eitri/internal/agent/exec"
19 )
20
21 // httpDoer is the slice of *http.Client the cache needs to fetch images. It is
22 // a field on Cache, not a package global, so a test can stub the transport the
23 // same way it stubs command execution through run — exercising slow, hung, and
24 // error responses without reaching the network.
25 type httpDoer interface {
26 Do(*http.Request) (*http.Response, error)
27 }
28
29 // sha256Re matches a valid lowercase hex SHA-256 digest (exactly 64 chars).
30 // Checked before building any filesystem path to prevent path traversal.
31 var sha256Re = regexp.MustCompile(`^[a-f0-9]{64}$`)
32
33 type Cache struct {
34 dir string
35 run exec.Runner
36 http httpDoer
37 }
38
39 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout
40 // so large images on slow links still complete, but a hung connection cannot
41 // stall a reconcile worker forever.
42 func New(dir string, run exec.Runner) *Cache {
43 return &Cache{dir: dir, run: run, http: &http.Client{Timeout: 10 * time.Minute}}
44 }
45
46 func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
47 // Guard path traversal: sha becomes part of the cache file path.
48 if !sha256Re.MatchString(sha) {
49 return "", fmt.Errorf("invalid sha256: %q", sha)
50 }
51
52 final := filepath.Join(c.dir, sha+".raw")
53 if _, err := os.Stat(final); err == nil {
54 return final, nil
55 }
56 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
57 if err != nil {
58 return "", err
59 }
60 resp, err := c.http.Do(req)
61 if err != nil {
62 return "", err
63 }
64 defer resp.Body.Close()
65 if resp.StatusCode != 200 {
66 return "", fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode)
67 }
68 tmp, err := os.CreateTemp(c.dir, "download-*")
69 if err != nil {
70 return "", err
71 }
72 defer os.Remove(tmp.Name())
73 h := sha256.New()
74 if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil {
75 tmp.Close()
76 return "", err
77 }
78 tmp.Close()
79 if got := hex.EncodeToString(h.Sum(nil)); got != sha {
80 return "", fmt.Errorf("checksum mismatch for %s: got %s want %s", url, got, sha)
81 }
82 // Convert to a temp file first; rename onto final atomically so a crash
83 // mid-convert cannot leave a corrupt file at the final path.
84 converting := final + ".converting"
85 if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", tmp.Name(), converting); err != nil {
86 os.Remove(converting)
87 return "", fmt.Errorf("qemu-img convert: %w", err)
88 }
89 if err := os.Rename(converting, final); err != nil {
90 os.Remove(converting)
91 return "", fmt.Errorf("imagecache: rename to final: %w", err)
92 }
93 return final, nil
94 }
internal/agent/imagecache/imagecache_test.go
Old New
@@ -0,0 +1,128 @@
1 package imagecache
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "testing"
13
14 "github.com/a73x/eitri/internal/agent/exec"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
18
19 // fake qemu-img: just copies src to dst (args: convert -O raw src dst)
20 func fakeRunner(t *testing.T) exec.Runner {
21 return func(ctx context.Context, name string, args ...string) (string, error) {
22 t.Helper()
23 if name != "qemu-img" {
24 return "", fmt.Errorf("unexpected command %s", name)
25 }
26 src, dst := args[len(args)-2], args[len(args)-1]
27 data, err := os.ReadFile(src)
28 if err != nil {
29 return "", err
30 }
31 return "", os.WriteFile(dst, data, 0o644)
32 }
33 }
34
35 func serve(t *testing.T, body []byte) (*httptest.Server, string) {
36 t.Helper()
37 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38 w.Write(body)
39 }))
40 t.Cleanup(ts.Close)
41 sum := sha256.Sum256(body)
42 return ts, hex.EncodeToString(sum[:])
43 }
44
45 func TestEnsureDownloadsVerifiesAndCaches(t *testing.T) {
46 body := []byte("pretend-qcow2-image")
47 ts, sum := serve(t, body)
48 var calls int
49 c := New(t.TempDir(), func(ctx context.Context, name string, args ...string) (string, error) {
50 calls++
51 return fakeRunner(t)(ctx, name, args...)
52 })
53
54 p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
55 require.NoError(t, err)
56 assert.FileExists(t, p1)
57
58 p2, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
59 require.NoError(t, err)
60 assert.Equal(t, p1, p2)
61 assert.Equal(t, 1, calls, "second Ensure must hit the cache, not re-convert")
62 }
63
64 func TestEnsureRejectsChecksumMismatch(t *testing.T) {
65 ts, _ := serve(t, []byte("evil-bytes"))
66 c := New(t.TempDir(), fakeRunner(t))
67 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000")
68 assert.Error(t, err, "tampered image must be rejected before conversion")
69 }
70
71 // --- M4: sha path traversal guard ---
72
73 func TestEnsureRejectsInvalidSha(t *testing.T) {
74 c := New(t.TempDir(), fakeRunner(t))
75
76 t.Run("path traversal", func(t *testing.T) {
77 _, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd")
78 assert.Error(t, err, "path traversal sha must be rejected")
79 assert.Contains(t, err.Error(), "invalid sha256")
80 })
81
82 t.Run("uppercase hex", func(t *testing.T) {
83 _, err := c.Ensure(context.Background(), "http://unused",
84 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
85 assert.Error(t, err, "uppercase sha must be rejected")
86 })
87
88 t.Run("too short", func(t *testing.T) {
89 _, err := c.Ensure(context.Background(), "http://unused", "abc123")
90 assert.Error(t, err)
91 })
92 }
93
94 // --- I1: atomic conversion via temp file ---
95
96 func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) {
97 body := []byte("pretend-qcow2-image")
98 ts, sum := serve(t, body)
99 dir := t.TempDir()
100
101 // Pre-seed a leftover partial converting file from a previous crashed run.
102 partial := filepath.Join(dir, sum+".raw.converting")
103 require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644))
104
105 c := New(dir, fakeRunner(t))
106 p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
107 require.NoError(t, err)
108
109 // The result must contain the correct data (not the corrupt partial).
110 got, err := os.ReadFile(p)
111 require.NoError(t, err)
112 assert.Equal(t, body, got, "final cache file must contain correct image data")
113 }
114
115 func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
116 body := []byte("pretend-qcow2-image")
117 ts, sum := serve(t, body)
118 dir := t.TempDir()
119 c := New(dir, fakeRunner(t))
120
121 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum)
122 require.NoError(t, err)
123
124 entries, err := os.ReadDir(dir)
125 require.NoError(t, err)
126 assert.Len(t, entries, 1, "only the final .raw file should remain in cache dir")
127 assert.Equal(t, sum+".raw", entries[0].Name())
128 }
internal/agent/ipalloc/ipalloc.go
Old New
@@ -0,0 +1,32 @@
1 // Package ipalloc allocates VM IPs within the host's bridge CIDR.
2 // .0 = network, .1 = bridge gateway, .255 = broadcast (for /24).
3 package ipalloc
4
5 import (
6 "fmt"
7 "net/netip"
8 )
9
10 func Alloc(cidr string, used []string) (string, error) {
11 prefix, err := netip.ParsePrefix(cidr)
12 if err != nil {
13 return "", err
14 }
15 if !prefix.Addr().Is4() {
16 return "", fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
17 }
18 inUse := map[string]bool{}
19 for _, u := range used {
20 inUse[u] = true
21 }
22 network := prefix.Masked().Addr()
23 addr := network.Next().Next() // skip network + gateway
24 for prefix.Contains(addr) {
25 a4 := addr.As4()
26 if a4[3] != 255 && !inUse[addr.String()] {
27 return addr.String(), nil
28 }
29 addr = addr.Next()
30 }
31 return "", fmt.Errorf("no free IP in %s", cidr)
32 }
internal/agent/ipalloc/ipalloc_test.go
Old New
@@ -0,0 +1,31 @@
1 package ipalloc
2
3 import (
4 "strconv"
5 "testing"
6 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"
8 )
9
10 func itoa(i int) string { return strconv.Itoa(i) }
11
12 func TestAllocSkipsGatewayAndUsed(t *testing.T) {
13 ip, err := Alloc("10.77.1.0/24", []string{"10.77.1.2", "10.77.1.3"})
14 require.NoError(t, err)
15 assert.Equal(t, "10.77.1.4", ip, ".1 is the bridge gateway; .2/.3 used")
16 }
17
18 func TestAllocFirstVM(t *testing.T) {
19 ip, err := Alloc("10.77.1.0/24", nil)
20 require.NoError(t, err)
21 assert.Equal(t, "10.77.1.2", ip)
22 }
23
24 func TestAllocExhausted(t *testing.T) {
25 used := make([]string, 0, 253)
26 for i := 2; i <= 254; i++ {
27 used = append(used, "10.77.1."+itoa(i))
28 }
29 _, err := Alloc("10.77.1.0/24", used)
30 assert.Error(t, err, "pool exhausted (quarantined VMs hold IPs — spec sizing note)")
31 }
internal/agent/netenv/netenv.go
Old New
@@ -0,0 +1,186 @@
1 // Package netenv manages the host side of VM networking: bridge eitri0 with
2 // the host as .1 gateway, per-VM taps, and NAT for outbound internet.
3 // Overlay-specific logic (Tailscale, none, etc.) lives in package overlay.
4 package netenv
5
6 import (
7 "context"
8 "fmt"
9 "net/netip"
10 "strings"
11
12 "github.com/a73x/eitri/internal/agent/exec"
13 "github.com/a73x/eitri/internal/agent/ipalloc"
14 )
15
16 // Bridge is the name of the Linux bridge device created by EnsureBridge.
17 const Bridge = "eitri0"
18
19 // Net holds the runner and the bridge CIDR for this agent instance.
20 type Net struct {
21 run exec.Runner
22 cidr netip.Prefix
23 }
24
25 // New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24").
26 func New(run exec.Runner, cidr string) (*Net, error) {
27 p, err := netip.ParsePrefix(cidr)
28 if err != nil {
29 return nil, err
30 }
31 if !p.Addr().Is4() {
32 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
33 }
34 return &Net{run: run, cidr: p}, nil
35 }
36
37 // Gateway returns the host-side IP (.1) on the bridge, as a bare address string.
38 func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() }
39
40 // AllocateIP returns an unused VM IP within the bridge network. It implements
41 // the reconcile NetEnv addressing seam with host-local allocation; a future
42 // central/per-network allocator replaces this method, not the reconcile loop.
43 func (n *Net) AllocateIP(_ context.Context, used []string) (string, error) {
44 return ipalloc.Alloc(n.cidr.String(), used)
45 }
46
47 // GuestNetwork returns the gateway (.1) and prefix length the guest is configured
48 // with — network properties the addressing seam owns, not bridge mechanics.
49 func (n *Net) GuestNetwork() (string, int) {
50 return n.Gateway(), n.cidr.Bits()
51 }
52
53 // tolerated reports whether err carries one of the given substrings in either
54 // the command stdout or the error message. iproute2 puts the same condition in
55 // different streams across versions, so both are checked.
56 func tolerated(out string, err error, substrs ...string) bool {
57 msg := err.Error()
58 for _, s := range substrs {
59 if strings.Contains(out, s) || strings.Contains(msg, s) {
60 return true
61 }
62 }
63 return false
64 }
65
66 // best runs a command, tolerating "already exists" / "File exists" errors so
67 // that EnsureBridge is idempotent (called on every agent start).
68 func (n *Net) best(ctx context.Context, name string, args ...string) (string, error) {
69 out, err := n.run(ctx, name, args...)
70 if err != nil {
71 if tolerated(out, err, "File exists", "already exists") {
72 return out, nil
73 }
74 return out, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
75 }
76 return out, nil
77 }
78
79 // EnsureBridge creates and configures the eitri0 Linux bridge, enables IP
80 // forwarding, and installs a scoped nftables NAT rule that masquerades VM
81 // outbound traffic on every interface except those in noMasqIfaces.
82 //
83 // noMasqIfaces is the combined list from Overlay.NoMasqueradeIfaces() — e.g.
84 // ["tailscale0"] for the tailscale overlay, or ["wg0"] for overlay=none with
85 // --no-masquerade-ifaces=wg0. Empty list → plain masquerade on all interfaces.
86 //
87 // The function is safe to call on every agent restart: the nft chain is
88 // flushed before the masquerade rule is added, so rules never accumulate
89 // across restarts.
90 func (n *Net) EnsureBridge(ctx context.Context, noMasqIfaces []string) error {
91 gw := n.Gateway()
92 bits := n.cidr.Bits()
93 cidrStr := n.cidr.Masked().String()
94
95 // Idempotency by genuinely-idempotent operations, NOT by parsing kernel
96 // error strings: iproute2 message wording varies across versions (a
97 // duplicate address says "Address already assigned." on some, "File exists"
98 // on others), so a string allow-list is fragile and silently strands a
99 // restarting agent. Instead: existence-check the link, and use `ip addr
100 // replace` (add-or-update, exit 0 whether or not the address is present).
101
102 // Bridge link — create only if absent.
103 if _, err := n.run(ctx, "ip", "link", "show", "dev", Bridge); err != nil {
104 if _, err := n.run(ctx, "ip", "link", "add", Bridge, "type", "bridge"); err != nil {
105 return fmt.Errorf("ip link add %s: %w", Bridge, err)
106 }
107 }
108
109 // Gateway address — `replace` is idempotent.
110 if _, err := n.run(ctx, "ip", "addr", "replace",
111 fmt.Sprintf("%s/%d", gw, bits), "dev", Bridge); err != nil {
112 return fmt.Errorf("ip addr replace %s/%d dev %s: %w", gw, bits, Bridge, err)
113 }
114
115 // Post-condition: the gateway must actually be on the bridge now. Catches a
116 // genuine failure (e.g. the address ended up elsewhere) with an actionable
117 // error instead of mysterious VM-networking failures later.
118 if out, err := n.run(ctx, "ip", "-o", "addr", "show", "dev", Bridge); err != nil ||
119 !strings.Contains(out, gw+"/") {
120 return fmt.Errorf("gateway IP %s not present on %s after setup", gw, Bridge)
121 }
122
123 // Remaining steps are idempotent by nature: `ip link set up` is a no-op if
124 // already up; `sysctl -w` just sets the value; nft `add table`/`add chain`
125 // are no-ops if the object already exists (only `create` errors).
126 idempotentSteps := [][]string{
127 {"ip", "link", "set", Bridge, "up"},
128 {"sysctl", "-w", "net.ipv4.ip_forward=1"},
129 {"nft", "add", "table", "ip", "eitri"},
130 {"nft", "add", "chain", "ip", "eitri", "postrouting",
131 "{ type nat hook postrouting priority srcnat ; }"},
132 }
133 for _, s := range idempotentSteps {
134 if _, err := n.run(ctx, s[0], s[1:]...); err != nil {
135 return fmt.Errorf("%s %s: %w", s[0], strings.Join(s[1:], " "), err)
136 }
137 }
138
139 // Flush the chain before adding the rule so that repeated agent restarts do
140 // not accumulate duplicate masquerade rules in the kernel ruleset.
141 if _, err := n.run(ctx, "nft", "flush", "chain", "ip", "eitri", "postrouting"); err != nil {
142 return fmt.Errorf("nft flush chain ip eitri postrouting: %w", err)
143 }
144
145 // Build the masquerade rule. For each interface in noMasqIfaces add an
146 // oifname != "<iface>" condition. All conditions are in a single nft rule.
147 // Empty noMasqIfaces → plain masquerade (no exclusions).
148 ruleArgs := []string{"add", "rule", "ip", "eitri", "postrouting",
149 "ip", "saddr", cidrStr}
150 for _, iface := range noMasqIfaces {
151 ruleArgs = append(ruleArgs, "oifname", "!=", `"`+iface+`"`)
152 }
153 ruleArgs = append(ruleArgs, "masquerade")
154
155 if _, err := n.run(ctx, "nft", ruleArgs...); err != nil {
156 return fmt.Errorf("nft add rule: %w", err)
157 }
158
159 return nil
160 }
161
162 // CreateTap creates a TAP device and attaches it to the eitri0 bridge.
163 // tap should be the value from state.TapName(vmID).
164 func (n *Net) CreateTap(ctx context.Context, tap string) error {
165 if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil {
166 return err
167 }
168 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil {
169 return err
170 }
171 _, err := n.best(ctx, "ip", "link", "set", tap, "up")
172 return err
173 }
174
175 // DeleteTap removes a TAP device. "Cannot find device" errors are tolerated so
176 // that Delete is idempotent (the device may have been cleaned up already).
177 func (n *Net) DeleteTap(ctx context.Context, tap string) error {
178 out, err := n.run(ctx, "ip", "link", "del", tap)
179 if err != nil {
180 if tolerated(out, err, "Cannot find device") {
181 return nil
182 }
183 return fmt.Errorf("ip link del %s: %w", tap, err)
184 }
185 return nil
186 }
internal/agent/netenv/netenv_test.go
Old New
@@ -0,0 +1,250 @@
1 package netenv
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/a73x/eitri/internal/agent/exec"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 type call struct {
15 name string
16 args string
17 }
18
19 // recorder returns a Runner fake that records all commands.
20 // out maps "name args" → stdout; errs maps "name args" → error.
21 // Either map may be nil.
22 func recorder(out map[string]string, errs map[string]error) (exec.Runner, *[]call) {
23 var calls []call
24 return func(ctx context.Context, name string, args ...string) (string, error) {
25 joined := strings.Join(args, " ")
26 key := name + " " + joined
27 calls = append(calls, call{name, joined})
28 return out[key], errs[key]
29 }, &calls
30 }
31
32 func joinCalls(calls *[]call) string {
33 var sb strings.Builder
34 for _, c := range *calls {
35 sb.WriteString(c.name + " " + c.args + "\n")
36 }
37 return sb.String()
38 }
39
40 func TestEnsureBridgeSetsUpGatewayForwardingAndScopedNAT(t *testing.T) {
41 // Fresh host: `ip link show` errors (bridge absent) so link-add runs.
42 errs := map[string]error{
43 "ip link show dev eitri0": errors.New("Device \"eitri0\" does not exist."),
44 }
45 out := map[string]string{
46 "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0",
47 }
48 run, calls := recorder(out, errs)
49 n, err := New(run, "10.77.1.0/24")
50 require.NoError(t, err)
51 require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"}))
52
53 all := joinCalls(calls)
54 assert.Contains(t, all, "ip link add eitri0 type bridge")
55 // Idempotent add-or-update — never the fragile, version-specific `ip addr add`.
56 assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0")
57 assert.NotContains(t, all, "ip addr add")
58 assert.Contains(t, all, "net.ipv4.ip_forward=1")
59 // NAT must NOT masquerade tailnet-bound traffic (spec): scoped by oifname.
60 assert.Contains(t, all, `oifname != "tailscale0"`)
61 assert.Contains(t, all, "10.77.1.0/24")
62 }
63
64 // TestEnsureBridgeIdempotentOnRestart is the regression test for the smoke-test
65 // failure: a prior run left eitri0 up with the gateway address, and the agent
66 // died at `ip addr add` because the duplicate-address message ("Error: ipv4:
67 // Address already assigned.") was not in best()'s allow-list. EnsureBridge must
68 // now survive a restart with the bridge already up — it existence-checks the
69 // link and uses `ip addr replace`, depending on no error-string parsing.
70 func TestEnsureBridgeIdempotentOnRestart(t *testing.T) {
71 out := map[string]string{
72 "ip link show dev eitri0": "7: eitri0: <BROADCAST,MULTICAST,UP> mtu 1500 state UP",
73 "ip -o addr show dev eitri0": "7: eitri0 inet 10.77.1.1/24 scope global eitri0",
74 }
75 // If the code ever regresses to `ip addr add`, fail it the real-world way.
76 errs := map[string]error{
77 "ip addr add 10.77.1.1/24 dev eitri0": errors.New("Error: ipv4: Address already assigned."),
78 }
79 run, calls := recorder(out, errs)
80 n, err := New(run, "10.77.1.0/24")
81 require.NoError(t, err)
82 require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"}),
83 "restart with existing bridge+address must succeed")
84
85 all := joinCalls(calls)
86 assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0")
87 assert.NotContains(t, all, "ip addr add", "must use replace, not add")
88 assert.NotContains(t, all, "ip link add", "bridge exists → no link add")
89 }
90
91 func TestTapLifecycle(t *testing.T) {
92 run, calls := recorder(nil, nil)
93 n, _ := New(run, "10.77.1.0/24")
94 require.NoError(t, n.CreateTap(context.Background(), "eit-abc123"))
95 all := joinCalls(calls)
96 assert.Contains(t, all, "ip tuntap add dev eit-abc123 mode tap")
97 assert.Contains(t, all, "ip link set eit-abc123 master eitri0")
98 }
99
100 // TestEnsureBridgeFlushPrecedesRuleAdd asserts that nft flush chain is issued
101 // before nft add rule on every call to EnsureBridge. Running it N times keeps
102 // the ruleset bounded by construction: each flush clears prior rules before the
103 // new one is appended.
104 func TestEnsureBridgeFlushPrecedesRuleAdd(t *testing.T) {
105 addrShowOut := "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0"
106 out := map[string]string{
107 "ip -o addr show dev eitri0": addrShowOut,
108 }
109 run, calls := recorder(out, nil)
110 n, err := New(run, "10.77.1.0/24")
111 require.NoError(t, err)
112
113 // Call EnsureBridge twice — simulating two agent restarts.
114 require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"}))
115 require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"}))
116
117 all := joinCalls(calls)
118
119 // Count flushes and rule-adds.
120 flushCount := strings.Count(all, "nft flush chain ip eitri postrouting")
121 addCount := strings.Count(all, "nft add rule ip eitri postrouting")
122 assert.Equal(t, 2, flushCount, "expected one flush per EnsureBridge call")
123 assert.Equal(t, 2, addCount, "expected one add-rule per EnsureBridge call")
124
125 // Each flush must appear before its corresponding rule-add in call order.
126 flushIdx := -1
127 addIdx := -1
128 for i, c := range *calls {
129 line := c.name + " " + c.args
130 if strings.Contains(line, "nft flush chain ip eitri postrouting") && flushIdx == -1 {
131 flushIdx = i
132 }
133 if strings.Contains(line, "nft add rule ip eitri postrouting") && addIdx == -1 {
134 addIdx = i
135 }
136 }
137 assert.Less(t, flushIdx, addIdx, "first flush must precede first rule-add")
138 }
139
140 // TestEnsureBridgeGatewayConflictDetected: the post-condition check catches a
141 // genuine failure where the gateway address did not end up on the bridge —
142 // EnsureBridge must return an explicit error instead of proceeding with a
143 // gatewayless bridge.
144 func TestEnsureBridgeGatewayConflictDetected(t *testing.T) {
145 // addr-show returns output WITHOUT the gateway.
146 out := map[string]string{
147 "ip -o addr show dev eitri0": "2: eitri0 inet scope global eitri0",
148 }
149 run, _ := recorder(out, nil)
150 n, err := New(run, "10.77.1.0/24")
151 require.NoError(t, err)
152
153 err = n.EnsureBridge(context.Background(), []string{"tailscale0"})
154 require.Error(t, err)
155 assert.Contains(t, err.Error(), "gateway IP 10.77.1.1 not present on eitri0")
156 }
157
158 // TestEnsureBridgePlainMasquerade: empty noMasqIfaces → masquerade without any
159 // oifname exclusions (e.g. overlay=none without --no-masquerade-ifaces).
160 func TestEnsureBridgePlainMasquerade(t *testing.T) {
161 out := map[string]string{
162 "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0",
163 }
164 run, calls := recorder(out, nil)
165 n, err := New(run, "10.77.1.0/24")
166 require.NoError(t, err)
167 require.NoError(t, n.EnsureBridge(context.Background(), nil))
168
169 all := joinCalls(calls)
170 assert.Contains(t, all, "masquerade", "masquerade rule must still be added")
171 assert.NotContains(t, all, "oifname",
172 "no noMasqIfaces → no oifname conditions in the rule")
173 }
174
175 // TestEnsureBridgeTwoNoMasqIfaces: two ifaces in noMasqIfaces → both appear
176 // as oifname != conditions in a single nft add rule call.
177 func TestEnsureBridgeTwoNoMasqIfaces(t *testing.T) {
178 out := map[string]string{
179 "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0",
180 }
181 run, calls := recorder(out, nil)
182 n, err := New(run, "10.77.1.0/24")
183 require.NoError(t, err)
184 require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0", "wg0"}))
185
186 // Find the nft add rule call and verify both exclusions are present.
187 var ruleCall string
188 for _, c := range *calls {
189 if c.name == "nft" && strings.HasPrefix(c.args, "add rule ip eitri postrouting") {
190 ruleCall = c.args
191 break
192 }
193 }
194 require.NotEmpty(t, ruleCall, "must find nft add rule call")
195 assert.Contains(t, ruleCall, `oifname != "tailscale0"`)
196 assert.Contains(t, ruleCall, `oifname != "wg0"`)
197 }
198
199 // CreateTap must tolerate "File exists" / "already exists" so it is idempotent.
200 // The marker may appear in stdout OR in the error message — both are tolerated.
201 func TestCreateTapToleratesAlreadyExists(t *testing.T) {
202 cases := []struct {
203 name string
204 out string
205 errText string
206 }{
207 {"file-exists-in-err", "", "ioctl(TUNSETIFF): File exists"},
208 {"already-exists-in-err", "", "Error: Device already exists"},
209 {"file-exists-in-stdout", "File exists", "exit status 2"},
210 }
211 for _, tc := range cases {
212 t.Run(tc.name, func(t *testing.T) {
213 key := "ip tuntap add dev eit-x mode tap"
214 run, _ := recorder(
215 map[string]string{key: tc.out},
216 map[string]error{key: errors.New(tc.errText)},
217 )
218 n, _ := New(run, "10.77.1.0/24")
219 require.NoError(t, n.CreateTap(context.Background(), "eit-x"),
220 "already-exists must be tolerated for idempotency")
221 })
222 }
223 }
224
225 // CreateTap must still surface a genuine (non-tolerated) error.
226 func TestCreateTapPropagatesRealError(t *testing.T) {
227 key := "ip tuntap add dev eit-x mode tap"
228 run, _ := recorder(nil, map[string]error{key: errors.New("Operation not permitted")})
229 n, _ := New(run, "10.77.1.0/24")
230 err := n.CreateTap(context.Background(), "eit-x")
231 require.Error(t, err)
232 assert.Contains(t, err.Error(), "Operation not permitted")
233 }
234
235 // DeleteTap must tolerate "Cannot find device" (already cleaned up), in stdout
236 // or in the error message, and propagate anything else.
237 func TestDeleteTapToleratesMissingDevice(t *testing.T) {
238 key := "ip link del eit-x"
239 run, _ := recorder(nil, map[string]error{key: errors.New(`Cannot find device "eit-x"`)})
240 n, _ := New(run, "10.77.1.0/24")
241 require.NoError(t, n.DeleteTap(context.Background(), "eit-x"))
242
243 run2, _ := recorder(map[string]string{key: "Cannot find device"}, map[string]error{key: errors.New("exit status 1")})
244 n2, _ := New(run2, "10.77.1.0/24")
245 require.NoError(t, n2.DeleteTap(context.Background(), "eit-x"))
246
247 run3, _ := recorder(nil, map[string]error{key: errors.New("RTNETLINK answers: Operation not permitted")})
248 n3, _ := New(run3, "10.77.1.0/24")
249 require.Error(t, n3.DeleteTap(context.Background(), "eit-x"))
250 }
internal/agent/overlay/overlay.go
Old New
@@ -0,0 +1,274 @@
1 // Package overlay abstracts how a host's bridge CIDR becomes reachable from
2 // the user's network. Implementations honor the ownership model: the host's
3 // networking belongs to the user; observe-and-instruct by default,
4 // additive-only with manage=true.
5 package overlay
6
7 import (
8 "context"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "net/netip"
13 "strings"
14
15 "github.com/a73x/eitri/internal/agent/exec"
16 )
17
18 // ErrActionRequired is returned by EnsureRoute (manage=false, or overlay=none)
19 // when the bridge CIDR is not reachable and the agent must not mutate state.
20 // The error message contains the exact operator command or instruction.
21 // Use errors.Is to detect it.
22 var ErrActionRequired = errors.New("action required")
23
24 // ErrUnverifiable is returned by VerifyRoute for overlay implementations that
25 // have no mechanism to verify reachability (e.g. overlay=none). Callers must
26 // treat it as "unknown by design", never as success.
27 var ErrUnverifiable = errors.New("route verification not supported by this overlay")
28
29 // Overlay abstracts the mechanism that makes the bridge CIDR reachable from
30 // the user's network.
31 type Overlay interface {
32 // EnsureRoute makes the bridge CIDR reachable over the user's network,
33 // honoring the ownership model: observe-and-instruct unless manage=true,
34 // and additive-only even then.
35 EnsureRoute(ctx context.Context, manage bool) error
36 // VerifyRoute reports whether the CIDR is actually announced/approved.
37 VerifyRoute(ctx context.Context) error
38 // NoMasqueradeIfaces returns egress interfaces excluded from NAT, so VM
39 // source IPs survive the overlay path.
40 NoMasqueradeIfaces() []string
41 }
42
43 // New constructs an Overlay. kind must be "tailscale" or "none".
44 // cidr is the bridge CIDR (e.g. "10.77.1.0/24").
45 // authkey is optional; non-empty with kind="none" is an error.
46 // extraNoMasq are additional interfaces to exclude from NAT beyond the
47 // overlay's own default.
48 func New(kind, cidr, authkey string, run exec.Runner, extraNoMasq []string) (Overlay, error) {
49 p, err := netip.ParsePrefix(cidr)
50 if err != nil {
51 return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
52 }
53 if !p.Addr().Is4() {
54 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
55 }
56
57 switch kind {
58 case "tailscale":
59 return &tailscaleOverlay{
60 run: run,
61 cidr: p,
62 authkey: authkey,
63 extraNoMasq: extraNoMasq,
64 }, nil
65 case "none":
66 if authkey != "" {
67 return nil, fmt.Errorf("authkey is meaningless with overlay=none")
68 }
69 return &noneOverlay{
70 cidr: p,
71 extraNoMasq: extraNoMasq,
72 }, nil
73 default:
74 return nil, fmt.Errorf("unknown overlay kind %q (supported: tailscale, none)", kind)
75 }
76 }
77
78 // ── tailscale overlay ─────────────────────────────────────────────────────────
79
80 type tailscaleOverlay struct {
81 run exec.Runner
82 cidr netip.Prefix
83 authkey string
84 extraNoMasq []string
85 }
86
87 // tsDebugPrefs is the subset of `tailscale debug prefs` JSON we care about.
88 type tsDebugPrefs struct {
89 AdvertiseRoutes []string `json:"AdvertiseRoutes"`
90 }
91
92 // advertisedRoutes returns the host's currently configured advertise-routes
93 // by parsing `tailscale debug prefs`. Returns nil slice (not error) when
94 // AdvertiseRoutes is JSON null or an empty array.
95 func (o *tailscaleOverlay) advertisedRoutes(ctx context.Context) ([]string, error) {
96 out, err := o.run(ctx, "tailscale", "debug", "prefs")
97 if err != nil {
98 return nil, fmt.Errorf("tailscale debug prefs: %w", err)
99 }
100 var prefs tsDebugPrefs
101 if err := json.Unmarshal([]byte(out), &prefs); err != nil {
102 return nil, fmt.Errorf("tailscale debug prefs parse: %w", err)
103 }
104 return prefs.AdvertiseRoutes, nil
105 }
106
107 // unionRoutes returns a new slice containing all routes from existing, with
108 // our CIDR appended if it is not already present.
109 func (o *tailscaleOverlay) unionRoutes(existing []string) []string {
110 want := o.cidr.Masked().String()
111 for _, r := range existing {
112 if r == want {
113 return existing
114 }
115 }
116 result := make([]string, len(existing), len(existing)+1)
117 copy(result, existing)
118 return append(result, want)
119 }
120
121 // EnsureRoute implements the spec Tailscale ownership model.
122 //
123 // manage=false (observe-and-instruct): NEVER mutates Tailscale state.
124 // If the bridge CIDR is missing, returns ErrActionRequired with the exact
125 // operator command (existing routes preserved in the suggestion). If reads
126 // fail, degrades gracefully: ErrActionRequired with just our CIDR.
127 //
128 // manage=true (additive): reads current AdvertiseRoutes, unions in our bridge
129 // CIDR, applies via `tailscale set --advertise-routes=<union>`. If authkey is
130 // non-empty, uses `tailscale up --advertise-routes=<union> --authkey=<key>`.
131 // If reads fail in manage=true, returns the read error (does NOT blind-write).
132 //
133 // Both modes: if the CIDR is already advertised → nil (no commands beyond read).
134 // Passing authkey with manage=false is a configuration error.
135 func (o *tailscaleOverlay) EnsureRoute(ctx context.Context, manage bool) error {
136 // Consent check: authkey implies joining a new tailnet — requires manage.
137 if !manage && o.authkey != "" {
138 return fmt.Errorf("authkey requires --manage-overlay")
139 }
140
141 want := o.cidr.Masked().String()
142
143 existing, err := o.advertisedRoutes(ctx)
144 if err != nil {
145 if !manage {
146 // Observe mode: degrade gracefully.
147 cmd := fmt.Sprintf("sudo tailscale set --advertise-routes=%s", want)
148 return fmt.Errorf("%w: run: %s (could not read existing routes; preserve them manually): %v",
149 ErrActionRequired, cmd, err)
150 }
151 // Manage mode: do NOT blind-write when read fails.
152 return err
153 }
154
155 // Check whether our CIDR is already present — fast-path for both modes.
156 for _, r := range existing {
157 if r == want {
158 return nil
159 }
160 }
161
162 // Our CIDR is not present. Compute the unioned route list.
163 unioned := o.unionRoutes(existing)
164 routeArg := "--advertise-routes=" + strings.Join(unioned, ",")
165
166 if !manage {
167 // Observe-and-instruct: return ErrActionRequired with the exact command.
168 cmd := fmt.Sprintf("sudo tailscale set %s", routeArg)
169 return fmt.Errorf("%w: run: %s", ErrActionRequired, cmd)
170 }
171
172 // Manage mode: apply additively.
173 // When an auth key is present, join the tailnet first with `tailscale login`
174 // (which does NOT clobber existing prefs), then apply routes additively via
175 // `tailscale set`. Never use `tailscale up` — it is an absolute command that
176 // replaces all prefs and errors on dedicated hosts with prior state.
177 if o.authkey != "" {
178 if _, err = o.run(ctx, "tailscale", "login", fmt.Sprintf("--auth-key=%s", o.authkey)); err != nil {
179 return fmt.Errorf("tailscale login: %w", err)
180 }
181 }
182 _, err = o.run(ctx, "tailscale", "set", routeArg)
183 if err != nil {
184 return fmt.Errorf("tailscale set: %w", err)
185 }
186 return nil
187 }
188
189 // tsStatus is the subset of `tailscale status --json` we care about.
190 type tsStatus struct {
191 Self struct {
192 PrimaryRoutes []string `json:"PrimaryRoutes"`
193 } `json:"Self"`
194 }
195
196 // VerifyRoute checks that the bridge CIDR is listed in Tailscale's
197 // PrimaryRoutes (i.e. the route has been approved in the tailnet ACL).
198 func (o *tailscaleOverlay) VerifyRoute(ctx context.Context) error {
199 out, err := o.run(ctx, "tailscale", "status", "--json")
200 if err != nil {
201 return fmt.Errorf("tailscale status: %w", err)
202 }
203
204 var st tsStatus
205 if err := json.Unmarshal([]byte(out), &st); err != nil {
206 return fmt.Errorf("tailscale status parse: %w", err)
207 }
208
209 want := o.cidr.Masked().String()
210 for _, r := range st.Self.PrimaryRoutes {
211 if r == want {
212 return nil
213 }
214 }
215
216 return fmt.Errorf(
217 "route %s pending approval in tailnet ACL — "+
218 "add an autoApprovers entry or approve manually in the Tailscale admin console",
219 want,
220 )
221 }
222
223 // NoMasqueradeIfaces returns ["tailscale0"] plus any extra interfaces.
224 func (o *tailscaleOverlay) NoMasqueradeIfaces() []string {
225 result := make([]string, 0, 1+len(o.extraNoMasq))
226 result = append(result, "tailscale0")
227 result = append(result, o.extraNoMasq...)
228 return result
229 }
230
231 // ── none overlay ──────────────────────────────────────────────────────────────
232
233 type noneOverlay struct {
234 cidr netip.Prefix
235 extraNoMasq []string
236 }
237
238 // EnsureRoute for overlay=none:
239 // - manage=true is a hard error (there is nothing to manage; the user should
240 // drop --manage-overlay). This is NOT ErrActionRequired.
241 // - manage=false returns ErrActionRequired with overlay-neutral text naming
242 // BOTH operator duties: make the CIDR reachable AND pass
243 // --no-masquerade-ifaces so VM source IPs are preserved over the tunnel.
244 func (o *noneOverlay) EnsureRoute(_ context.Context, manage bool) error {
245 if manage {
246 return fmt.Errorf("overlay=none has nothing to manage; drop --manage-overlay")
247 }
248 want := o.cidr.Masked().String()
249 return fmt.Errorf(
250 "%w: make %s reachable from your network "+
251 "(e.g. WireGuard AllowedIPs includes the bridge CIDR); "+
252 "also pass --no-masquerade-ifaces=<egress-iface> so VM source IPs "+
253 "are preserved (without it, VM-originated flows are SNATted to the host address); "+
254 "Eitri will not configure this",
255 ErrActionRequired, want,
256 )
257 }
258
259 // VerifyRoute returns ErrUnverifiable for overlay=none — there is nothing to
260 // check; callers must treat this as "unknown by design", never as success.
261 func (o *noneOverlay) VerifyRoute(_ context.Context) error {
262 want := o.cidr.Masked().String()
263 return fmt.Errorf("%w: %s", ErrUnverifiable, want)
264 }
265
266 // NoMasqueradeIfaces returns only the extra interfaces supplied at construction.
267 func (o *noneOverlay) NoMasqueradeIfaces() []string {
268 if len(o.extraNoMasq) == 0 {
269 return nil
270 }
271 result := make([]string, len(o.extraNoMasq))
272 copy(result, o.extraNoMasq)
273 return result
274 }
internal/agent/overlay/overlay_test.go
Old New
@@ -0,0 +1,367 @@
1 package overlay
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/a73x/eitri/internal/agent/exec"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 // ── test helpers ──────────────────────────────────────────────────────────────
15
16 type call struct {
17 name string
18 args string
19 }
20
21 func recorder(out map[string]string, errs map[string]error) (exec.Runner, *[]call) {
22 var calls []call
23 return func(ctx context.Context, name string, args ...string) (string, error) {
24 joined := strings.Join(args, " ")
25 key := name + " " + joined
26 calls = append(calls, call{name, joined})
27 return out[key], errs[key]
28 }, &calls
29 }
30
31 func joinCalls(calls *[]call) string {
32 var sb strings.Builder
33 for _, c := range *calls {
34 sb.WriteString(c.name + " " + c.args + "\n")
35 }
36 return sb.String()
37 }
38
39 func hasMutatingCall(calls *[]call) bool {
40 for _, c := range *calls {
41 if c.name == "tailscale" && (strings.HasPrefix(c.args, "set ") || strings.HasPrefix(c.args, "up ")) {
42 return true
43 }
44 }
45 return false
46 }
47
48 func debugPrefsJSON(routes []string) string {
49 if routes == nil {
50 return `{"AdvertiseRoutes": null}`
51 }
52 if len(routes) == 0 {
53 return `{"AdvertiseRoutes": []}`
54 }
55 quoted := make([]string, len(routes))
56 for i, r := range routes {
57 quoted[i] = `"` + r + `"`
58 }
59 return `{"AdvertiseRoutes": [` + strings.Join(quoted, ",") + `]}`
60 }
61
62 // ── New() constructor tests ───────────────────────────────────────────────────
63
64 func TestNew_InvalidKindReturnsError(t *testing.T) {
65 run, _ := recorder(nil, nil)
66 _, err := New("wireguard", "10.77.1.0/24", "", run, nil)
67 require.Error(t, err)
68 assert.Contains(t, err.Error(), "wireguard")
69 }
70
71 func TestNew_InvalidCIDRReturnsError(t *testing.T) {
72 run, _ := recorder(nil, nil)
73 _, err := New("tailscale", "not-a-cidr", "", run, nil)
74 require.Error(t, err)
75 }
76
77 func TestNew_IPv6CIDRReturnsError(t *testing.T) {
78 run, _ := recorder(nil, nil)
79 _, err := New("tailscale", "fd00::/48", "", run, nil)
80 require.Error(t, err)
81 assert.Contains(t, err.Error(), "IPv4")
82 }
83
84 func TestNew_NoneWithAuthkeyReturnsError(t *testing.T) {
85 run, _ := recorder(nil, nil)
86 _, err := New("none", "10.77.1.0/24", "tskey-abc", run, nil)
87 require.Error(t, err)
88 assert.Contains(t, err.Error(), "authkey is meaningless with overlay=none")
89 }
90
91 // ── tailscale: EnsureRoute ────────────────────────────────────────────────────
92
93 func TestTailscale_EnsureRoute_ObserveMode_CIDRMissing(t *testing.T) {
94 existing := []string{"192.168.1.0/24"}
95 out := map[string]string{
96 "tailscale debug prefs": debugPrefsJSON(existing),
97 }
98 run, calls := recorder(out, nil)
99 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
100 require.NoError(t, err)
101
102 err = ov.EnsureRoute(context.Background(), false)
103 require.Error(t, err)
104
105 assert.True(t, errors.Is(err, ErrActionRequired),
106 "error must wrap ErrActionRequired, got: %v", err)
107 assert.Contains(t, err.Error(), "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24",
108 "suggest command must union existing routes with our CIDR")
109 assert.False(t, hasMutatingCall(calls),
110 "manage=false must NEVER execute tailscale set or tailscale up")
111 }
112
113 func TestTailscale_EnsureRoute_ObserveMode_CIDRAlreadyPresent(t *testing.T) {
114 existing := []string{"192.168.1.0/24", "10.77.1.0/24"}
115 out := map[string]string{
116 "tailscale debug prefs": debugPrefsJSON(existing),
117 }
118 run, calls := recorder(out, nil)
119 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
120 require.NoError(t, err)
121
122 err = ov.EnsureRoute(context.Background(), false)
123 assert.NoError(t, err, "CIDR already present: nothing to do → nil")
124 assert.False(t, hasMutatingCall(calls))
125 }
126
127 func TestTailscale_EnsureRoute_ManageMode_UnionsExistingRoutes(t *testing.T) {
128 existing := []string{"192.168.1.0/24"}
129 out := map[string]string{
130 "tailscale debug prefs": debugPrefsJSON(existing),
131 }
132 run, calls := recorder(out, nil)
133 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
134 require.NoError(t, err)
135
136 err = ov.EnsureRoute(context.Background(), true)
137 require.NoError(t, err)
138
139 all := joinCalls(calls)
140 assert.Contains(t, all, "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24")
141 assert.NotContains(t, all, "tailscale up",
142 "manage=true without authkey must use `set`, never `up`")
143 }
144
145 func TestTailscale_EnsureRoute_ManageMode_EmptyRoutes(t *testing.T) {
146 out := map[string]string{
147 "tailscale debug prefs": debugPrefsJSON(nil),
148 }
149 run, calls := recorder(out, nil)
150 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
151 require.NoError(t, err)
152
153 err = ov.EnsureRoute(context.Background(), true)
154 require.NoError(t, err)
155
156 all := joinCalls(calls)
157 assert.Contains(t, all, "tailscale set --advertise-routes=10.77.1.0/24")
158 assert.NotContains(t, all, "tailscale up")
159 }
160
161
162 func TestTailscale_EnsureRoute_ObserveMode_WithAuthkey_IsError(t *testing.T) {
163 // Authkey stored at construction time is irrelevant here; the constructor
164 // allows it (authkey only forbidden on none). The consent check is at
165 // EnsureRoute time: manage=false + non-empty authkey → config error.
166 // We need to test this through the struct directly since New with tailscale
167 // allows authkey. Build the overlay and call EnsureRoute(manage=false).
168 run, calls := recorder(nil, nil)
169 ov, err := New("tailscale", "10.77.1.0/24", "tskey-abc", run, nil)
170 require.NoError(t, err)
171
172 err = ov.EnsureRoute(context.Background(), false)
173 require.Error(t, err, "authkey without --manage-overlay must be an error")
174 assert.False(t, errors.Is(err, ErrActionRequired),
175 "config/consent error, not action-required")
176 assert.False(t, hasMutatingCall(calls))
177 }
178
179 func TestTailscale_EnsureRoute_ManageMode_CIDRAlreadyPresent(t *testing.T) {
180 existing := []string{"192.168.1.0/24", "10.77.1.0/24"}
181 out := map[string]string{
182 "tailscale debug prefs": debugPrefsJSON(existing),
183 }
184 run, calls := recorder(out, nil)
185 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
186 require.NoError(t, err)
187
188 err = ov.EnsureRoute(context.Background(), true)
189 assert.NoError(t, err, "manage=true with CIDR already present: nothing to do → nil")
190 assert.False(t, hasMutatingCall(calls))
191 }
192
193 func TestTailscale_EnsureRoute_ObserveMode_ReadFails(t *testing.T) {
194 errs := map[string]error{
195 "tailscale debug prefs": errors.New("tailscaled not running"),
196 }
197 run, calls := recorder(nil, errs)
198 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
199 require.NoError(t, err)
200
201 err = ov.EnsureRoute(context.Background(), false)
202 require.Error(t, err)
203 assert.True(t, errors.Is(err, ErrActionRequired),
204 "read failure in observe mode degrades to ErrActionRequired")
205 assert.Contains(t, err.Error(), "10.77.1.0/24")
206 assert.False(t, hasMutatingCall(calls))
207 }
208
209 func TestTailscale_EnsureRoute_ManageMode_ReadFails(t *testing.T) {
210 errs := map[string]error{
211 "tailscale debug prefs": errors.New("tailscaled not running"),
212 }
213 run, calls := recorder(nil, errs)
214 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
215 require.NoError(t, err)
216
217 err = ov.EnsureRoute(context.Background(), true)
218 require.Error(t, err, "manage=true read failure must return the error")
219 assert.False(t, errors.Is(err, ErrActionRequired),
220 "manage=true read failure is NOT action-required; it's a real error")
221 assert.False(t, hasMutatingCall(calls))
222 }
223
224 // ── tailscale: VerifyRoute ────────────────────────────────────────────────────
225
226 func TestTailscale_VerifyRoute_ApprovedRoute(t *testing.T) {
227 good := `{"Self":{"PrimaryRoutes":["10.77.1.0/24"]}}`
228 run, _ := recorder(map[string]string{"tailscale status --json": good}, nil)
229 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
230 require.NoError(t, err)
231 assert.NoError(t, ov.VerifyRoute(context.Background()))
232 }
233
234 func TestTailscale_VerifyRoute_PendingApproval(t *testing.T) {
235 bad := `{"Self":{"PrimaryRoutes":[]}}`
236 run, _ := recorder(map[string]string{"tailscale status --json": bad}, nil)
237 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
238 require.NoError(t, err)
239 err = ov.VerifyRoute(context.Background())
240 require.Error(t, err)
241 assert.Contains(t, err.Error(), "pending approval",
242 "actionable error: fix autoApprovers in the tailnet ACL")
243 }
244
245 // ── tailscale: NoMasqueradeIfaces ─────────────────────────────────────────────
246
247 func TestTailscale_NoMasqueradeIfaces_DefaultOnly(t *testing.T) {
248 run, _ := recorder(nil, nil)
249 ov, err := New("tailscale", "10.77.1.0/24", "", run, nil)
250 require.NoError(t, err)
251 ifaces := ov.NoMasqueradeIfaces()
252 assert.Equal(t, []string{"tailscale0"}, ifaces)
253 }
254
255 func TestTailscale_NoMasqueradeIfaces_WithExtras(t *testing.T) {
256 run, _ := recorder(nil, nil)
257 ov, err := New("tailscale", "10.77.1.0/24", "", run, []string{"wg0"})
258 require.NoError(t, err)
259 ifaces := ov.NoMasqueradeIfaces()
260 assert.Equal(t, []string{"tailscale0", "wg0"}, ifaces)
261 }
262
263 // ── tailscale: EnsureRoute with authkey uses login+set, never tailscale up ───
264
265 func TestTailscale_EnsureRoute_ManageMode_WithAuthkey_UsesLoginThenSet(t *testing.T) {
266 // Fix 1: authkey path must run `tailscale login --auth-key=<key>` then
267 // `tailscale set --advertise-routes=<union>`, NEVER `tailscale up`.
268 existing := []string{"192.168.1.0/24"}
269 out := map[string]string{
270 "tailscale debug prefs": debugPrefsJSON(existing),
271 // login produces no meaningful output
272 "tailscale login --auth-key=tskey-abc": "",
273 // set produces no meaningful output
274 "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24": "",
275 }
276 run, calls := recorder(out, nil)
277 ov, err := New("tailscale", "10.77.1.0/24", "tskey-abc", run, nil)
278 require.NoError(t, err)
279
280 err = ov.EnsureRoute(context.Background(), true)
281 require.NoError(t, err)
282
283 all := joinCalls(calls)
284 // Must see `tailscale login --auth-key=tskey-abc` then `tailscale set --advertise-routes=…`
285 assert.Contains(t, all, "tailscale login --auth-key=tskey-abc",
286 "authkey path must run tailscale login first")
287 assert.Contains(t, all, "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24",
288 "authkey path must run tailscale set after login")
289 assert.NotContains(t, all, "tailscale up",
290 "tailscale up must NEVER be used — including the authkey join path")
291
292 // Assert ordering: login comes before set.
293 var loginIdx, setIdx int = -1, -1
294 for i, c := range *calls {
295 if c.name == "tailscale" && strings.HasPrefix(c.args, "login ") {
296 loginIdx = i
297 }
298 if c.name == "tailscale" && strings.HasPrefix(c.args, "set ") {
299 setIdx = i
300 }
301 }
302 assert.GreaterOrEqual(t, loginIdx, 0, "login call must be present")
303 assert.GreaterOrEqual(t, setIdx, 0, "set call must be present")
304 assert.Less(t, loginIdx, setIdx, "login must precede set")
305 }
306
307 // ── none overlay tests ────────────────────────────────────────────────────────
308
309 func TestNone_EnsureRoute_AlwaysReturnsErrActionRequired(t *testing.T) {
310 run, calls := recorder(nil, nil)
311 ov, err := New("none", "10.77.3.0/24", "", run, nil)
312 require.NoError(t, err)
313
314 // manage=false
315 err = ov.EnsureRoute(context.Background(), false)
316 require.Error(t, err)
317 assert.True(t, errors.Is(err, ErrActionRequired), "none always returns ErrActionRequired")
318 assert.Contains(t, err.Error(), "10.77.3.0/24", "message must contain the CIDR")
319 assert.Contains(t, err.Error(), "WireGuard AllowedIPs",
320 "Fix 2: message must name WireGuard AllowedIPs as example overlay")
321 assert.Contains(t, err.Error(), "--no-masquerade-ifaces",
322 "Fix 2: message must mention --no-masquerade-ifaces so VM source IPs are preserved")
323 assert.Empty(t, *calls, "none EnsureRoute must run ZERO commands")
324 }
325
326 func TestNone_EnsureRoute_ManageTrue_IsHardError(t *testing.T) {
327 // Fix 3: manage=true with overlay=none must be a hard error (not ErrActionRequired).
328 run, calls := recorder(nil, nil)
329 ov, err := New("none", "10.77.3.0/24", "", run, nil)
330 require.NoError(t, err)
331
332 err = ov.EnsureRoute(context.Background(), true)
333 require.Error(t, err)
334 assert.False(t, errors.Is(err, ErrActionRequired),
335 "Fix 3: manage=true on overlay=none must be a hard error, NOT ErrActionRequired")
336 assert.Contains(t, err.Error(), "nothing to manage",
337 "Fix 3: error must say there is nothing to manage")
338 assert.Empty(t, *calls, "none EnsureRoute must run ZERO commands even with manage=true")
339 }
340
341 func TestNone_VerifyRoute_ReturnsErrUnverifiable(t *testing.T) {
342 // Fix 4: VerifyRoute for overlay=none must return ErrUnverifiable (not nil).
343 run, _ := recorder(nil, nil)
344 ov, err := New("none", "10.77.3.0/24", "", run, nil)
345 require.NoError(t, err)
346
347 err = ov.VerifyRoute(context.Background())
348 require.Error(t, err, "Fix 4: none VerifyRoute must return an error (ErrUnverifiable)")
349 assert.True(t, errors.Is(err, ErrUnverifiable),
350 "Fix 4: error must wrap ErrUnverifiable, got: %v", err)
351 assert.Contains(t, err.Error(), "10.77.3.0/24",
352 "Fix 4: ErrUnverifiable must include the CIDR")
353 }
354
355 func TestNone_NoMasqueradeIfaces_EmptyWithoutExtras(t *testing.T) {
356 run, _ := recorder(nil, nil)
357 ov, err := New("none", "10.77.3.0/24", "", run, nil)
358 require.NoError(t, err)
359 assert.Nil(t, ov.NoMasqueradeIfaces())
360 }
361
362 func TestNone_NoMasqueradeIfaces_ReflectsExtras(t *testing.T) {
363 run, _ := recorder(nil, nil)
364 ov, err := New("none", "10.77.3.0/24", "", run, []string{"wg0", "eth1"})
365 require.NoError(t, err)
366 assert.Equal(t, []string{"wg0", "eth1"}, ov.NoMasqueradeIfaces())
367 }
internal/agent/overlay/watch.go
Old New
@@ -0,0 +1,79 @@
1 package overlay
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "time"
8 )
9
10 // Watch runs a level-triggered reconcile loop: every interval it calls
11 // EnsureRoute and VerifyRoute on ov and logs only on state transitions.
12 // It returns when ctx is cancelled.
13 //
14 // Logging policy:
15 // - ErrActionRequired / non-nil error → warning on first occurrence and on
16 // every transition from a different status.
17 // - nil after non-nil (recovery) → info "overlay route restored".
18 // - ErrUnverifiable → info "route verification unavailable for this overlay
19 // — unverifiable by design" on first occurrence and on each re-occurrence
20 // only if the previous status was different.
21 // - Steady-state repetitions (same status string) are silently dropped.
22 func Watch(ctx context.Context, ov Overlay, manage bool, interval time.Duration, logger *slog.Logger) {
23 if logger == nil {
24 logger = slog.Default()
25 }
26 var lastStatus string
27 tick := time.NewTicker(interval)
28 defer tick.Stop()
29 for {
30 select {
31 case <-ctx.Done():
32 return
33 case <-tick.C:
34 res := probeStatus(ctx, ov, manage)
35 if res.status != lastStatus {
36 logTransition(logger, lastStatus, res)
37 lastStatus = res.status
38 }
39 }
40 }
41 }
42
43 // probeResult is the outcome of one probe. status is a canonical string used
44 // for change detection (empty means fully healthy); err is the original error,
45 // if any, preserved so callers can classify it with errors.Is.
46 type probeResult struct {
47 status string
48 err error
49 }
50
51 // probeStatus runs EnsureRoute then VerifyRoute and returns the probe result.
52 func probeStatus(ctx context.Context, ov Overlay, manage bool) probeResult {
53 if err := ov.EnsureRoute(ctx, manage); err != nil {
54 return probeResult{status: "ensure:" + err.Error(), err: err}
55 }
56 if err := ov.VerifyRoute(ctx); err != nil {
57 if errors.Is(err, ErrUnverifiable) {
58 return probeResult{status: "unverifiable", err: err}
59 }
60 return probeResult{status: "verify:" + err.Error(), err: err}
61 }
62 return probeResult{}
63 }
64
65 // logTransition emits one log line on status change.
66 func logTransition(logger *slog.Logger, prev string, curr probeResult) {
67 switch {
68 case curr.status == "":
69 // Recovered from a prior error.
70 logger.Info("overlay route restored")
71 case curr.status == "unverifiable":
72 logger.Info("route verification unavailable for this overlay — unverifiable by design")
73 case errors.Is(curr.err, ErrActionRequired):
74 logger.Warn("overlay action required — VMs reachable locally; network reachability pending operator action",
75 "status", curr.status)
76 default:
77 logger.Warn("overlay route check failed", "status", curr.status, "prev", prev)
78 }
79 }
internal/agent/overlay/watch_test.go
Old New
@@ -0,0 +1,203 @@
1 package overlay
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "log/slog"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 // fakeOverlay is a minimal Overlay stub that returns pre-programmed errors.
18 // A mutex guards ensureErr and verifyErr so the test can change them safely
19 // from the main goroutine while Watch reads them concurrently.
20 type fakeOverlay struct {
21 mu sync.Mutex
22 ensureErr error
23 verifyErr error
24 }
25
26 func (f *fakeOverlay) setEnsureErr(err error) {
27 f.mu.Lock()
28 defer f.mu.Unlock()
29 f.ensureErr = err
30 }
31
32 func (f *fakeOverlay) EnsureRoute(_ context.Context, _ bool) error {
33 f.mu.Lock()
34 defer f.mu.Unlock()
35 return f.ensureErr
36 }
37
38 func (f *fakeOverlay) VerifyRoute(_ context.Context) error {
39 f.mu.Lock()
40 defer f.mu.Unlock()
41 return f.verifyErr
42 }
43
44 func (f *fakeOverlay) RemoveRoute(_ context.Context) error { return nil }
45 func (f *fakeOverlay) NoMasqueradeIfaces() []string { return nil }
46
47 // safeBuffer wraps bytes.Buffer with a mutex so the Watch goroutine can write
48 // concurrently while the test goroutine reads.
49 type safeBuffer struct {
50 mu sync.Mutex
51 buf bytes.Buffer
52 }
53
54 func (b *safeBuffer) Write(p []byte) (int, error) {
55 b.mu.Lock()
56 defer b.mu.Unlock()
57 return b.buf.Write(p)
58 }
59
60 func (b *safeBuffer) String() string {
61 b.mu.Lock()
62 defer b.mu.Unlock()
63 return b.buf.String()
64 }
65
66 func (b *safeBuffer) Len() int {
67 b.mu.Lock()
68 defer b.mu.Unlock()
69 return b.buf.Len()
70 }
71
72 func newLogger(buf *safeBuffer) *slog.Logger {
73 return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
74 }
75
76 // TestWatch_RecoveryLoggedExactlyOnce tests Fix 6:
77 // a transition from ErrActionRequired → nil logs recovery exactly once,
78 // and steady-state OK repetitions stay silent.
79 func TestWatch_RecoveryLoggedExactlyOnce(t *testing.T) {
80 logBuf := &safeBuffer{}
81 logger := newLogger(logBuf)
82
83 ov := &fakeOverlay{}
84 ov.setEnsureErr(fmt.Errorf("%w: run: sudo tailscale set --advertise-routes=10.77.1.0/24", ErrActionRequired))
85
86 ctx, cancel := context.WithCancel(context.Background())
87 interval := 10 * time.Millisecond
88
89 done := make(chan struct{})
90 go func() {
91 defer close(done)
92 Watch(ctx, ov, false, interval, logger)
93 }()
94
95 // Let at least 3 ticks fire with the error.
96 time.Sleep(60 * time.Millisecond)
97
98 logSnapshot1 := logBuf.String()
99 // Should have exactly one warning (first occurrence), not three.
100 count := countOccurrences(logSnapshot1, "overlay action required")
101 assert.Equal(t, 1, count, "ErrActionRequired logged once on first occurrence, not on repeats")
102
103 // Now clear the error — simulate recovery.
104 ov.setEnsureErr(nil)
105 time.Sleep(60 * time.Millisecond)
106
107 logSnapshot2 := logBuf.String()
108 assert.Contains(t, logSnapshot2, "overlay route restored",
109 "recovery must be logged on transition from error to ok")
110
111 // Let a few more ticks pass — steady-state OK must stay silent.
112 logLenAfterRecovery := logBuf.Len()
113 time.Sleep(60 * time.Millisecond)
114 assert.Equal(t, logLenAfterRecovery, logBuf.Len(),
115 "steady-state OK ticks must produce no additional log lines")
116
117 cancel()
118 select {
119 case <-done:
120 case <-time.After(time.Second):
121 t.Fatal("Watch did not exit after context cancel")
122 }
123 }
124
125 // TestWatch_ErrUnverifiable_LoggedAsInfo verifies that ErrUnverifiable triggers
126 // an info-level log (not a warning) and is silent on steady repetition.
127 func TestWatch_ErrUnverifiable_LoggedAsInfo(t *testing.T) {
128 logBuf := &safeBuffer{}
129 logger := newLogger(logBuf)
130
131 ov := &fakeOverlay{}
132 ov.verifyErr = fmt.Errorf("%w: 10.77.1.0/24", ErrUnverifiable)
133
134 ctx, cancel := context.WithCancel(context.Background())
135 interval := 10 * time.Millisecond
136 done := make(chan struct{})
137 go func() {
138 defer close(done)
139 Watch(ctx, ov, false, interval, logger)
140 }()
141
142 time.Sleep(80 * time.Millisecond)
143 cancel()
144 <-done
145
146 log := logBuf.String()
147 assert.Contains(t, log, "unverifiable by design",
148 "ErrUnverifiable must log the unverifiable message")
149 count := countOccurrences(log, "unverifiable by design")
150 assert.Equal(t, 1, count,
151 "ErrUnverifiable in steady state must be logged only once, not on every tick")
152 }
153
154 // TestProbeStatus_ReturnsEmptyOnSuccess verifies that probeStatus returns an
155 // empty status when both EnsureRoute and VerifyRoute succeed.
156 func TestProbeStatus_ReturnsEmptyOnSuccess(t *testing.T) {
157 ov := &fakeOverlay{}
158 res := probeStatus(context.Background(), ov, false)
159 assert.Equal(t, "", res.status, "healthy overlay must return empty status")
160 assert.NoError(t, res.err)
161 }
162
163 // TestProbeStatus_ErrActionRequired verifies the original error is preserved on
164 // EnsureRoute failure so it can be classified with errors.Is.
165 func TestProbeStatus_ErrActionRequired(t *testing.T) {
166 errMsg := fmt.Errorf("%w: run: sudo tailscale set --advertise-routes=10.77.1.0/24", ErrActionRequired)
167 ov := &fakeOverlay{ensureErr: errMsg}
168 res := probeStatus(context.Background(), ov, false)
169 require.NotEmpty(t, res.status)
170 assert.True(t, errors.Is(res.err, ErrActionRequired),
171 "ErrActionRequired must be detectable from the preserved error, got: %s", res.status)
172 }
173
174 // TestProbeStatus_Unverifiable verifies the canonical "unverifiable" status.
175 func TestProbeStatus_Unverifiable(t *testing.T) {
176 ov := &fakeOverlay{}
177 ov.verifyErr = fmt.Errorf("%w: 10.77.1.0/24", ErrUnverifiable)
178 res := probeStatus(context.Background(), ov, false)
179 assert.Equal(t, "unverifiable", res.status)
180 }
181
182 // countOccurrences counts non-overlapping occurrences of sub in s.
183 func countOccurrences(s, sub string) int {
184 n, start := 0, 0
185 for {
186 idx := indexOf(s[start:], sub)
187 if idx < 0 {
188 break
189 }
190 n++
191 start += idx + len(sub)
192 }
193 return n
194 }
195
196 func indexOf(s, sub string) int {
197 for i := 0; i <= len(s)-len(sub); i++ {
198 if s[i:i+len(sub)] == sub {
199 return i
200 }
201 }
202 return -1
203 }
internal/agent/reconcile/reconcile.go
Old New
@@ -0,0 +1,471 @@
1 // Package reconcile implements the agent's level-triggered reconcile loop.
2 //
3 // Definitions (normative, from the spec):
4 //
5 // exists = state-dir record present AND (disk present OR create completed)
6 // lost = boot ID changed OR process died without a recorded stop request;
7 // a deliberately stopped VM is stopped, NOT lost
8 // destroyed[] ack = level-triggered: every tombstoned vm_id with no local
9 // record, repeated until the server hard-deletes it
10 //
11 // The "exists" definition deserves a comment:
12 // On real hosts, disk presence is the physical witness that a create completed.
13 // With fake provisioners (tests), no disk file is written, so we fall back to
14 // the logical witness: a completed create always sets rec.BootID to the current
15 // host boot ID. rec.BootID != "" means create succeeded. Absence of both (no
16 // disk AND BootID == "") means an incomplete create that must be retried.
17 package reconcile
18
19 import (
20 "context"
21 "encoding/json"
22 "time"
23
24 "github.com/a73x/eitri/internal/agent/seed"
25 "github.com/a73x/eitri/internal/agent/state"
26 "github.com/a73x/eitri/internal/pb"
27 )
28
29 // Provisioner is implemented by the cloud-hypervisor backend (cloudhv.Provisioner).
30 type Provisioner interface {
31 PrepareDisk(ctx context.Context, spec state.VMSpec, basePath string) error
32 Boot(ctx context.Context, vmID string, spec state.VMSpec) error
33 Shutdown(ctx context.Context, vmID string) error
34 Kill(ctx context.Context, vmID string) error
35 Running(vmID string) bool
36 }
37
38 // NetEnv is implemented by the host networking layer (netenv.Net).
39 type NetEnv interface {
40 CreateTap(ctx context.Context, tap string) error
41 DeleteTap(ctx context.Context, tap string) error
42 // AllocateIP returns an unused VM IP for this host's network. `used` lists
43 // addresses already taken. Kept behind the seam so a future central or
44 // per-network allocator can replace host-local allocation without touching
45 // the reconcile loop.
46 AllocateIP(ctx context.Context, used []string) (string, error)
47 // GuestNetwork returns the gateway address and prefix length the guest is
48 // configured with — network properties, not bridge mechanics.
49 GuestNetwork() (gateway string, prefixLen int)
50 }
51
52 // Engine is the reconcile loop. All fields must be set before calling Step.
53 type Engine struct {
54 St *state.Store
55 Prov Provisioner
56 Net NetEnv
57
58 // Images resolves an image URL+sha256 to a local base-image path, fetching
59 // if necessary. Returns the path to the raw base image.
60 Images func(ctx context.Context, url, sha string) (string, error)
61
62 // Seed builds the cloud-init NoCloud seed ISO at outPath.
63 Seed func(outPath string, p seed.Params) error
64
65 // BootID returns the current host boot identifier (e.g. /proc/sys/kernel/random/boot_id).
66 // Changes on reboot, enabling lost-VM detection.
67 BootID func() string
68
69 // Now returns the current time. Injectable for deterministic tests.
70 Now func() time.Time
71
72 // TombstoneGrace is the quarantine period for tombstoned VMs before destroy.
73 TombstoneGrace time.Duration
74
75 // VanishGrace is the quarantine period for VMs that vanished without a tombstone.
76 VanishGrace time.Duration
77
78 // MaxCreateAttempts is the maximum number of create attempts before terminal failed.
79 MaxCreateAttempts int
80 }
81
82 // Step reconciles the desired snapshot against actual state and returns an
83 // ActualStateReport for the server.
84 //
85 // The algorithm is level-triggered: every call re-examines full state and
86 // drives toward desired. Idempotent under repeated identical snapshots.
87 func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.ActualStateReport {
88 rep := &pb.ActualStateReport{}
89
90 // ── 1. Epoch fence ───────────────────────────────────────────────────────
91 // CRITICAL: the fence path must touch NOTHING: no SaveEpoch, no provisioner
92 // calls, no state mutations. It returns the current actual state so the
93 // server can observe what the agent actually has.
94 currentEpoch := e.St.Epoch()
95 if snap.Epoch < currentEpoch {
96 rep.FenceViolation = true
97 rep.LastSeenEpoch = currentEpoch
98 // Fill Vms and Quarantined from current records so the server sees actual
99 // state. Quarantined VMs are reported in Quarantined[], not in Vms[].
100 // No mutations: fence path is strictly read-only.
101 if recs, err := e.St.LoadVMs(); err == nil {
102 for _, rec := range recs {
103 // Fix 4: quarantined VMs belong in Quarantined[], not Vms[].
104 if rec.QuarantinedAt != nil {
105 grace := e.VanishGrace
106 if rec.QuarantineTombstoned {
107 grace = e.TombstoneGrace
108 }
109 rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, grace))
110 continue
111 }
112 power := "stopped"
113 if e.Prov.Running(rec.Spec.VMID) {
114 power = "running"
115 }
116 phase := "ready"
117 if rec.LastError != "" {
118 phase = "failed"
119 }
120 addReport(rep, rec.Spec.VMID, rec.IP, power, phase, rec.LastError)
121 }
122 }
123 return rep
124 }
125
126 // Advance epoch (equal is fine — same snapshot repeated).
127 _ = e.St.SaveEpoch(snap.Epoch)
128 rep.LastSeenEpoch = snap.Epoch
129
130 // ── 2. Build desired map + tombstoned set ─────────────────────────────────
131 desired := make(map[string]*pb.VMDesired, len(snap.Vms))
132 tombstoned := make(map[string]bool, len(snap.Vms))
133 for _, d := range snap.Vms {
134 desired[d.VmId] = d
135 if d.Tombstoned {
136 tombstoned[d.VmId] = true
137 }
138 }
139
140 // ── 3. Reap pass ──────────────────────────────────────────────────────────
141 // For each local record: if it is absent from desired OR tombstoned,
142 // quarantine it (shutting it down) or destroy it once grace expires.
143 recs, _ := e.St.LoadVMs()
144 for id, rec := range recs {
145 isTombstoned := tombstoned[id]
146 _, inDesired := desired[id]
147 if inDesired && !isTombstoned {
148 // Fix 1: un-delete path — VM re-appears in desired while still carrying
149 // a stale QuarantinedAt from a previous tombstone. Clear it so the NEXT
150 // delete starts a fresh grace window (not instant kill from stale timestamp).
151 if rec.QuarantinedAt != nil {
152 rec.QuarantinedAt = nil
153 rec.QuarantineTombstoned = false
154 _ = e.St.SaveVM(rec)
155 }
156 continue // active desired VM; handled in converge pass
157 }
158
159 now := e.Now()
160
161 if rec.QuarantinedAt == nil {
162 // First time we see this VM needs reaping: enter quarantine.
163 t := now
164 rec.QuarantinedAt = &t
165 rec.QuarantineTombstoned = isTombstoned
166 rec.StopRequested = true // record BEFORE side effects
167 // Fix 5: only Shutdown after the stop intent is durably persisted.
168 // If SaveVM fails, skip Shutdown this cycle — the next reconcile
169 // will retry. This upholds "record stop BEFORE stopping".
170 if err := e.St.SaveVM(rec); err == nil {
171 _ = e.Prov.Shutdown(ctx, id)
172 }
173 } else if isTombstoned && !rec.QuarantineTombstoned {
174 // Upgrade: vanished quarantine → tombstoned quarantine (shorter grace).
175 rec.QuarantineTombstoned = true
176 _ = e.St.SaveVM(rec)
177 }
178
179 grace := e.VanishGrace
180 if rec.QuarantineTombstoned {
181 grace = e.TombstoneGrace
182 }
183
184 if now.Sub(*rec.QuarantinedAt) >= grace {
185 // Grace expired: destroy the VM.
186 _ = e.Prov.Kill(ctx, id)
187 _ = e.Net.DeleteTap(ctx, state.TapName(id))
188 _ = e.St.DeleteVM(id)
189 // Do NOT append to rep.Quarantined — VM is gone.
190 } else {
191 // Still in grace: report as quarantined.
192 rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, grace))
193 }
194 }
195
196 // ── 4. Level-triggered destroy ack ───────────────────────────────────────
197 // Re-load records after reap pass. Every tombstoned ID with NO local record
198 // is acked in destroyed[] every report until the server hard-deletes it.
199 recs, _ = e.St.LoadVMs()
200 for id := range tombstoned {
201 if _, hasRecord := recs[id]; !hasRecord {
202 rep.Destroyed = append(rep.Destroyed, id)
203 }
204 }
205
206 // ── 5. Converge pass ─────────────────────────────────────────────────────
207 // Drive each active (non-tombstoned) desired VM toward its desired state.
208 for id, d := range desired {
209 if tombstoned[id] {
210 continue // tombstoned VMs are handled by reap + destroyed[]
211 }
212 rec, ok := recs[id]
213 // exists = record present AND (disk present OR create completed).
214 // BootID != "" is the logical witness that create finished: the real
215 // provisioner creates the disk; in tests the fake does not, but both
216 // set BootID after a successful create.
217 exists := ok && (e.St.DiskExists(id) || rec.BootID != "")
218 if !exists {
219 e.create(ctx, d, rec, ok, rep)
220 } else {
221 e.converge(ctx, d, rec, rep)
222 }
223 }
224
225 return rep
226 }
227
228 // create attempts to create a new VM from desired state d.
229 // rec is the existing (potentially stale) record, ok indicates whether one exists.
230 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) {
231 // Fix 2: if the desired spec differs from the stored spec, the user edited the
232 // VM definition. Reset CreateAttempts so the new spec gets a fresh retry budget
233 // instead of being permanently terminal-failed due to the old spec's failures.
234 // VMSpec is fully comparable (all fields are strings/ints/bool), so == is safe.
235 if ok && specFromDesired(d) != rec.Spec {
236 rec.CreateAttempts = 0
237 rec.LastError = ""
238 }
239
240 // Terminal check: if we've hit MaxCreateAttempts, stop retrying.
241 if ok && rec.CreateAttempts >= e.MaxCreateAttempts {
242 addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError)
243 return
244 }
245
246 // Build the spec from desired.
247 rec.Spec = specFromDesired(d)
248 rec.CreateAttempts++
249 rec.CreatedAt = e.Now()
250 rec.LastError = "" // clear for this attempt
251
252 // Allocate an IP if we don't have one yet.
253 if rec.IP == "" {
254 allRecs, _ := e.St.LoadVMs()
255 used := make([]string, 0, len(allRecs))
256 for _, r := range allRecs {
257 if r.IP != "" {
258 used = append(used, r.IP)
259 }
260 }
261 ip, err := e.Net.AllocateIP(ctx, used)
262 if err != nil {
263 e.failCreate(ctx, rec, err, rep)
264 return
265 }
266 rec.IP = ip
267 }
268
269 // Record BEFORE side effects so a crash is recoverable.
270 if err := e.St.SaveVM(rec); err != nil {
271 e.failCreate(ctx, rec, err, rep)
272 return
273 }
274
275 // Resolve base image.
276 basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256)
277 if err != nil {
278 e.failCreate(ctx, rec, err, rep)
279 return
280 }
281
282 // Create tap device.
283 if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil {
284 e.failCreate(ctx, rec, err, rep)
285 return
286 }
287
288 // Prepare disk.
289 if err := e.Prov.PrepareDisk(ctx, rec.Spec, basePath); err != nil {
290 e.failCreate(ctx, rec, err, rep)
291 return
292 }
293
294 // Build cloud-init seed ISO. Gateway + prefix come from the addressing seam,
295 // not derived from a CIDR here — the core holds no network-shaped state.
296 gateway, prefixLen := e.Net.GuestNetwork()
297 if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{
298 Hostname: d.Name,
299 InstanceID: d.VmId,
300 IP: rec.IP,
301 PrefixLen: prefixLen,
302 Gateway: gateway,
303 SSHAuthorizedKey: d.SshAuthorizedKey,
304 UserData: d.CloudInit,
305 }); err != nil {
306 e.failCreate(ctx, rec, err, rep)
307 return
308 }
309
310 // Boot if desired running.
311 if d.PowerState == "running" {
312 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
313 e.failCreate(ctx, rec, err, rep)
314 return
315 }
316 }
317
318 // Success: record completion.
319 rec.BootID = e.BootID()
320 rec.StopRequested = d.PowerState != "running"
321 rec.LastError = ""
322 _ = e.St.SaveVM(rec)
323
324 power := "running"
325 if d.PowerState != "running" {
326 power = "stopped"
327 }
328 addReport(rep, d.VmId, rec.IP, power, "ready", "")
329 }
330
331 // failCreate records a failed create attempt and appends a report row.
332 func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, rep *pb.ActualStateReport) {
333 rec.LastError = err.Error()
334 _ = e.St.SaveVM(rec)
335
336 phase := "creating"
337 if rec.CreateAttempts >= e.MaxCreateAttempts {
338 phase = "failed"
339 }
340 addReport(rep, rec.Spec.VMID, rec.IP, "stopped", phase, rec.LastError)
341 }
342
343 // converge drives an existing VM toward its desired power state,
344 // handling lost detection and restart logic.
345 func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record, rep *pb.ActualStateReport) {
346 running := e.Prov.Running(d.VmId)
347 bootID := e.BootID()
348
349 // lost = boot ID changed OR process died without a recorded stop request.
350 // A deliberately stopped VM has StopRequested=true, so !running && StopRequested is NOT lost.
351 lost := rec.BootID != bootID || (!running && !rec.StopRequested)
352
353 if lost {
354 if !d.Persistent {
355 // Ephemeral lost VMs are reported failed and NEVER restarted.
356 errMsg := "ephemeral VM lost"
357 rec.LastError = errMsg
358 _ = e.St.SaveVM(rec)
359 addReport(rep, d.VmId, rec.IP, "stopped", "failed", errMsg)
360 return
361 }
362
363 // Persistent lost VM.
364 if d.PowerState == "running" {
365 // Restart: tap dies on reboot, recreate it.
366 _ = e.Net.CreateTap(ctx, state.TapName(d.VmId))
367 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
368 rec.LastError = err.Error()
369 _ = e.St.SaveVM(rec)
370 addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError)
371 return
372 }
373 rec.BootID = bootID
374 rec.StopRequested = false
375 rec.LastError = "" // Fix 3: clear stale error on successful restart
376 _ = e.St.SaveVM(rec)
377 addReport(rep, d.VmId, rec.IP, "running", "ready", "")
378 } else {
379 // Persistent + desired stopped: update boot ID, mark stop recorded.
380 rec.BootID = bootID
381 rec.StopRequested = true
382 _ = e.St.SaveVM(rec)
383 addReport(rep, d.VmId, rec.IP, "stopped", "ready", "")
384 }
385 return
386 }
387
388 // Not lost: drive power state.
389 if d.PowerState == "running" && !running {
390 // Start the VM.
391 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
392 rec.LastError = err.Error()
393 _ = e.St.SaveVM(rec)
394 addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError)
395 return
396 }
397 rec.StopRequested = false
398 rec.LastError = "" // Fix 3: clear stale error on successful boot
399 _ = e.St.SaveVM(rec)
400 addReport(rep, d.VmId, rec.IP, "running", "ready", "")
401 } else if d.PowerState == "stopped" && running {
402 // Stop the VM. Record stop BEFORE side effects so a crash between
403 // SaveVM and Shutdown is recoverable (the persisted StopRequested
404 // prevents the VM from being treated as "lost" on next reconcile).
405 // Fix 5: if SaveVM fails, skip Shutdown — the durability guarantee
406 // (record stop BEFORE stopping) must hold; stopping without a durable
407 // record would cause the VM to be treated as lost after a crash.
408 rec.StopRequested = true
409 if err := e.St.SaveVM(rec); err != nil {
410 // Cannot durably record the stop intent; skip Shutdown this cycle.
411 // The next reconcile will retry once the store recovers.
412 addReport(rep, d.VmId, rec.IP, "running", "failed", err.Error())
413 return
414 }
415 _ = e.Prov.Shutdown(ctx, d.VmId)
416 addReport(rep, d.VmId, rec.IP, "stopped", "ready", "")
417 } else {
418 // Already at desired state.
419 power := "stopped"
420 if running {
421 power = "running"
422 }
423 phase := "ready"
424 // Preserve last error in the report field but phase stays ready
425 // (the VM is converged; the error is informational history).
426 addReport(rep, d.VmId, rec.IP, power, phase, rec.LastError)
427 }
428 }
429
430 // quarantinedEntry builds a QuarantinedVM proto from a record and its grace
431 // duration. Used by both the reap pass and the fence-path report (Fix 4) so
432 // the JSON shape is identical in both places.
433 func quarantinedEntry(rec state.Record, grace time.Duration) *pb.QuarantinedVM {
434 specJSON, _ := json.Marshal(rec.Spec)
435 destroyAt := rec.QuarantinedAt.Add(grace).Unix()
436 return &pb.QuarantinedVM{
437 VmId: rec.Spec.VMID,
438 Name: rec.Spec.Name,
439 VmspecJson: specJSON,
440 DestroyAtUnix: destroyAt,
441 }
442 }
443
444 // addReport appends one ActualVM row to the report. It centralizes the
445 // construction repeated across the create/converge/fence paths. pb.ActualVM has
446 // exactly these five fields; unset values are the proto zero-value "".
447 func addReport(rep *pb.ActualStateReport, vmID, ip, power, phase, lastError string) {
448 rep.Vms = append(rep.Vms, &pb.ActualVM{
449 VmId: vmID,
450 Ip: ip,
451 Power: power,
452 Phase: phase,
453 LastError: lastError,
454 })
455 }
456
457 // specFromDesired maps a pb.VMDesired to state.VMSpec.
458 func specFromDesired(d *pb.VMDesired) state.VMSpec {
459 return state.VMSpec{
460 VMID: d.VmId,
461 Name: d.Name,
462 ImageURL: d.ImageUrl,
463 ImageSHA256: d.ImageSha256,
464 CloudInit: d.CloudInit,
465 SSHAuthorizedKey: d.SshAuthorizedKey,
466 VCPUs: d.Vcpus,
467 MemMB: d.MemMb,
468 DiskGB: d.DiskGb,
469 Persistent: d.Persistent,
470 }
471 }
internal/agent/reconcile/reconcile_test.go
Old New
@@ -0,0 +1,351 @@
1 package reconcile
2
3 import (
4 "context"
5 "net/netip"
6 "testing"
7 "time"
8
9 "github.com/a73x/eitri/internal/agent/ipalloc"
10 "github.com/a73x/eitri/internal/agent/seed"
11 "github.com/a73x/eitri/internal/agent/state"
12 "github.com/a73x/eitri/internal/pb"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 // ---- fakes ----
18
19 type fakeProv struct {
20 running map[string]bool
21 prepared []string
22 booted []string
23 shutdown []string
24 killed []string
25 prepErr error
26 bootErr error // one-shot: consumed and cleared on first Boot call
27 }
28
29 func newFakeProv() *fakeProv { return &fakeProv{running: map[string]bool{}} }
30
31 func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) error {
32 if f.prepErr != nil {
33 return f.prepErr
34 }
35 f.prepared = append(f.prepared, s.VMID)
36 return nil
37 }
38 func (f *fakeProv) Boot(_ context.Context, id string, _ state.VMSpec) error {
39 if f.bootErr != nil {
40 err := f.bootErr
41 f.bootErr = nil // one-shot: clear after first use
42 return err
43 }
44 f.booted = append(f.booted, id)
45 f.running[id] = true
46 return nil
47 }
48 func (f *fakeProv) Shutdown(_ context.Context, id string) error {
49 f.shutdown = append(f.shutdown, id)
50 f.running[id] = false
51 return nil
52 }
53 func (f *fakeProv) Kill(_ context.Context, id string) error {
54 f.killed = append(f.killed, id)
55 f.running[id] = false
56 return nil
57 }
58 func (f *fakeProv) Running(id string) bool { return f.running[id] }
59
60 type fakeNet struct {
61 taps []string
62 deleted []string
63 cidr string
64 }
65
66 func (f *fakeNet) CreateTap(_ context.Context, t string) error { f.taps = append(f.taps, t); return nil }
67 func (f *fakeNet) DeleteTap(_ context.Context, t string) error {
68 f.deleted = append(f.deleted, t)
69 return nil
70 }
71
72 // AllocateIP / GuestNetwork mirror netenv's host-local behavior so reconcile
73 // tests exercise identical addressing through the seam.
74 func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) {
75 return ipalloc.Alloc(f.cidr, used)
76 }
77 func (f *fakeNet) GuestNetwork() (string, int) {
78 p, _ := netip.ParsePrefix(f.cidr)
79 return p.Masked().Addr().Next().String(), p.Bits()
80 }
81
82 type fixture struct {
83 eng *Engine
84 prov *fakeProv
85 net *fakeNet
86 st *state.Store
87 now time.Time
88 boot string
89 }
90
91 func setup(t *testing.T) *fixture {
92 t.Helper()
93 st, err := state.Open(t.TempDir())
94 require.NoError(t, err)
95 f := &fixture{st: st, prov: newFakeProv(), net: &fakeNet{cidr: "10.77.1.0/24"}, now: time.Unix(1_700_000_000, 0), boot: "boot-1"}
96 f.eng = &Engine{
97 St: st,
98 Prov: f.prov,
99 Net: f.net,
100 Images: func(ctx context.Context, url, sha string) (string, error) {
101 return "/cache/" + sha + ".raw", nil
102 },
103 Seed: func(out string, p seed.Params) error { return nil },
104 BootID: func() string { return f.boot },
105 Now: func() time.Time { return f.now },
106 TombstoneGrace: 5 * time.Minute,
107 VanishGrace: time.Hour,
108 MaxCreateAttempts: 3,
109 }
110 return f
111 }
112
113 func snap(epoch uint64, vms ...*pb.VMDesired) *pb.DesiredStateSnapshot {
114 return &pb.DesiredStateSnapshot{Epoch: epoch, Vms: vms}
115 }
116
117 func vm(id string, opts ...func(*pb.VMDesired)) *pb.VMDesired {
118 v := &pb.VMDesired{VmId: id, Name: "vm-" + id, ImageUrl: "http://x/i.img",
119 ImageSha256: "abc", Vcpus: 1, MemMb: 512, DiskGb: 5, PowerState: "running"}
120 for _, o := range opts {
121 o(v)
122 }
123 return v
124 }
125
126 func tombstoned(v *pb.VMDesired) *pb.VMDesired { v.Tombstoned = true; return v }
127 func stopped(v *pb.VMDesired) { v.PowerState = "stopped" }
128 func persistent(v *pb.VMDesired) { v.Persistent = true }
129
130 func findVM(rep *pb.ActualStateReport, id string) *pb.ActualVM {
131 for _, v := range rep.Vms {
132 if v.VmId == id {
133 return v
134 }
135 }
136 return nil
137 }
138
139 // ---- tests ----
140
141 func TestCreateAllocatesIPPreparesAndBoots(t *testing.T) {
142 f := setup(t)
143 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
144 assert.Equal(t, []string{"vm1"}, f.prov.prepared)
145 assert.Equal(t, []string{"vm1"}, f.prov.booted)
146 av := findVM(rep, "vm1")
147 require.NotNil(t, av)
148 assert.Equal(t, "10.77.1.2", av.Ip, ".1 is the gateway")
149 assert.Equal(t, "running", av.Power)
150 assert.Equal(t, "ready", av.Phase)
151 assert.Equal(t, uint64(1), rep.LastSeenEpoch)
152 }
153
154 func TestFenceRefusesLowerEpochWithoutActing(t *testing.T) {
155 f := setup(t)
156 f.eng.Step(context.Background(), snap(5, vm("vm1")))
157 rep := f.eng.Step(context.Background(), snap(3)) // restore signature: vm1 missing, lower epoch
158 assert.True(t, rep.FenceViolation)
159 assert.Empty(t, f.prov.killed, "fenced snapshot must trigger no destroys")
160 assert.Empty(t, f.prov.shutdown)
161 assert.Equal(t, uint64(5), rep.LastSeenEpoch)
162 }
163
164 func TestCreateRetryIsBoundedThenTerminalFailed(t *testing.T) {
165 f := setup(t)
166 f.prov.prepErr = assert.AnError
167 for i := 0; i < 3; i++ {
168 f.eng.Step(context.Background(), snap(1, vm("vm1")))
169 }
170 f.prov.prepErr = nil // even if the cause clears...
171 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
172 av := findVM(rep, "vm1")
173 require.NotNil(t, av)
174 assert.Equal(t, "failed", av.Phase)
175 assert.Empty(t, f.prov.prepared, "...no 4th attempt after MaxCreateAttempts")
176 }
177
178 func TestUserStopIsStoppedNotLost(t *testing.T) {
179 f := setup(t)
180 f.eng.Step(context.Background(), snap(1, vm("vm1")))
181 f.eng.Step(context.Background(), snap(2, vm("vm1", stopped)))
182 assert.Equal(t, []string{"vm1"}, f.prov.shutdown)
183
184 rep := f.eng.Step(context.Background(), snap(2, vm("vm1", stopped)))
185 av := findVM(rep, "vm1")
186 assert.Equal(t, "stopped", av.Power)
187 assert.NotEqual(t, "failed", av.Phase, "recorded stop request: stopped != lost")
188 }
189
190 func TestEphemeralLostOnHostRebootNeverRestarts(t *testing.T) {
191 f := setup(t)
192 f.eng.Step(context.Background(), snap(1, vm("vm1")))
193 f.boot = "boot-2" // host rebooted
194 f.prov.running["vm1"] = false
195 f.prov.booted = nil
196 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
197 av := findVM(rep, "vm1")
198 assert.Equal(t, "failed", av.Phase)
199 assert.Contains(t, av.LastError, "ephemeral VM lost")
200 assert.Empty(t, f.prov.booted, "ephemeral lost VMs are never restarted (spec)")
201 }
202
203 func TestPersistentRestartsAfterHostReboot(t *testing.T) {
204 f := setup(t)
205 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent)))
206 f.boot = "boot-2"
207 f.prov.running["vm1"] = false
208 f.prov.booted = nil
209 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent)))
210 assert.Equal(t, []string{"vm1"}, f.prov.booted, "persistent + desired running: restart")
211 }
212
213 func TestProcessDiedWithoutStopIsLost(t *testing.T) {
214 f := setup(t)
215 f.eng.Step(context.Background(), snap(1, vm("vm1")))
216 f.prov.running["vm1"] = false // crashed; no stop request, same boot ID
217 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
218 assert.Equal(t, "failed", findVM(rep, "vm1").Phase)
219 }
220
221 func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
222 f := setup(t)
223 f.eng.Step(context.Background(), snap(1, vm("vm1")))
224 rep := f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1"))))
225 require.Len(t, rep.Quarantined, 1)
226 assert.Equal(t, "vm1", rep.Quarantined[0].VmId)
227 assert.NotEmpty(t, rep.Quarantined[0].VmspecJson, "spec travels with quarantine (un-delete after restore)")
228 assert.Equal(t, []string{"vm1"}, f.prov.shutdown)
229 assert.Empty(t, rep.Destroyed, "still in grace")
230
231 f.now = f.now.Add(6 * time.Minute) // past TombstoneGrace
232 rep = f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1"))))
233 assert.Equal(t, []string{"vm1"}, f.prov.killed)
234 assert.Contains(t, rep.Destroyed, "vm1", "destroy ack after grace")
235 recs, _ := f.st.LoadVMs()
236 assert.NotContains(t, recs, "vm1")
237 // Fix 6: tap must be cleaned up on destroy
238 assert.Contains(t, f.net.deleted, state.TapName("vm1"), "tap cleaned up on destroy")
239 }
240
241 func TestVanishedWithoutTombstoneGetsLongGrace(t *testing.T) {
242 f := setup(t)
243 f.eng.Step(context.Background(), snap(1, vm("vm1")))
244 // vm1 absent AND not tombstoned at a HIGHER epoch: the bug signature, long grace.
245 f.eng.Step(context.Background(), snap(2))
246 f.now = f.now.Add(30 * time.Minute)
247 rep := f.eng.Step(context.Background(), snap(2))
248 assert.Empty(t, f.prov.killed, "vanished VMs get the full VanishGrace (1h)")
249 require.Len(t, rep.Quarantined, 1)
250
251 f.now = f.now.Add(31 * time.Minute)
252 f.eng.Step(context.Background(), snap(2))
253 assert.Equal(t, []string{"vm1"}, f.prov.killed)
254 }
255
256 func TestDestroyedIsLevelTriggeredForUnknownTombstones(t *testing.T) {
257 f := setup(t)
258 // Tombstoned VM the agent has no record of (created+deleted while offline,
259 // or state dir wiped): ack it EVERY report until the server hard-deletes.
260 rep := f.eng.Step(context.Background(), snap(1, tombstoned(vm("ghost"))))
261 assert.Contains(t, rep.Destroyed, "ghost")
262 rep = f.eng.Step(context.Background(), snap(1, tombstoned(vm("ghost"))))
263 assert.Contains(t, rep.Destroyed, "ghost", "repeated until it leaves desired state")
264 }
265
266 // Fix 1: un-quarantine on un-delete so next delete gets a fresh grace window.
267 func TestUndeleteClearsQuarantineSoNextDeleteGetsFullGrace(t *testing.T) {
268 f := setup(t)
269 f.eng.Step(context.Background(), snap(1, vm("vm1")))
270 // delete -> quarantined
271 f.eng.Step(context.Background(), snap(2, tombstoned(vm("vm1"))))
272 // un-delete: vm1 back in desired, not tombstoned
273 f.now = f.now.Add(2 * time.Minute)
274 f.eng.Step(context.Background(), snap(3, vm("vm1")))
275 // much later, delete again: must get a FRESH grace window, not instant kill
276 f.now = f.now.Add(24 * time.Hour)
277 rep := f.eng.Step(context.Background(), snap(4, tombstoned(vm("vm1"))))
278 assert.Empty(t, f.prov.killed, "fresh quarantine window required after un-delete")
279 require.Len(t, rep.Quarantined, 1)
280 recs, _ := f.st.LoadVMs()
281 require.Contains(t, recs, "vm1")
282 assert.NotNil(t, recs["vm1"].QuarantinedAt)
283 }
284
285 // Fix 2: editing a VM's spec resets CreateAttempts so the new spec gets a fresh retry budget.
286 func TestEditedSpecResetsCreateAttempts(t *testing.T) {
287 f := setup(t)
288 f.prov.prepErr = assert.AnError
289 for i := 0; i < 3; i++ {
290 f.eng.Step(context.Background(), snap(1, vm("vm1")))
291 }
292 f.prov.prepErr = nil
293 // user edits the VM (more memory): retry must happen
294 edited := vm("vm1")
295 edited.MemMb = 1024
296 rep := f.eng.Step(context.Background(), snap(2, edited))
297 assert.Equal(t, []string{"vm1"}, f.prov.prepared, "edited spec must reset the attempt budget")
298 assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
299 }
300
301 // Fix 3: a successful converge-path boot clears LastError.
302 // Scenario: persistent VM created successfully, then crashes (lost), converge restarts
303 // it but boot fails (one-shot bootErr). Record now has LastError set. Next step:
304 // bootErr cleared, converge retries and succeeds → LastError must be cleared.
305 func TestConvergeBootSuccessClearsLastError(t *testing.T) {
306 f := setup(t)
307 // Step 1: create succeeds — VM is running, BootID set.
308 f.eng.Step(context.Background(), snap(1, vm("vm1", persistent)))
309 require.Equal(t, []string{"vm1"}, f.prov.booted)
310
311 // Step 2: simulate host reboot (boot-2), VM process gone. Converge will try to
312 // restart but boot fails (one-shot bootErr). This sets LastError on the record.
313 f.boot = "boot-2"
314 f.prov.running["vm1"] = false
315 f.prov.bootErr = assert.AnError
316 rep := f.eng.Step(context.Background(), snap(1, vm("vm1", persistent)))
317 av := findVM(rep, "vm1")
318 require.NotNil(t, av)
319 require.Equal(t, "failed", av.Phase, "boot failure must report failed")
320 require.NotEmpty(t, av.LastError)
321
322 // Step 3: bootErr is cleared (one-shot). Converge retries and succeeds.
323 // LastError must be cleared from both report and persisted record.
324 rep = f.eng.Step(context.Background(), snap(1, vm("vm1", persistent)))
325 av = findVM(rep, "vm1")
326 require.NotNil(t, av)
327 assert.Equal(t, "ready", av.Phase)
328 assert.Empty(t, av.LastError, "successful converge boot must clear LastError")
329 recs, _ := f.st.LoadVMs()
330 assert.Empty(t, recs["vm1"].LastError, "persisted LastError must be cleared after successful boot")
331 }
332
333 // Fix 4: fence-path report must include quarantined VMs.
334 func TestFenceReportIncludesQuarantinedVMs(t *testing.T) {
335 f := setup(t)
336 // Create a VM, then quarantine it.
337 f.eng.Step(context.Background(), snap(5, vm("vm1")))
338 f.eng.Step(context.Background(), snap(6, tombstoned(vm("vm1"))))
339 // Confirm it is quarantined.
340 recs, _ := f.st.LoadVMs()
341 require.Contains(t, recs, "vm1")
342 require.NotNil(t, recs["vm1"].QuarantinedAt)
343
344 // Send a lower-epoch snapshot: fence path must fire and include quarantined entry.
345 rep := f.eng.Step(context.Background(), snap(3))
346 assert.True(t, rep.FenceViolation)
347 require.Len(t, rep.Quarantined, 1, "fence report must include quarantined VMs")
348 assert.Equal(t, "vm1", rep.Quarantined[0].VmId)
349 // The quarantined VM must NOT appear in rep.Vms (it is quarantined, not active).
350 assert.Nil(t, findVM(rep, "vm1"), "quarantined VM must not appear in Vms on fence path")
351 }
internal/agent/seed/seed.go
Old New
@@ -0,0 +1,164 @@
1 // Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).
2 package seed
3
4 import (
5 "fmt"
6 "os"
7 "strings"
8
9 diskfs "github.com/diskfs/go-diskfs"
10 "github.com/diskfs/go-diskfs/disk"
11 "github.com/diskfs/go-diskfs/filesystem"
12 "github.com/diskfs/go-diskfs/filesystem/iso9660"
13 )
14
15 // Params holds the cloud-init configuration for a single VM.
16 type Params struct {
17 Hostname string
18 IP string
19 PrefixLen int
20 Gateway string
21 SSHAuthorizedKey string
22 UserData string // verbatim if set; default generated otherwise
23 InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty
24 }
25
26 // validateParams checks that fields embedded into YAML do not contain newlines
27 // or carriage returns, which would allow YAML injection into cloud-init documents.
28 // UserData is deliberately exempt: it is a verbatim multi-line document.
29 func validateParams(p Params) error {
30 for _, f := range []struct {
31 name string
32 value string
33 }{
34 {"Hostname", p.Hostname},
35 {"SSHAuthorizedKey", p.SSHAuthorizedKey},
36 {"IP", p.IP},
37 {"Gateway", p.Gateway},
38 } {
39 if strings.ContainsAny(f.value, "\n\r") {
40 return fmt.Errorf("seed: %s must not contain newline or carriage return", f.name)
41 }
42 }
43 return nil
44 }
45
46 // userData returns the cloud-config string. If p.UserData is non-empty it is
47 // returned verbatim (advanced users own their user-data). Otherwise a sensible
48 // default is generated with an SSH key, growpart, and a default ubuntu user.
49 func userData(p Params) string {
50 if p.UserData != "" {
51 return p.UserData
52 }
53 return fmt.Sprintf(`#cloud-config
54 hostname: %s
55 disk_setup:
56 /dev/vda:
57 table_type: gpt
58 layout: true
59 overwrite: false
60 growpart:
61 mode: auto
62 devices: ["/"]
63 users:
64 - name: ubuntu
65 sudo: ALL=(ALL) NOPASSWD:ALL
66 shell: /bin/bash
67 ssh_authorized_keys:
68 - %s
69 `, p.Hostname, p.SSHAuthorizedKey)
70 }
71
72 // metaData returns the cloud-init meta-data content.
73 // InstanceID is used when set; falls back to Hostname for backward compatibility.
74 func metaData(p Params) string {
75 id := p.InstanceID
76 if id == "" {
77 id = p.Hostname
78 }
79 return fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", id, p.Hostname)
80 }
81
82 // networkConfig returns a netplan v2 network-config for a static IP with no DHCP.
83 func networkConfig(p Params) string {
84 return fmt.Sprintf(`network:
85 version: 2
86 ethernets:
87 primary:
88 match:
89 name: "en*"
90 addresses:
91 - %s/%d
92 routes:
93 - to: default
94 via: %s
95 nameservers:
96 addresses: [1.1.1.1, 9.9.9.9]
97 `, p.IP, p.PrefixLen, p.Gateway)
98 }
99
100 // Build creates a cloud-init NoCloud seed ISO at outPath.
101 // The ISO uses volume label "cidata" as required by cloud-init's NoCloud source.
102 // Files written: /user-data, /meta-data, /network-config.
103 func Build(outPath string, p Params) error {
104 if err := validateParams(p); err != nil {
105 return err
106 }
107
108 const isoSize = 1 * 1024 * 1024 // 1 MiB
109 // ISO9660 requires 2048-byte logical block size; diskfs.SectorSize512 (default) would fail.
110 const isoSectorSize diskfs.SectorSize = 2048
111
112 // Workspace dir for iso9660 staging; cleaned up after Finalize writes to outPath.
113 workDir, err := os.MkdirTemp("", "eitri-seed-")
114 if err != nil {
115 return fmt.Errorf("seed: create workspace: %w", err)
116 }
117 defer os.RemoveAll(workDir)
118
119 d, err := diskfs.Create(outPath, isoSize, isoSectorSize)
120 if err != nil {
121 return fmt.Errorf("seed: create disk image: %w", err)
122 }
123 defer d.Close()
124
125 fsi, err := d.CreateFilesystem(disk.FilesystemSpec{
126 Partition: 0,
127 FSType: filesystem.TypeISO9660,
128 WorkDir: workDir,
129 })
130 if err != nil {
131 return fmt.Errorf("seed: create iso9660 filesystem: %w", err)
132 }
133
134 fs, ok := fsi.(*iso9660.FileSystem)
135 if !ok {
136 return fmt.Errorf("seed: unexpected filesystem type %T", fsi)
137 }
138
139 files := map[string]string{
140 "/user-data": userData(p),
141 "/meta-data": metaData(p),
142 "/network-config": networkConfig(p),
143 }
144 for name, content := range files {
145 f, err := fs.OpenFile(name, os.O_CREATE|os.O_RDWR)
146 if err != nil {
147 return fmt.Errorf("seed: open %s: %w", name, err)
148 }
149 if _, err := f.Write([]byte(content)); err != nil {
150 return fmt.Errorf("seed: write %s: %w", name, err)
151 }
152 }
153
154 // RockRidge extensions preserve lowercase names and hyphens (e.g. "user-data").
155 // Without RockRidge, ISO9660 level-1 would mangle "user-data" → "USER_DAT".
156 if err := fs.Finalize(iso9660.FinalizeOptions{
157 VolumeIdentifier: "cidata",
158 RockRidge: true,
159 }); err != nil {
160 return fmt.Errorf("seed: finalize iso: %w", err)
161 }
162
163 return nil
164 }
internal/agent/seed/seed_test.go
Old New
@@ -0,0 +1,123 @@
1 package seed
2
3 import (
4 "os"
5 "strings"
6 "testing"
7
8 diskfs "github.com/diskfs/go-diskfs"
9 "github.com/diskfs/go-diskfs/filesystem/iso9660"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 func TestBuildProducesISOWithNoCloudFiles(t *testing.T) {
15 out := t.TempDir() + "/seed.iso"
16 err := Build(out, Params{
17 Hostname: "sandbox-7", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1",
18 SSHAuthorizedKey: "ssh-ed25519 AAAA test@example",
19 })
20 require.NoError(t, err)
21 st, err := os.Stat(out)
22 require.NoError(t, err)
23 assert.Greater(t, st.Size(), int64(0))
24
25 // Read the ISO back and verify the three NoCloud files are present.
26 // Must specify the same 2048-byte sector size used at creation time.
27 d, err := diskfs.Open(out, diskfs.WithSectorSize(2048))
28 require.NoError(t, err)
29 defer d.Close()
30
31 fsi, err := d.GetFilesystem(0)
32 require.NoError(t, err)
33 fs, ok := fsi.(*iso9660.FileSystem)
34 require.True(t, ok, "expected iso9660 filesystem")
35
36 // iso9660.FileSystem follows fs.ValidPath rules: root is "." not "/".
37 entries, err := fs.ReadDir(".")
38 require.NoError(t, err)
39 names := make(map[string]bool)
40 for _, e := range entries {
41 names[strings.ToLower(e.Name())] = true
42 }
43 assert.True(t, names["user-data"], "user-data must be present in ISO")
44 assert.True(t, names["meta-data"], "meta-data must be present in ISO")
45 assert.True(t, names["network-config"], "network-config must be present in ISO")
46 }
47
48 func TestUserDataDefaultInjectsKeyAndGrowpart(t *testing.T) {
49 ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"})
50 assert.True(t, strings.HasPrefix(ud, "#cloud-config\n"))
51 assert.Contains(t, ud, "ssh-ed25519 KEY")
52 assert.Contains(t, ud, "growpart") // disk_gb resize completes in-guest (spec)
53 }
54
55 func TestUserDataCustomPassthrough(t *testing.T) {
56 ud := userData(Params{Hostname: "h", UserData: "#cloud-config\npackages: [htop]"})
57 assert.Equal(t, "#cloud-config\npackages: [htop]", ud,
58 "advanced users own their user-data verbatim")
59 }
60
61 func TestNetworkConfigStaticIP(t *testing.T) {
62 nc := networkConfig(Params{IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1"})
63 assert.Contains(t, nc, "10.77.1.2/24")
64 assert.Contains(t, nc, "10.77.1.1")
65 }
66
67 // --- C1b: seed injection-defense tests ---
68
69 func TestBuildRejectsNewlineInHostname(t *testing.T) {
70 out := t.TempDir() + "/seed.iso"
71 err := Build(out, Params{
72 Hostname: "a\nb", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1",
73 })
74 assert.Error(t, err, "Build must reject Hostname containing newline")
75 }
76
77 func TestBuildRejectsNewlineInSSHKey(t *testing.T) {
78 out := t.TempDir() + "/seed.iso"
79 err := Build(out, Params{
80 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1",
81 SSHAuthorizedKey: "ssh-ed25519 AAAA\ninjected: yaml",
82 })
83 assert.Error(t, err, "Build must reject SSHAuthorizedKey containing newline")
84 }
85
86 func TestBuildRejectsNewlineInIP(t *testing.T) {
87 out := t.TempDir() + "/seed.iso"
88 err := Build(out, Params{
89 Hostname: "ok", IP: "10.0.0.1\nbad", PrefixLen: 24, Gateway: "10.0.0.1",
90 })
91 assert.Error(t, err, "Build must reject IP containing newline")
92 }
93
94 func TestBuildRejectsCarriageReturnInGateway(t *testing.T) {
95 out := t.TempDir() + "/seed.iso"
96 err := Build(out, Params{
97 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1\rinjected",
98 })
99 assert.Error(t, err, "Build must reject Gateway containing carriage return")
100 }
101
102 func TestBuildDoesNotRejectMultilineUserData(t *testing.T) {
103 out := t.TempDir() + "/seed.iso"
104 err := Build(out, Params{
105 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1",
106 UserData: "#cloud-config\npackages: [htop]\n",
107 })
108 assert.NoError(t, err, "UserData is exempt from newline validation")
109 }
110
111 // --- M2: instance-id tests ---
112
113 func TestMetaDataUsesInstanceIDWhenSet(t *testing.T) {
114 md := metaData(Params{Hostname: "myhostname", InstanceID: "vm-abc123"})
115 assert.Contains(t, md, "instance-id: vm-abc123")
116 assert.Contains(t, md, "local-hostname: myhostname")
117 }
118
119 func TestMetaDataFallsBackToHostnameWhenInstanceIDEmpty(t *testing.T) {
120 md := metaData(Params{Hostname: "myhostname"})
121 assert.Contains(t, md, "instance-id: myhostname")
122 assert.Contains(t, md, "local-hostname: myhostname")
123 }
internal/agent/state/state.go
Old New
@@ -0,0 +1,199 @@
1 // Package state is the agent's durable state directory (default
2 // /var/lib/eitri-agent). Records are JSON, written atomically
3 // (tmp + rename) so a crash mid-write never corrupts a record.
4 package state
5
6 import (
7 "encoding/json"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "time"
13 )
14
15 type VMSpec struct {
16 VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string
17 VCPUs, MemMB, DiskGB int64
18 Persistent bool
19 }
20
21 type Record struct {
22 Spec VMSpec
23 IP string
24 BootID string // host boot ID at last start (lost-detection)
25 StopRequested bool // set BEFORE stopping: stopped != lost
26 QuarantinedAt *time.Time
27 QuarantineTombstoned bool // tombstoned (short grace) vs vanished (long grace)
28 CreateAttempts int // bounded retry before terminal failed (spec)
29 LastError string
30 CreatedAt time.Time
31 }
32
33 type Identity struct {
34 HostID, Credential, BridgeCIDR string
35 ServerQUICAddr string // host:port for QUIC dial
36 ServerCertSHA256 string // pinned server cert fingerprint
37 }
38
39 type Store struct{ dir string }
40
41 // Open initialises the state directory, creating required subdirectories if
42 // they do not already exist. Returns a ready-to-use *Store.
43 func Open(dir string) (*Store, error) {
44 for _, sub := range []string{dir, filepath.Join(dir, "vms"), filepath.Join(dir, "images")} {
45 if err := os.MkdirAll(sub, 0o700); err != nil {
46 return nil, err
47 }
48 }
49 return &Store{dir: dir}, nil
50 }
51
52 // ImagesDir returns the path where downloaded images are cached.
53 func (s *Store) ImagesDir() string { return filepath.Join(s.dir, "images") }
54
55 // VMDir returns the per-VM directory for the given vmID.
56 func (s *Store) VMDir(vmID string) string { return filepath.Join(s.dir, "vms", vmID) }
57
58 // DiskPath returns the path of the VM's root disk image.
59 func (s *Store) DiskPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "disk.raw") }
60
61 // SeedPath returns the path of the VM's cloud-init seed ISO.
62 func (s *Store) SeedPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "seed.iso") }
63
64 // SocketPath returns the path of the VM's cloud-hypervisor API socket.
65 func (s *Store) SocketPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "ch.sock") }
66
67 // TapName returns the TAP device name for a given vmID. The name is truncated
68 // to the first 8 characters of the vmID, giving "eit-XXXXXXXX" (12 chars),
69 // which is safely below the 15-char IFNAMSIZ limit.
70 func TapName(vmID string) string {
71 prefix := vmID
72 if len(prefix) > 8 {
73 prefix = prefix[:8]
74 }
75 return "eit-" + prefix
76 }
77
78 // atomicWrite writes data to path using a tmp file + rename so that readers
79 // never see a partial write.
80 func atomicWrite(path string, data []byte) error {
81 dir := filepath.Dir(path)
82 f, err := os.CreateTemp(dir, ".tmp-")
83 if err != nil {
84 return err
85 }
86 tmpName := f.Name()
87 if _, err := f.Write(data); err != nil {
88 f.Close()
89 os.Remove(tmpName)
90 return err
91 }
92 if err := f.Close(); err != nil {
93 os.Remove(tmpName)
94 return err
95 }
96 return os.Rename(tmpName, path)
97 }
98
99 // SaveVM persists rec to disk atomically. The VM directory is created if needed.
100 func (s *Store) SaveVM(rec Record) error {
101 if err := os.MkdirAll(s.VMDir(rec.Spec.VMID), 0o700); err != nil {
102 return err
103 }
104 data, err := json.MarshalIndent(rec, "", " ")
105 if err != nil {
106 return err
107 }
108 return atomicWrite(filepath.Join(s.VMDir(rec.Spec.VMID), "record.json"), data)
109 }
110
111 // LoadVMs scans the vms/ subdirectory and returns all parseable records keyed
112 // by VMID. Unreadable or unparseable records are skipped — they indicate an
113 // incomplete create and the reconciler re-derives the correct state.
114 func (s *Store) LoadVMs() (map[string]Record, error) {
115 entries, err := os.ReadDir(filepath.Join(s.dir, "vms"))
116 if err != nil {
117 return nil, err
118 }
119 out := make(map[string]Record, len(entries))
120 for _, e := range entries {
121 if !e.IsDir() {
122 continue
123 }
124 recPath := filepath.Join(s.dir, "vms", e.Name(), "record.json")
125 raw, err := os.ReadFile(recPath)
126 if err != nil {
127 continue // incomplete create — skip
128 }
129 var rec Record
130 if err := json.Unmarshal(raw, &rec); err != nil {
131 continue // unparseable — skip
132 }
133 out[rec.Spec.VMID] = rec
134 }
135 return out, nil
136 }
137
138 // DeleteVM removes the entire VM directory (record + disk + seed + socket).
139 func (s *Store) DeleteVM(vmID string) error {
140 return os.RemoveAll(s.VMDir(vmID))
141 }
142
143 // DiskExists reports whether the VM's disk image file exists on disk.
144 func (s *Store) DiskExists(vmID string) bool {
145 _, err := os.Stat(s.DiskPath(vmID))
146 return err == nil
147 }
148
149 // epochPath returns the path to the epoch file.
150 func (s *Store) epochPath() string { return filepath.Join(s.dir, "epoch") }
151
152 // Epoch reads the current epoch from disk, returning 0 if the file does not
153 // exist (fresh store).
154 // Epoch returns the highest epoch this agent has seen, or 0 for a fresh
155 // store. DELIBERATE FAIL-OPEN: a corrupt epoch file also reads as 0, which
156 // resets the reaping fence; the quarantine grace period (not the fence) is
157 // the backstop in that case. SaveEpoch writes atomically, so corruption
158 // requires external interference, not a crash.
159 func (s *Store) Epoch() uint64 {
160 raw, err := os.ReadFile(s.epochPath())
161 if err != nil {
162 return 0
163 }
164 v, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
165 if err != nil {
166 return 0
167 }
168 return v
169 }
170
171 // SaveEpoch writes epoch to disk atomically.
172 func (s *Store) SaveEpoch(epoch uint64) error {
173 return atomicWrite(s.epochPath(), []byte(strconv.FormatUint(epoch, 10)))
174 }
175
176 // identityPath returns the path to the identity file.
177 func (s *Store) identityPath() string { return filepath.Join(s.dir, "identity.json") }
178
179 // Identity reads the agent's persisted identity. Returns false if not yet enrolled.
180 func (s *Store) Identity() (Identity, bool) {
181 raw, err := os.ReadFile(s.identityPath())
182 if err != nil {
183 return Identity{}, false
184 }
185 var id Identity
186 if err := json.Unmarshal(raw, &id); err != nil {
187 return Identity{}, false
188 }
189 return id, true
190 }
191
192 // SaveIdentity persists the agent's identity atomically.
193 func (s *Store) SaveIdentity(id Identity) error {
194 data, err := json.MarshalIndent(id, "", " ")
195 if err != nil {
196 return err
197 }
198 return atomicWrite(s.identityPath(), data)
199 }
internal/agent/state/state_test.go
Old New
@@ -0,0 +1,64 @@
1 package state
2
3 import (
4 "os"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func open(t *testing.T) *Store {
13 t.Helper()
14 s, err := Open(t.TempDir())
15 require.NoError(t, err)
16 return s
17 }
18
19 func TestVMRecordRoundTripSurvivesReopen(t *testing.T) {
20 dir := t.TempDir()
21 s, _ := Open(dir)
22 rec := Record{
23 Spec: VMSpec{VMID: "vm1", Name: "a", ImageURL: "u", ImageSHA256: "s",
24 VCPUs: 2, MemMB: 2048, DiskGB: 10},
25 IP: "10.77.1.2", BootID: "boot-1", CreatedAt: time.Now().UTC(),
26 }
27 require.NoError(t, s.SaveVM(rec))
28
29 s2, _ := Open(dir) // simulate agent restart
30 got, err := s2.LoadVMs()
31 require.NoError(t, err)
32 require.Contains(t, got, "vm1")
33 assert.Equal(t, "10.77.1.2", got["vm1"].IP)
34 assert.Equal(t, int64(2048), got["vm1"].Spec.MemMB)
35 }
36
37 func TestEpochPersists(t *testing.T) {
38 dir := t.TempDir()
39 s, _ := Open(dir)
40 assert.Equal(t, uint64(0), s.Epoch(), "fresh store starts at 0")
41 require.NoError(t, s.SaveEpoch(42))
42 s2, _ := Open(dir)
43 assert.Equal(t, uint64(42), s2.Epoch())
44 }
45
46 func TestDeleteVMRemovesRecordAndDir(t *testing.T) {
47 s := open(t)
48 rec := Record{Spec: VMSpec{VMID: "vm1"}}
49 require.NoError(t, s.SaveVM(rec))
50 require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644))
51 require.NoError(t, s.DeleteVM("vm1"))
52 got, _ := s.LoadVMs()
53 assert.NotContains(t, got, "vm1")
54 _, err := os.Stat(s.VMDir("vm1"))
55 assert.True(t, os.IsNotExist(err))
56 }
57
58 func TestDiskExistsReflectsDiskFile(t *testing.T) {
59 s := open(t)
60 require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}}))
61 assert.False(t, s.DiskExists("vm1"), "record without disk: VM does not 'exist' (spec)")
62 require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644))
63 assert.True(t, s.DiskExists("vm1"))
64 }
internal/agent/syncclient/client.go
Old New
@@ -0,0 +1,272 @@
1 // Package syncclient holds the agent's stream loop: receive snapshots,
2 // run engine steps, send reports. Reconnects with backoff forever.
3 package syncclient
4
5 import (
6 "context"
7 "errors"
8 "log/slog"
9 "os"
10 "runtime"
11 "strings"
12 "sync"
13 "syscall"
14 "time"
15
16 "github.com/a73x/eitri/internal/agent/reconcile"
17 "github.com/a73x/eitri/internal/agent/state"
18 "github.com/a73x/eitri/internal/pb"
19 "github.com/a73x/eitri/internal/transport"
20 "github.com/quic-go/quic-go"
21 )
22
23 // HostBootID reads /proc/sys/kernel/random/boot_id and returns the trimmed value.
24 func HostBootID() string {
25 b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
26 if err != nil {
27 return ""
28 }
29 return strings.TrimSpace(string(b))
30 }
31
32 // capacity returns the host's TOTAL capacity: total disk at stateDir, total mem,
33 // and CPU count. The server computes allocated/available by subtracting the sum
34 // of live VM specs, so capacity must be totals (not free) for the math to cohere.
35 func capacity(stateDir string) *pb.Capacity {
36 var fs syscall.Statfs_t
37 var diskGB int64
38 if err := syscall.Statfs(stateDir, &fs); err == nil {
39 // Total blocks * block size → bytes → GB
40 diskGB = int64(fs.Blocks) * fs.Bsize / (1024 * 1024 * 1024)
41 }
42
43 var info syscall.Sysinfo_t
44 var memMB int64
45 if err := syscall.Sysinfo(&info); err == nil {
46 memMB = int64(info.Totalram) * int64(info.Unit) / (1024 * 1024)
47 }
48
49 return &pb.Capacity{
50 Vcpus: int64(runtime.NumCPU()),
51 MemMb: memMB,
52 DiskGb: diskGB,
53 }
54 }
55
56 // Client manages the agent's QUIC sync session.
57 type Client struct {
58 Engine *reconcile.Engine
59 St *state.Store
60 Identity state.Identity
61 StateDir string
62
63 // TickInterval is the period of the fallback ticker that drives a reconcile
64 // step even when no new snapshot has arrived (e.g. for periodic health
65 // reports). Zero uses the default of 10 seconds.
66 TickInterval time.Duration
67
68 // ReconnectBackoff is the sleep between a transient session failure and the
69 // next dial attempt. Zero uses the production default of 5 seconds.
70 ReconnectBackoff time.Duration
71 }
72
73 // errPermanentAuth marks a credential rejection so Run() backs off long instead
74 // of tight-looping a dead credential.
75 var errPermanentAuth = errors.New("auth rejected (permanent)")
76
77 // Run loops forever: open a session, and on error back off then retry.
78 // Exits when ctx is cancelled.
79 func (c *Client) Run(ctx context.Context) {
80 failures := 0
81 for {
82 err := c.session(ctx)
83 if errors.Is(err, context.Canceled) {
84 return
85 }
86 if errors.Is(err, errPermanentAuth) {
87 slog.Error("not retrying quickly: credential rejected")
88 if !sleep(ctx, 60*time.Second) {
89 return
90 }
91 continue
92 }
93 // A cert pin mismatch (from transport.ClientTLS's VerifyConnection) is not
94 // a network-reachability problem, so it must not increment the UDP-blocked
95 // counter or fire that misleading warning. Treat it like the permanent path:
96 // log a distinct, actionable ERROR and back off long.
97 if err != nil && strings.Contains(err.Error(), "fingerprint mismatch") {
98 slog.Error("server cert pin mismatch — the server cert changed or enrollment is stale; re-enroll this host")
99 if !sleep(ctx, 60*time.Second) {
100 return
101 }
102 continue
103 }
104 // A session that successfully read ≥1 snapshot returns errSessionConnected
105 // wrapped around the underlying error; reset the failure counter so a
106 // long-lived-then-dropped connection is not mistaken for an unreachable
107 // control plane. (Documented choice: the connectedOnce sentinel keeps the
108 // UDP-blocked diagnostic accurate — it only fires when no handshake ever
109 // succeeded across N attempts.)
110 if errors.Is(err, errSessionConnected) {
111 failures = 0
112 } else {
113 failures++
114 if failures >= 3 {
115 slog.Warn("control-plane unreachable — QUIC/UDP may be blocked or filtered on this network",
116 "server", c.Identity.ServerQUICAddr, "consecutive_failures", failures)
117 }
118 }
119 backoff := c.ReconnectBackoff
120 if backoff == 0 {
121 backoff = 5 * time.Second
122 }
123 slog.Warn("sync session ended, retrying", "err", err, "backoff", backoff)
124 if !sleep(ctx, backoff) {
125 return
126 }
127 }
128 }
129
130 func sleep(ctx context.Context, d time.Duration) bool {
131 select {
132 case <-ctx.Done():
133 return false
134 case <-time.After(d):
135 return true
136 }
137 }
138
139 // errSessionConnected is wrapped onto a session's terminal error once that
140 // session has read at least one snapshot, so Run() can reset its failure count.
141 var errSessionConnected = errors.New("session was connected")
142
143 // session opens one QUIC connection, runs the dual-stream loop, and returns
144 // when the session ends (for any reason). The caller retries.
145 func (c *Client) session(ctx context.Context) error {
146 tlsConf := transport.ClientTLS(c.Identity.ServerCertSHA256)
147 conn, err := quic.DialAddr(ctx, c.Identity.ServerQUICAddr, tlsConf,
148 &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second})
149 if err != nil {
150 return classifyErr(err) // transient: Run() backs off
151 }
152 defer conn.CloseWithError(0, "")
153
154 up, err := conn.OpenStreamSync(ctx)
155 if err != nil {
156 return classifyErr(err)
157 }
158
159 stateDir := c.StateDir
160 if stateDir == "" {
161 stateDir = "/var/lib/eitri-agent"
162 }
163 hostname, _ := os.Hostname()
164 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{
165 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH,
166 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR,
167 LastSeenEpoch: c.St.Epoch(), Capacity: capacity(stateDir),
168 Credential: c.Identity.Credential,
169 }}}
170 if err := transport.WriteMsg(up, hello); err != nil {
171 return classifyErr(err)
172 }
173
174 // Accept the server's down-stream (visible on first server write).
175 down, err := conn.AcceptStream(ctx)
176 if err != nil {
177 // If the server rejected auth, CloseWithError surfaces here as an
178 // ApplicationError with CodeAuthRejected.
179 return classifyErr(err)
180 }
181
182 var mu sync.Mutex
183 var latest *pb.DesiredStateSnapshot
184 // connectedOnce is set true (under mu) once the recv goroutine reads its
185 // first snapshot. session wraps its terminal error with errSessionConnected
186 // when set, so Run() resets its consecutive-failure counter.
187 connectedOnce := false
188 stepSignal := make(chan struct{}, 1)
189 errc := make(chan error, 2)
190
191 step := func() error {
192 mu.Lock()
193 snap := latest
194 mu.Unlock()
195 if snap == nil {
196 return nil
197 }
198 rep := c.Engine.Step(ctx, snap)
199 rep.Capacity = capacity(stateDir)
200 return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}})
201 }
202
203 // Recv goroutine: read down-stream snapshots only; never writes.
204 go func() {
205 for {
206 var msg pb.ServerMessage
207 if err := transport.ReadMsg(down, &msg, transport.DefaultMaxFrame); err != nil {
208 errc <- classifyErr(err)
209 return
210 }
211 if snap := msg.GetSnapshot(); snap != nil {
212 mu.Lock()
213 latest = snap
214 connectedOnce = true
215 mu.Unlock()
216 select {
217 case stepSignal <- struct{}{}:
218 default:
219 }
220 }
221 }
222 }()
223
224 // Worker goroutine: owns all up-stream writes (snapshot-driven + ticker).
225 tick := c.TickInterval
226 if tick == 0 {
227 tick = 10 * time.Second
228 }
229 ticker := time.NewTicker(tick)
230 defer ticker.Stop()
231 go func() {
232 for {
233 select {
234 case <-ctx.Done():
235 errc <- ctx.Err()
236 return
237 case <-stepSignal:
238 if err := step(); err != nil {
239 errc <- err
240 return
241 }
242 case <-ticker.C:
243 if err := step(); err != nil {
244 errc <- err
245 return
246 }
247 }
248 }
249 }()
250
251 sessErr := <-errc
252 mu.Lock()
253 connected := connectedOnce
254 mu.Unlock()
255 if connected && !errors.Is(sessErr, errPermanentAuth) {
256 // Preserve errPermanentAuth's semantics; otherwise mark the session as
257 // having connected so Run() resets its failure counter.
258 return errors.Join(errSessionConnected, sessErr)
259 }
260 return sessErr
261 }
262
263 // classifyErr distinguishes a permanent auth rejection from a transient error so
264 // Run() can log loudly and avoid a tight reconnect loop on a dead credential.
265 func classifyErr(err error) error {
266 var appErr *quic.ApplicationError
267 if errors.As(err, &appErr) && appErr.ErrorCode == transport.CodeAuthRejected {
268 slog.Error("host credential rejected by server — re-enroll this host", "detail", appErr.ErrorMessage)
269 return errPermanentAuth
270 }
271 return err
272 }
internal/agent/syncclient/client_test.go
Old New
@@ -0,0 +1,230 @@
1 package syncclient
2
3 import (
4 "context"
5 "sync/atomic"
6 "testing"
7 "time"
8
9 "github.com/a73x/eitri/internal/agent/reconcile"
10 "github.com/a73x/eitri/internal/agent/seed"
11 "github.com/a73x/eitri/internal/agent/state"
12 "github.com/a73x/eitri/internal/server/hosttoken"
13 "github.com/a73x/eitri/internal/server/hub"
14 "github.com/a73x/eitri/internal/server/registry"
15 "github.com/a73x/eitri/internal/server/store"
16 "github.com/a73x/eitri/internal/server/syncsvc"
17 "github.com/a73x/eitri/internal/transport"
18 "github.com/quic-go/quic-go"
19 "github.com/stretchr/testify/require"
20 )
21
22 // noopProv / noopNet satisfy the reconcile interfaces with no side effects so we
23 // can drive a real Client.Run against a real syncsvc server over QUIC loopback.
24 type noopProv struct{}
25
26 func (noopProv) PrepareDisk(context.Context, state.VMSpec, string) error { return nil }
27 func (noopProv) Boot(context.Context, string, state.VMSpec) error { return nil }
28 func (noopProv) Shutdown(context.Context, string) error { return nil }
29 func (noopProv) Kill(context.Context, string) error { return nil }
30 func (noopProv) Running(string) bool { return false }
31
32 type noopNet struct{}
33
34 func (noopNet) CreateTap(context.Context, string) error { return nil }
35 func (noopNet) DeleteTap(context.Context, string) error { return nil }
36 func (noopNet) AllocateIP(context.Context, []string) (string, error) {
37 return "10.77.1.2", nil
38 }
39 func (noopNet) GuestNetwork() (string, int) { return "10.77.1.1", 24 }
40
41 // serverHarness owns a real syncsvc.Service on a fixed UDP loopback port so it
42 // can be stopped and restarted (TestReconnectAfterDrop) on the same address.
43 type serverHarness struct {
44 t *testing.T
45 st *store.Store
46 reg *registry.Registry
47 hub *hub.Hub
48 secret []byte
49 certPEM []byte
50 keyPEM []byte
51 fp string
52 addr string
53 cancel context.CancelFunc
54 lis *quic.Listener
55 }
56
57 func newServerHarness(t *testing.T) *serverHarness {
58 t.Helper()
59 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
60 require.NoError(t, err)
61 t.Cleanup(func() { st.Close() })
62 certPEM, keyPEM, err := transport.GenerateServerCert()
63 require.NoError(t, err)
64 fp, err := transport.CertFingerprint(certPEM)
65 require.NoError(t, err)
66 h := &serverHarness{
67 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(),
68 secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp,
69 }
70 h.start("127.0.0.1:0")
71 t.Cleanup(h.stop)
72 return h
73 }
74
75 func (h *serverHarness) start(addr string) {
76 tlsConf, err := transport.ServerTLS(h.certPEM, h.keyPEM)
77 require.NoError(h.t, err)
78 lis, err := quic.ListenAddr(addr, tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
79 require.NoError(h.t, err)
80 h.lis = lis
81 h.addr = lis.Addr().String()
82 ctx, cancel := context.WithCancel(context.Background())
83 h.cancel = cancel
84 svc := syncsvc.New(h.st, h.reg, h.hub, h.secret)
85 go svc.Serve(ctx, lis) //nolint:errcheck
86 }
87
88 func (h *serverHarness) stop() {
89 if h.cancel != nil {
90 h.cancel()
91 }
92 if h.lis != nil {
93 h.lis.Close()
94 }
95 }
96
97 // enroll creates a host and returns a valid credential for it.
98 func (h *serverHarness) enroll() (hostID, cred string) {
99 tok, _ := h.st.CreateEnrollmentToken()
100 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
101 require.NoError(h.t, err)
102 return host.ID, hosttoken.Mint(h.secret, host.ID)
103 }
104
105 func newClient(t *testing.T, addr, fp, hostID, cred string) *Client {
106 t.Helper()
107 agentSt, err := state.Open(t.TempDir())
108 require.NoError(t, err)
109 id := state.Identity{
110 HostID: hostID, Credential: cred,
111 ServerQUICAddr: addr, ServerCertSHA256: fp,
112 }
113 require.NoError(t, agentSt.SaveIdentity(id))
114 engine := &reconcile.Engine{
115 St: agentSt, Prov: noopProv{}, Net: noopNet{},
116 Images: func(context.Context, string, string) (string, error) { return "/x.raw", nil },
117 Seed: func(string, seed.Params) error { return nil },
118 // CIDR/grace not exercised by these tests.
119 BootID: func() string { return "boot-test" }, Now: time.Now,
120 TombstoneGrace: time.Hour, VanishGrace: time.Hour, MaxCreateAttempts: 3,
121 }
122 return &Client{
123 Engine: engine, St: agentSt, Identity: id,
124 StateDir: t.TempDir(), TickInterval: 100 * time.Millisecond,
125 ReconnectBackoff: 100 * time.Millisecond,
126 }
127 }
128
129 func TestReconnectAfterDrop(t *testing.T) {
130 h := newServerHarness(t)
131 hostID, cred := h.enroll()
132 c := newClient(t, h.addr, h.fp, hostID, cred)
133
134 // Seed a VM so there's content; reports flow on each tick.
135 require.NoError(t, h.st.CreateVM(store.VM{ID: "vm1", HostID: hostID, Name: "a",
136 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "stopped"}))
137
138 ctx, cancel := context.WithCancel(context.Background())
139 defer cancel()
140 go c.Run(ctx)
141
142 // First connection: wait for the host to register a report.
143 require.Eventually(t, func() bool {
144 _, ok := h.reg.Get(hostID)
145 return ok
146 }, 5*time.Second, 50*time.Millisecond, "client should connect and report initially")
147
148 addr := h.addr
149 // Drop the server, then bring it back on the SAME UDP port.
150 h.stop()
151 time.Sleep(200 * time.Millisecond)
152 h.reg = registry.New(time.Now) // fresh registry: a re-report proves reconnect
153 h.start(addr)
154
155 require.Eventually(t, func() bool {
156 _, ok := h.reg.Get(hostID)
157 return ok
158 }, 15*time.Second, 100*time.Millisecond, "client should reconnect after the server restarts on the same port")
159 }
160
161 func TestAuthRejectedClassified(t *testing.T) {
162 h := newServerHarness(t)
163 hostID, _ := h.enroll()
164 // Use a junk credential so the server rejects every connection with
165 // CodeAuthRejected.
166 c := newClient(t, h.addr, h.fp, hostID, "host-x.deadbeef")
167
168 // session() is the unit that classifies the server's rejection; assert it
169 // returns the permanent-auth sentinel (not a transient error). This is what
170 // drives Run's 60s backoff branch instead of the 5s transient retry.
171 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
172 defer cancel()
173 err := c.session(ctx)
174 require.ErrorIs(t, err, errPermanentAuth,
175 "a server auth rejection must classify as permanent, not transient")
176
177 // And confirm Run takes the long-backoff path and does NOT tight-loop. We
178 // hook dial-counting by counting connections the server accepts: after the
179 // first rejected session Run sleeps 60s, so within ~1s the server must see at
180 // most one connection attempt.
181 var accepts atomic.Int64
182 cs := newCountingServer(t, accepts.Add)
183 csHostID, _ := cs.enroll()
184 dc := newClient(t, cs.addr, cs.fp, csHostID, "host-x.deadbeef")
185 rctx, rcancel := context.WithCancel(context.Background())
186 go dc.Run(rctx)
187 time.Sleep(1 * time.Second)
188 rcancel()
189 require.LessOrEqual(t, accepts.Load(), int64(1),
190 "Run must not tight-loop on a permanently rejected credential")
191 }
192
193 // newCountingServer is a serverHarness whose accept loop calls onAccept(1) for
194 // every connection, so tests can observe reconnect attempts.
195 func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness {
196 t.Helper()
197 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
198 require.NoError(t, err)
199 t.Cleanup(func() { st.Close() })
200 certPEM, keyPEM, err := transport.GenerateServerCert()
201 require.NoError(t, err)
202 fp, err := transport.CertFingerprint(certPEM)
203 require.NoError(t, err)
204 h := &serverHarness{
205 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(),
206 secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp,
207 }
208 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
209 require.NoError(t, err)
210 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
211 require.NoError(t, err)
212 h.lis = lis
213 h.addr = lis.Addr().String()
214 ctx, cancel := context.WithCancel(context.Background())
215 h.cancel = cancel
216 go func() {
217 for {
218 conn, err := lis.Accept(ctx)
219 if err != nil {
220 return
221 }
222 onAccept(1)
223 // Reject everything: close immediately as auth-rejected. The client's
224 // AcceptStream/session surfaces this as errPermanentAuth.
225 _ = conn.CloseWithError(transport.CodeAuthRejected, "test reject")
226 }
227 }()
228 t.Cleanup(func() { cancel(); lis.Close() })
229 return h
230 }
internal/pb/sync.pb.go
Old New
@@ -0,0 +1,896 @@
1 // Code generated by protoc-gen-go. DO NOT EDIT.
2 // versions:
3 // protoc-gen-go v1.36.11
4 // protoc v7.35.0
5 // source: proto/eitri/v1/sync.proto
6
7 package pb
8
9 import (
10 protoreflect "google.golang.org/protobuf/reflect/protoreflect"
11 protoimpl "google.golang.org/protobuf/runtime/protoimpl"
12 reflect "reflect"
13 sync "sync"
14 unsafe "unsafe"
15 )
16
17 const (
18 // Verify that this generated code is sufficiently up-to-date.
19 _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
20 // Verify that runtime/protoimpl is sufficiently up-to-date.
21 _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
22 )
23
24 type AgentMessage struct {
25 state protoimpl.MessageState `protogen:"open.v1"`
26 // Types that are valid to be assigned to Msg:
27 //
28 // *AgentMessage_Hello
29 // *AgentMessage_Report
30 Msg isAgentMessage_Msg `protobuf_oneof:"msg"`
31 unknownFields protoimpl.UnknownFields
32 sizeCache protoimpl.SizeCache
33 }
34
35 func (x *AgentMessage) Reset() {
36 *x = AgentMessage{}
37 mi := &file_proto_eitri_v1_sync_proto_msgTypes[0]
38 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
39 ms.StoreMessageInfo(mi)
40 }
41
42 func (x *AgentMessage) String() string {
43 return protoimpl.X.MessageStringOf(x)
44 }
45
46 func (*AgentMessage) ProtoMessage() {}
47
48 func (x *AgentMessage) ProtoReflect() protoreflect.Message {
49 mi := &file_proto_eitri_v1_sync_proto_msgTypes[0]
50 if x != nil {
51 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
52 if ms.LoadMessageInfo() == nil {
53 ms.StoreMessageInfo(mi)
54 }
55 return ms
56 }
57 return mi.MessageOf(x)
58 }
59
60 // Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead.
61 func (*AgentMessage) Descriptor() ([]byte, []int) {
62 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{0}
63 }
64
65 func (x *AgentMessage) GetMsg() isAgentMessage_Msg {
66 if x != nil {
67 return x.Msg
68 }
69 return nil
70 }
71
72 func (x *AgentMessage) GetHello() *Hello {
73 if x != nil {
74 if x, ok := x.Msg.(*AgentMessage_Hello); ok {
75 return x.Hello
76 }
77 }
78 return nil
79 }
80
81 func (x *AgentMessage) GetReport() *ActualStateReport {
82 if x != nil {
83 if x, ok := x.Msg.(*AgentMessage_Report); ok {
84 return x.Report
85 }
86 }
87 return nil
88 }
89
90 type isAgentMessage_Msg interface {
91 isAgentMessage_Msg()
92 }
93
94 type AgentMessage_Hello struct {
95 Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
96 }
97
98 type AgentMessage_Report struct {
99 Report *ActualStateReport `protobuf:"bytes,2,opt,name=report,proto3,oneof"`
100 }
101
102 func (*AgentMessage_Hello) isAgentMessage_Msg() {}
103
104 func (*AgentMessage_Report) isAgentMessage_Msg() {}
105
106 type ServerMessage struct {
107 state protoimpl.MessageState `protogen:"open.v1"`
108 // Types that are valid to be assigned to Msg:
109 //
110 // *ServerMessage_Snapshot
111 Msg isServerMessage_Msg `protobuf_oneof:"msg"`
112 unknownFields protoimpl.UnknownFields
113 sizeCache protoimpl.SizeCache
114 }
115
116 func (x *ServerMessage) Reset() {
117 *x = ServerMessage{}
118 mi := &file_proto_eitri_v1_sync_proto_msgTypes[1]
119 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
120 ms.StoreMessageInfo(mi)
121 }
122
123 func (x *ServerMessage) String() string {
124 return protoimpl.X.MessageStringOf(x)
125 }
126
127 func (*ServerMessage) ProtoMessage() {}
128
129 func (x *ServerMessage) ProtoReflect() protoreflect.Message {
130 mi := &file_proto_eitri_v1_sync_proto_msgTypes[1]
131 if x != nil {
132 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
133 if ms.LoadMessageInfo() == nil {
134 ms.StoreMessageInfo(mi)
135 }
136 return ms
137 }
138 return mi.MessageOf(x)
139 }
140
141 // Deprecated: Use ServerMessage.ProtoReflect.Descriptor instead.
142 func (*ServerMessage) Descriptor() ([]byte, []int) {
143 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{1}
144 }
145
146 func (x *ServerMessage) GetMsg() isServerMessage_Msg {
147 if x != nil {
148 return x.Msg
149 }
150 return nil
151 }
152
153 func (x *ServerMessage) GetSnapshot() *DesiredStateSnapshot {
154 if x != nil {
155 if x, ok := x.Msg.(*ServerMessage_Snapshot); ok {
156 return x.Snapshot
157 }
158 }
159 return nil
160 }
161
162 type isServerMessage_Msg interface {
163 isServerMessage_Msg()
164 }
165
166 type ServerMessage_Snapshot struct {
167 Snapshot *DesiredStateSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"`
168 }
169
170 func (*ServerMessage_Snapshot) isServerMessage_Msg() {}
171
172 type Hello struct {
173 state protoimpl.MessageState `protogen:"open.v1"`
174 HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"`
175 Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"`
176 Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"`
177 Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"`
178 Provisioner string `protobuf:"bytes,5,opt,name=provisioner,proto3" json:"provisioner,omitempty"` // "cloudhv"
179 BridgeCidr string `protobuf:"bytes,6,opt,name=bridge_cidr,json=bridgeCidr,proto3" json:"bridge_cidr,omitempty"` // echo of server-assigned CIDR
180 LastSeenEpoch uint64 `protobuf:"varint,7,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` // for the restore runbook
181 Capacity *Capacity `protobuf:"bytes,8,opt,name=capacity,proto3" json:"capacity,omitempty"`
182 Credential string `protobuf:"bytes,9,opt,name=credential,proto3" json:"credential,omitempty"` // Bearer host credential, verified in first frame
183 unknownFields protoimpl.UnknownFields
184 sizeCache protoimpl.SizeCache
185 }
186
187 func (x *Hello) Reset() {
188 *x = Hello{}
189 mi := &file_proto_eitri_v1_sync_proto_msgTypes[2]
190 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
191 ms.StoreMessageInfo(mi)
192 }
193
194 func (x *Hello) String() string {
195 return protoimpl.X.MessageStringOf(x)
196 }
197
198 func (*Hello) ProtoMessage() {}
199
200 func (x *Hello) ProtoReflect() protoreflect.Message {
201 mi := &file_proto_eitri_v1_sync_proto_msgTypes[2]
202 if x != nil {
203 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
204 if ms.LoadMessageInfo() == nil {
205 ms.StoreMessageInfo(mi)
206 }
207 return ms
208 }
209 return mi.MessageOf(x)
210 }
211
212 // Deprecated: Use Hello.ProtoReflect.Descriptor instead.
213 func (*Hello) Descriptor() ([]byte, []int) {
214 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{2}
215 }
216
217 func (x *Hello) GetHostId() string {
218 if x != nil {
219 return x.HostId
220 }
221 return ""
222 }
223
224 func (x *Hello) GetHostname() string {
225 if x != nil {
226 return x.Hostname
227 }
228 return ""
229 }
230
231 func (x *Hello) GetOs() string {
232 if x != nil {
233 return x.Os
234 }
235 return ""
236 }
237
238 func (x *Hello) GetArch() string {
239 if x != nil {
240 return x.Arch
241 }
242 return ""
243 }
244
245 func (x *Hello) GetProvisioner() string {
246 if x != nil {
247 return x.Provisioner
248 }
249 return ""
250 }
251
252 func (x *Hello) GetBridgeCidr() string {
253 if x != nil {
254 return x.BridgeCidr
255 }
256 return ""
257 }
258
259 func (x *Hello) GetLastSeenEpoch() uint64 {
260 if x != nil {
261 return x.LastSeenEpoch
262 }
263 return 0
264 }
265
266 func (x *Hello) GetCapacity() *Capacity {
267 if x != nil {
268 return x.Capacity
269 }
270 return nil
271 }
272
273 func (x *Hello) GetCredential() string {
274 if x != nil {
275 return x.Credential
276 }
277 return ""
278 }
279
280 type Capacity struct {
281 state protoimpl.MessageState `protogen:"open.v1"`
282 Vcpus int64 `protobuf:"varint,1,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
283 MemMb int64 `protobuf:"varint,2,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"`
284 DiskGb int64 `protobuf:"varint,3,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"`
285 unknownFields protoimpl.UnknownFields
286 sizeCache protoimpl.SizeCache
287 }
288
289 func (x *Capacity) Reset() {
290 *x = Capacity{}
291 mi := &file_proto_eitri_v1_sync_proto_msgTypes[3]
292 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
293 ms.StoreMessageInfo(mi)
294 }
295
296 func (x *Capacity) String() string {
297 return protoimpl.X.MessageStringOf(x)
298 }
299
300 func (*Capacity) ProtoMessage() {}
301
302 func (x *Capacity) ProtoReflect() protoreflect.Message {
303 mi := &file_proto_eitri_v1_sync_proto_msgTypes[3]
304 if x != nil {
305 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
306 if ms.LoadMessageInfo() == nil {
307 ms.StoreMessageInfo(mi)
308 }
309 return ms
310 }
311 return mi.MessageOf(x)
312 }
313
314 // Deprecated: Use Capacity.ProtoReflect.Descriptor instead.
315 func (*Capacity) Descriptor() ([]byte, []int) {
316 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{3}
317 }
318
319 func (x *Capacity) GetVcpus() int64 {
320 if x != nil {
321 return x.Vcpus
322 }
323 return 0
324 }
325
326 func (x *Capacity) GetMemMb() int64 {
327 if x != nil {
328 return x.MemMb
329 }
330 return 0
331 }
332
333 func (x *Capacity) GetDiskGb() int64 {
334 if x != nil {
335 return x.DiskGb
336 }
337 return 0
338 }
339
340 type ActualVM struct {
341 state protoimpl.MessageState `protogen:"open.v1"`
342 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
343 Power string `protobuf:"bytes,2,opt,name=power,proto3" json:"power,omitempty"` // "running"|"stopped"
344 Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined"
345 Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // agent-allocated; server validates within bridge_cidr
346 LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"`
347 unknownFields protoimpl.UnknownFields
348 sizeCache protoimpl.SizeCache
349 }
350
351 func (x *ActualVM) Reset() {
352 *x = ActualVM{}
353 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4]
354 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
355 ms.StoreMessageInfo(mi)
356 }
357
358 func (x *ActualVM) String() string {
359 return protoimpl.X.MessageStringOf(x)
360 }
361
362 func (*ActualVM) ProtoMessage() {}
363
364 func (x *ActualVM) ProtoReflect() protoreflect.Message {
365 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4]
366 if x != nil {
367 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
368 if ms.LoadMessageInfo() == nil {
369 ms.StoreMessageInfo(mi)
370 }
371 return ms
372 }
373 return mi.MessageOf(x)
374 }
375
376 // Deprecated: Use ActualVM.ProtoReflect.Descriptor instead.
377 func (*ActualVM) Descriptor() ([]byte, []int) {
378 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{4}
379 }
380
381 func (x *ActualVM) GetVmId() string {
382 if x != nil {
383 return x.VmId
384 }
385 return ""
386 }
387
388 func (x *ActualVM) GetPower() string {
389 if x != nil {
390 return x.Power
391 }
392 return ""
393 }
394
395 func (x *ActualVM) GetPhase() string {
396 if x != nil {
397 return x.Phase
398 }
399 return ""
400 }
401
402 func (x *ActualVM) GetIp() string {
403 if x != nil {
404 return x.Ip
405 }
406 return ""
407 }
408
409 func (x *ActualVM) GetLastError() string {
410 if x != nil {
411 return x.LastError
412 }
413 return ""
414 }
415
416 type QuarantinedVM struct {
417 state protoimpl.MessageState `protogen:"open.v1"`
418 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
419 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
420 VmspecJson []byte `protobuf:"bytes,3,opt,name=vmspec_json,json=vmspecJson,proto3" json:"vmspec_json,omitempty"` // full VMSpec — only surviving copy after a DB restore
421 DestroyAtUnix int64 `protobuf:"varint,4,opt,name=destroy_at_unix,json=destroyAtUnix,proto3" json:"destroy_at_unix,omitempty"`
422 unknownFields protoimpl.UnknownFields
423 sizeCache protoimpl.SizeCache
424 }
425
426 func (x *QuarantinedVM) Reset() {
427 *x = QuarantinedVM{}
428 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5]
429 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
430 ms.StoreMessageInfo(mi)
431 }
432
433 func (x *QuarantinedVM) String() string {
434 return protoimpl.X.MessageStringOf(x)
435 }
436
437 func (*QuarantinedVM) ProtoMessage() {}
438
439 func (x *QuarantinedVM) ProtoReflect() protoreflect.Message {
440 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5]
441 if x != nil {
442 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
443 if ms.LoadMessageInfo() == nil {
444 ms.StoreMessageInfo(mi)
445 }
446 return ms
447 }
448 return mi.MessageOf(x)
449 }
450
451 // Deprecated: Use QuarantinedVM.ProtoReflect.Descriptor instead.
452 func (*QuarantinedVM) Descriptor() ([]byte, []int) {
453 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{5}
454 }
455
456 func (x *QuarantinedVM) GetVmId() string {
457 if x != nil {
458 return x.VmId
459 }
460 return ""
461 }
462
463 func (x *QuarantinedVM) GetName() string {
464 if x != nil {
465 return x.Name
466 }
467 return ""
468 }
469
470 func (x *QuarantinedVM) GetVmspecJson() []byte {
471 if x != nil {
472 return x.VmspecJson
473 }
474 return nil
475 }
476
477 func (x *QuarantinedVM) GetDestroyAtUnix() int64 {
478 if x != nil {
479 return x.DestroyAtUnix
480 }
481 return 0
482 }
483
484 type ActualStateReport struct {
485 state protoimpl.MessageState `protogen:"open.v1"`
486 Vms []*ActualVM `protobuf:"bytes,1,rep,name=vms,proto3" json:"vms,omitempty"`
487 // LEVEL-TRIGGERED destroy ack: ALL tombstoned vm_ids with no local
488 // record/disk/process, repeated every report until hard-deleted server-side.
489 Destroyed []string `protobuf:"bytes,2,rep,name=destroyed,proto3" json:"destroyed,omitempty"`
490 Quarantined []*QuarantinedVM `protobuf:"bytes,3,rep,name=quarantined,proto3" json:"quarantined,omitempty"`
491 Capacity *Capacity `protobuf:"bytes,4,opt,name=capacity,proto3" json:"capacity,omitempty"`
492 FenceViolation bool `protobuf:"varint,5,opt,name=fence_violation,json=fenceViolation,proto3" json:"fence_violation,omitempty"`
493 LastSeenEpoch uint64 `protobuf:"varint,6,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"`
494 unknownFields protoimpl.UnknownFields
495 sizeCache protoimpl.SizeCache
496 }
497
498 func (x *ActualStateReport) Reset() {
499 *x = ActualStateReport{}
500 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6]
501 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
502 ms.StoreMessageInfo(mi)
503 }
504
505 func (x *ActualStateReport) String() string {
506 return protoimpl.X.MessageStringOf(x)
507 }
508
509 func (*ActualStateReport) ProtoMessage() {}
510
511 func (x *ActualStateReport) ProtoReflect() protoreflect.Message {
512 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6]
513 if x != nil {
514 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
515 if ms.LoadMessageInfo() == nil {
516 ms.StoreMessageInfo(mi)
517 }
518 return ms
519 }
520 return mi.MessageOf(x)
521 }
522
523 // Deprecated: Use ActualStateReport.ProtoReflect.Descriptor instead.
524 func (*ActualStateReport) Descriptor() ([]byte, []int) {
525 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{6}
526 }
527
528 func (x *ActualStateReport) GetVms() []*ActualVM {
529 if x != nil {
530 return x.Vms
531 }
532 return nil
533 }
534
535 func (x *ActualStateReport) GetDestroyed() []string {
536 if x != nil {
537 return x.Destroyed
538 }
539 return nil
540 }
541
542 func (x *ActualStateReport) GetQuarantined() []*QuarantinedVM {
543 if x != nil {
544 return x.Quarantined
545 }
546 return nil
547 }
548
549 func (x *ActualStateReport) GetCapacity() *Capacity {
550 if x != nil {
551 return x.Capacity
552 }
553 return nil
554 }
555
556 func (x *ActualStateReport) GetFenceViolation() bool {
557 if x != nil {
558 return x.FenceViolation
559 }
560 return false
561 }
562
563 func (x *ActualStateReport) GetLastSeenEpoch() uint64 {
564 if x != nil {
565 return x.LastSeenEpoch
566 }
567 return 0
568 }
569
570 type VMDesired struct {
571 state protoimpl.MessageState `protogen:"open.v1"`
572 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
573 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
574 ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
575 ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"`
576 CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty
577 Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
578 MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"`
579 DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"`
580 Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"`
581 PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped"
582 Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[])
583 SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"`
584 unknownFields protoimpl.UnknownFields
585 sizeCache protoimpl.SizeCache
586 }
587
588 func (x *VMDesired) Reset() {
589 *x = VMDesired{}
590 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7]
591 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
592 ms.StoreMessageInfo(mi)
593 }
594
595 func (x *VMDesired) String() string {
596 return protoimpl.X.MessageStringOf(x)
597 }
598
599 func (*VMDesired) ProtoMessage() {}
600
601 func (x *VMDesired) ProtoReflect() protoreflect.Message {
602 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7]
603 if x != nil {
604 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
605 if ms.LoadMessageInfo() == nil {
606 ms.StoreMessageInfo(mi)
607 }
608 return ms
609 }
610 return mi.MessageOf(x)
611 }
612
613 // Deprecated: Use VMDesired.ProtoReflect.Descriptor instead.
614 func (*VMDesired) Descriptor() ([]byte, []int) {
615 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{7}
616 }
617
618 func (x *VMDesired) GetVmId() string {
619 if x != nil {
620 return x.VmId
621 }
622 return ""
623 }
624
625 func (x *VMDesired) GetName() string {
626 if x != nil {
627 return x.Name
628 }
629 return ""
630 }
631
632 func (x *VMDesired) GetImageUrl() string {
633 if x != nil {
634 return x.ImageUrl
635 }
636 return ""
637 }
638
639 func (x *VMDesired) GetImageSha256() string {
640 if x != nil {
641 return x.ImageSha256
642 }
643 return ""
644 }
645
646 func (x *VMDesired) GetCloudInit() string {
647 if x != nil {
648 return x.CloudInit
649 }
650 return ""
651 }
652
653 func (x *VMDesired) GetVcpus() int64 {
654 if x != nil {
655 return x.Vcpus
656 }
657 return 0
658 }
659
660 func (x *VMDesired) GetMemMb() int64 {
661 if x != nil {
662 return x.MemMb
663 }
664 return 0
665 }
666
667 func (x *VMDesired) GetDiskGb() int64 {
668 if x != nil {
669 return x.DiskGb
670 }
671 return 0
672 }
673
674 func (x *VMDesired) GetPersistent() bool {
675 if x != nil {
676 return x.Persistent
677 }
678 return false
679 }
680
681 func (x *VMDesired) GetPowerState() string {
682 if x != nil {
683 return x.PowerState
684 }
685 return ""
686 }
687
688 func (x *VMDesired) GetTombstoned() bool {
689 if x != nil {
690 return x.Tombstoned
691 }
692 return false
693 }
694
695 func (x *VMDesired) GetSshAuthorizedKey() string {
696 if x != nil {
697 return x.SshAuthorizedKey
698 }
699 return ""
700 }
701
702 type DesiredStateSnapshot struct {
703 state protoimpl.MessageState `protogen:"open.v1"`
704 Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen
705 Vms []*VMDesired `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned
706 unknownFields protoimpl.UnknownFields
707 sizeCache protoimpl.SizeCache
708 }
709
710 func (x *DesiredStateSnapshot) Reset() {
711 *x = DesiredStateSnapshot{}
712 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8]
713 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
714 ms.StoreMessageInfo(mi)
715 }
716
717 func (x *DesiredStateSnapshot) String() string {
718 return protoimpl.X.MessageStringOf(x)
719 }
720
721 func (*DesiredStateSnapshot) ProtoMessage() {}
722
723 func (x *DesiredStateSnapshot) ProtoReflect() protoreflect.Message {
724 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8]
725 if x != nil {
726 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
727 if ms.LoadMessageInfo() == nil {
728 ms.StoreMessageInfo(mi)
729 }
730 return ms
731 }
732 return mi.MessageOf(x)
733 }
734
735 // Deprecated: Use DesiredStateSnapshot.ProtoReflect.Descriptor instead.
736 func (*DesiredStateSnapshot) Descriptor() ([]byte, []int) {
737 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{8}
738 }
739
740 func (x *DesiredStateSnapshot) GetEpoch() uint64 {
741 if x != nil {
742 return x.Epoch
743 }
744 return 0
745 }
746
747 func (x *DesiredStateSnapshot) GetVms() []*VMDesired {
748 if x != nil {
749 return x.Vms
750 }
751 return nil
752 }
753
754 var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor
755
756 const file_proto_eitri_v1_sync_proto_rawDesc = "" +
757 "\n" +
758 "\x19proto/eitri/v1/sync.proto\x12\beitri.v1\"u\n" +
759 "\fAgentMessage\x12'\n" +
760 "\x05hello\x18\x01 \x01(\v2\x0f.eitri.v1.HelloH\x00R\x05hello\x125\n" +
761 "\x06report\x18\x02 \x01(\v2\x1b.eitri.v1.ActualStateReportH\x00R\x06reportB\x05\n" +
762 "\x03msg\"T\n" +
763 "\rServerMessage\x12<\n" +
764 "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshotB\x05\n" +
765 "\x03msg\"\x9b\x02\n" +
766 "\x05Hello\x12\x17\n" +
767 "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" +
768 "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x0e\n" +
769 "\x02os\x18\x03 \x01(\tR\x02os\x12\x12\n" +
770 "\x04arch\x18\x04 \x01(\tR\x04arch\x12 \n" +
771 "\vprovisioner\x18\x05 \x01(\tR\vprovisioner\x12\x1f\n" +
772 "\vbridge_cidr\x18\x06 \x01(\tR\n" +
773 "bridgeCidr\x12&\n" +
774 "\x0flast_seen_epoch\x18\a \x01(\x04R\rlastSeenEpoch\x12.\n" +
775 "\bcapacity\x18\b \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12\x1e\n" +
776 "\n" +
777 "credential\x18\t \x01(\tR\n" +
778 "credential\"P\n" +
779 "\bCapacity\x12\x14\n" +
780 "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" +
781 "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" +
782 "\adisk_gb\x18\x03 \x01(\x03R\x06diskGb\"z\n" +
783 "\bActualVM\x12\x13\n" +
784 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" +
785 "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" +
786 "\x05phase\x18\x03 \x01(\tR\x05phase\x12\x0e\n" +
787 "\x02ip\x18\x04 \x01(\tR\x02ip\x12\x1d\n" +
788 "\n" +
789 "last_error\x18\x05 \x01(\tR\tlastError\"\x81\x01\n" +
790 "\rQuarantinedVM\x12\x13\n" +
791 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" +
792 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" +
793 "\vvmspec_json\x18\x03 \x01(\fR\n" +
794 "vmspecJson\x12&\n" +
795 "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\x93\x02\n" +
796 "\x11ActualStateReport\x12$\n" +
797 "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" +
798 "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" +
799 "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" +
800 "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" +
801 "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" +
802 "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\xe8\x02\n" +
803 "\tVMDesired\x12\x13\n" +
804 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" +
805 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
806 "\timage_url\x18\x03 \x01(\tR\bimageUrl\x12!\n" +
807 "\fimage_sha256\x18\x04 \x01(\tR\vimageSha256\x12\x1d\n" +
808 "\n" +
809 "cloud_init\x18\x05 \x01(\tR\tcloudInit\x12\x14\n" +
810 "\x05vcpus\x18\x06 \x01(\x03R\x05vcpus\x12\x15\n" +
811 "\x06mem_mb\x18\a \x01(\x03R\x05memMb\x12\x17\n" +
812 "\adisk_gb\x18\b \x01(\x03R\x06diskGb\x12\x1e\n" +
813 "\n" +
814 "persistent\x18\t \x01(\bR\n" +
815 "persistent\x12\x1f\n" +
816 "\vpower_state\x18\n" +
817 " \x01(\tR\n" +
818 "powerState\x12\x1e\n" +
819 "\n" +
820 "tombstoned\x18\v \x01(\bR\n" +
821 "tombstoned\x12,\n" +
822 "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\"S\n" +
823 "\x14DesiredStateSnapshot\x12\x14\n" +
824 "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" +
825 "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vmsB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3"
826
827 var (
828 file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once
829 file_proto_eitri_v1_sync_proto_rawDescData []byte
830 )
831
832 func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte {
833 file_proto_eitri_v1_sync_proto_rawDescOnce.Do(func() {
834 file_proto_eitri_v1_sync_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)))
835 })
836 return file_proto_eitri_v1_sync_proto_rawDescData
837 }
838
839 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
840 var file_proto_eitri_v1_sync_proto_goTypes = []any{
841 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage
842 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage
843 (*Hello)(nil), // 2: eitri.v1.Hello
844 (*Capacity)(nil), // 3: eitri.v1.Capacity
845 (*ActualVM)(nil), // 4: eitri.v1.ActualVM
846 (*QuarantinedVM)(nil), // 5: eitri.v1.QuarantinedVM
847 (*ActualStateReport)(nil), // 6: eitri.v1.ActualStateReport
848 (*VMDesired)(nil), // 7: eitri.v1.VMDesired
849 (*DesiredStateSnapshot)(nil), // 8: eitri.v1.DesiredStateSnapshot
850 }
851 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
852 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello
853 6, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport
854 8, // 2: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot
855 3, // 3: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity
856 4, // 4: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM
857 5, // 5: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM
858 3, // 6: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity
859 7, // 7: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired
860 8, // [8:8] is the sub-list for method output_type
861 8, // [8:8] is the sub-list for method input_type
862 8, // [8:8] is the sub-list for extension type_name
863 8, // [8:8] is the sub-list for extension extendee
864 0, // [0:8] is the sub-list for field type_name
865 }
866
867 func init() { file_proto_eitri_v1_sync_proto_init() }
868 func file_proto_eitri_v1_sync_proto_init() {
869 if File_proto_eitri_v1_sync_proto != nil {
870 return
871 }
872 file_proto_eitri_v1_sync_proto_msgTypes[0].OneofWrappers = []any{
873 (*AgentMessage_Hello)(nil),
874 (*AgentMessage_Report)(nil),
875 }
876 file_proto_eitri_v1_sync_proto_msgTypes[1].OneofWrappers = []any{
877 (*ServerMessage_Snapshot)(nil),
878 }
879 type x struct{}
880 out := protoimpl.TypeBuilder{
881 File: protoimpl.DescBuilder{
882 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
883 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)),
884 NumEnums: 0,
885 NumMessages: 9,
886 NumExtensions: 0,
887 NumServices: 0,
888 },
889 GoTypes: file_proto_eitri_v1_sync_proto_goTypes,
890 DependencyIndexes: file_proto_eitri_v1_sync_proto_depIdxs,
891 MessageInfos: file_proto_eitri_v1_sync_proto_msgTypes,
892 }.Build()
893 File_proto_eitri_v1_sync_proto = out.File
894 file_proto_eitri_v1_sync_proto_goTypes = nil
895 file_proto_eitri_v1_sync_proto_depIdxs = nil
896 }
internal/server/api/allocation_api_test.go
Old New
@@ -0,0 +1,34 @@
1 package api
2
3 import (
4 "net/http"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 // TestHostResponseIncludesAllocated verifies the host wire shape carries
12 // server-computed allocation summed from the host's live VMs.
13 func TestHostResponseIncludesAllocated(t *testing.T) {
14 ts, _, _ := apiServer(t)
15 out := enroll(t, ts)
16 hostID := out["host_id"]
17
18 mk := func(vcpus, mem, disk int) {
19 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{
20 "host_id": hostID, "vcpus": vcpus, "mem_mb": mem, "disk_gb": disk,
21 })
22 require.Equal(t, http.StatusCreated, resp.StatusCode)
23 }
24 mk(2, 2048, 10)
25 mk(1, 1024, 5)
26
27 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil))
28 require.Len(t, hosts, 1)
29 alloc, ok := hosts[0]["allocated"].(map[string]any)
30 require.True(t, ok, "host response must contain allocated object")
31 assert.Equal(t, float64(3), alloc["vcpus"])
32 assert.Equal(t, float64(3072), alloc["mem_mb"])
33 assert.Equal(t, float64(15), alloc["disk_gb"])
34 }
internal/server/api/api.go
Old New
@@ -0,0 +1,486 @@
1 // Package api implements the admin REST API and the unauthenticated enrollment endpoint.
2 package api
3
4 import (
5 "context"
6 "crypto/sha256"
7 "crypto/subtle"
8 "database/sql"
9 "encoding/json"
10 "errors"
11 "net/http"
12 "regexp"
13 "strings"
14 "time"
15
16 "github.com/a73x/eitri/internal/server/api/types"
17 "github.com/a73x/eitri/internal/server/hosttoken"
18 "github.com/a73x/eitri/internal/server/hub"
19 "github.com/a73x/eitri/internal/server/registry"
20 "github.com/a73x/eitri/internal/server/store"
21 )
22
23 // rfc1123Label matches valid RFC-1123 DNS label names.
24 // Rules: lowercase alphanum start/end, lowercase alphanum or hyphen in between,
25 // max 63 characters total.
26 var rfc1123Label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
27
28 // sha256Hex matches exactly 64 lowercase hex characters.
29 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)
30
31 // DefaultImage is the image applied to one-click VM creates.
32 type DefaultImage struct {
33 URL string
34 SHA256 string
35 }
36
37 // Config holds static configuration for the API server.
38 type Config struct {
39 AdminToken string
40 HostSecret []byte
41 DefaultImage DefaultImage
42 ServerCertSHA256 string
43 }
44
45 // API is the HTTP handler container.
46 type API struct {
47 cfg Config
48 st *store.Store
49 reg *registry.Registry
50 hub *hub.Hub
51 notif *notifier
52 }
53
54 // New constructs an API.
55 func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API {
56 return &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier()}
57 }
58
59 // Handler returns the ServeMux with all routes registered from the declared
60 // route table (routes.go) — the table is the single enumerable surface, shared
61 // with the OpenAPI generator.
62 func (a *API) Handler() http.Handler {
63 mux := http.NewServeMux()
64 admin := http.NewServeMux()
65 for _, rt := range routeTable {
66 h := rt.handler
67 hf := func(w http.ResponseWriter, r *http.Request) { h(a, w, r) }
68 pattern := rt.Method + " " + rt.Path
69 if rt.Auth == AuthAdmin {
70 admin.HandleFunc(pattern, hf)
71 } else {
72 mux.HandleFunc(pattern, hf)
73 }
74 }
75 mux.Handle("/api/v1/", a.adminAuth(admin))
76 return mux
77 }
78
79 // StartBackground launches the decommission sweeper, which finalizes drained
80 // decommissioning hosts. It returns when ctx is cancelled.
81 func (a *API) StartBackground(ctx context.Context) {
82 t := time.NewTicker(2 * time.Second)
83 defer t.Stop()
84 for {
85 select {
86 case <-ctx.Done():
87 return
88 case <-t.C:
89 if a.sweepDecommissioned() {
90 a.notif.notify()
91 }
92 }
93 }
94 }
95
96 // sweepDecommissioned finalizes any decommissioning host with no VM rows left
97 // (fully drained). Returns true if it removed at least one host.
98 func (a *API) sweepDecommissioned() bool {
99 hosts, err := a.st.ListHosts()
100 if err != nil {
101 return false
102 }
103 removed := false
104 for _, h := range hosts {
105 if h.Status != "decommissioning" {
106 continue
107 }
108 n, err := a.st.HostVMCount(h.ID)
109 if err != nil || n > 0 {
110 continue
111 }
112 if a.st.RemoveHost(h.ID) == nil {
113 removed = true
114 }
115 }
116 return removed
117 }
118
119 // adminAuth returns middleware that requires a valid admin bearer token.
120 // Both sides are hashed before comparison so that constant-time compare
121 // genuinely prevents both value and length leaks.
122 func (a *API) adminAuth(next http.Handler) http.Handler {
123 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124 // Reject immediately when no token is configured — avoids accepting
125 // every request against an unconfigured server.
126 if a.cfg.AdminToken == "" {
127 http.Error(w, "unauthorized", http.StatusUnauthorized)
128 return
129 }
130 if !constantTimeTokenMatch(r.Header.Get("Authorization"), "Bearer "+a.cfg.AdminToken) {
131 http.Error(w, "unauthorized", http.StatusUnauthorized)
132 return
133 }
134 next.ServeHTTP(w, r)
135 })
136 }
137
138 // constantTimeTokenMatch reports whether got equals want without leaking the
139 // value or length via timing — it compares fixed-width SHA-256 digests. Callers
140 // pass the fully-built strings (the wire formats differ: a "Bearer "-prefixed
141 // header vs. a bare query token).
142 func constantTimeTokenMatch(got, want string) bool {
143 gotSum := sha256.Sum256([]byte(got))
144 wantSum := sha256.Sum256([]byte(want))
145 return subtle.ConstantTimeCompare(gotSum[:], wantSum[:]) == 1
146 }
147
148 // writeJSON encodes v as JSON with the correct Content-Type header and status.
149 func writeJSON(w http.ResponseWriter, status int, v any) {
150 w.Header().Set("Content-Type", "application/json")
151 w.WriteHeader(status)
152 json.NewEncoder(w).Encode(v) //nolint:errcheck
153 }
154
155 // httpError writes a plain-text error with the given status. Thin wrapper over
156 // http.Error kept for symmetry with writeJSON; the message is always explicit.
157 func httpError(w http.ResponseWriter, msg string, status int) {
158 http.Error(w, msg, status)
159 }
160
161 // decodeJSON decodes the request body into v, reporting a 400 with the standard
162 // "bad request" body on failure. Returns false when it has already written the
163 // response (caller must return).
164 func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
165 if err := json.NewDecoder(r.Body).Decode(v); err != nil {
166 httpError(w, "bad request", http.StatusBadRequest)
167 return false
168 }
169 return true
170 }
171
172 // --- enrollment ---
173
174 // validOverlays is the set of accepted overlay values at enrollment.
175 var validOverlays = map[string]bool{"tailscale": true, "none": true}
176
177 func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
178 var req types.EnrollRequest
179 if !decodeJSON(w, r, &req) {
180 return
181 }
182 // Default overlay to "tailscale" when omitted; reject unknown values.
183 if req.Overlay == "" {
184 req.Overlay = "tailscale"
185 }
186 if !validOverlays[req.Overlay] {
187 httpError(w, "overlay must be one of: tailscale, none", http.StatusBadRequest)
188 return
189 }
190 host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, req.Overlay)
191 if err != nil {
192 httpError(w, "forbidden", http.StatusForbidden)
193 return
194 }
195 cred := hosttoken.Mint(a.cfg.HostSecret, host.ID)
196 writeJSON(w, http.StatusCreated, types.EnrollResponse{
197 BridgeCIDR: host.BridgeCIDR,
198 Credential: cred,
199 HostID: host.ID,
200 Overlay: host.Overlay,
201 ServerCertSHA256: a.cfg.ServerCertSHA256,
202 })
203 }
204
205 func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) {
206 tok, err := a.st.CreateEnrollmentToken()
207 if err != nil {
208 httpError(w, "internal error", http.StatusInternalServerError)
209 return
210 }
211 writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok})
212 }
213
214 // --- hosts ---
215
216 // toHostResponse merges a durable host row with its live registry state into
217 // the wire shape (types.Host).
218 func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Alloc) types.Host {
219 hr := types.Host{
220 ID: h.ID,
221 Name: h.Name,
222 OS: h.OS,
223 Arch: h.Arch,
224 Provisioner: h.Provisioner,
225 Overlay: h.Overlay,
226 BridgeCIDR: h.BridgeCIDR,
227 Status: h.Status,
228 EnrolledAt: h.EnrolledAt,
229 Allocated: types.Capacity{VCPUs: alloc.VCPUs, MemMB: alloc.MemMB, DiskGB: alloc.DiskGB},
230 }
231 if ok {
232 hr.Online = st.Online
233 hr.Capacity = types.Capacity{
234 VCPUs: st.Capacity.VCPUs,
235 MemMB: st.Capacity.MemMB,
236 DiskGB: st.Capacity.DiskGB,
237 }
238 }
239 return hr
240 }
241
242 // snapshotHosts builds the wire host list (durable host rows merged with live
243 // registry state and server-computed allocation). Shared by GET /hosts and SSE.
244 func (a *API) snapshotHosts() ([]types.Host, error) {
245 hosts, err := a.st.ListHosts()
246 if err != nil {
247 return nil, err
248 }
249 alloc, err := a.st.AllocatedByHost()
250 if err != nil {
251 return nil, err
252 }
253 out := make([]types.Host, len(hosts))
254 for i, h := range hosts {
255 st, ok := a.reg.Get(h.ID)
256 out[i] = toHostResponse(h, st, ok, alloc[h.ID])
257 }
258 return out, nil
259 }
260
261 func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) {
262 out, err := a.snapshotHosts()
263 if err != nil {
264 httpError(w, "internal error", http.StatusInternalServerError)
265 return
266 }
267 writeJSON(w, http.StatusOK, out)
268 }
269
270 // --- VMs ---
271
272 // toVMResponse merges a durable VM row with live agent-reported actual-state
273 // into the wire shape (types.VM).
274 func toVMResponse(vm store.VM, actualPower, phase string) types.VM {
275 return types.VM{
276 ID: vm.ID,
277 HostID: vm.HostID,
278 Name: vm.Name,
279 ImageURL: vm.ImageURL,
280 VCPUs: vm.VCPUs,
281 MemMB: vm.MemMB,
282 DiskGB: vm.DiskGB,
283 Persistent: vm.Persistent,
284 PowerState: vm.PowerState,
285 Status: vm.Status,
286 LastError: vm.LastError,
287 AssignedIP: vm.AssignedIP,
288 CreatedAt: vm.CreatedAt,
289 Deleted: vm.DeletedAt != nil,
290 ActualPower: actualPower,
291 Phase: phase,
292 }
293 }
294
295 // snapshotVMs builds the wire VM list (durable VM rows merged with live
296 // actual-state from the registry). Shared by GET /vms and the SSE stream.
297 func (a *API) snapshotVMs() ([]types.VM, error) {
298 vms, err := a.st.ListVMs()
299 if err != nil {
300 return nil, err
301 }
302 out := make([]types.VM, len(vms))
303 for i, vm := range vms {
304 var actualPower, phase string
305 if st, ok := a.reg.Get(vm.HostID); ok {
306 for _, av := range st.Report.VMs {
307 if av.VMID == vm.ID {
308 actualPower = av.Power
309 phase = av.Phase
310 break
311 }
312 }
313 }
314 out[i] = toVMResponse(vm, actualPower, phase)
315 }
316 return out, nil
317 }
318
319 func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) {
320 out, err := a.snapshotVMs()
321 if err != nil {
322 httpError(w, "internal error", http.StatusInternalServerError)
323 return
324 }
325 writeJSON(w, http.StatusOK, out)
326 }
327
328 // applyVMDefaults fills one-click defaults in place. It returns an error message
329 // and HTTP status (msg=="" when ok) for the image-pairing rule, which is a
330 // validation, not a default.
331 func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) {
332 if req.Name == "" {
333 req.Name = "sandbox-" + store.RandHex(3)
334 }
335 // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty.
336 if req.ImageURL == "" && req.ImageSHA256 == "" {
337 req.ImageURL = a.cfg.DefaultImage.URL
338 req.ImageSHA256 = a.cfg.DefaultImage.SHA256
339 } else if req.ImageURL == "" || req.ImageSHA256 == "" {
340 return "image_url and image_sha256 must be provided together", http.StatusBadRequest
341 }
342 if req.VCPUs == 0 {
343 req.VCPUs = 2
344 }
345 if req.MemMB == 0 {
346 req.MemMB = 2048
347 }
348 if req.DiskGB == 0 {
349 req.DiskGB = 10
350 }
351 if req.PowerState == "" {
352 req.PowerState = "running"
353 }
354 return "", 0
355 }
356
357 // validateCreateVM checks the post-defaults request, returning (msg, status) on
358 // failure or ("", 0) when valid. Messages are byte-identical to the prior inline
359 // checks so response bodies do not change.
360 func validateCreateVM(req *types.CreateVMRequest) (string, int) {
361 // Name becomes the guest hostname and is embedded into cloud-init YAML, so it
362 // must be a valid RFC-1123 DNS label.
363 if !rfc1123Label.MatchString(req.Name) {
364 return "invalid name", http.StatusBadRequest
365 }
366 // SSH key must be single-line: a newline would allow YAML injection into the
367 // cloud-init user-data that embeds the key verbatim.
368 if strings.ContainsAny(req.SSHAuthorizedKey, "\n\r") {
369 return "ssh_authorized_key must be single-line", http.StatusBadRequest
370 }
371 // Exactly 64 lowercase hex digits — catches a misconfigured server default
372 // (e.g. "pinned") at create time with a clear error instead of a later mismatch.
373 if !sha256Hex.MatchString(req.ImageSHA256) {
374 return "invalid image_sha256", http.StatusBadRequest
375 }
376 return "", 0
377 }
378
379 func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
380 var req types.CreateVMRequest
381 if !decodeJSON(w, r, &req) {
382 return
383 }
384 if req.HostID == "" {
385 httpError(w, "host_id required", http.StatusBadRequest)
386 return
387 }
388
389 if msg, code := a.applyVMDefaults(&req); msg != "" {
390 httpError(w, msg, code)
391 return
392 }
393 if msg, code := validateCreateVM(&req); msg != "" {
394 httpError(w, msg, code)
395 return
396 }
397
398 // Generate ID here so we can return it.
399 id := store.RandHex(16)
400
401 vm := store.VM{
402 ID: id,
403 HostID: req.HostID,
404 Name: req.Name,
405 ImageURL: req.ImageURL,
406 ImageSHA256: req.ImageSHA256,
407 CloudInit: req.CloudInit,
408 SSHAuthorizedKey: req.SSHAuthorizedKey,
409 VCPUs: req.VCPUs,
410 MemMB: req.MemMB,
411 DiskGB: req.DiskGB,
412 Persistent: req.Persistent,
413 PowerState: req.PowerState,
414 }
415
416 if err := a.st.CreateVM(vm); err != nil {
417 switch {
418 case errors.Is(err, store.ErrNameTaken):
419 httpError(w, "name already in use", http.StatusConflict)
420 case errors.Is(err, store.ErrHostNotFound):
421 httpError(w, "unknown host_id", http.StatusBadRequest)
422 default:
423 httpError(w, "internal error", http.StatusInternalServerError)
424 }
425 return
426 }
427
428 a.hub.Poke(req.HostID)
429 a.notif.notify()
430 writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name})
431 }
432
433 func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) {
434 id := r.PathValue("id")
435 var req types.PatchVMRequest
436 if !decodeJSON(w, r, &req) {
437 return
438 }
439 if req.PowerState != "running" && req.PowerState != "stopped" {
440 httpError(w, "power_state must be running or stopped", http.StatusBadRequest)
441 return
442 }
443 if err := a.st.SetVMPower(id, req.PowerState); err != nil {
444 if err == sql.ErrNoRows {
445 httpError(w, "not found", http.StatusNotFound)
446 return
447 }
448 httpError(w, "internal error", http.StatusInternalServerError)
449 return
450 }
451 // Find owning host and poke.
452 a.pokeVMHost(id)
453 a.notif.notify()
454 w.WriteHeader(http.StatusNoContent)
455 }
456
457 func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) {
458 id := r.PathValue("id")
459 if err := a.st.TombstoneVM(id); err != nil {
460 if err == sql.ErrNoRows {
461 httpError(w, "not found", http.StatusNotFound)
462 return
463 }
464 httpError(w, "internal error", http.StatusInternalServerError)
465 return
466 }
467 // TombstoneVM keeps the row, so we can still scan for host_id.
468 a.pokeVMHost(id)
469 a.notif.notify()
470 w.WriteHeader(http.StatusNoContent)
471 }
472
473 // pokeVMHost scans ListVMs to find the owning host and pokes it.
474 // Phase 1 scale: linear scan is acceptable.
475 func (a *API) pokeVMHost(vmID string) {
476 vms, err := a.st.ListVMs()
477 if err != nil {
478 return
479 }
480 for _, vm := range vms {
481 if vm.ID == vmID {
482 a.hub.Poke(vm.HostID)
483 return
484 }
485 }
486 }
internal/server/api/api_test.go
Old New
@@ -0,0 +1,409 @@
1 package api
2
3 import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/server/hub"
13 "github.com/a73x/eitri/internal/server/registry"
14 "github.com/a73x/eitri/internal/server/store"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
18
19 // --- contract-pinning helpers ---
20
21 func decodeJSONKeys(t *testing.T, resp *http.Response) []map[string]any {
22 t.Helper()
23 var out []map[string]any
24 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
25 return out
26 }
27
28 // TestResponseJSONKeysAreSnakeCase pins the wire shape of GET /api/v1/hosts and
29 // GET /api/v1/vms: only snake_case keys allowed, PascalCase keys (from embedded
30 // structs) must be absent, and write-only fields must not appear.
31 func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
32 ts, _, _ := testServer(t)
33 out := enroll(t, ts)
34
35 // Create a VM so the list is non-empty.
36 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
37 map[string]any{"host_id": out["host_id"], "name": "test-vm"})
38 require.Equal(t, 201, resp.StatusCode)
39
40 t.Run("hosts", func(t *testing.T) {
41 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)
42 require.Equal(t, 200, resp.StatusCode)
43 items := decodeJSONKeys(t, resp)
44 require.Len(t, items, 1)
45 h := items[0]
46
47 // Required snake_case keys must be present.
48 for _, k := range []string{"id", "name", "os", "arch", "provisioner", "overlay", "bridge_cidr", "status", "enrolled_at", "online", "capacity"} {
49 assert.Contains(t, h, k, "host response must contain key %q", k)
50 }
51 // PascalCase keys from embedded store.Host must be absent.
52 for _, k := range []string{"ID", "Name", "OS", "Arch", "Provisioner", "Overlay", "BridgeCIDR", "Status", "EnrolledAt"} {
53 assert.NotContains(t, h, k, "host response must NOT contain PascalCase key %q", k)
54 }
55 // Capacity sub-object must use snake_case.
56 cap, ok := h["capacity"].(map[string]any)
57 require.True(t, ok, "capacity must be an object")
58 for _, k := range []string{"vcpus", "mem_mb", "disk_gb"} {
59 assert.Contains(t, cap, k, "capacity must contain key %q", k)
60 }
61 for _, k := range []string{"VCPUs", "MemMB", "DiskGB"} {
62 assert.NotContains(t, cap, k, "capacity must NOT contain PascalCase key %q", k)
63 }
64 })
65
66 t.Run("vms", func(t *testing.T) {
67 resp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)
68 require.Equal(t, 200, resp.StatusCode)
69 items := decodeJSONKeys(t, resp)
70 require.Len(t, items, 1)
71 v := items[0]
72
73 // Required snake_case keys must be present.
74 for _, k := range []string{
75 "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb",
76 "persistent", "power_state", "status", "last_error", "assigned_ip",
77 "created_at", "deleted", "actual_power", "phase",
78 } {
79 assert.Contains(t, v, k, "vm response must contain key %q", k)
80 }
81 // PascalCase keys from embedded store.VM must be absent.
82 for _, k := range []string{
83 "ID", "HostID", "Name", "ImageURL", "ImageSHA256", "CloudInit",
84 "VCPUs", "MemMB", "DiskGB", "Persistent", "PowerState",
85 "Status", "LastError", "AssignedIP", "SSHAuthorizedKey",
86 "CreatedAt", "DeletedAt",
87 } {
88 assert.NotContains(t, v, k, "vm response must NOT contain PascalCase key %q", k)
89 }
90 // Write-only fields must not be on the wire.
91 for _, k := range []string{"image_sha256", "cloud_init", "ssh_authorized_key"} {
92 assert.NotContains(t, v, k, "write-only field %q must not appear in response", k)
93 }
94 // deleted should be false (not tombstoned).
95 assert.Equal(t, false, v["deleted"])
96 })
97 }
98
99 // TestCreateVMDuplicateNameReturns409 pins that a duplicate live VM name returns
100 // 409 with no error details leaked.
101 func TestCreateVMDuplicateNameReturns409(t *testing.T) {
102 ts, _, _ := testServer(t)
103 out := enroll(t, ts)
104
105 resp1 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
106 map[string]any{"host_id": out["host_id"], "name": "clash"})
107 require.Equal(t, 201, resp1.StatusCode)
108
109 resp2 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
110 map[string]any{"host_id": out["host_id"], "name": "clash"})
111 assert.Equal(t, 409, resp2.StatusCode)
112
113 // Body must not leak raw error text.
114 var body map[string]any
115 json.NewDecoder(resp2.Body).Decode(&body)
116 bodyStr, _ := json.Marshal(body)
117 assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response")
118 }
119
120 // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400.
121 func TestCreateVMUnknownHostReturns400(t *testing.T) {
122 ts, _, _ := testServer(t)
123 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
124 map[string]any{"host_id": "deadbeef00000000000000000000000000000000", "name": "vm-orphan"})
125 assert.Equal(t, 400, resp.StatusCode)
126 }
127
128 func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) {
129 t.Helper()
130 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
131 require.NoError(t, err)
132 t.Cleanup(func() { st.Close() })
133 h := hub.New()
134 a := New(Config{
135 AdminToken: "admintok",
136 HostSecret: []byte("hostsecret"),
137 DefaultImage: DefaultImage{
138 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
139 SHA256: strings.Repeat("a", 64)},
140 }, st, registry.New(time.Now), h)
141 ts := httptest.NewServer(a.Handler())
142 t.Cleanup(ts.Close)
143 return ts, st, h
144 }
145
146 func do(t *testing.T, method, url, token string, body any) *http.Response {
147 t.Helper()
148 var buf bytes.Buffer
149 if body != nil {
150 require.NoError(t, json.NewEncoder(&buf).Encode(body))
151 }
152 req, _ := http.NewRequest(method, url, &buf)
153 if token != "" {
154 req.Header.Set("Authorization", "Bearer "+token)
155 }
156 resp, err := http.DefaultClient.Do(req)
157 require.NoError(t, err)
158 t.Cleanup(func() { resp.Body.Close() })
159 return resp
160 }
161
162 func enroll(t *testing.T, ts *httptest.Server) map[string]string {
163 t.Helper()
164 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil)
165 require.Equal(t, 201, resp.StatusCode)
166 var tok map[string]string
167 json.NewDecoder(resp.Body).Decode(&tok)
168 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
169 "token": tok["token"], "name": "host-a", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
170 require.Equal(t, 201, resp.StatusCode)
171 var out map[string]string
172 json.NewDecoder(resp.Body).Decode(&out)
173 return out // host_id, credential, bridge_cidr
174 }
175
176 // Fix 5: overlay field in enroll request/response tests.
177
178 func TestEnrollWithOverlayNone_PersistsAndReturnsOverlay(t *testing.T) {
179 ts, st, _ := testServer(t)
180
181 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil)
182 require.Equal(t, 201, resp.StatusCode)
183 var tok map[string]string
184 json.NewDecoder(resp.Body).Decode(&tok)
185
186 // Enroll with overlay=none.
187 resp2 := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
188 "token": tok["token"],
189 "name": "host-none",
190 "os": "linux",
191 "arch": "amd64",
192 "provisioner": "cloudhv",
193 "overlay": "none",
194 })
195 require.Equal(t, 201, resp2.StatusCode)
196 var enrollOut map[string]string
197 json.NewDecoder(resp2.Body).Decode(&enrollOut)
198 require.NotEmpty(t, enrollOut["host_id"])
199
200 // Verify persistence: list hosts, check overlay field.
201 hosts, err := st.ListHosts()
202 require.NoError(t, err)
203 var found bool
204 for _, h := range hosts {
205 if h.ID == enrollOut["host_id"] {
206 assert.Equal(t, "none", h.Overlay, "overlay must be persisted as 'none'")
207 found = true
208 }
209 }
210 assert.True(t, found, "enrolled host must appear in store")
211 }
212
213 func TestEnrollWithBogusOverlay_Returns400(t *testing.T) {
214 ts, _, _ := testServer(t)
215
216 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil)
217 require.Equal(t, 201, resp.StatusCode)
218 var tok map[string]string
219 json.NewDecoder(resp.Body).Decode(&tok)
220
221 resp2 := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
222 "token": tok["token"],
223 "name": "host-bad",
224 "os": "linux",
225 "arch": "amd64",
226 "provisioner": "cloudhv",
227 "overlay": "wireguard", // unsupported value
228 })
229 assert.Equal(t, 400, resp2.StatusCode, "bogus overlay must return 400")
230 }
231
232 func TestEnrollHostsResponseContainsOverlayField(t *testing.T) {
233 ts, _, _ := testServer(t)
234 out := enroll(t, ts)
235 _ = out
236
237 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)
238 require.Equal(t, 200, resp.StatusCode)
239 items := decodeJSONKeys(t, resp)
240 require.Len(t, items, 1)
241 h := items[0]
242 assert.Contains(t, h, "overlay", "hostResponse must include 'overlay' field")
243 assert.Equal(t, "tailscale", h["overlay"], "default overlay must be 'tailscale'")
244 }
245
246 func TestAdminAuthRequired(t *testing.T) {
247 ts, _, _ := testServer(t)
248 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode)
249 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "wrong", nil).StatusCode)
250 assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil).StatusCode)
251 }
252
253 func TestEnrollIssuesCredentialAndCIDR(t *testing.T) {
254 ts, _, _ := testServer(t)
255 out := enroll(t, ts)
256 assert.NotEmpty(t, out["host_id"])
257 assert.Contains(t, out["credential"], out["host_id"]+".")
258 assert.Equal(t, "10.77.1.0/24", out["bridge_cidr"])
259 }
260
261 func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
262 ts, st, h := testServer(t)
263 out := enroll(t, ts)
264 poked, cancel := h.Subscribe(out["host_id"])
265 defer cancel()
266
267 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
268 map[string]any{"host_id": out["host_id"]}) // one-click: everything else defaulted
269 require.Equal(t, 201, resp.StatusCode)
270
271 vms, _ := st.ListVMs()
272 require.Len(t, vms, 1)
273 assert.Equal(t, int64(2), vms[0].VCPUs)
274 assert.Equal(t, int64(2048), vms[0].MemMB)
275 assert.Equal(t, int64(10), vms[0].DiskGB)
276 assert.False(t, vms[0].Persistent, "one-click default is ephemeral")
277 assert.Equal(t, "running", vms[0].PowerState)
278 assert.NotEmpty(t, vms[0].Name)
279 assert.Contains(t, vms[0].ImageURL, "ubuntu")
280 select {
281 case <-poked:
282 default:
283 t.Fatal("create must poke the host's stream")
284 }
285 }
286
287 func TestDeleteTombstones(t *testing.T) {
288 ts, st, _ := testServer(t)
289 out := enroll(t, ts)
290 do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": out["host_id"], "name": "doomed"})
291 vms, _ := st.ListVMs()
292 resp := do(t, "DELETE", ts.URL+"/api/v1/vms/"+vms[0].ID, "admintok", nil)
293 assert.Equal(t, 204, resp.StatusCode)
294 vms, _ = st.ListVMs()
295 assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps")
296 }
297
298 // --- C1: input-validation tests ---
299
300 func TestCreateVMNameValidation(t *testing.T) {
301 ts, _, _ := testServer(t)
302 out := enroll(t, ts)
303
304 tests := []struct {
305 name string
306 vmName string
307 wantStatus int
308 }{
309 {"yaml injection via newline", "evil\nruncmd:", 400},
310 {"name with spaces", "Has Spaces", 400},
311 {"name too long (64 chars)", "a123456789012345678901234567890123456789012345678901234567890123", 400},
312 {"valid name", "my-vm-2", 201},
313 {"single char", "a", 201},
314 {"starts with digit", "3vm", 201},
315 }
316 for _, tc := range tests {
317 t.Run(tc.name, func(t *testing.T) {
318 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
319 map[string]any{"host_id": out["host_id"], "name": tc.vmName})
320 assert.Equal(t, tc.wantStatus, resp.StatusCode)
321 })
322 }
323 }
324
325 func TestCreateVMSSHKeyValidation(t *testing.T) {
326 ts, _, _ := testServer(t)
327 out := enroll(t, ts)
328
329 t.Run("ssh key with newline is rejected", func(t *testing.T) {
330 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
331 map[string]any{
332 "host_id": out["host_id"],
333 "name": "safe-vm",
334 "ssh_authorized_key": "ssh-ed25519 AAAA\ninjected: yaml",
335 })
336 assert.Equal(t, 400, resp.StatusCode)
337 })
338
339 t.Run("ssh key with carriage return is rejected", func(t *testing.T) {
340 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
341 map[string]any{
342 "host_id": out["host_id"],
343 "name": "safe-vm-2",
344 "ssh_authorized_key": "ssh-ed25519 AAAA\rinjected",
345 })
346 assert.Equal(t, 400, resp.StatusCode)
347 })
348
349 t.Run("valid single-line ssh key is accepted", func(t *testing.T) {
350 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
351 map[string]any{
352 "host_id": out["host_id"],
353 "name": "valid-vm",
354 "ssh_authorized_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@host",
355 })
356 assert.Equal(t, 201, resp.StatusCode)
357 })
358 }
359
360 // TestCreateVMImageSHAAdmission pins that image_url and image_sha256 must be
361 // provided together and that sha256 must be 64 lowercase hex characters.
362 func TestCreateVMImageSHAAdmission(t *testing.T) {
363 ts, _, _ := testServer(t)
364 out := enroll(t, ts)
365 validSHA := strings.Repeat("b", 64)
366 validURL := "https://example.com/custom.img"
367
368 t.Run("custom url without sha is rejected", func(t *testing.T) {
369 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
370 map[string]any{
371 "host_id": out["host_id"],
372 "name": "bad-url-no-sha",
373 "image_url": validURL,
374 })
375 assert.Equal(t, 400, resp.StatusCode)
376 })
377
378 t.Run("sha without url is rejected", func(t *testing.T) {
379 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
380 map[string]any{
381 "host_id": out["host_id"],
382 "name": "bad-sha-no-url",
383 "image_sha256": validSHA,
384 })
385 assert.Equal(t, 400, resp.StatusCode)
386 })
387
388 t.Run("bad-format sha is rejected", func(t *testing.T) {
389 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
390 map[string]any{
391 "host_id": out["host_id"],
392 "name": "bad-sha-format",
393 "image_url": validURL,
394 "image_sha256": "notahexstring",
395 })
396 assert.Equal(t, 400, resp.StatusCode)
397 })
398
399 t.Run("both custom url and valid sha are accepted", func(t *testing.T) {
400 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
401 map[string]any{
402 "host_id": out["host_id"],
403 "name": "good-custom-image",
404 "image_url": validURL,
405 "image_sha256": validSHA,
406 })
407 assert.Equal(t, 201, resp.StatusCode)
408 })
409 }
internal/server/api/decommission_api_test.go
Old New
@@ -0,0 +1,112 @@
1 package api
2
3 import (
4 "bufio"
5 "context"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/server/hub"
13 "github.com/a73x/eitri/internal/server/registry"
14 "github.com/a73x/eitri/internal/server/store"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
18
19 // apiServer is like testServer but also returns the *API so tests can drive the
20 // background sweeper deterministically.
21 func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
22 t.Helper()
23 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
24 require.NoError(t, err)
25 t.Cleanup(func() { st.Close() })
26 a := New(Config{
27 AdminToken: "admintok",
28 HostSecret: []byte("hostsecret"),
29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)},
30 }, st, registry.New(time.Now), hub.New())
31 ts := httptest.NewServer(a.Handler())
32 t.Cleanup(ts.Close)
33 return ts, a, st
34 }
35
36 func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) {
37 ts, a, _ := apiServer(t)
38 out := enroll(t, ts)
39 hostID := out["host_id"]
40
41 // Decommission a host with no VMs.
42 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil)
43 require.Equal(t, http.StatusAccepted, resp.StatusCode)
44
45 // It now reports decommissioning.
46 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil))
47 require.Len(t, hosts, 1)
48 assert.Equal(t, "decommissioning", hosts[0]["status"])
49
50 // The sweeper finalizes it (no VMs => drained).
51 assert.True(t, a.sweepDecommissioned(), "sweep should remove the drained host")
52 hosts = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil))
53 assert.Empty(t, hosts, "host should be gone after sweep")
54 }
55
56 func TestDecommissionUnknownHostIs404(t *testing.T) {
57 ts, _, _ := apiServer(t)
58 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", "admintok", nil)
59 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
60 }
61
62 func TestSweepLeavesHostWithVMs(t *testing.T) {
63 ts, a, _ := apiServer(t)
64 out := enroll(t, ts)
65 hostID := out["host_id"]
66 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-a"})
67 require.Equal(t, http.StatusCreated, resp.StatusCode)
68
69 do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil)
70 // VM row still present (not yet reaped) => sweep must not remove the host.
71 assert.False(t, a.sweepDecommissioned(), "host with VM rows must not be swept")
72 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil))
73 require.Len(t, hosts, 1)
74 }
75
76 func TestEventsStreamSendsSnapshot(t *testing.T) {
77 ts, _, _ := apiServer(t)
78 enroll(t, ts)
79
80 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
81 defer cancel()
82 req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?token=admintok", nil)
83 resp, err := http.DefaultClient.Do(req)
84 require.NoError(t, err)
85 defer resp.Body.Close()
86 require.Equal(t, http.StatusOK, resp.StatusCode)
87 assert.Contains(t, resp.Header.Get("Content-Type"), "text/event-stream")
88
89 // Read the initial event: must be a state event whose data has hosts + vms.
90 sc := bufio.NewScanner(resp.Body)
91 var sawState, sawData bool
92 for sc.Scan() {
93 line := sc.Text()
94 if line == "event: state" {
95 sawState = true
96 }
97 if strings.HasPrefix(line, "data: ") {
98 sawData = true
99 assert.Contains(t, line, `"hosts"`)
100 assert.Contains(t, line, `"vms"`)
101 break
102 }
103 }
104 assert.True(t, sawState, "should receive a state event")
105 assert.True(t, sawData, "should receive snapshot data")
106 }
107
108 func TestEventsRejectsBadToken(t *testing.T) {
109 ts, _, _ := apiServer(t)
110 resp := do(t, "GET", ts.URL+"/api/v1/events?token=wrong", "", nil)
111 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
112 }
internal/server/api/events.go
Old New
@@ -0,0 +1,110 @@
1 package api
2
3 import (
4 "bytes"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "time"
11
12 "github.com/a73x/eitri/internal/server/api/types"
13 )
14
15 // handleDecommissionHost begins graceful host decommission: its VMs are
16 // tombstoned and reaped, then the sweeper removes the host and frees its CIDR.
17 func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
18 id := r.PathValue("id")
19 switch err := a.st.DecommissionHost(id); {
20 case errors.Is(err, sql.ErrNoRows):
21 httpError(w, "host not found", http.StatusNotFound)
22 return
23 case err != nil:
24 httpError(w, "internal error", http.StatusInternalServerError)
25 return
26 }
27 a.notif.notify()
28 w.WriteHeader(http.StatusAccepted)
29 }
30
31 // handleEvents streams the fleet snapshot as Server-Sent Events. It pushes on
32 // every desired-state change (via the notifier) and re-checks on a 1s tick to
33 // catch agent-reported actual-state changes, sending only when the snapshot
34 // actually changed. A periodic comment keeps the connection alive.
35 func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
36 if a.cfg.AdminToken == "" {
37 http.Error(w, "unauthorized", http.StatusUnauthorized)
38 return
39 }
40 if !constantTimeTokenMatch(r.URL.Query().Get("token"), a.cfg.AdminToken) {
41 http.Error(w, "unauthorized", http.StatusUnauthorized)
42 return
43 }
44 flusher, ok := w.(http.Flusher)
45 if !ok {
46 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
47 return
48 }
49
50 w.Header().Set("Content-Type", "text/event-stream")
51 w.Header().Set("Cache-Control", "no-cache")
52 w.Header().Set("Connection", "keep-alive")
53
54 wake, unsub := a.notif.subscribe()
55 defer unsub()
56
57 tick := time.NewTicker(time.Second)
58 defer tick.Stop()
59 heartbeat := time.NewTicker(15 * time.Second)
60 defer heartbeat.Stop()
61
62 var last []byte
63 sendIfChanged := func() bool {
64 payload, err := a.marshalSnapshot()
65 if err != nil || bytes.Equal(payload, last) {
66 return err == nil
67 }
68 last = payload
69 if _, err := fmt.Fprintf(w, "event: state\ndata: %s\n\n", payload); err != nil {
70 return false
71 }
72 flusher.Flush()
73 return true
74 }
75
76 sendIfChanged() // initial snapshot
77 ctx := r.Context()
78 for {
79 select {
80 case <-ctx.Done():
81 return
82 case <-wake:
83 if !sendIfChanged() {
84 return
85 }
86 case <-tick.C:
87 if !sendIfChanged() {
88 return
89 }
90 case <-heartbeat.C:
91 if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
92 return
93 }
94 flusher.Flush()
95 }
96 }
97 }
98
99 // marshalSnapshot builds and JSON-encodes the current fleet snapshot.
100 func (a *API) marshalSnapshot() ([]byte, error) {
101 hosts, err := a.snapshotHosts()
102 if err != nil {
103 return nil, err
104 }
105 vms, err := a.snapshotVMs()
106 if err != nil {
107 return nil, err
108 }
109 return json.Marshal(types.StateSnapshot{Hosts: hosts, VMs: vms})
110 }
internal/server/api/notifier.go
Old New
@@ -0,0 +1,42 @@
1 package api
2
3 import "sync"
4
5 // notifier is a tiny fan-out broadcaster: SSE subscribers get a (coalesced)
6 // wake-up whenever desired state changes. It carries no payload — woken
7 // subscribers re-read the current snapshot — so a burst of mutations collapses
8 // into at most one pending wake per subscriber.
9 type notifier struct {
10 mu sync.Mutex
11 subs map[chan struct{}]struct{}
12 }
13
14 func newNotifier() *notifier {
15 return &notifier{subs: make(map[chan struct{}]struct{})}
16 }
17
18 // subscribe returns a wake channel and an unsubscribe func. The channel has
19 // buffer 1 so notify never blocks and repeated notifies coalesce.
20 func (n *notifier) subscribe() (<-chan struct{}, func()) {
21 ch := make(chan struct{}, 1)
22 n.mu.Lock()
23 n.subs[ch] = struct{}{}
24 n.mu.Unlock()
25 return ch, func() {
26 n.mu.Lock()
27 delete(n.subs, ch)
28 n.mu.Unlock()
29 }
30 }
31
32 // notify wakes all subscribers without blocking.
33 func (n *notifier) notify() {
34 n.mu.Lock()
35 defer n.mu.Unlock()
36 for ch := range n.subs {
37 select {
38 case ch <- struct{}{}:
39 default: // already has a pending wake; coalesce
40 }
41 }
42 }
internal/server/api/routes.go
Old New
@@ -0,0 +1,146 @@
1 package api
2
3 import (
4 "net/http"
5 "slices"
6
7 "github.com/a73x/eitri/internal/server/api/types"
8 )
9
10 // AuthTier is who may call a route.
11 type AuthTier int
12
13 const (
14 AuthPublic AuthTier = iota // no auth (public material)
15 AuthAdmin // Authorization: Bearer <admin token>
16 )
17
18 // RouteKind is what rides the connection after the status line.
19 type RouteKind int
20
21 const (
22 KindJSON RouteKind = iota
23 KindSSE
24 )
25
26 // QueryParam is a documented query-string parameter (all string-typed).
27 type QueryParam struct{ Name, Doc string }
28
29 // Route is one entry of the server's HTTP surface. The table below IS the
30 // enumerable contract: Handler registers from it and cmd/eitri-apispec
31 // projects it into docs/openapi.json.
32 type Route struct {
33 Method string
34 Path string
35 Auth AuthTier
36 Kind RouteKind
37 Request any // typed nil exemplar of the JSON request body; nil = no body
38 Response any // typed nil exemplar of the success JSON body; nil = no body
39 Success int // the success status the handler writes
40 Query []QueryParam
41 Doc string
42
43 handler func(*API, http.ResponseWriter, *http.Request)
44 }
45
46 // Routes returns the surface for tooling (the OpenAPI generator).
47 func Routes() []Route { return slices.Clone(routeTable) }
48
49 var routeTable = []Route{
50 // Unauthenticated enrollment endpoint.
51 {
52 Method: "POST",
53 Path: "/api/v1/enroll",
54 Auth: AuthPublic,
55 Kind: KindJSON,
56 Request: (*types.EnrollRequest)(nil),
57 Response: (*types.EnrollResponse)(nil),
58 Success: http.StatusCreated,
59 Doc: "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof.",
60 handler: (*API).handleEnroll,
61 },
62 // SSE live status. EventSource cannot set headers, so this endpoint
63 // authenticates via a ?token= query param instead of the admin middleware.
64 {
65 Method: "GET",
66 Path: "/api/v1/events",
67 Auth: AuthPublic,
68 Kind: KindSSE,
69 Response: (*types.StateSnapshot)(nil),
70 Success: http.StatusOK,
71 Query: []QueryParam{{Name: "token", Doc: "admin token"}},
72 Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.",
73 handler: (*API).handleEvents,
74 },
75
76 // Admin routes — wrapped with auth middleware.
77 {
78 Method: "POST",
79 Path: "/api/v1/enroll-tokens",
80 Auth: AuthAdmin,
81 Kind: KindJSON,
82 Response: (*types.EnrollTokenResponse)(nil),
83 Success: http.StatusCreated,
84 Doc: "Mint a one-time host enrollment token.",
85 handler: (*API).handleCreateEnrollToken,
86 },
87 {
88 Method: "GET",
89 Path: "/api/v1/hosts",
90 Auth: AuthAdmin,
91 Kind: KindJSON,
92 Response: []types.Host(nil),
93 Success: http.StatusOK,
94 Doc: "List fleet hosts: durable rows merged with live agent state and allocation.",
95 handler: (*API).handleListHosts,
96 },
97 {
98 Method: "DELETE",
99 Path: "/api/v1/hosts/{id}",
100 Auth: AuthAdmin,
101 Kind: KindJSON,
102 Success: http.StatusAccepted,
103 Doc: "Decommission a host: tombstone its VMs and drain gracefully (202).",
104 handler: (*API).handleDecommissionHost,
105 },
106 {
107 Method: "GET",
108 Path: "/api/v1/vms",
109 Auth: AuthAdmin,
110 Kind: KindJSON,
111 Response: []types.VM(nil),
112 Success: http.StatusOK,
113 Doc: "List VMs: durable rows merged with live agent-reported actual state.",
114 handler: (*API).handleListVMs,
115 },
116 {
117 Method: "POST",
118 Path: "/api/v1/vms",
119 Auth: AuthAdmin,
120 Kind: KindJSON,
121 Request: (*types.CreateVMRequest)(nil),
122 Response: (*types.CreateVMResponse)(nil),
123 Success: http.StatusCreated,
124 Doc: "Create a VM on a host. Omitted fields get one-click defaults.",
125 handler: (*API).handleCreateVM,
126 },
127 {
128 Method: "PATCH",
129 Path: "/api/v1/vms/{id}",
130 Auth: AuthAdmin,
131 Kind: KindJSON,
132 Request: (*types.PatchVMRequest)(nil),
133 Success: http.StatusNoContent,
134 Doc: "Set a VM's desired power state (running or stopped).",
135 handler: (*API).handlePatchVM,
136 },
137 {
138 Method: "DELETE",
139 Path: "/api/v1/vms/{id}",
140 Auth: AuthAdmin,
141 Kind: KindJSON,
142 Success: http.StatusNoContent,
143 Doc: "Tombstone a VM for teardown.",
144 handler: (*API).handleDeleteVM,
145 },
146 }
internal/server/api/routes_test.go
Old New
@@ -0,0 +1,81 @@
1 package api
2
3 import (
4 "net/http"
5 "reflect"
6 "testing"
7 )
8
9 // typesPkgPath is where every wire exemplar's element type must live — the
10 // contract package, and nothing else.
11 const typesPkgPath = "github.com/a73x/eitri/internal/server/api/types"
12
13 // exemplarElem unwraps a table exemplar (typed nil pointer-to-struct or typed
14 // nil slice) to its element struct type, failing the test on any other shape.
15 func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
16 t.Helper()
17 rt := reflect.TypeOf(v)
18 switch rt.Kind() {
19 case reflect.Pointer, reflect.Slice:
20 elem := rt.Elem()
21 if elem.Kind() != reflect.Struct {
22 t.Fatalf("%s %s: %s exemplar %v is not pointer-to-struct or slice-of-struct", route.Method, route.Path, role, rt)
23 }
24 return elem
25 default:
26 t.Fatalf("%s %s: %s exemplar has kind %v; want typed nil pointer or slice", route.Method, route.Path, role, rt.Kind())
27 return nil
28 }
29 }
30
31 // TestRouteTable pins the structural invariants the OpenAPI generator relies
32 // on: complete entries, unique method+path, exemplars drawn from the contract
33 // package, and disjoint request/response type sets (the generator emits no
34 // `required` array for request schemas, so a type serving both roles would
35 // get the wrong treatment on one of them).
36 func TestRouteTable(t *testing.T) {
37 const wantRoutes = 9
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)
40 }
41
42 seen := make(map[string]bool, len(routeTable))
43 requestTypes := map[reflect.Type]bool{}
44 responseTypes := map[reflect.Type]bool{}
45
46 for _, rt := range routeTable {
47 if rt.Method == "" || rt.Path == "" || rt.Doc == "" || rt.handler == nil {
48 t.Errorf("%s %s: incomplete entry (method/path/doc/handler must all be set)", rt.Method, rt.Path)
49 }
50 key := rt.Method + " " + rt.Path
51 if seen[key] {
52 t.Errorf("duplicate route %s", key)
53 }
54 seen[key] = true
55
56 if rt.Request != nil {
57 elem := exemplarElem(t, rt, "request", rt.Request)
58 if elem.PkgPath() != typesPkgPath {
59 t.Errorf("%s: request exemplar %v lives outside the contract package", key, elem)
60 }
61 requestTypes[elem] = true
62 }
63 if rt.Response != nil {
64 elem := exemplarElem(t, rt, "response", rt.Response)
65 if elem.PkgPath() != typesPkgPath {
66 t.Errorf("%s: response exemplar %v lives outside the contract package", key, elem)
67 }
68 responseTypes[elem] = true
69 }
70
71 if rt.Kind == KindJSON && (rt.Success == http.StatusOK || rt.Success == http.StatusCreated) && rt.Response == nil {
72 t.Errorf("%s: succeeds with %d but declares no response body", key, rt.Success)
73 }
74 }
75
76 for typ := range requestTypes {
77 if responseTypes[typ] {
78 t.Errorf("type %v is used as both request and response exemplar; the sets must stay disjoint", typ)
79 }
80 }
81 }
internal/server/api/spec/direction_internal_test.go
Old New
@@ -0,0 +1,24 @@
1 package spec
2
3 import (
4 "reflect"
5 "testing"
6
7 "github.com/a73x/eitri/internal/server/api/types"
8 )
9
10 // TestSharedTypeAcrossDirectionsPanics bite-proves the direction guard: a
11 // contract struct memoized response-side must refuse to also serve
12 // request-side (the two sides get different schemas — required vs not — and
13 // silent first-wins would ship whichever the route order happened to pick).
14 func TestSharedTypeAcrossDirectionsPanics(t *testing.T) {
15 g := &generator{schemas: map[string]any{}, direction: map[string]bool{}}
16 g.schemaFor(reflect.TypeFor[types.Capacity](), false) // response side first
17
18 defer func() {
19 if recover() == nil {
20 t.Fatal("reusing a response-side schema request-side did not panic; the sides get different required treatment and must stay disjoint")
21 }
22 }()
23 g.schemaFor(reflect.TypeFor[types.Capacity](), true)
24 }
internal/server/api/spec/spec.go
Old New
@@ -0,0 +1,249 @@
1 // Package spec projects the api route table into an OpenAPI 3.1 document.
2 // It reflects over the contract types — json tags and Go kinds only, no
3 // annotations — so the document can never drift from the code.
4 //
5 // Two deliberate asymmetries encode how the server actually behaves:
6 // request schemas emit NO required array (the server defaults absent fields;
7 // hand-rolled validation is the authority), while response schemas require
8 // every non-pointer field (writeJSON always emits all of them). Pointer
9 // FIELDS become nullable; a typed-nil pointer exemplar at a route's top
10 // level just means "this struct is the payload" and is unwrapped, never
11 // rendered nullable.
12 package spec
13
14 import (
15 "encoding/json"
16 "fmt"
17 "reflect"
18 "regexp"
19 "sort"
20 "strconv"
21 "strings"
22 "time"
23
24 "github.com/a73x/eitri/internal/server/api"
25 "github.com/a73x/eitri/internal/server/api/types"
26 )
27
28 // typesPkgPath guards the wire boundary: every struct that reaches the spec
29 // must live in the contract package.
30 var typesPkgPath = reflect.TypeFor[types.Host]().PkgPath()
31
32 var pathParamRe = regexp.MustCompile(`\{([a-z]+)\}`)
33
34 // Generate renders the full OpenAPI 3.1 document for api.Routes(),
35 // deterministically (sorted keys, sorted required arrays, trailing newline).
36 func Generate() ([]byte, error) {
37 g := &generator{schemas: map[string]any{}, direction: map[string]bool{}}
38 paths := map[string]any{}
39 for _, r := range api.Routes() {
40 item, _ := paths[r.Path].(map[string]any)
41 if item == nil {
42 item = map[string]any{}
43 paths[r.Path] = item
44 }
45 item[strings.ToLower(r.Method)] = g.operation(r)
46 }
47 doc := map[string]any{
48 "openapi": "3.1.0",
49 "info": map[string]any{
50 "title": "eitri server API",
51 "version": "v1", // the /api/v1 surface version, not a release
52 },
53 "paths": paths,
54 "components": map[string]any{
55 "schemas": g.schemas,
56 "securitySchemes": map[string]any{
57 "adminToken": map[string]any{"type": "http", "scheme": "bearer"},
58 },
59 },
60 }
61 out, err := json.MarshalIndent(doc, "", " ")
62 if err != nil {
63 return nil, err
64 }
65 return append(out, '\n'), nil
66 }
67
68 type generator struct {
69 schemas map[string]any // components.schemas, memoized by type name
70 // direction remembers which side (request=true) first memoized each
71 // schema. A schema's shape depends on the side — request structs carry no
72 // required array — so one type reached from both sides would silently get
73 // whichever shape came first; sharing is instead rejected loudly in
74 // schemaFor, forcing the contract to keep the sets disjoint (the route
75 // table test pins the top level; this guards nested structs too).
76 direction map[string]bool
77 }
78
79 func (g *generator) operation(r api.Route) map[string]any {
80 op := map[string]any{"summary": r.Doc}
81
82 var params []any
83 for _, m := range pathParamRe.FindAllStringSubmatch(r.Path, -1) {
84 params = append(params, map[string]any{
85 "name": m[1],
86 "in": "path",
87 "required": true,
88 "schema": map[string]any{"type": "string"},
89 })
90 }
91 for _, q := range r.Query {
92 params = append(params, map[string]any{
93 "name": q.Name,
94 "in": "query",
95 "required": false,
96 "description": q.Doc,
97 "schema": map[string]any{"type": "string"},
98 })
99 }
100 if len(params) > 0 {
101 op["parameters"] = params
102 }
103
104 if r.Auth == api.AuthAdmin {
105 op["security"] = []any{map[string]any{"adminToken": []any{}}}
106 }
107
108 if r.Request != nil {
109 op["requestBody"] = map[string]any{
110 "required": true,
111 "content": map[string]any{
112 "application/json": map[string]any{
113 "schema": g.schemaFor(rootType(r.Request), true),
114 },
115 },
116 }
117 }
118
119 responses := map[string]any{}
120 success := strconv.Itoa(r.Success)
121 switch r.Kind {
122 case api.KindSSE:
123 responses[success] = map[string]any{
124 "description": "success",
125 "content": map[string]any{
126 "text/event-stream": map[string]any{
127 "schema": g.schemaFor(rootType(r.Response), false),
128 },
129 },
130 }
131 default:
132 resp := map[string]any{"description": "success"}
133 if r.Response != nil {
134 resp["content"] = map[string]any{
135 "application/json": map[string]any{
136 "schema": g.schemaFor(rootType(r.Response), false),
137 },
138 }
139 }
140 responses[success] = resp
141 }
142 responses["default"] = map[string]any{
143 "description": "error (plain text)",
144 "content": map[string]any{
145 "text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
146 },
147 }
148 op["responses"] = responses
149 return op
150 }
151
152 // rootType unwraps ONE pointer level from a route exemplar: a typed-nil
153 // pointer at the top level means "this struct is the payload", not nullable.
154 func rootType(exemplar any) reflect.Type {
155 t := reflect.TypeOf(exemplar)
156 if t.Kind() == reflect.Pointer {
157 t = t.Elem()
158 }
159 return t
160 }
161
162 // schemaFor renders one Go type as a JSON schema. The request flag threads
163 // through nesting so request-side structs skip the required array.
164 func (g *generator) schemaFor(t reflect.Type, request bool) map[string]any {
165 switch t {
166 case reflect.TypeFor[time.Time]():
167 return map[string]any{"type": "string", "format": "date-time"}
168 case reflect.TypeFor[json.RawMessage]():
169 return map[string]any{} // any JSON value
170 }
171 switch t.Kind() {
172 case reflect.Pointer:
173 return nullable(g.schemaFor(t.Elem(), request))
174 case reflect.Slice:
175 return map[string]any{"type": "array", "items": g.schemaFor(t.Elem(), request)}
176 case reflect.String:
177 return map[string]any{"type": "string"}
178 case reflect.Bool:
179 return map[string]any{"type": "boolean"}
180 case reflect.Int, reflect.Int64, reflect.Uint64:
181 return map[string]any{"type": "integer"}
182 case reflect.Float64:
183 return map[string]any{"type": "number"}
184 case reflect.Struct:
185 if t.PkgPath() != typesPkgPath {
186 panic("spec: non-contract struct on the wire: " + t.String())
187 }
188 name := t.Name()
189 if _, seen := g.schemas[name]; !seen {
190 g.schemas[name] = nil // reserve before recursing (cycle safety)
191 g.direction[name] = request
192 g.schemas[name] = g.structSchema(t, request)
193 } else if g.direction[name] != request {
194 panic("spec: contract type " + name + " is reachable from both request and response sides; split it — the sides get different schemas")
195 }
196 return map[string]any{"$ref": "#/components/schemas/" + name}
197 default:
198 panic(fmt.Sprintf("spec: unsupported kind %s for %s", t.Kind(), t))
199 }
200 }
201
202 func (g *generator) structSchema(t reflect.Type, request bool) map[string]any {
203 props := map[string]any{}
204 var required []string
205 for i := range t.NumField() {
206 f := t.Field(i)
207 if !f.IsExported() {
208 continue
209 }
210 name, opts, _ := strings.Cut(f.Tag.Get("json"), ",")
211 if name == "" || name == "-" {
212 continue
213 }
214 props[name] = g.schemaFor(f.Type, request)
215 // Responses require every field writeJSON is guaranteed to emit:
216 // non-pointer, no omitempty (none exists in the contract today).
217 if !request && f.Type.Kind() != reflect.Pointer && !hasOpt(opts, "omitempty") {
218 required = append(required, name)
219 }
220 }
221 s := map[string]any{"type": "object", "properties": props}
222 if len(required) > 0 {
223 sort.Strings(required)
224 s["required"] = required
225 }
226 return s
227 }
228
229 func hasOpt(opts, want string) bool {
230 for opt := range strings.SplitSeq(opts, ",") {
231 if opt == want {
232 return true
233 }
234 }
235 return false
236 }
237
238 // nullable widens a field schema for a pointer field: $refs wrap in
239 // anyOf [$ref, null]; typed schemas grow "null" into their type; typeless
240 // schemas (raw JSON) already admit null.
241 func nullable(s map[string]any) map[string]any {
242 if _, isRef := s["$ref"]; isRef {
243 return map[string]any{"anyOf": []any{s, map[string]any{"type": "null"}}}
244 }
245 if typ, ok := s["type"].(string); ok {
246 s["type"] = []any{typ, "null"}
247 }
248 return s
249 }
internal/server/api/spec/spec_test.go
Old New
@@ -0,0 +1,210 @@
1 package spec_test
2
3 import (
4 "bytes"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "github.com/a73x/eitri/internal/server/api"
10 "github.com/a73x/eitri/internal/server/api/spec"
11 )
12
13 // generate runs the generator once and unmarshals the document.
14 func generate(t *testing.T) (map[string]any, []byte) {
15 t.Helper()
16 out, err := spec.Generate()
17 if err != nil {
18 t.Fatalf("Generate: %v", err)
19 }
20 var doc map[string]any
21 if err := json.Unmarshal(out, &doc); err != nil {
22 t.Fatalf("generated spec is not valid JSON: %v", err)
23 }
24 return doc, out
25 }
26
27 // dig walks nested map[string]any keys, failing the test on a missing step.
28 func dig(t *testing.T, v any, keys ...string) any {
29 t.Helper()
30 for _, k := range keys {
31 m, ok := v.(map[string]any)
32 if !ok {
33 t.Fatalf("dig %v: not an object at %q", keys, k)
34 }
35 v, ok = m[k]
36 if !ok {
37 t.Fatalf("dig %v: missing key %q", keys, k)
38 }
39 }
40 return v
41 }
42
43 func TestOpenAPIVersion(t *testing.T) {
44 doc, _ := generate(t)
45 if got := doc["openapi"]; got != "3.1.0" {
46 t.Errorf("openapi = %v, want 3.1.0", got)
47 }
48 }
49
50 func TestEveryRouteHasAnOperation(t *testing.T) {
51 doc, _ := generate(t)
52 for _, r := range api.Routes() {
53 op, ok := dig(t, doc, "paths", r.Path).(map[string]any)[strings.ToLower(r.Method)]
54 if !ok {
55 t.Errorf("%s %s: no operation in paths", r.Method, r.Path)
56 continue
57 }
58 // {id} path params must be declared required string params.
59 for _, name := range []string{"id"} {
60 if !strings.Contains(r.Path, "{"+name+"}") {
61 continue
62 }
63 var found bool
64 params, _ := op.(map[string]any)["parameters"].([]any)
65 for _, p := range params {
66 pm := p.(map[string]any)
67 if pm["name"] == name && pm["in"] == "path" {
68 found = true
69 if pm["required"] != true {
70 t.Errorf("%s %s: path param %q not required", r.Method, r.Path, name)
71 }
72 if typ := dig(t, pm, "schema", "type"); typ != "string" {
73 t.Errorf("%s %s: path param %q type = %v, want string", r.Method, r.Path, name, typ)
74 }
75 }
76 }
77 if !found {
78 t.Errorf("%s %s: path param %q not declared", r.Method, r.Path, name)
79 }
80 }
81 }
82 }
83
84 func TestCreateVMOperation(t *testing.T) {
85 doc, _ := generate(t)
86 op := dig(t, doc, "paths", "/api/v1/vms", "post")
87
88 // The requestBody schema is a plain $ref — never wrapped in anyOf/nullable.
89 reqSchema := dig(t, op, "requestBody", "content", "application/json", "schema").(map[string]any)
90 if len(reqSchema) != 1 {
91 t.Errorf("requestBody schema has extra keys: %v", reqSchema)
92 }
93 if ref, _ := reqSchema["$ref"].(string); !strings.HasSuffix(ref, "CreateVMRequest") {
94 t.Errorf("requestBody $ref = %v, want ...CreateVMRequest", reqSchema["$ref"])
95 }
96
97 respRef := dig(t, op, "responses", "201", "content", "application/json", "schema", "$ref").(string)
98 if !strings.HasSuffix(respRef, "CreateVMResponse") {
99 t.Errorf("201 $ref = %q, want ...CreateVMResponse", respRef)
100 }
101 }
102
103 func TestRequiredArrays(t *testing.T) {
104 doc, _ := generate(t)
105
106 // Request schemas claim NO required fields: the server defaults absent
107 // fields and hand-rolled validation is the authority.
108 req := dig(t, doc, "components", "schemas", "CreateVMRequest").(map[string]any)
109 if _, ok := req["required"]; ok {
110 t.Errorf("CreateVMRequest has a required array: %v", req["required"])
111 }
112
113 // Response schemas DO claim required (writeJSON always emits every field),
114 // and the array is sorted for deterministic output.
115 host := dig(t, doc, "components", "schemas", "Host").(map[string]any)
116 raw, ok := host["required"].([]any)
117 if !ok {
118 t.Fatalf("Host has no required array")
119 }
120 var required []string
121 for _, v := range raw {
122 required = append(required, v.(string))
123 }
124 if len(required) == 0 {
125 t.Fatal("Host required array is empty")
126 }
127 for i := 1; i < len(required); i++ {
128 if required[i-1] >= required[i] {
129 t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i])
130 }
131 }
132 }
133
134 func TestSecurity(t *testing.T) {
135 doc, _ := generate(t)
136 for _, r := range api.Routes() {
137 op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method)).(map[string]any)
138 sec, has := op["security"]
139 if r.Auth == api.AuthAdmin {
140 want := []any{map[string]any{"adminToken": []any{}}}
141 if !has {
142 t.Errorf("%s %s: admin route missing security", r.Method, r.Path)
143 } else if wantJSON, _ := json.Marshal(want); string(mustJSON(t, sec)) != string(wantJSON) {
144 t.Errorf("%s %s: security = %v", r.Method, r.Path, sec)
145 }
146 } else if has {
147 t.Errorf("%s %s: non-admin route carries security %v", r.Method, r.Path, sec)
148 }
149 }
150 }
151
152 func mustJSON(t *testing.T, v any) []byte {
153 t.Helper()
154 b, err := json.Marshal(v)
155 if err != nil {
156 t.Fatalf("marshal: %v", err)
157 }
158 return b
159 }
160
161 func TestHostSchemaFields(t *testing.T) {
162 doc, _ := generate(t)
163 props := dig(t, doc, "components", "schemas", "Host", "properties")
164
165 // Nested contract struct → $ref.
166 if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") {
167 t.Errorf("capacity $ref = %q", ref)
168 }
169 }
170
171 func TestSSEResponses(t *testing.T) {
172 doc, _ := generate(t)
173
174 // SSE stream: 200 with a text/event-stream body carrying StateSnapshot.
175 ref := dig(t, doc, "paths", "/api/v1/events", "get", "responses", "200",
176 "content", "text/event-stream", "schema", "$ref").(string)
177 if !strings.HasSuffix(ref, "StateSnapshot") {
178 t.Errorf("events stream $ref = %q, want ...StateSnapshot", ref)
179 }
180 }
181
182 func TestDefaultErrorResponse(t *testing.T) {
183 doc, _ := generate(t)
184 for _, r := range api.Routes() {
185 op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method))
186 typ := dig(t, op, "responses", "default", "content", "text/plain", "schema", "type")
187 if typ != "string" {
188 t.Errorf("%s %s: default error schema type = %v, want string", r.Method, r.Path, typ)
189 }
190 }
191 }
192
193 func TestSecuritySchemes(t *testing.T) {
194 doc, _ := generate(t)
195 scheme := dig(t, doc, "components", "securitySchemes", "adminToken").(map[string]any)
196 if scheme["type"] != "http" || scheme["scheme"] != "bearer" {
197 t.Errorf("adminToken scheme = %v, want {type: http, scheme: bearer}", scheme)
198 }
199 }
200
201 func TestDeterministicOutput(t *testing.T) {
202 _, first := generate(t)
203 _, second := generate(t)
204 if !bytes.Equal(first, second) {
205 t.Fatal("two Generate() calls differ")
206 }
207 if !bytes.HasSuffix(first, []byte("\n")) {
208 t.Error("output missing trailing newline")
209 }
210 }
internal/server/api/testdata/create-vm-request.golden.json
Old New
@@ -0,0 +1,13 @@
1 {
2 "host_id": "h-1234",
3 "name": "worker-7",
4 "image_url": "https://images.example.com/resolute.img",
5 "image_sha256": "deadbeefcafe",
6 "cloud_init": "#cloud-config\npackages: [git]",
7 "ssh_authorized_key": "ssh-ed25519 AAAAC3Nza key-comment",
8 "power_state": "running",
9 "vcpus": 4,
10 "mem_mb": 4096,
11 "disk_gb": 20,
12 "persistent": true
13 }
internal/server/api/testdata/create-vm-response.golden.json
Old New
@@ -0,0 +1,4 @@
1 {
2 "id": "v-5678",
3 "name": "sandbox-abc123"
4 }
internal/server/api/testdata/enroll-request.golden.json
Old New
@@ -0,0 +1,8 @@
1 {
2 "token": "tok-secret-01",
3 "name": "host-nine",
4 "os": "linux",
5 "arch": "arm64",
6 "provisioner": "cloudhypervisor",
7 "overlay": "none"
8 }
internal/server/api/testdata/enroll-response.golden.json
Old New
@@ -0,0 +1,7 @@
1 {
2 "bridge_cidr": "10.77.1.0/24",
3 "credential": "cred-opaque-01",
4 "host_id": "h-1234",
5 "overlay": "tailscale",
6 "server_cert_sha256": "cafebabef00d"
7 }
internal/server/api/testdata/enroll-token-response.golden.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "token": "tok-secret-01"
3 }
internal/server/api/testdata/host.golden.json
Old New
@@ -0,0 +1,22 @@
1 {
2 "id": "h-1234",
3 "name": "mewtwo",
4 "os": "linux",
5 "arch": "amd64",
6 "provisioner": "cloudhypervisor",
7 "overlay": "tailscale",
8 "bridge_cidr": "10.77.1.0/24",
9 "status": "active",
10 "enrolled_at": "2026-07-27T12:00:00Z",
11 "online": true,
12 "capacity": {
13 "vcpus": 16,
14 "mem_mb": 32768,
15 "disk_gb": 512
16 },
17 "allocated": {
18 "vcpus": 4,
19 "mem_mb": 8192,
20 "disk_gb": 100
21 }
22 }
internal/server/api/testdata/patch-vm-request.golden.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "power_state": "stopped"
3 }
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -0,0 +1,46 @@
1 {
2 "hosts": [
3 {
4 "id": "h-1234",
5 "name": "mewtwo",
6 "os": "linux",
7 "arch": "amd64",
8 "provisioner": "cloudhypervisor",
9 "overlay": "tailscale",
10 "bridge_cidr": "10.77.1.0/24",
11 "status": "active",
12 "enrolled_at": "2026-07-27T12:00:00Z",
13 "online": true,
14 "capacity": {
15 "vcpus": 16,
16 "mem_mb": 32768,
17 "disk_gb": 512
18 },
19 "allocated": {
20 "vcpus": 4,
21 "mem_mb": 8192,
22 "disk_gb": 100
23 }
24 }
25 ],
26 "vms": [
27 {
28 "id": "v-5678",
29 "host_id": "h-1234",
30 "name": "sandbox-abc123",
31 "image_url": "https://images.example.com/resolute.img",
32 "vcpus": 2,
33 "mem_mb": 2048,
34 "disk_gb": 10,
35 "persistent": true,
36 "power_state": "running",
37 "status": "ready",
38 "last_error": "boot timeout",
39 "assigned_ip": "10.77.1.2",
40 "created_at": "2026-07-27T12:01:00Z",
41 "deleted": true,
42 "actual_power": "stopped",
43 "phase": "creating"
44 }
45 ]
46 }
internal/server/api/testdata/vm.golden.json
Old New
@@ -0,0 +1,18 @@
1 {
2 "id": "v-5678",
3 "host_id": "h-1234",
4 "name": "sandbox-abc123",
5 "image_url": "https://images.example.com/resolute.img",
6 "vcpus": 2,
7 "mem_mb": 2048,
8 "disk_gb": 10,
9 "persistent": true,
10 "power_state": "running",
11 "status": "ready",
12 "last_error": "boot timeout",
13 "assigned_ip": "10.77.1.2",
14 "created_at": "2026-07-27T12:01:00Z",
15 "deleted": true,
16 "actual_power": "stopped",
17 "phase": "creating"
18 }
internal/server/api/types/types.go
Old New
@@ -0,0 +1,127 @@
1 // Package types is the server HTTP API's wire contract: every request and
2 // response JSON shape the API speaks, and nothing else. It is a leaf — it
3 // imports only the standard library — so the contract can be consumed by the
4 // spec generator, the shared client, and the handlers without dragging in
5 // server internals.
6 package types
7
8 import (
9 "time"
10 )
11
12 // Capacity is the snake_case wire form of a host resource triple. It appears
13 // twice per Host in GET /api/v1/hosts and the SSE snapshot: as the host's
14 // TOTALS (capacity) and as the amount committed to live VMs (allocated).
15 type Capacity struct {
16 VCPUs int64 `json:"vcpus"`
17 MemMB int64 `json:"mem_mb"`
18 DiskGB int64 `json:"disk_gb"`
19 }
20
21 // Host is the explicit snake_case wire representation of a host, served by
22 // GET /api/v1/hosts and the SSE snapshot. Every field is spelled out — no
23 // struct embedding — to prevent PascalCase field leakage.
24 type Host struct {
25 ID string `json:"id"`
26 Name string `json:"name"`
27 OS string `json:"os"`
28 Arch string `json:"arch"`
29 Provisioner string `json:"provisioner"`
30 Overlay string `json:"overlay"`
31 BridgeCIDR string `json:"bridge_cidr"`
32 Status string `json:"status"`
33 EnrolledAt time.Time `json:"enrolled_at"`
34 Online bool `json:"online"`
35 Capacity Capacity `json:"capacity"` // host TOTALS (when online)
36 Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed)
37 }
38
39 // VM is the explicit snake_case wire representation of a VM, served by
40 // GET /api/v1/vms and the SSE snapshot. Write-only fields — image_sha256,
41 // cloud_init, ssh_authorized_key — are deliberately excluded. Every field is
42 // spelled out — no struct embedding.
43 type VM struct {
44 ID string `json:"id"`
45 HostID string `json:"host_id"`
46 Name string `json:"name"`
47 ImageURL string `json:"image_url"`
48 VCPUs int64 `json:"vcpus"`
49 MemMB int64 `json:"mem_mb"`
50 DiskGB int64 `json:"disk_gb"`
51 Persistent bool `json:"persistent"`
52 PowerState string `json:"power_state"`
53 Status string `json:"status"`
54 LastError string `json:"last_error"`
55 AssignedIP string `json:"assigned_ip"`
56 CreatedAt time.Time `json:"created_at"`
57 Deleted bool `json:"deleted"`
58 ActualPower string `json:"actual_power"`
59 Phase string `json:"phase"`
60 }
61
62 // StateSnapshot is the full fleet state pushed as each `event: state` frame
63 // over the SSE stream (GET /api/v1/events).
64 type StateSnapshot struct {
65 Hosts []Host `json:"hosts"`
66 VMs []VM `json:"vms"`
67 }
68
69 // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an
70 // enrollment token to join the fleet.
71 type EnrollRequest struct {
72 Token string `json:"token"`
73 Name string `json:"name"`
74 OS string `json:"os"`
75 Arch string `json:"arch"`
76 Provisioner string `json:"provisioner"`
77 Overlay string `json:"overlay"` // optional; defaults to "tailscale"
78 }
79
80 // CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is
81 // optional: the server fills one-click defaults (name, image pair, sizes,
82 // power state) before validating.
83 type CreateVMRequest struct {
84 HostID string `json:"host_id"`
85 Name string `json:"name"`
86 ImageURL string `json:"image_url"`
87 ImageSHA256 string `json:"image_sha256"`
88 CloudInit string `json:"cloud_init"`
89 SSHAuthorizedKey string `json:"ssh_authorized_key"`
90 PowerState string `json:"power_state"`
91 VCPUs int64 `json:"vcpus"`
92 MemMB int64 `json:"mem_mb"`
93 DiskGB int64 `json:"disk_gb"`
94 Persistent bool `json:"persistent"`
95 }
96
97 // PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state,
98 // "running" or "stopped".
99 type PatchVMRequest struct {
100 PowerState string `json:"power_state"`
101 }
102
103 // The response shapes below replace handlers' inline map[string]string
104 // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose:
105 // encoding/json marshals map keys sorted, so keeping struct fields in that
106 // same order means swapping a map for its struct leaves the wire bytes
107 // byte-identical (the golden fixtures pin this).
108
109 // EnrollResponse answers POST /api/v1/enroll.
110 type EnrollResponse struct {
111 BridgeCIDR string `json:"bridge_cidr"`
112 Credential string `json:"credential"`
113 HostID string `json:"host_id"`
114 Overlay string `json:"overlay"`
115 ServerCertSHA256 string `json:"server_cert_sha256"`
116 }
117
118 // EnrollTokenResponse answers POST /api/v1/enroll-tokens.
119 type EnrollTokenResponse struct {
120 Token string `json:"token"`
121 }
122
123 // CreateVMResponse answers POST /api/v1/vms.
124 type CreateVMResponse struct {
125 ID string `json:"id"`
126 Name string `json:"name"`
127 }
internal/server/api/wire_golden_test.go
Old New
@@ -0,0 +1,139 @@
1 package api
2
3 import (
4 "bytes"
5 "encoding/json"
6 "flag"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/server/api/types"
13 )
14
15 var updateGolden = flag.Bool("update", false, "rewrite golden wire fixtures")
16
17 // goldenCheck marshals v (indented, deterministic) and compares it to
18 // testdata/<name>.golden.json. The fixtures byte-pin the HTTP wire contract:
19 // the contract-extraction refactor must not change a single byte.
20 func goldenCheck(t *testing.T, name string, v any) {
21 t.Helper()
22 got, err := json.MarshalIndent(v, "", " ")
23 if err != nil {
24 t.Fatalf("marshal %s: %v", name, err)
25 }
26 got = append(got, '\n')
27 path := filepath.Join("testdata", name+".golden.json")
28 if *updateGolden {
29 if err := os.MkdirAll("testdata", 0o755); err != nil {
30 t.Fatal(err)
31 }
32 if err := os.WriteFile(path, got, 0o644); err != nil {
33 t.Fatal(err)
34 }
35 return
36 }
37 want, err := os.ReadFile(path)
38 if err != nil {
39 t.Fatalf("read %s (run with -update to create): %v", path, err)
40 }
41 if !bytes.Equal(got, want) {
42 t.Errorf("%s: wire bytes changed\ngot:\n%s\nwant:\n%s", name, got, want)
43 }
44 }
45
46 // TestWireGolden byte-pins the JSON wire shape of every DTO the HTTP API
47 // serves or accepts. Every field carries a distinctive non-zero value so a
48 // dropped field, renamed tag, or swapped tag shows up as a byte diff.
49 func TestWireGolden(t *testing.T) {
50 base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
51
52 host := types.Host{
53 ID: "h-1234",
54 Name: "mewtwo",
55 OS: "linux",
56 Arch: "amd64",
57 Provisioner: "cloudhypervisor",
58 Overlay: "tailscale",
59 BridgeCIDR: "10.77.1.0/24",
60 Status: "active",
61 EnrolledAt: base,
62 Online: true,
63 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512},
64 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100},
65 }
66 goldenCheck(t, "host", host)
67
68 vm := types.VM{
69 ID: "v-5678",
70 HostID: "h-1234",
71 Name: "sandbox-abc123",
72 ImageURL: "https://images.example.com/resolute.img",
73 VCPUs: 2,
74 MemMB: 2048,
75 DiskGB: 10,
76 Persistent: true,
77 PowerState: "running",
78 Status: "ready",
79 LastError: "boot timeout",
80 AssignedIP: "10.77.1.2",
81 CreatedAt: base.Add(time.Minute),
82 Deleted: true,
83 ActualPower: "stopped",
84 Phase: "creating",
85 }
86 goldenCheck(t, "vm", vm)
87
88 goldenCheck(t, "snapshot", types.StateSnapshot{
89 Hosts: []types.Host{host},
90 VMs: []types.VM{vm},
91 })
92
93 goldenCheck(t, "enroll-request", types.EnrollRequest{
94 Token: "tok-secret-01",
95 Name: "host-nine",
96 OS: "linux",
97 Arch: "arm64",
98 Provisioner: "cloudhypervisor",
99 Overlay: "none",
100 })
101
102 goldenCheck(t, "create-vm-request", types.CreateVMRequest{
103 HostID: "h-1234",
104 Name: "worker-7",
105 ImageURL: "https://images.example.com/resolute.img",
106 ImageSHA256: "deadbeefcafe",
107 CloudInit: "#cloud-config\npackages: [git]",
108 SSHAuthorizedKey: "ssh-ed25519 AAAAC3Nza key-comment",
109 PowerState: "running",
110 VCPUs: 4,
111 MemMB: 4096,
112 DiskGB: 20,
113 Persistent: true,
114 })
115
116 goldenCheck(t, "patch-vm-request", types.PatchVMRequest{
117 PowerState: "stopped",
118 })
119
120 // The named response shapes that replaced handlers' inline
121 // map[string]string literals (their declarations in the types package
122 // explain the byte-compatible field ordering).
123 goldenCheck(t, "enroll-response", types.EnrollResponse{
124 BridgeCIDR: "10.77.1.0/24",
125 Credential: "cred-opaque-01",
126 HostID: "h-1234",
127 Overlay: "tailscale",
128 ServerCertSHA256: "cafebabef00d",
129 })
130
131 goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{
132 Token: "tok-secret-01",
133 })
134
135 goldenCheck(t, "create-vm-response", types.CreateVMResponse{
136 ID: "v-5678",
137 Name: "sandbox-abc123",
138 })
139 }
internal/server/hosttoken/hosttoken.go
Old New
@@ -0,0 +1,34 @@
1 // Package hosttoken mints and verifies host credentials: "<host_id>.<hex hmac-sha256>".
2 // Phase 1 has no revocation list (single-tenant; rotate the server secret to revoke all).
3 // Host IDs are hex strings and must not contain '.'; a dotted input fails verification
4 // safely because the signature is computed over the full ID and will never match the
5 // truncated parse produced by strings.Cut.
6 package hosttoken
7
8 import (
9 "crypto/hmac"
10 "crypto/sha256"
11 "encoding/hex"
12 "strings"
13 )
14
15 func sign(secret []byte, hostID string) string {
16 m := hmac.New(sha256.New, secret)
17 m.Write([]byte(hostID))
18 return hex.EncodeToString(m.Sum(nil))
19 }
20
21 func Mint(secret []byte, hostID string) string {
22 return hostID + "." + sign(secret, hostID)
23 }
24
25 func Verify(secret []byte, cred string) (hostID string, ok bool) {
26 id, sig, found := strings.Cut(cred, ".")
27 if !found || id == "" {
28 return "", false
29 }
30 if !hmac.Equal([]byte(sig), []byte(sign(secret, id))) {
31 return "", false
32 }
33 return id, true
34 }
internal/server/hosttoken/hosttoken_test.go
Old New
@@ -0,0 +1,25 @@
1 package hosttoken
2
3 import (
4 "testing"
5 "github.com/stretchr/testify/assert"
6 )
7
8 func TestMintedCredentialVerifiesAndRecoversHostID(t *testing.T) {
9 secret := []byte("server-secret")
10 cred := Mint(secret, "host-123")
11 hostID, ok := Verify(secret, cred)
12 assert.True(t, ok)
13 assert.Equal(t, "host-123", hostID)
14 }
15
16 func TestVerifyRejectsTamperedAndWrongSecret(t *testing.T) {
17 secret := []byte("server-secret")
18 cred := Mint(secret, "host-123")
19 _, ok := Verify([]byte("other"), cred)
20 assert.False(t, ok, "wrong secret")
21 _, ok = Verify(secret, cred+"x")
22 assert.False(t, ok, "tampered")
23 _, ok = Verify(secret, "garbage")
24 assert.False(t, ok, "malformed")
25 }
internal/server/hub/hub.go
Old New
@@ -0,0 +1,51 @@
1 // Package hub wakes per-host QUIC streams when desired state changes.
2 package hub
3
4 import "sync"
5
6 type Hub struct {
7 mu sync.Mutex
8 m map[string]chan struct{}
9 }
10
11 func New() *Hub { return &Hub{m: map[string]chan struct{}{}} }
12
13 // Subscribe returns a channel that receives a poke whenever desired state
14 // changes for hostID, and a cancel function to deregister. A second Subscribe
15 // for the same host supersedes the first (last-writer-wins for reconnecting
16 // agents); the displaced channel is closed so any orphaned reader unblocks.
17 // The returned channel is closed when the subscription is cancelled or superseded,
18 // so callers using "for range" on the channel will exit naturally.
19 func (h *Hub) Subscribe(hostID string) (<-chan struct{}, func()) {
20 h.mu.Lock()
21 defer h.mu.Unlock()
22 ch := make(chan struct{}, 1)
23 if old := h.m[hostID]; old != nil {
24 close(old)
25 }
26 h.m[hostID] = ch
27 return ch, func() {
28 h.mu.Lock()
29 defer h.mu.Unlock()
30 if h.m[hostID] == ch {
31 delete(h.m, hostID)
32 close(ch) // unblocks any goroutine ranging over this channel
33 }
34 }
35 }
36
37 func (h *Hub) Poke(hostID string) {
38 // The send happens under the lock: Subscribe closes displaced channels,
39 // and a send racing that close would panic. The send is non-blocking
40 // (buffered-1 + default), so holding the lock here cannot deadlock.
41 h.mu.Lock()
42 defer h.mu.Unlock()
43 ch := h.m[hostID]
44 if ch == nil {
45 return
46 }
47 select {
48 case ch <- struct{}{}:
49 default: // already pending; level-triggered consumers re-read full state anyway
50 }
51 }
internal/server/hub/hub_test.go
Old New
@@ -0,0 +1,34 @@
1 package hub
2
3 import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 )
8
9 func TestCancelClosesChannel(t *testing.T) {
10 h := New()
11 ch, cancel := h.Subscribe("h1")
12 cancel()
13 select {
14 case _, ok := <-ch:
15 assert.False(t, ok, "cancel must close the channel so range loops exit")
16 default:
17 t.Fatal("channel must be closed after cancel")
18 }
19 }
20
21 func TestPokeWakesSubscriberAndNeverBlocks(t *testing.T) {
22 h := New()
23 ch, cancel := h.Subscribe("h1")
24 defer cancel()
25 h.Poke("h1")
26 h.Poke("h1") // second poke while un-drained must not block or panic
27 select {
28 case <-ch:
29 default:
30 t.Fatal("expected pending poke")
31 }
32 h.Poke("unsubscribed-host") // no subscriber: no-op
33 assert.True(t, true)
34 }
internal/server/registry/registry.go
Old New
@@ -0,0 +1,72 @@
1 // Package registry holds volatile actual state in memory. A server restart
2 // loses nothing meaningful: agents reconnect and re-report (spec).
3 package registry
4
5 import (
6 "bytes"
7 "slices"
8 "sync"
9 "time"
10 )
11
12 const OnlineWindow = 30 * time.Second
13
14 type Capacity struct{ VCPUs, MemMB, DiskGB int64 }
15
16 type ActualVM struct {
17 VMID, Power, Phase, IP, LastError string
18 }
19
20 type QuarantinedVM struct {
21 VMID, Name string
22 VMSpecJSON []byte
23 DestroyAtUnix int64
24 }
25
26 type Report struct {
27 VMs []ActualVM
28 Quarantined []QuarantinedVM
29 Capacity Capacity
30 FenceViolation bool
31 LastSeenEpoch uint64
32 }
33
34 type HostState struct {
35 Report
36 LastSeen time.Time
37 Online bool
38 }
39
40 type Registry struct {
41 mu sync.RWMutex
42 m map[string]HostState
43 now func() time.Time
44 }
45
46 func New(now func() time.Time) *Registry {
47 return &Registry{m: map[string]HostState{}, now: now}
48 }
49
50 func (r *Registry) UpdateReport(hostID string, rep Report) {
51 r.mu.Lock()
52 defer r.mu.Unlock()
53 r.m[hostID] = HostState{Report: rep, LastSeen: r.now()}
54 }
55
56 func (r *Registry) Get(hostID string) (HostState, bool) {
57 r.mu.RLock()
58 defer r.mu.RUnlock()
59 st, ok := r.m[hostID]
60 if !ok {
61 return st, false
62 }
63 st.Online = r.now().Sub(st.LastSeen) < OnlineWindow
64 // Deep-copy slices so callers cannot corrupt registry state.
65 st.Report.VMs = slices.Clone(st.Report.VMs)
66 quarantined := slices.Clone(st.Report.Quarantined)
67 for i := range quarantined {
68 quarantined[i].VMSpecJSON = bytes.Clone(quarantined[i].VMSpecJSON)
69 }
70 st.Report.Quarantined = quarantined
71 return st, true
72 }
internal/server/registry/registry_test.go
Old New
@@ -0,0 +1,45 @@
1 package registry
2
3 import (
4 "testing"
5 "time"
6 "github.com/stretchr/testify/assert"
7 )
8
9 func TestGetReturnsDefensiveCopy(t *testing.T) {
10 r := New(time.Now)
11 r.UpdateReport("h1", Report{
12 VMs: []ActualVM{{VMID: "vm1", Phase: "ready"}},
13 Quarantined: []QuarantinedVM{{VMID: "q1", VMSpecJSON: []byte("{}")}},
14 })
15 st, _ := r.Get("h1")
16 st.VMs[0].Phase = "corrupted"
17 st.Quarantined[0].VMSpecJSON[0] = 'X'
18 st2, _ := r.Get("h1")
19 assert.Equal(t, "ready", st2.VMs[0].Phase)
20 assert.Equal(t, byte('{'), st2.Quarantined[0].VMSpecJSON[0])
21 }
22
23 func TestReportRoundTripsAndOnlineWindow(t *testing.T) {
24 now := time.Now()
25 r := New(func() time.Time { return now })
26 r.UpdateReport("h1", Report{
27 VMs: []ActualVM{{VMID: "vm1", Power: "running", Phase: "ready", IP: "10.77.1.2"}},
28 Capacity: Capacity{VCPUs: 8, MemMB: 16384, DiskGB: 200},
29 LastSeenEpoch: 4,
30 })
31 st, ok := r.Get("h1")
32 assert.True(t, ok)
33 assert.Equal(t, "ready", st.VMs[0].Phase)
34 assert.True(t, st.Online)
35
36 now = now.Add(2 * OnlineWindow)
37 st, _ = r.Get("h1")
38 assert.False(t, st.Online, "stale heartbeat means offline")
39 }
40
41 func TestUnknownHostNotFound(t *testing.T) {
42 r := New(time.Now)
43 _, ok := r.Get("nope")
44 assert.False(t, ok)
45 }
internal/server/store/allocation_test.go
Old New
@@ -0,0 +1,65 @@
1 package store
2
3 import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"
8 )
9
10 func vmWithResources(t *testing.T, s *Store, h Host, name string, vcpus, mem, disk int64) VM {
11 t.Helper()
12 vm := VM{
13 ID: RandHex(8), HostID: h.ID, Name: name,
14 ImageURL: "http://img", ImageSHA256: "abc",
15 VCPUs: vcpus, MemMB: mem, DiskGB: disk, PowerState: "running",
16 }
17 require.NoError(t, s.CreateVM(vm))
18 return vm
19 }
20
21 func TestAllocatedByHostSumsLiveVMs(t *testing.T) {
22 s := newStore(t)
23 h := enrollHost(t, s)
24 vmWithResources(t, s, h, "a", 2, 2048, 10)
25 vmWithResources(t, s, h, "b", 1, 1024, 5)
26
27 alloc := s.mustAllocated(t)
28 got := alloc[h.ID]
29 assert.Equal(t, int64(3), got.VCPUs)
30 assert.Equal(t, int64(3072), got.MemMB)
31 assert.Equal(t, int64(15), got.DiskGB)
32 }
33
34 func TestAllocatedByHostExcludesTombstoned(t *testing.T) {
35 s := newStore(t)
36 h := enrollHost(t, s)
37 vmWithResources(t, s, h, "a", 2, 2048, 10)
38 dead := vmWithResources(t, s, h, "b", 4, 4096, 20)
39 require.NoError(t, s.TombstoneVM(dead.ID))
40
41 got := s.mustAllocated(t)[h.ID]
42 assert.Equal(t, int64(2), got.VCPUs, "tombstoned VM must not count as allocated")
43 assert.Equal(t, int64(2048), got.MemMB)
44 assert.Equal(t, int64(10), got.DiskGB)
45 }
46
47 func TestAllocatedByHostSeparatesHosts(t *testing.T) {
48 s := newStore(t)
49 h1 := enrollHost(t, s)
50 tok, _ := s.CreateEnrollmentToken()
51 h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "")
52 vmWithResources(t, s, h1, "a", 2, 2048, 10)
53 vmWithResources(t, s, h2, "b", 8, 8192, 40)
54
55 alloc := s.mustAllocated(t)
56 assert.Equal(t, int64(2), alloc[h1.ID].VCPUs)
57 assert.Equal(t, int64(8), alloc[h2.ID].VCPUs)
58 }
59
60 func (s *Store) mustAllocated(t *testing.T) map[string]Alloc {
61 t.Helper()
62 a, err := s.AllocatedByHost()
63 require.NoError(t, err)
64 return a
65 }
internal/server/store/decommission_test.go
Old New
@@ -0,0 +1,99 @@
1 package store
2
3 import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"
8 )
9
10 func makeVM(t *testing.T, s *Store, host Host, name string) VM {
11 t.Helper()
12 vm := VM{
13 ID: RandHex(8), HostID: host.ID, Name: name,
14 ImageURL: "http://img", ImageSHA256: "abc",
15 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
16 }
17 require.NoError(t, s.CreateVM(vm))
18 return vm
19 }
20
21 func TestDecommissionHostTombstonesVMsAndSetsStatus(t *testing.T) {
22 s := newStore(t)
23 h := enrollHost(t, s)
24 makeVM(t, s, h, "vm-a")
25 makeVM(t, s, h, "vm-b")
26
27 epochBefore, _ := s.Epoch()
28 require.NoError(t, s.DecommissionHost(h.ID))
29
30 got, err := s.GetHost(h.ID)
31 require.NoError(t, err)
32 assert.Equal(t, "decommissioning", got.Status)
33
34 // All the host's VMs are tombstoned (no longer live).
35 vms, err := s.ListVMs()
36 require.NoError(t, err)
37 for _, vm := range vms {
38 if vm.HostID == h.ID {
39 assert.NotNil(t, vm.DeletedAt, "vm %s should be tombstoned", vm.Name)
40 }
41 }
42
43 epochAfter, _ := s.Epoch()
44 assert.Greater(t, epochAfter, epochBefore, "decommission must bump epoch")
45 }
46
47 func TestRemoveHostFreesCIDRForReuse(t *testing.T) {
48 s := newStore(t)
49 h1 := enrollHost(t, s)
50 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
51 tok2, _ := s.CreateEnrollmentToken()
52 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
53 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
54
55 // Decommission h1 and simulate the agent reaping its VMs (hard-delete).
56 vm := makeVM(t, s, h1, "vm-a")
57 require.NoError(t, s.DecommissionHost(h1.ID))
58 require.NoError(t, s.HardDeleteVM(vm.ID))
59
60 require.NoError(t, s.RemoveHost(h1.ID))
61
62 _, err := s.GetHost(h1.ID)
63 assert.Error(t, err, "removed host should be gone")
64
65 // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one.
66 tok3, _ := s.CreateEnrollmentToken()
67 h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "")
68 require.NoError(t, err)
69 assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused")
70 }
71
72 func TestRemoveHostRefusesWhileVMsRemain(t *testing.T) {
73 s := newStore(t)
74 h := enrollHost(t, s)
75 makeVM(t, s, h, "vm-a")
76 require.NoError(t, s.DecommissionHost(h.ID))
77 // VM row still present (not yet reaped) — removal must refuse.
78 err := s.RemoveHost(h.ID)
79 assert.Error(t, err, "RemoveHost must refuse while VM rows remain")
80 }
81
82 func TestHostVMCount(t *testing.T) {
83 s := newStore(t)
84 h := enrollHost(t, s)
85 assert.Equal(t, 0, s.mustHostVMCount(t, h.ID))
86 vm := makeVM(t, s, h, "vm-a")
87 assert.Equal(t, 1, s.mustHostVMCount(t, h.ID))
88 require.NoError(t, s.TombstoneVM(vm.ID))
89 assert.Equal(t, 1, s.mustHostVMCount(t, h.ID), "tombstoned but not reaped still counts")
90 require.NoError(t, s.HardDeleteVM(vm.ID))
91 assert.Equal(t, 0, s.mustHostVMCount(t, h.ID))
92 }
93
94 func (s *Store) mustHostVMCount(t *testing.T, id string) int {
95 t.Helper()
96 n, err := s.HostVMCount(id)
97 require.NoError(t, err)
98 return n
99 }
internal/server/store/store.go
Old New
@@ -0,0 +1,624 @@
1 package store
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "net/netip"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 _ "modernc.org/sqlite"
17 "github.com/a73x/eitri/internal/transport"
18 )
19
20 // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM.
21 var ErrNameTaken = errors.New("vm name already in use")
22
23 // ErrHostNotFound is returned by CreateVM when the host_id does not exist.
24 var ErrHostNotFound = errors.New("host not found")
25
26 // RandHex returns n cryptographically-random bytes encoded as hex. rand.Read is
27 // documented never to fail (Go 1.24+), so its error is intentionally ignored.
28 func RandHex(n int) string {
29 b := make([]byte, n)
30 rand.Read(b) //nolint:errcheck // crypto/rand.Read never returns an error
31 return hex.EncodeToString(b)
32 }
33
34 type Store struct {
35 db *sql.DB
36 dbDir string
37 }
38
39 type Host struct {
40 ID, Name, OS, Arch, Provisioner, Overlay, BridgeCIDR, Status string
41 EnrolledAt time.Time
42 }
43
44 type VM struct {
45 ID, HostID, Name, ImageURL, ImageSHA256, CloudInit string
46 VCPUs, MemMB, DiskGB int64
47 Persistent bool
48 PowerState, Status, LastError, AssignedIP string
49 SSHAuthorizedKey string
50 CreatedAt time.Time
51 DeletedAt *time.Time
52 }
53
54 const schema = `
55 CREATE TABLE IF NOT EXISTS meta (
56 key TEXT PRIMARY KEY,
57 value TEXT NOT NULL
58 );
59
60 CREATE TABLE IF NOT EXISTS hosts (
61 id TEXT PRIMARY KEY,
62 name TEXT NOT NULL,
63 os TEXT NOT NULL,
64 arch TEXT NOT NULL,
65 provisioner TEXT NOT NULL,
66 overlay TEXT NOT NULL DEFAULT 'tailscale',
67 bridge_cidr TEXT NOT NULL,
68 status TEXT NOT NULL DEFAULT 'enrolled',
69 enrolled_at DATETIME NOT NULL
70 );
71
72 CREATE TABLE IF NOT EXISTS enrollment_tokens (
73 token_hash TEXT PRIMARY KEY,
74 expires_at DATETIME NOT NULL,
75 used_at DATETIME
76 );
77
78 CREATE TABLE IF NOT EXISTS vms (
79 id TEXT PRIMARY KEY,
80 host_id TEXT NOT NULL REFERENCES hosts(id),
81 name TEXT NOT NULL,
82 image_url TEXT NOT NULL,
83 image_sha256 TEXT NOT NULL,
84 cloud_init TEXT NOT NULL DEFAULT '',
85 ssh_authorized_key TEXT NOT NULL DEFAULT '',
86 vcpus INTEGER NOT NULL,
87 mem_mb INTEGER NOT NULL,
88 disk_gb INTEGER NOT NULL,
89 persistent INTEGER NOT NULL DEFAULT 0,
90 power_state TEXT NOT NULL,
91 status TEXT NOT NULL DEFAULT 'pending',
92 last_error TEXT NOT NULL DEFAULT '',
93 assigned_ip TEXT NOT NULL DEFAULT '',
94 created_at DATETIME NOT NULL,
95 deleted_at DATETIME
96 );
97
98 CREATE UNIQUE INDEX IF NOT EXISTS vms_name ON vms(name) WHERE deleted_at IS NULL;
99
100 -- bridge CIDRs returned to the pool by host decommission, available for reuse
101 -- before the monotonic next_cidr_index allocator is consulted.
102 CREATE TABLE IF NOT EXISTS freed_cidrs (
103 bridge_cidr TEXT PRIMARY KEY
104 );
105
106 INSERT INTO meta(key, value) VALUES ('epoch', '0') ON CONFLICT DO NOTHING;
107 INSERT INTO meta(key, value) VALUES ('next_cidr_index', '1') ON CONFLICT DO NOTHING;
108 `
109
110 func Open(path, cidrPool string) (*Store, error) {
111 dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
112 db, err := sql.Open("sqlite", dsn)
113 if err != nil {
114 return nil, fmt.Errorf("open db: %w", err)
115 }
116 db.SetMaxOpenConns(1)
117
118 if _, err := db.Exec(schema); err != nil {
119 db.Close()
120 return nil, fmt.Errorf("apply schema: %w", err)
121 }
122
123 // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins.
124 if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('cidr_pool', ?) ON CONFLICT DO NOTHING`, cidrPool); err != nil {
125 db.Close()
126 return nil, fmt.Errorf("seed cidr_pool: %w", err)
127 }
128
129 return &Store{db: db, dbDir: filepath.Dir(path)}, nil
130 }
131
132 func (s *Store) Close() error { return s.db.Close() }
133
134 func (s *Store) Epoch() (uint64, error) {
135 var v uint64
136 err := s.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&v)
137 return v, err
138 }
139
140 func bumpEpoch(tx *sql.Tx) error {
141 _, err := tx.Exec(`UPDATE meta SET value = CAST(value AS INTEGER)+1 WHERE key='epoch'`)
142 return err
143 }
144
145 // subnetForIndex returns the idx-th /24 within pool using 32-bit arithmetic.
146 // idx starts at 1; the 0th /24 (the pool's own network address block) is reserved.
147 // Returns an error when the resulting /24 is outside the pool (exhaustion).
148 func subnetForIndex(pool netip.Prefix, idx int64) (string, error) {
149 if idx > 1<<23 {
150 return "", fmt.Errorf("cidr pool %s exhausted at host index %d", pool, idx)
151 }
152 base := pool.Masked().Addr().As4()
153 b := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3])
154 start := b + uint32(idx)*256 // idx-th /24; each /24 is 256 addresses
155 last := start + 255
156 startAddr := netip.AddrFrom4([4]byte{byte(start >> 24), byte(start >> 16), byte(start >> 8), byte(start)})
157 lastAddr := netip.AddrFrom4([4]byte{byte(last >> 24), byte(last >> 16), byte(last >> 8), byte(last)})
158 if !pool.Contains(startAddr) || !pool.Contains(lastAddr) {
159 return "", fmt.Errorf("cidr pool %s exhausted at host index %d", pool, idx)
160 }
161 return fmt.Sprintf("%d.%d.%d.0/24", start>>24&0xff, start>>16&0xff, start>>8&0xff), nil
162 }
163
164 func (s *Store) CreateEnrollmentToken() (string, error) {
165 tok := RandHex(32)
166 h := sha256.Sum256([]byte(tok))
167 hash := hex.EncodeToString(h[:])
168 expiresAt := time.Now().UTC().Add(15 * time.Minute)
169 _, err := s.db.Exec(
170 `INSERT INTO enrollment_tokens(token_hash, expires_at) VALUES (?, ?)`,
171 hash, expiresAt.Format(time.RFC3339),
172 )
173 if err != nil {
174 return "", fmt.Errorf("insert token: %w", err)
175 }
176 return tok, nil
177 }
178
179 func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, overlay string) (Host, error) {
180 if overlay == "" {
181 overlay = "tailscale"
182 }
183 h := sha256.Sum256([]byte(tok))
184 hash := hex.EncodeToString(h[:])
185
186 tx, err := s.db.Begin()
187 if err != nil {
188 return Host{}, err
189 }
190 defer tx.Rollback()
191
192 now := time.Now().UTC()
193 res, err := tx.Exec(
194 `UPDATE enrollment_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND expires_at > ?`,
195 now.Format(time.RFC3339), hash, now.Format(time.RFC3339),
196 )
197 if err != nil {
198 return Host{}, fmt.Errorf("mark token used: %w", err)
199 }
200 n, _ := res.RowsAffected()
201 if n != 1 {
202 return Host{}, fmt.Errorf("token invalid, expired, or already used")
203 }
204
205 var cidrPool string
206 var nextIdx int64
207 if err := tx.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&cidrPool); err != nil {
208 return Host{}, fmt.Errorf("read cidr_pool: %w", err)
209 }
210 if err := tx.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='next_cidr_index'`).Scan(&nextIdx); err != nil {
211 return Host{}, fmt.Errorf("read next_cidr_index: %w", err)
212 }
213
214 prefix, err := netip.ParsePrefix(cidrPool)
215 if err != nil {
216 return Host{}, fmt.Errorf("parse cidr_pool: %w", err)
217 }
218
219 // Reuse a CIDR freed by a prior decommission before extending the monotonic
220 // allocator, so the pool doesn't leak across host churn.
221 var bridgeCIDR string
222 if err := tx.QueryRow(`SELECT bridge_cidr FROM freed_cidrs ORDER BY bridge_cidr LIMIT 1`).Scan(&bridgeCIDR); err == nil {
223 if _, err := tx.Exec(`DELETE FROM freed_cidrs WHERE bridge_cidr=?`, bridgeCIDR); err != nil {
224 return Host{}, fmt.Errorf("consume freed cidr: %w", err)
225 }
226 } else if err == sql.ErrNoRows {
227 bridgeCIDR, err = subnetForIndex(prefix, nextIdx)
228 if err != nil {
229 return Host{}, err
230 }
231 if _, err := tx.Exec(`UPDATE meta SET value=? WHERE key='next_cidr_index'`, nextIdx+1); err != nil {
232 return Host{}, fmt.Errorf("increment next_cidr_index: %w", err)
233 }
234 } else {
235 return Host{}, fmt.Errorf("read freed_cidrs: %w", err)
236 }
237
238 id := RandHex(16)
239
240 if _, err := tx.Exec(
241 `INSERT INTO hosts(id, name, os, arch, provisioner, overlay, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?,?)`,
242 id, name, osName, arch, provisioner, overlay, bridgeCIDR, now.Format(time.RFC3339),
243 ); err != nil {
244 return Host{}, fmt.Errorf("insert host: %w", err)
245 }
246
247 if err := tx.Commit(); err != nil {
248 return Host{}, err
249 }
250
251 return Host{
252 ID: id,
253 Name: name,
254 OS: osName,
255 Arch: arch,
256 Provisioner: provisioner,
257 Overlay: overlay,
258 BridgeCIDR: bridgeCIDR,
259 Status: "enrolled",
260 EnrolledAt: now,
261 }, nil
262 }
263
264 func (s *Store) GetHost(id string) (Host, error) {
265 var h Host
266 var enrolledAt string
267 err := s.db.QueryRow(
268 `SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at FROM hosts WHERE id=?`, id,
269 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt)
270 if err != nil {
271 return Host{}, err
272 }
273 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt)
274 return h, nil
275 }
276
277 func (s *Store) ListHosts() ([]Host, error) {
278 rows, err := s.db.Query(`SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at FROM hosts`)
279 if err != nil {
280 return nil, err
281 }
282 defer rows.Close()
283 var hosts []Host
284 for rows.Next() {
285 var h Host
286 var enrolledAt string
287 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt); err != nil {
288 return nil, err
289 }
290 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt)
291 hosts = append(hosts, h)
292 }
293 return hosts, rows.Err()
294 }
295
296 func (s *Store) CreateVM(vm VM) error {
297 tx, err := s.db.Begin()
298 if err != nil {
299 return err
300 }
301 defer tx.Rollback()
302
303 now := time.Now().UTC()
304 if vm.ID == "" {
305 vm.ID = RandHex(16)
306 }
307
308 _, err = tx.Exec(
309 `INSERT INTO vms(id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key,
310 vcpus, mem_mb, disk_gb, persistent, power_state, created_at)
311 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
312 vm.ID, vm.HostID, vm.Name, vm.ImageURL, vm.ImageSHA256,
313 vm.CloudInit, vm.SSHAuthorizedKey,
314 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState,
315 now.Format(time.RFC3339),
316 )
317 if err != nil {
318 msg := err.Error()
319 if strings.Contains(msg, "UNIQUE constraint failed: vms.name") {
320 return ErrNameTaken
321 }
322 if strings.Contains(msg, "FOREIGN KEY constraint failed") {
323 return ErrHostNotFound
324 }
325 return fmt.Errorf("insert vm: %w", err)
326 }
327
328 if err := bumpEpoch(tx); err != nil {
329 return fmt.Errorf("bump epoch: %w", err)
330 }
331
332 return tx.Commit()
333 }
334
335 // mutate runs a single mutation SQL that should affect exactly 1 row, then bumps the epoch, all in a tx.
336 func (s *Store) mutate(query string, args ...any) error {
337 tx, err := s.db.Begin()
338 if err != nil {
339 return err
340 }
341 defer tx.Rollback()
342
343 res, err := tx.Exec(query, args...)
344 if err != nil {
345 return err
346 }
347 n, _ := res.RowsAffected()
348 if n == 0 {
349 return sql.ErrNoRows
350 }
351
352 if err := bumpEpoch(tx); err != nil {
353 return err
354 }
355 return tx.Commit()
356 }
357
358 func (s *Store) SetVMPower(id, power string) error {
359 return s.mutate(`UPDATE vms SET power_state=? WHERE id=? AND deleted_at IS NULL`, power, id)
360 }
361
362 func (s *Store) TombstoneVM(id string) error {
363 return s.mutate(`UPDATE vms SET deleted_at=? WHERE id=? AND deleted_at IS NULL`,
364 time.Now().UTC().Format(time.RFC3339), id)
365 }
366
367 func (s *Store) HardDeleteVM(id string) error {
368 return s.mutate(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id)
369 }
370
371 // DecommissionHost marks a host as decommissioning and tombstones all its live
372 // VMs so the agent reaps them through the normal quarantine→destroy path. One
373 // epoch bump for the whole transition.
374 func (s *Store) DecommissionHost(id string) error {
375 tx, err := s.db.Begin()
376 if err != nil {
377 return err
378 }
379 defer tx.Rollback()
380
381 res, err := tx.Exec(`UPDATE hosts SET status='decommissioning' WHERE id=?`, id)
382 if err != nil {
383 return fmt.Errorf("set host status: %w", err)
384 }
385 if n, _ := res.RowsAffected(); n == 0 {
386 return sql.ErrNoRows
387 }
388 if _, err := tx.Exec(
389 `UPDATE vms SET deleted_at=? WHERE host_id=? AND deleted_at IS NULL`,
390 time.Now().UTC().Format(time.RFC3339), id,
391 ); err != nil {
392 return fmt.Errorf("tombstone host vms: %w", err)
393 }
394 if err := bumpEpoch(tx); err != nil {
395 return fmt.Errorf("bump epoch: %w", err)
396 }
397 return tx.Commit()
398 }
399
400 // Alloc is the sum of resources committed to live VMs on a host.
401 type Alloc struct{ VCPUs, MemMB, DiskGB int64 }
402
403 // AllocatedByHost returns, per host, the resources allocated to its live
404 // (non-tombstoned) VMs. Hosts with no live VMs are absent from the map.
405 func (s *Store) AllocatedByHost() (map[string]Alloc, error) {
406 rows, err := s.db.Query(`
407 SELECT host_id, COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0)
408 FROM vms WHERE deleted_at IS NULL GROUP BY host_id`)
409 if err != nil {
410 return nil, err
411 }
412 defer rows.Close()
413 out := make(map[string]Alloc)
414 for rows.Next() {
415 var id string
416 var a Alloc
417 if err := rows.Scan(&id, &a.VCPUs, &a.MemMB, &a.DiskGB); err != nil {
418 return nil, err
419 }
420 out[id] = a
421 }
422 return out, rows.Err()
423 }
424
425 // HostVMCount returns the number of VM rows for a host (live + tombstoned).
426 // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means
427 // the host is fully drained.
428 func (s *Store) HostVMCount(id string) (int, error) {
429 var n int
430 err := s.db.QueryRow(`SELECT COUNT(*) FROM vms WHERE host_id=?`, id).Scan(&n)
431 return n, err
432 }
433
434 // RemoveHost finalizes decommission: it returns the host's bridge CIDR to the
435 // pool and deletes the host row. It refuses while any VM rows remain (not yet
436 // reaped), so it must be called only after HostVMCount reaches 0.
437 func (s *Store) RemoveHost(id string) error {
438 tx, err := s.db.Begin()
439 if err != nil {
440 return err
441 }
442 defer tx.Rollback()
443
444 var n int
445 if err := tx.QueryRow(`SELECT COUNT(*) FROM vms WHERE host_id=?`, id).Scan(&n); err != nil {
446 return fmt.Errorf("count host vms: %w", err)
447 }
448 if n > 0 {
449 return fmt.Errorf("host %s still has %d VM(s); not drained", id, n)
450 }
451
452 var bridgeCIDR string
453 if err := tx.QueryRow(`SELECT bridge_cidr FROM hosts WHERE id=?`, id).Scan(&bridgeCIDR); err != nil {
454 return fmt.Errorf("lookup host cidr: %w", err)
455 }
456 if _, err := tx.Exec(`INSERT INTO freed_cidrs(bridge_cidr) VALUES(?) ON CONFLICT DO NOTHING`, bridgeCIDR); err != nil {
457 return fmt.Errorf("free cidr: %w", err)
458 }
459 res, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id)
460 if err != nil {
461 return fmt.Errorf("delete host: %w", err)
462 }
463 if rows, _ := res.RowsAffected(); rows == 0 {
464 return sql.ErrNoRows
465 }
466 if err := bumpEpoch(tx); err != nil {
467 return fmt.Errorf("bump epoch: %w", err)
468 }
469 return tx.Commit()
470 }
471
472 func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error {
473 if ip != "" {
474 // Validate IP is within the owning host's bridge_cidr.
475 var cidrStr string
476 err := s.db.QueryRow(
477 `SELECT h.bridge_cidr FROM vms v JOIN hosts h ON h.id = v.host_id WHERE v.id=?`, id,
478 ).Scan(&cidrStr)
479 if err != nil {
480 return fmt.Errorf("lookup host cidr: %w", err)
481 }
482 prefix, err := netip.ParsePrefix(cidrStr)
483 if err != nil {
484 return fmt.Errorf("parse bridge_cidr: %w", err)
485 }
486 addr, err := netip.ParseAddr(ip)
487 if err != nil {
488 return fmt.Errorf("parse ip: %w", err)
489 }
490 if !prefix.Contains(addr) {
491 return fmt.Errorf("ip %s is outside host cidr %s", ip, cidrStr)
492 }
493 }
494
495 res, err := s.db.Exec(
496 `UPDATE vms SET status=?, last_error=?, assigned_ip=CASE WHEN ?='' THEN assigned_ip ELSE ? END WHERE id=?`,
497 status, lastErr, ip, ip, id,
498 )
499 if err != nil {
500 return err
501 }
502 n, _ := res.RowsAffected()
503 if n == 0 {
504 return sql.ErrNoRows
505 }
506 return nil
507 }
508
509 func scanVM(rows *sql.Rows) (VM, error) {
510 var vm VM
511 var createdAt string
512 var deletedAt sql.NullString
513 err := rows.Scan(
514 &vm.ID, &vm.HostID, &vm.Name, &vm.ImageURL, &vm.ImageSHA256,
515 &vm.CloudInit, &vm.SSHAuthorizedKey,
516 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent,
517 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP,
518 &createdAt, &deletedAt,
519 )
520 if err != nil {
521 return VM{}, err
522 }
523 vm.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
524 if deletedAt.Valid {
525 t, _ := time.Parse(time.RFC3339, deletedAt.String)
526 vm.DeletedAt = &t
527 }
528 return vm, nil
529 }
530
531 func (s *Store) ListVMs() ([]VM, error) {
532 rows, err := s.db.Query(
533 `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key,
534 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
535 created_at, deleted_at FROM vms`,
536 )
537 if err != nil {
538 return nil, err
539 }
540 defer rows.Close()
541 var vms []VM
542 for rows.Next() {
543 vm, err := scanVM(rows)
544 if err != nil {
545 return nil, err
546 }
547 vms = append(vms, vm)
548 }
549 return vms, rows.Err()
550 }
551
552 func (s *Store) DesiredForHost(hostID string) (uint64, []VM, error) {
553 tx, err := s.db.Begin()
554 if err != nil {
555 return 0, nil, err
556 }
557 defer tx.Rollback()
558
559 var epoch uint64
560 if err := tx.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&epoch); err != nil {
561 return 0, nil, fmt.Errorf("read epoch: %w", err)
562 }
563
564 rows, err := tx.Query(
565 `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key,
566 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
567 created_at, deleted_at FROM vms WHERE host_id=?`, hostID,
568 )
569 if err != nil {
570 return 0, nil, err
571 }
572 defer rows.Close()
573
574 var vms []VM
575 for rows.Next() {
576 vm, err := scanVM(rows)
577 if err != nil {
578 return 0, nil, err
579 }
580 vms = append(vms, vm)
581 }
582 if err := rows.Err(); err != nil {
583 return 0, nil, err
584 }
585
586 if err := tx.Commit(); err != nil {
587 return 0, nil, err
588 }
589
590 return epoch, vms, nil
591 }
592
593 // ServerCert returns the server's TLS cert PEM and its hex sha256 fingerprint,
594 // generating and persisting a self-signed cert on first call. Cert and key live
595 // beside the DB as server.crt / server.key.
596 func (s *Store) ServerCert() (certPEM []byte, fingerprint string, err error) {
597 certPath := filepath.Join(s.dbDir, "server.crt")
598 keyPath := filepath.Join(s.dbDir, "server.key")
599 certPEM, errC := os.ReadFile(certPath)
600 _, errK := os.ReadFile(keyPath)
601 if errC != nil || errK != nil {
602 var keyPEM []byte
603 certPEM, keyPEM, err = transport.GenerateServerCert()
604 if err != nil {
605 return nil, "", err
606 }
607 if err = os.WriteFile(certPath, certPEM, 0o600); err != nil {
608 return nil, "", err
609 }
610 if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
611 return nil, "", err
612 }
613 }
614 fp, err := transport.CertFingerprint(certPEM)
615 if err != nil {
616 return nil, "", err
617 }
618 return certPEM, fp, nil
619 }
620
621 // ServerKeyPEM returns the server key PEM (call after ServerCert has run).
622 func (s *Store) ServerKeyPEM() ([]byte, error) {
623 return os.ReadFile(filepath.Join(s.dbDir, "server.key"))
624 }
internal/server/store/store_test.go
Old New
@@ -0,0 +1,183 @@
1 package store
2
3 import (
4 "path/filepath"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 func newStore(t *testing.T) *Store {
12 t.Helper()
13 s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
14 require.NoError(t, err)
15 t.Cleanup(func() { s.Close() })
16 return s
17 }
18
19 func enrollHost(t *testing.T, s *Store) Host {
20 t.Helper()
21 tok, err := s.CreateEnrollmentToken()
22 require.NoError(t, err)
23 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "")
24 require.NoError(t, err)
25 return h
26 }
27
28 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
29 s := newStore(t)
30 tok1, _ := s.CreateEnrollmentToken()
31 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
32 require.NoError(t, err)
33 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
34
35 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "")
36 assert.Error(t, err, "token must be one-time use")
37
38 tok2, _ := s.CreateEnrollmentToken()
39 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
40 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
41 }
42
43 func TestDesiredStateMutationsBumpEpochButStatusWritesDoNot(t *testing.T) {
44 s := newStore(t)
45 h := enrollHost(t, s)
46 e0, _ := s.Epoch()
47
48 vm := VM{ID: "vm1", HostID: h.ID, Name: "sandbox-1", ImageURL: "http://x/img.qcow2",
49 ImageSHA256: "abc", VCPUs: 2, MemMB: 2048, DiskGB: 10, PowerState: "running"}
50 require.NoError(t, s.CreateVM(vm))
51 e1, _ := s.Epoch()
52 assert.Equal(t, e0+1, e1, "create bumps")
53
54 require.NoError(t, s.SetVMPower("vm1", "stopped"))
55 e2, _ := s.Epoch()
56 assert.Equal(t, e1+1, e2, "power edit bumps")
57
58 require.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.2"))
59 e3, _ := s.Epoch()
60 assert.Equal(t, e2, e3, "agent-reported status does NOT bump")
61
62 require.NoError(t, s.TombstoneVM("vm1"))
63 e4, _ := s.Epoch()
64 assert.Equal(t, e3+1, e4, "tombstone bumps")
65
66 require.NoError(t, s.HardDeleteVM("vm1"))
67 e5, _ := s.Epoch()
68 assert.Equal(t, e4+1, e5, "hard-delete bumps")
69 }
70
71 func TestNameUniqueAmongLiveRowsOnly(t *testing.T) {
72 s := newStore(t)
73 h := enrollHost(t, s)
74 mk := func(id string) VM {
75 return VM{ID: id, HostID: h.ID, Name: "sandbox-7", ImageURL: "u", ImageSHA256: "s",
76 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}
77 }
78 require.NoError(t, s.CreateVM(mk("vm1")))
79 assert.Error(t, s.CreateVM(mk("vm2")), "live duplicate rejected")
80 require.NoError(t, s.TombstoneVM("vm1"))
81 assert.NoError(t, s.CreateVM(mk("vm3")), "tombstoned row must not block the name")
82 }
83
84 func TestDesiredForHostIncludesTombstonedAndEpochConsistently(t *testing.T) {
85 s := newStore(t)
86 h := enrollHost(t, s)
87 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u",
88 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
89 require.NoError(t, s.TombstoneVM("vm1"))
90 epoch, vms, err := s.DesiredForHost(h.ID)
91 require.NoError(t, err)
92 e, _ := s.Epoch()
93 assert.Equal(t, e, epoch)
94 require.Len(t, vms, 1)
95 assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked")
96 }
97
98 func TestRecordVMStatusValidatesIPWithinHostCIDR(t *testing.T) {
99 s := newStore(t)
100 h := enrollHost(t, s) // 10.77.1.0/24
101 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u",
102 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
103 assert.Error(t, s.RecordVMStatus("vm1", "ready", "", "10.77.2.9"),
104 "IP outside the host's CIDR must be rejected")
105 assert.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.9"))
106 }
107
108 func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) {
109 s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22")
110 require.NoError(t, err)
111 defer s.Close()
112 tok, _ := s.CreateEnrollmentToken()
113 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "")
114 require.NoError(t, err)
115 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved")
116 }
117
118 func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
119 s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1)
120 require.NoError(t, err)
121 defer s.Close()
122 tok1, _ := s.CreateEnrollmentToken()
123 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
124 require.NoError(t, err)
125 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR)
126 tok2, _ := s.CreateEnrollmentToken()
127 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
128 assert.ErrorContains(t, err, "exhausted")
129 }
130
131 func TestRecordVMStatusUnknownVMErrors(t *testing.T) {
132 s := newStore(t)
133 err := s.RecordVMStatus("nope", "ready", "", "")
134 assert.Error(t, err)
135 }
136
137 // Fix 5: hosts.overlay column tests.
138
139 func TestRedeemEnrollmentToken_OverlayPersistedAndDefaultsTailscale(t *testing.T) {
140 s := newStore(t)
141 tok, _ := s.CreateEnrollmentToken()
142 // Explicit overlay="none" must be persisted.
143 h, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "none")
144 require.NoError(t, err)
145 assert.Equal(t, "none", h.Overlay, "overlay must be 'none' as requested")
146
147 // Empty overlay → defaults to "tailscale".
148 tok2, _ := s.CreateEnrollmentToken()
149 h2, err := s.RedeemEnrollmentToken(tok2, "host-c", "linux", "amd64", "cloudhv", "")
150 require.NoError(t, err)
151 assert.Equal(t, "tailscale", h2.Overlay, "empty overlay must default to 'tailscale'")
152 }
153
154 func TestListHosts_ReturnsOverlay(t *testing.T) {
155 s := newStore(t)
156 tok, _ := s.CreateEnrollmentToken()
157 _, err := s.RedeemEnrollmentToken(tok, "host-x", "linux", "amd64", "cloudhv", "none")
158 require.NoError(t, err)
159 hosts, err := s.ListHosts()
160 require.NoError(t, err)
161 require.Len(t, hosts, 1)
162 assert.Equal(t, "none", hosts[0].Overlay)
163 }
164
165 func TestServerCertLoadOrCreatePersists(t *testing.T) {
166 dir := t.TempDir()
167 st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16")
168 require.NoError(t, err)
169
170 cert1, fp1, err := st.ServerCert()
171 require.NoError(t, err)
172 require.NotEmpty(t, cert1)
173 require.Len(t, fp1, 64)
174
175 cert2, fp2, err := st.ServerCert()
176 require.NoError(t, err)
177 assert.Equal(t, fp1, fp2) // same persisted cert, not regenerated
178 assert.Equal(t, cert1, cert2)
179
180 key, err := st.ServerKeyPEM()
181 require.NoError(t, err)
182 require.NotEmpty(t, key)
183 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -0,0 +1,273 @@
1 // Package syncsvc is the QUIC server end of the agent reconcile stream.
2 package syncsvc
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "time"
11
12 "github.com/a73x/eitri/internal/pb"
13 "github.com/a73x/eitri/internal/server/hosttoken"
14 "github.com/a73x/eitri/internal/server/hub"
15 "github.com/a73x/eitri/internal/server/registry"
16 "github.com/a73x/eitri/internal/server/store"
17 "github.com/a73x/eitri/internal/transport"
18 "github.com/quic-go/quic-go"
19 )
20
21 // defaultWriteTimeout bounds each down-stream snapshot write so a stalled or
22 // malicious agent (one that keeps the QUIC connection alive but stops reading
23 // the down-stream) cannot pin server goroutines once the flow-control window
24 // fills.
25 const defaultWriteTimeout = 30 * time.Second
26
27 // Service is the QUIC server end of the agent reconcile stream.
28 type Service struct {
29 st *store.Store
30 reg *registry.Registry
31 hub *hub.Hub
32 secret []byte
33 // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout).
34 writeTimeout time.Duration
35 }
36
37 // New constructs a Service with the production-default down-stream write timeout.
38 func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte) *Service {
39 return newWithWriteTimeout(st, reg, h, secret, defaultWriteTimeout)
40 }
41
42 // newWithWriteTimeout constructs a Service with an explicit down-stream write
43 // timeout. A zero timeout falls back to defaultWriteTimeout. Tests use this to
44 // inject a short timeout; New keeps the public signature unchanged.
45 func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, writeTimeout time.Duration) *Service {
46 if writeTimeout <= 0 {
47 writeTimeout = defaultWriteTimeout
48 }
49 return &Service{st: st, reg: reg, hub: h, secret: secret, writeTimeout: writeTimeout}
50 }
51
52 // Serve accepts QUIC connections until ctx is cancelled.
53 func (s *Service) Serve(ctx context.Context, lis *quic.Listener) error {
54 for {
55 conn, err := lis.Accept(ctx)
56 if err != nil {
57 return err
58 }
59 go s.handleConn(ctx, conn)
60 }
61 }
62
63 // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and
64 // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+).
65 func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
66 // Up-stream: agent opens it and sends Hello first.
67 up, err := conn.AcceptStream(ctx)
68 if err != nil {
69 return
70 }
71 var first pb.AgentMessage
72 if err := transport.ReadMsg(up, &first, transport.DefaultMaxFrame); err != nil {
73 return
74 }
75 h := first.GetHello()
76 if h == nil {
77 _ = conn.CloseWithError(transport.CodeAuthRejected, "first frame must be Hello")
78 return
79 }
80 // Auth: the Hello carries a Bearer host credential (string credential = 9).
81 cred := h.GetCredential()
82 // host_id in Hello is advisory; the authenticated identity comes from the credential (hosttoken.Verify), so a spoofed Hello.HostId cannot mislead us.
83 hostID, ok := hosttoken.Verify(s.secret, cred)
84 if !ok {
85 _ = conn.CloseWithError(transport.CodeAuthRejected, "invalid host credential")
86 return
87 }
88 if _, err := s.st.GetHost(hostID); err != nil {
89 _ = conn.CloseWithError(transport.CodeAuthRejected, "host not found")
90 return
91 }
92 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch())
93
94 // Down-stream: server opens it; first write makes it visible to the agent.
95 down, err := conn.OpenStreamSync(ctx)
96 if err != nil {
97 return
98 }
99
100 // Single writer for the down-stream: the poke goroutine.
101 pokes, cancel := s.hub.Subscribe(hostID)
102 defer cancel()
103 sendErr := make(chan error, 1)
104 go func() {
105 // Initial snapshot first (single-sender rule), then one per poke.
106 if err := s.pushSnapshot(down, hostID); err != nil {
107 s.failWrite(conn, hostID, err)
108 sendErr <- err
109 return
110 }
111 for range pokes {
112 if err := s.pushSnapshot(down, hostID); err != nil {
113 s.failWrite(conn, hostID, err)
114 sendErr <- err
115 return
116 }
117 }
118 sendErr <- nil
119 }()
120
121 // Read loop: up-stream reports only.
122 for {
123 var msg pb.AgentMessage
124 if err := transport.ReadMsg(up, &msg, transport.DefaultMaxFrame); err != nil {
125 if !errors.Is(err, io.EOF) {
126 slog.Warn("agent stream ended", "host", hostID, "err", err)
127 }
128 return
129 }
130 if rep := msg.GetReport(); rep != nil {
131 s.applyReport(hostID, rep)
132 }
133 select {
134 case e := <-sendErr:
135 if e != nil {
136 slog.Warn("down-stream push failed", "host", hostID, "err", e)
137 }
138 return
139 default:
140 }
141 }
142 }
143
144 // pushSnapshot reads the current desired state in a single transaction and sends it.
145 func (s *Service) pushSnapshot(down quic.Stream, hostID string) error {
146 epoch, vms, err := s.st.DesiredForHost(hostID)
147 if err != nil {
148 return fmt.Errorf("desired for host: %w", err)
149 }
150 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))}
151 for _, v := range vms {
152 snap.Vms = append(snap.Vms, &pb.VMDesired{
153 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256,
154 CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB,
155 Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil,
156 SshAuthorizedKey: v.SSHAuthorizedKey,
157 })
158 }
159 // Bound the write: if a stalled agent stops reading the down-stream but keeps
160 // the connection alive, the flow-control window fills and an unbounded Write
161 // would block forever, pinning this goroutine. The deadline turns that into a
162 // write error, which the caller uses to close the connection.
163 if err := down.SetWriteDeadline(time.Now().Add(s.writeTimeout)); err != nil {
164 return fmt.Errorf("set write deadline: %w", err)
165 }
166 return transport.WriteMsg(down, &pb.ServerMessage{Msg: &pb.ServerMessage_Snapshot{Snapshot: snap}})
167 }
168
169 // failWrite handles a failed down-stream write by closing the connection. A
170 // canceled hub subscription does NOT unblock an in-flight Write, and the read
171 // loop is parked in ReadMsg(up); closing the connection unblocks that ReadMsg so
172 // handleConn returns and runs its cleanup (cancel the hub subscription).
173 func (s *Service) failWrite(conn quic.Connection, hostID string, err error) {
174 slog.Warn("down-stream write failed; closing connection", "host", hostID, "err", err)
175 // Close code 0 (not CodeAuthRejected): a write timeout is a transport/liveness
176 // problem, not a permanent auth failure, so the agent should reconnect with
177 // normal backoff rather than treat its credential as dead.
178 _ = conn.CloseWithError(0, "down-stream write timeout")
179 }
180
181 // applyReport updates the registry and durably records VM status changes.
182 // Errors within the report are logged and skipped — they must never kill the stream.
183 func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) {
184 // Build registry report.
185 r := registry.Report{
186 LastSeenEpoch: rep.GetLastSeenEpoch(),
187 FenceViolation: rep.GetFenceViolation(),
188 }
189
190 r.VMs = toRegistryVMs(rep.GetVms())
191 r.Quarantined = toRegistryQuarantined(rep.GetQuarantined())
192 r.Capacity = toRegistryCapacity(rep.GetCapacity())
193
194 s.reg.UpdateReport(hostID, r)
195
196 // Write-through durable status for lifecycle phases ready/failed only.
197 for _, v := range rep.GetVms() {
198 phase := v.GetPhase()
199 if phase != "ready" && phase != "failed" {
200 continue
201 }
202 if err := s.st.RecordVMStatus(v.GetVmId(), phase, v.GetLastError(), v.GetIp()); err != nil {
203 slog.Warn("RecordVMStatus rejected", "vm", v.GetVmId(), "host", hostID, "err", err)
204 }
205 }
206
207 // Fence violation: log ERROR and point at the restore runbook.
208 if rep.GetFenceViolation() {
209 slog.Error("agent refused snapshot: epoch fence violation — see restore runbook",
210 "host", hostID, "agent_epoch", rep.GetLastSeenEpoch())
211 }
212
213 // Hard-delete each VM the agent has confirmed destroyed (level-triggered acks).
214 // Track whether any delete succeeded so we can poke the agent once after the
215 // loop — otherwise the agent holds a stale snapshot containing the tombstone
216 // and re-acks every tick forever (log spam) until some other edit pokes it.
217 anyDeleted := false
218 for _, id := range rep.GetDestroyed() {
219 if err := s.st.HardDeleteVM(id); err != nil {
220 slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err)
221 } else {
222 anyDeleted = true
223 }
224 }
225 if anyDeleted {
226 s.hub.Poke(hostID)
227 }
228 }
229
230 // toRegistryVMs maps reported ActualVMs to registry rows. Returns nil (not an
231 // empty slice) for empty input, matching the original append-into-nil behavior.
232 func toRegistryVMs(in []*pb.ActualVM) []registry.ActualVM {
233 if len(in) == 0 {
234 return nil
235 }
236 out := make([]registry.ActualVM, 0, len(in))
237 for _, v := range in {
238 out = append(out, registry.ActualVM{
239 VMID: v.GetVmId(),
240 Power: v.GetPower(),
241 Phase: v.GetPhase(),
242 IP: v.GetIp(),
243 LastError: v.GetLastError(),
244 })
245 }
246 return out
247 }
248
249 // toRegistryQuarantined maps reported quarantined VMs to registry rows. Returns
250 // nil (not an empty slice) for empty input, matching append-into-nil behavior.
251 func toRegistryQuarantined(in []*pb.QuarantinedVM) []registry.QuarantinedVM {
252 if len(in) == 0 {
253 return nil
254 }
255 out := make([]registry.QuarantinedVM, 0, len(in))
256 for _, q := range in {
257 out = append(out, registry.QuarantinedVM{
258 VMID: q.GetVmId(),
259 Name: q.GetName(),
260 VMSpecJSON: q.GetVmspecJson(),
261 DestroyAtUnix: q.GetDestroyAtUnix(),
262 })
263 }
264 return out
265 }
266
267 // toRegistryCapacity maps reported capacity (nil → zero value).
268 func toRegistryCapacity(c *pb.Capacity) registry.Capacity {
269 if c == nil {
270 return registry.Capacity{}
271 }
272 return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()}
273 }
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -0,0 +1,351 @@
1 package syncsvc
2
3 import (
4 "context"
5 "errors"
6 "runtime"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/a73x/eitri/internal/pb"
12 "github.com/a73x/eitri/internal/server/hosttoken"
13 "github.com/a73x/eitri/internal/server/hub"
14 "github.com/a73x/eitri/internal/server/registry"
15 "github.com/a73x/eitri/internal/server/store"
16 "github.com/a73x/eitri/internal/transport"
17 "github.com/quic-go/quic-go"
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20 )
21
22 type fixture struct {
23 st *store.Store
24 reg *registry.Registry
25 hub *hub.Hub
26 addr string
27 fp string
28 secret []byte
29 host store.Host
30 cred string
31 }
32
33 func setup(t *testing.T) *fixture {
34 t.Helper()
35 return setupWithWriteTimeout(t, 0) // 0 → production default
36 }
37
38 func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture {
39 t.Helper()
40 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
41 require.NoError(t, err)
42 t.Cleanup(func() { st.Close() })
43 tok, _ := st.CreateEnrollmentToken()
44 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
45 require.NoError(t, err)
46
47 reg := registry.New(time.Now)
48 h := hub.New()
49 secret := []byte("s3cret")
50
51 addr, fp, _ := startTestServer(t, st, reg, h, secret, writeTimeout)
52 return &fixture{st: st, reg: reg, hub: h, addr: addr, fp: fp, secret: secret,
53 host: host, cred: hosttoken.Mint(secret, host.ID)}
54 }
55
56 // startTestServer listens on 127.0.0.1:0 (random UDP port) and returns the addr,
57 // the server cert fingerprint, and a cleanup func. A zero writeTimeout uses the
58 // production default.
59 func startTestServer(t *testing.T, st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, writeTimeout time.Duration) (addr, fp string, stop func()) {
60 t.Helper()
61 certPEM, keyPEM, err := transport.GenerateServerCert()
62 require.NoError(t, err)
63 fp, err = transport.CertFingerprint(certPEM)
64 require.NoError(t, err)
65 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
66 require.NoError(t, err)
67 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
68 require.NoError(t, err)
69 svc := newWithWriteTimeout(st, reg, h, secret, writeTimeout)
70 ctx, cancel := context.WithCancel(context.Background())
71 go svc.Serve(ctx, lis) //nolint:errcheck
72 stop = func() { cancel(); lis.Close() }
73 t.Cleanup(stop)
74 return lis.Addr().String(), fp, stop
75 }
76
77 // conn is the agent-side dual-stream connection used by the test scenarios.
78 type testConn struct {
79 conn quic.Connection
80 up quic.Stream
81 down quic.Stream
82 }
83
84 func (c *testConn) send(t *testing.T, msg *pb.AgentMessage) {
85 t.Helper()
86 require.NoError(t, transport.WriteMsg(c.up, msg))
87 }
88
89 func (c *testConn) recv(t *testing.T) *pb.ServerMessage {
90 t.Helper()
91 var msg pb.ServerMessage
92 require.NoError(t, transport.ReadMsg(c.down, &msg, transport.DefaultMaxFrame))
93 return &msg
94 }
95
96 // dial opens a QUIC connection pinned to fp, opens the up-stream, sends the
97 // Hello (carrying cred), and accepts the server's down-stream. Returns the conn
98 // or an error from AcceptStream (auth rejection surfaces there).
99 func dial(t *testing.T, addr, fp, hostID, cred string) (*testConn, error) {
100 t.Helper()
101 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
102 defer cancel()
103 conn, err := quic.DialAddr(ctx, addr, transport.ClientTLS(fp),
104 &quic.Config{MaxIdleTimeout: 5 * time.Second})
105 if err != nil {
106 return nil, err
107 }
108 up, err := conn.OpenStreamSync(ctx)
109 if err != nil {
110 return nil, err
111 }
112 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{
113 HostId: hostID, Provisioner: "cloudhv", Credential: cred}}}
114 if err := transport.WriteMsg(up, hello); err != nil {
115 return nil, err
116 }
117 down, err := conn.AcceptStream(ctx)
118 if err != nil {
119 return nil, err
120 }
121 c := &testConn{conn: conn, up: up, down: down}
122 t.Cleanup(func() { conn.CloseWithError(0, "") })
123 return c, nil
124 }
125
126 func mustDial(t *testing.T, f *fixture) *testConn {
127 t.Helper()
128 c, err := dial(t, f.addr, f.fp, f.host.ID, f.cred)
129 require.NoError(t, err)
130 return c
131 }
132
133 func TestRejectsBadCredential(t *testing.T) {
134 f := setup(t)
135 // A junk credential: the server CloseWithError(CodeAuthRejected) surfaces on
136 // the agent's AcceptStream as a *quic.ApplicationError.
137 _, err := dial(t, f.addr, f.fp, f.host.ID, "host-x.deadbeef")
138 require.Error(t, err, "stream must be terminated")
139 var appErr *quic.ApplicationError
140 require.True(t, errors.As(err, &appErr), "expected *quic.ApplicationError, got %T: %v", err, err)
141 assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode)
142 }
143
144 func TestPushesSnapshotOnConnectAndOnPoke(t *testing.T) {
145 f := setup(t)
146 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
147 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
148 c := mustDial(t, f)
149
150 msg := c.recv(t)
151 snap := msg.GetSnapshot()
152 require.NotNil(t, snap)
153 require.Len(t, snap.Vms, 1)
154 assert.Equal(t, "vm1", snap.Vms[0].VmId)
155 first := snap.Epoch
156
157 // A desired-state edit + poke re-pushes with a higher epoch.
158 require.NoError(t, f.st.SetVMPower("vm1", "stopped"))
159 f.hub.Poke(f.host.ID)
160 msg = c.recv(t)
161 snap = msg.GetSnapshot()
162 assert.Greater(t, snap.Epoch, first)
163 assert.Equal(t, "stopped", snap.Vms[0].PowerState)
164 }
165
166 func TestReportWritesThroughAndHardDeletesAckedTombstones(t *testing.T) {
167 f := setup(t)
168 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
169 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
170 require.NoError(t, f.st.TombstoneVM("vm1"))
171 c := mustDial(t, f)
172 c.recv(t) // initial snapshot
173
174 c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{
175 Report: &pb.ActualStateReport{
176 Destroyed: []string{"vm1"}, // level-triggered ack
177 Capacity: &pb.Capacity{Vcpus: 8},
178 LastSeenEpoch: 2,
179 }}})
180
181 require.Eventually(t, func() bool {
182 vms, _ := f.st.ListVMs()
183 return len(vms) == 0
184 }, 2*time.Second, 20*time.Millisecond, "acked tombstone must be hard-deleted")
185
186 st, ok := f.reg.Get(f.host.ID)
187 require.True(t, ok)
188 assert.Equal(t, int64(8), st.Capacity.VCPUs)
189 }
190
191 func TestReportWithValidIPUpdatesRegistryAndStore(t *testing.T) {
192 f := setup(t)
193 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
194 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
195 c := mustDial(t, f)
196 c.recv(t)
197
198 c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{
199 Report: &pb.ActualStateReport{Vms: []*pb.ActualVM{
200 {VmId: "vm1", Power: "running", Phase: "ready", Ip: "10.77.1.2"},
201 }}}})
202
203 require.Eventually(t, func() bool {
204 vms, _ := f.st.ListVMs()
205 return len(vms) == 1 && vms[0].Status == "ready" && vms[0].AssignedIP == "10.77.1.2"
206 }, 2*time.Second, 20*time.Millisecond)
207 }
208
209 func TestHardDeleteTriggersRepush(t *testing.T) {
210 f := setup(t)
211 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
212 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
213 require.NoError(t, f.st.TombstoneVM("vm1"))
214 c := mustDial(t, f)
215
216 // Receive initial snapshot (contains vm1 tombstoned).
217 snap := c.recv(t).GetSnapshot()
218 require.NotNil(t, snap)
219 require.Len(t, snap.Vms, 1)
220
221 // Send a report acking the destroyed VM.
222 c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{
223 Report: &pb.ActualStateReport{
224 Destroyed: []string{"vm1"},
225 LastSeenEpoch: snap.Epoch,
226 }}})
227
228 // Expect a second snapshot push after the hard-delete — Vms list must be empty.
229 snap2 := c.recv(t).GetSnapshot()
230 require.NotNil(t, snap2)
231 assert.Empty(t, snap2.Vms, "snapshot after hard-delete must have no VMs")
232 }
233
234 func TestNoGoroutineLeakOnDisconnect(t *testing.T) {
235 f := setup(t)
236
237 // Warm to steady state: open several connections, read a snapshot, close.
238 const warm = 5
239 for i := 0; i < warm; i++ {
240 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
241 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(f.fp),
242 &quic.Config{MaxIdleTimeout: 5 * time.Second})
243 require.NoError(t, err)
244 up, err := conn.OpenStreamSync(ctx)
245 require.NoError(t, err)
246 require.NoError(t, transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{
247 Hello: &pb.Hello{HostId: f.host.ID, Credential: f.cred}}}))
248 down, err := conn.AcceptStream(ctx)
249 require.NoError(t, err)
250 var m pb.ServerMessage
251 require.NoError(t, transport.ReadMsg(down, &m, transport.DefaultMaxFrame))
252 conn.CloseWithError(0, "")
253 cancel()
254 }
255 time.Sleep(500 * time.Millisecond) // let server-side handlers tear down
256
257 before := runtime.NumGoroutine()
258
259 const n = 5
260 for i := 0; i < n; i++ {
261 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
262 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(f.fp),
263 &quic.Config{MaxIdleTimeout: 5 * time.Second})
264 require.NoError(t, err)
265 up, err := conn.OpenStreamSync(ctx)
266 require.NoError(t, err)
267 require.NoError(t, transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{
268 Hello: &pb.Hello{HostId: f.host.ID, Credential: f.cred}}}))
269 down, err := conn.AcceptStream(ctx)
270 require.NoError(t, err)
271 var m pb.ServerMessage
272 require.NoError(t, transport.ReadMsg(down, &m, transport.DefaultMaxFrame))
273 conn.CloseWithError(0, "")
274 cancel()
275 }
276
277 // Each disconnected connection must tear down its handler (read loop + poke
278 // goroutine). Allow small headroom for quic-go's own per-connection cleanup;
279 // a poke-goroutine leak would grow by one per connection beyond that.
280 require.Eventually(t, func() bool {
281 return runtime.NumGoroutine() <= before+n+2
282 }, 5*time.Second, 50*time.Millisecond, "handler goroutines must exit on disconnect")
283 }
284
285 // TestStalledReaderDoesNotPinServer asserts that an authenticated agent which
286 // keeps its QUIC connection alive but never reads the down-stream cannot pin the
287 // server's poke/read goroutines forever. With a short write timeout the server
288 // must close the connection once the flow-control window fills and a down-stream
289 // write hits the deadline.
290 //
291 // Against the unbounded-write code this test hangs: the poke goroutine blocks in
292 // Write (window full), cancel() does not unblock it, and the read loop is parked
293 // in ReadMsg(up) — so conn.Context() never fires and this asserts to failure.
294 func TestStalledReaderDoesNotPinServer(t *testing.T) {
295 f := setupWithWriteTimeout(t, 300*time.Millisecond)
296
297 // A VM with a large CloudInit blob so a few snapshots fill the flow-control
298 // window of the unread down-stream quickly.
299 bigBlob := strings.Repeat("x", 256*1024)
300 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
301 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5,
302 PowerState: "running", CloudInit: bigBlob}))
303
304 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
305 defer cancel()
306 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(f.fp),
307 &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second})
308 require.NoError(t, err)
309 defer conn.CloseWithError(0, "")
310
311 up, err := conn.OpenStreamSync(ctx)
312 require.NoError(t, err)
313 require.NoError(t, transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{
314 Hello: &pb.Hello{HostId: f.host.ID, Credential: f.cred}}}))
315
316 // Deliberately DO NOT AcceptStream/read the down-stream. Poke repeatedly so
317 // the server keeps trying to write snapshots until the window fills and the
318 // next write hits the 300ms deadline.
319 go func() {
320 for i := 0; i < 100; i++ {
321 f.hub.Poke(f.host.ID)
322 time.Sleep(20 * time.Millisecond)
323 }
324 }()
325
326 // The server must close the connection promptly once a write times out. This
327 // is deterministic: it is driven by the injected 300ms write timeout, not by
328 // any wall-clock guess about how long writes "should" take.
329 select {
330 case <-conn.Context().Done():
331 // pass: server closed the connection, unblocking its goroutines.
332 case <-time.After(5 * time.Second):
333 t.Fatal("server did not close the stalled connection — poke/read goroutines are pinned")
334 }
335 }
336
337 func TestWrongCertPinRejected(t *testing.T) {
338 f := setup(t)
339 wrongFP := strings.Repeat("00", 32)
340 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
341 defer cancel()
342 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(wrongFP),
343 &quic.Config{MaxIdleTimeout: 5 * time.Second})
344 if err == nil {
345 // Handshake may complete lazily; the failure surfaces on first stream op.
346 _, err = conn.OpenStreamSync(ctx)
347 }
348 require.Error(t, err, "wrong cert pin must fail the TLS handshake")
349 assert.Contains(t, strings.ToLower(err.Error()), "fingerprint",
350 "expected cert fingerprint mismatch, got: %v", err)
351 }
internal/server/web/dist/.gitkeep
No textual changes available.
internal/server/web/embed.go
Old New
@@ -0,0 +1,24 @@
1 package web
2
3 import (
4 "embed"
5 "io/fs"
6 "net/http"
7 )
8
9 // dist holds the built SvelteKit app. `make web` populates internal/server/web/dist
10 // from the SvelteKit static build; only .gitkeep is committed, so a fresh checkout
11 // embeds an empty tree and the handler serves a "UI not built" notice until built.
12 // The all: prefix includes SvelteKit's _app directory (leading underscore).
13 //
14 //go:embed all:dist
15 var dist embed.FS
16
17 // Handler serves the embedded SPA with index.html fallback.
18 func Handler() http.Handler {
19 sub, err := fs.Sub(dist, "dist")
20 if err != nil {
21 panic(err) // dist is always embedded (at least .gitkeep)
22 }
23 return spaHandler(sub)
24 }
internal/server/web/spa.go
Old New
@@ -0,0 +1,43 @@
1 // Package web embeds the built SvelteKit single-page app and serves it with
2 // SPA-style fallback (unknown paths resolve to index.html for client routing).
3 package web
4
5 import (
6 "io/fs"
7 "net/http"
8 "path"
9 "strings"
10 )
11
12 // spaHandler serves static files from fsys, falling back to index.html for any
13 // path that doesn't resolve to a real file (so client-side routes like
14 // /vms/abc load the app). If index.html is absent (UI not built yet) it returns
15 // a clear 503 instead of a confusing 404.
16 func spaHandler(fsys fs.FS) http.Handler {
17 fileServer := http.FileServerFS(fsys)
18 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 p := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
20 if p != "" && fileExists(fsys, p) {
21 fileServer.ServeHTTP(w, r)
22 return
23 }
24 if !fileExists(fsys, "index.html") {
25 http.Error(w, "UI not built — run `make web`", http.StatusServiceUnavailable)
26 return
27 }
28 // SPA fallback: serve index.html for unknown (client-routed) paths.
29 r2 := r.Clone(r.Context())
30 r2.URL.Path = "/"
31 fileServer.ServeHTTP(w, r2)
32 })
33 }
34
35 func fileExists(fsys fs.FS, name string) bool {
36 f, err := fsys.Open(name)
37 if err != nil {
38 return false
39 }
40 defer f.Close()
41 st, err := f.Stat()
42 return err == nil && !st.IsDir()
43 }
internal/server/web/spa_test.go
Old New
@@ -0,0 +1,62 @@
1 package web
2
3 import (
4 "io"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8 "testing/fstest"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 func get(t *testing.T, h http.Handler, path string) (int, string) {
15 t.Helper()
16 req := httptest.NewRequest("GET", path, nil)
17 rec := httptest.NewRecorder()
18 h.ServeHTTP(rec, req)
19 body, _ := io.ReadAll(rec.Result().Body)
20 return rec.Code, string(body)
21 }
22
23 func TestSPAServesIndexAtRoot(t *testing.T) {
24 h := spaHandler(fstest.MapFS{
25 "index.html": {Data: []byte("<title>eitri</title>")},
26 })
27 code, body := get(t, h, "/")
28 assert.Equal(t, http.StatusOK, code)
29 assert.Contains(t, body, "eitri")
30 }
31
32 func TestSPAServesRealAsset(t *testing.T) {
33 h := spaHandler(fstest.MapFS{
34 "index.html": {Data: []byte("index")},
35 "_app/app.js": {Data: []byte("console.log(1)")},
36 })
37 code, body := get(t, h, "/_app/app.js")
38 assert.Equal(t, http.StatusOK, code)
39 assert.Contains(t, body, "console.log")
40 }
41
42 func TestSPAFallsBackToIndexForClientRoute(t *testing.T) {
43 h := spaHandler(fstest.MapFS{
44 "index.html": {Data: []byte("APP_SHELL")},
45 })
46 // An unknown path (client-side route) must serve the app shell, not 404.
47 code, body := get(t, h, "/vms/abc123")
48 assert.Equal(t, http.StatusOK, code)
49 assert.Contains(t, body, "APP_SHELL")
50 }
51
52 func TestSPAReportsNotBuiltWhenEmpty(t *testing.T) {
53 h := spaHandler(fstest.MapFS{})
54 code, body := get(t, h, "/")
55 assert.Equal(t, http.StatusServiceUnavailable, code)
56 assert.Contains(t, body, "not built")
57 }
58
59 func TestEmbeddedHandlerConstructs(t *testing.T) {
60 // Handler() must not panic even with only .gitkeep embedded.
61 require.NotNil(t, Handler())
62 }
internal/transport/apicheck_test.go
Old New
@@ -0,0 +1,56 @@
1 package transport
2
3 import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "testing"
8 "time"
9
10 "github.com/quic-go/quic-go"
11 )
12
13 // VERIFIED quic-go API SURFACE — pinned version v0.48.2.
14 //
15 // The plan's draft assumed the v0.49+ concrete types (*quic.Conn, *quic.Stream,
16 // *quic.Listener). In v0.48.2 the connection and stream are still INTERFACES,
17 // not pointer-to-struct. Later tasks MUST use these verbatim:
18 //
19 // Listen func: func ListenAddr(addr string, *tls.Config, *quic.Config) (*quic.Listener, error)
20 // Dial func: func DialAddr(ctx context.Context, addr string, *tls.Config, *quic.Config) (quic.Connection, error)
21 // Listener type: *quic.Listener (concrete struct)
22 // Accept: func (*quic.Listener) Accept(ctx context.Context) (quic.Connection, error)
23 // Connection: quic.Connection (INTERFACE — NOT *quic.Conn; there is no quic.Conn in v0.48.2)
24 // OpenStreamSync: OpenStreamSync(ctx context.Context) (quic.Stream, error)
25 // AcceptStream: AcceptStream(ctx context.Context) (quic.Stream, error)
26 // CloseWithError: CloseWithError(quic.ApplicationErrorCode, string) error
27 // Stream: quic.Stream (INTERFACE — NOT *quic.Stream)
28 // Error code: quic.ApplicationErrorCode (uint64 alias)
29 // Client-side error: *quic.ApplicationError (struct, alias of qerr.ApplicationError) with
30 // fields { Remote bool; ErrorCode quic.ApplicationErrorCode; ErrorMessage string }.
31 // Read the peer's close code via errors.As(err, &appErr); appErr.ErrorCode.
32 // Config fields: quic.Config{KeepAlivePeriod, MaxIdleTimeout} — confirmed present.
33
34 // TestQUICAPISurface is a compile-time check that the pinned quic-go version
35 // exposes exactly the symbols this plan targets.
36 func TestQUICAPISurface(t *testing.T) {
37 var _ func(string, *tls.Config, *quic.Config) (*quic.Listener, error) = quic.ListenAddr
38 var _ func(context.Context, string, *tls.Config, *quic.Config) (quic.Connection, error) = quic.DialAddr
39 cfg := &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second}
40 _ = cfg
41 var conn quic.Connection
42 if conn != nil {
43 var _ func(context.Context) (quic.Stream, error) = conn.OpenStreamSync
44 var _ func(context.Context) (quic.Stream, error) = conn.AcceptStream
45 var _ func(quic.ApplicationErrorCode, string) error = conn.CloseWithError
46 }
47 var lis *quic.Listener
48 if lis != nil {
49 var _ func(context.Context) (quic.Connection, error) = lis.Accept
50 }
51 // Client-side: reading the peer's application close code.
52 var appErr *quic.ApplicationError
53 if errors.As(error(nil), &appErr) {
54 var _ quic.ApplicationErrorCode = appErr.ErrorCode
55 }
56 }
internal/transport/doc.go
Old New
@@ -0,0 +1,2 @@
1 // Package transport carries the agent↔server sync protocol over QUIC.
2 package transport
internal/transport/frame.go
Old New
@@ -0,0 +1,48 @@
1 package transport
2
3 import (
4 "encoding/binary"
5 "fmt"
6 "io"
7
8 "google.golang.org/protobuf/proto"
9 )
10
11 // DefaultMaxFrame bounds a single framed message (protect against a corrupt or
12 // hostile length prefix). Snapshots/reports are small; 4 MiB is generous.
13 const DefaultMaxFrame = 4 << 20
14
15 // WriteMsg writes msg as a 4-byte big-endian length prefix followed by its
16 // protobuf-marshaled bytes.
17 func WriteMsg(w io.Writer, msg proto.Message) error {
18 b, err := proto.Marshal(msg)
19 if err != nil {
20 return fmt.Errorf("marshal: %w", err)
21 }
22 var hdr [4]byte
23 binary.BigEndian.PutUint32(hdr[:], uint32(len(b)))
24 if _, err := w.Write(hdr[:]); err != nil {
25 return err
26 }
27 _, err = w.Write(b)
28 return err
29 }
30
31 // ReadMsg reads one framed message into out. It bounds-checks the length against
32 // maxFrame BEFORE allocating, then reads exactly that many bytes. Returns io.EOF
33 // when the stream is cleanly closed at a frame boundary.
34 func ReadMsg(r io.Reader, out proto.Message, maxFrame uint32) error {
35 var hdr [4]byte
36 if _, err := io.ReadFull(r, hdr[:]); err != nil {
37 return err
38 }
39 n := binary.BigEndian.Uint32(hdr[:])
40 if n > maxFrame {
41 return fmt.Errorf("frame too large: %d > %d", n, maxFrame)
42 }
43 body := make([]byte, n)
44 if _, err := io.ReadFull(r, body); err != nil {
45 return err
46 }
47 return proto.Unmarshal(body, out)
48 }
internal/transport/frame_test.go
Old New
@@ -0,0 +1,36 @@
1 package transport
2
3 import (
4 "bytes"
5 "testing"
6
7 "github.com/a73x/eitri/internal/pb"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestFrameRoundTrip(t *testing.T) {
13 var buf bytes.Buffer
14 in := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{HostId: "h1", LastSeenEpoch: 7}}}
15 require.NoError(t, WriteMsg(&buf, in))
16
17 var out pb.AgentMessage
18 require.NoError(t, ReadMsg(&buf, &out, DefaultMaxFrame))
19 assert.Equal(t, "h1", out.GetHello().GetHostId())
20 assert.Equal(t, uint64(7), out.GetHello().GetLastSeenEpoch())
21 }
22
23 func TestReadMsgRejectsOversizedFrame(t *testing.T) {
24 var buf bytes.Buffer
25 buf.Write([]byte{0x00, 0x50, 0x00, 0x00}) // 0x500000 ~ 5 MB
26 var out pb.AgentMessage
27 err := ReadMsg(&buf, &out, 1024)
28 require.Error(t, err)
29 assert.Contains(t, err.Error(), "frame too large")
30 }
31
32 func TestReadMsgEmptyStreamReturnsEOF(t *testing.T) {
33 var out pb.AgentMessage
34 err := ReadMsg(bytes.NewReader(nil), &out, DefaultMaxFrame)
35 require.Error(t, err) // io.EOF
36 }
internal/transport/tlsconf.go
Old New
@@ -0,0 +1,98 @@
1 package transport
2
3 import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/sha256"
8 "crypto/tls"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/hex"
12 "encoding/pem"
13 "fmt"
14 "math/big"
15 "time"
16 )
17
18 // ALPN is the QUIC application-layer protocol token. Both ends MUST set it; the
19 // handshake fails without a match.
20 const ALPN = "eitri-sync/1"
21
22 // Application error codes sent via CloseWithError so the agent can classify why
23 // the server dropped it.
24 const (
25 CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop
26 )
27
28 // GenerateServerCert returns a fresh self-signed ECDSA cert+key as PEM.
29 func GenerateServerCert() (certPEM, keyPEM []byte, err error) {
30 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31 if err != nil {
32 return nil, nil, err
33 }
34 tmpl := &x509.Certificate{
35 SerialNumber: big.NewInt(1),
36 Subject: pkix.Name{CommonName: "eitri-server"},
37 NotBefore: time.Now().Add(-time.Hour),
38 NotAfter: time.Now().AddDate(10, 0, 0),
39 KeyUsage: x509.KeyUsageDigitalSignature,
40 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
41 }
42 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
43 if err != nil {
44 return nil, nil, err
45 }
46 certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
47 keyDER, err := x509.MarshalECPrivateKey(key)
48 if err != nil {
49 return nil, nil, err
50 }
51 keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
52 return certPEM, keyPEM, nil
53 }
54
55 // CertFingerprint returns the hex SHA-256 of the cert's DER bytes.
56 func CertFingerprint(certPEM []byte) (string, error) {
57 block, _ := pem.Decode(certPEM)
58 if block == nil {
59 return "", fmt.Errorf("no PEM block in cert")
60 }
61 sum := sha256.Sum256(block.Bytes)
62 return hex.EncodeToString(sum[:]), nil
63 }
64
65 // ServerTLS builds the server's tls.Config from PEM cert+key with ALPN set.
66 func ServerTLS(certPEM, keyPEM []byte) (*tls.Config, error) {
67 cert, err := tls.X509KeyPair(certPEM, keyPEM)
68 if err != nil {
69 return nil, err
70 }
71 return &tls.Config{
72 Certificates: []tls.Certificate{cert},
73 NextProtos: []string{ALPN},
74 MinVersion: tls.VersionTLS13,
75 }, nil
76 }
77
78 // ClientTLS builds the agent's tls.Config pinned to wantFP (hex sha256 of the
79 // server cert DER). Uses VerifyConnection (called on resumed connections too,
80 // unlike VerifyPeerCertificate) and disables default CA verification since the
81 // cert is self-signed and pinned instead.
82 func ClientTLS(wantFP string) *tls.Config {
83 return &tls.Config{
84 NextProtos: []string{ALPN},
85 MinVersion: tls.VersionTLS13,
86 InsecureSkipVerify: true, // CA path disabled; pinning is the trust root
87 VerifyConnection: func(cs tls.ConnectionState) error {
88 if len(cs.PeerCertificates) == 0 {
89 return fmt.Errorf("server presented no certificate")
90 }
91 sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
92 if got := hex.EncodeToString(sum[:]); got != wantFP {
93 return fmt.Errorf("server cert fingerprint mismatch: got %s want %s", got, wantFP)
94 }
95 return nil
96 },
97 }
98 }
internal/transport/tlsconf_test.go
Old New
@@ -0,0 +1,48 @@
1 package transport
2
3 import (
4 "crypto/tls"
5 "crypto/x509"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestGenerateAndPinRoundTrip(t *testing.T) {
13 certPEM, keyPEM, err := GenerateServerCert()
14 require.NoError(t, err)
15
16 fp, err := CertFingerprint(certPEM)
17 require.NoError(t, err)
18 assert.Len(t, fp, 64) // hex sha256
19
20 cert, err := tls.X509KeyPair(certPEM, keyPEM)
21 require.NoError(t, err)
22 leaf, err := x509.ParseCertificate(cert.Certificate[0])
23 require.NoError(t, err)
24
25 cc := ClientTLS(fp)
26 state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}}
27 require.NoError(t, cc.VerifyConnection(state), "matching fingerprint must verify")
28 }
29
30 func TestPinRejectsWrongCert(t *testing.T) {
31 c1, _, _ := GenerateServerCert()
32 fp1, _ := CertFingerprint(c1)
33 c2, k2, _ := GenerateServerCert()
34 cert2, _ := tls.X509KeyPair(c2, k2)
35 leaf2, _ := x509.ParseCertificate(cert2.Certificate[0])
36
37 cc := ClientTLS(fp1)
38 state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf2}}
39 require.Error(t, cc.VerifyConnection(state), "non-matching fingerprint must be rejected")
40 }
41
42 func TestServerTLSHasALPN(t *testing.T) {
43 certPEM, keyPEM, err := GenerateServerCert()
44 require.NoError(t, err)
45 sc, err := ServerTLS(certPEM, keyPEM)
46 require.NoError(t, err)
47 assert.Contains(t, sc.NextProtos, ALPN)
48 }
proto/eitri/v1/sync.proto
Old New
@@ -0,0 +1,80 @@
1 syntax = "proto3";
2 package eitri.v1;
3 option go_package = "github.com/a73x/eitri/internal/pb;pb";
4
5 message AgentMessage {
6 oneof msg {
7 Hello hello = 1;
8 ActualStateReport report = 2;
9 }
10 }
11
12 message ServerMessage {
13 oneof msg {
14 DesiredStateSnapshot snapshot = 1;
15 }
16 }
17
18 message Hello {
19 string host_id = 1;
20 string hostname = 2;
21 string os = 3;
22 string arch = 4;
23 string provisioner = 5; // "cloudhv"
24 string bridge_cidr = 6; // echo of server-assigned CIDR
25 uint64 last_seen_epoch = 7; // for the restore runbook
26 Capacity capacity = 8;
27 string credential = 9; // Bearer host credential, verified in first frame
28 }
29
30 message Capacity {
31 int64 vcpus = 1;
32 int64 mem_mb = 2;
33 int64 disk_gb = 3;
34 }
35
36 message ActualVM {
37 string vm_id = 1;
38 string power = 2; // "running"|"stopped"
39 string phase = 3; // "creating"|"ready"|"failed"|"quarantined"
40 string ip = 4; // agent-allocated; server validates within bridge_cidr
41 string last_error = 5;
42 }
43
44 message QuarantinedVM {
45 string vm_id = 1;
46 string name = 2;
47 bytes vmspec_json = 3; // full VMSpec — only surviving copy after a DB restore
48 int64 destroy_at_unix = 4;
49 }
50
51 message ActualStateReport {
52 repeated ActualVM vms = 1;
53 // LEVEL-TRIGGERED destroy ack: ALL tombstoned vm_ids with no local
54 // record/disk/process, repeated every report until hard-deleted server-side.
55 repeated string destroyed = 2;
56 repeated QuarantinedVM quarantined = 3;
57 Capacity capacity = 4;
58 bool fence_violation = 5;
59 uint64 last_seen_epoch = 6;
60 }
61
62 message VMDesired {
63 string vm_id = 1;
64 string name = 2;
65 string image_url = 3;
66 string image_sha256 = 4;
67 string cloud_init = 5; // user-data YAML, may be empty
68 int64 vcpus = 6;
69 int64 mem_mb = 7;
70 int64 disk_gb = 8;
71 bool persistent = 9;
72 string power_state = 10; // "running"|"stopped"
73 bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[])
74 string ssh_authorized_key = 12;
75 }
76
77 message DesiredStateSnapshot {
78 uint64 epoch = 1; // agents refuse epoch < highest seen
79 repeated VMDesired vms = 2; // FULL set for this host, including tombstoned
80 }
web/.gitignore
Old New
@@ -0,0 +1,23 @@
1 node_modules
2
3 # Output
4 .output
5 .vercel
6 .netlify
7 .wrangler
8 /.svelte-kit
9 /build
10
11 # OS
12 .DS_Store
13 Thumbs.db
14
15 # Env
16 .env
17 .env.*
18 !.env.example
19 !.env.test
20
21 # Vite
22 vite.config.js.timestamp-*
23 vite.config.ts.timestamp-*
web/.npmrc
Old New
@@ -0,0 +1 @@
1 engine-strict=true
web/.vscode/extensions.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "recommendations": ["svelte.svelte-vscode"]
3 }
web/README.md
Old New
@@ -0,0 +1,42 @@
1 # sv
2
3 Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
4
5 ## Creating a project
6
7 If you're seeing this, you've probably already done this step. Congrats!
8
9 ```sh
10 # create a new project
11 npx sv create my-app
12 ```
13
14 To recreate this project with the same configuration:
15
16 ```sh
17 # recreate this project
18 npx sv@0.16.1 create --template minimal --types ts --install npm web
19 ```
20
21 ## Developing
22
23 Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
24
25 ```sh
26 npm run dev
27
28 # or start the server and open the app in a new browser tab
29 npm run dev -- --open
30 ```
31
32 ## Building
33
34 To create a production version of your app:
35
36 ```sh
37 npm run build
38 ```
39
40 You can preview the production build with `npm run preview`.
41
42 > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
web/package-lock.json
Old New
@@ -0,0 +1,1789 @@
1 {
2 "name": "web",
3 "version": "0.0.1",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "web",
9 "version": "0.0.1",
10 "devDependencies": {
11 "@sveltejs/adapter-auto": "^7.0.1",
12 "@sveltejs/adapter-static": "^3.0.10",
13 "@sveltejs/kit": "^2.63.0",
14 "@sveltejs/vite-plugin-svelte": "^7.1.2",
15 "openapi-typescript": "^7.13.0",
16 "svelte": "^5.56.1",
17 "svelte-check": "^4.6.0",
18 "typescript": "^6.0.3",
19 "vite": "^8.0.16"
20 }
21 },
22 "node_modules/@babel/code-frame": {
23 "version": "7.29.7",
24 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
25 "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
26 "dev": true,
27 "license": "MIT",
28 "dependencies": {
29 "@babel/helper-validator-identifier": "^7.29.7",
30 "js-tokens": "^4.0.0",
31 "picocolors": "^1.1.1"
32 },
33 "engines": {
34 "node": ">=6.9.0"
35 }
36 },
37 "node_modules/@babel/helper-validator-identifier": {
38 "version": "7.29.7",
39 "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
40 "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
41 "dev": true,
42 "license": "MIT",
43 "engines": {
44 "node": ">=6.9.0"
45 }
46 },
47 "node_modules/@emnapi/core": {
48 "version": "1.10.0",
49 "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
50 "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
51 "dev": true,
52 "license": "MIT",
53 "optional": true,
54 "dependencies": {
55 "@emnapi/wasi-threads": "1.2.1",
56 "tslib": "^2.4.0"
57 }
58 },
59 "node_modules/@emnapi/runtime": {
60 "version": "1.10.0",
61 "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
62 "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
63 "dev": true,
64 "license": "MIT",
65 "optional": true,
66 "dependencies": {
67 "tslib": "^2.4.0"
68 }
69 },
70 "node_modules/@emnapi/wasi-threads": {
71 "version": "1.2.1",
72 "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
73 "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
74 "dev": true,
75 "license": "MIT",
76 "optional": true,
77 "dependencies": {
78 "tslib": "^2.4.0"
79 }
80 },
81 "node_modules/@jridgewell/gen-mapping": {
82 "version": "0.3.13",
83 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
84 "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
85 "dev": true,
86 "license": "MIT",
87 "dependencies": {
88 "@jridgewell/sourcemap-codec": "^1.5.0",
89 "@jridgewell/trace-mapping": "^0.3.24"
90 }
91 },
92 "node_modules/@jridgewell/remapping": {
93 "version": "2.3.5",
94 "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
95 "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
96 "dev": true,
97 "license": "MIT",
98 "dependencies": {
99 "@jridgewell/gen-mapping": "^0.3.5",
100 "@jridgewell/trace-mapping": "^0.3.24"
101 }
102 },
103 "node_modules/@jridgewell/resolve-uri": {
104 "version": "3.1.2",
105 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
106 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
107 "dev": true,
108 "license": "MIT",
109 "engines": {
110 "node": ">=6.0.0"
111 }
112 },
113 "node_modules/@jridgewell/sourcemap-codec": {
114 "version": "1.5.5",
115 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
116 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
117 "dev": true,
118 "license": "MIT"
119 },
120 "node_modules/@jridgewell/trace-mapping": {
121 "version": "0.3.31",
122 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
123 "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
124 "dev": true,
125 "license": "MIT",
126 "dependencies": {
127 "@jridgewell/resolve-uri": "^3.1.0",
128 "@jridgewell/sourcemap-codec": "^1.4.14"
129 }
130 },
131 "node_modules/@napi-rs/wasm-runtime": {
132 "version": "1.1.5",
133 "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
134 "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
135 "dev": true,
136 "license": "MIT",
137 "optional": true,
138 "dependencies": {
139 "@tybys/wasm-util": "^0.10.2"
140 },
141 "funding": {
142 "type": "github",
143 "url": "https://github.com/sponsors/Brooooooklyn"
144 },
145 "peerDependencies": {
146 "@emnapi/core": "^1.7.1",
147 "@emnapi/runtime": "^1.7.1"
148 }
149 },
150 "node_modules/@oxc-project/types": {
151 "version": "0.133.0",
152 "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
153 "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
154 "dev": true,
155 "license": "MIT",
156 "funding": {
157 "url": "https://github.com/sponsors/Boshen"
158 }
159 },
160 "node_modules/@polka/url": {
161 "version": "1.0.0-next.29",
162 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
163 "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
164 "dev": true,
165 "license": "MIT"
166 },
167 "node_modules/@redocly/ajv": {
168 "version": "8.11.2",
169 "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
170 "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
171 "dev": true,
172 "license": "MIT",
173 "dependencies": {
174 "fast-deep-equal": "^3.1.1",
175 "json-schema-traverse": "^1.0.0",
176 "require-from-string": "^2.0.2",
177 "uri-js-replace": "^1.0.1"
178 },
179 "funding": {
180 "type": "github",
181 "url": "https://github.com/sponsors/epoberezkin"
182 }
183 },
184 "node_modules/@redocly/config": {
185 "version": "0.22.0",
186 "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
187 "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
188 "dev": true,
189 "license": "MIT"
190 },
191 "node_modules/@redocly/openapi-core": {
192 "version": "1.34.17",
193 "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz",
194 "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==",
195 "dev": true,
196 "license": "MIT",
197 "dependencies": {
198 "@redocly/ajv": "8.11.2",
199 "@redocly/config": "0.22.0",
200 "colorette": "1.4.0",
201 "https-proxy-agent": "7.0.6",
202 "js-levenshtein": "1.1.6",
203 "js-yaml": "4.2.0",
204 "minimatch": "5.1.9",
205 "pluralize": "8.0.0",
206 "yaml-ast-parser": "0.0.43"
207 },
208 "engines": {
209 "node": ">=18.17.0",
210 "npm": ">=9.5.0"
211 }
212 },
213 "node_modules/@rolldown/binding-android-arm64": {
214 "version": "1.0.3",
215 "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
216 "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
217 "cpu": [
218 "arm64"
219 ],
220 "dev": true,
221 "license": "MIT",
222 "optional": true,
223 "os": [
224 "android"
225 ],
226 "engines": {
227 "node": "^20.19.0 || >=22.12.0"
228 }
229 },
230 "node_modules/@rolldown/binding-darwin-arm64": {
231 "version": "1.0.3",
232 "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
233 "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
234 "cpu": [
235 "arm64"
236 ],
237 "dev": true,
238 "license": "MIT",
239 "optional": true,
240 "os": [
241 "darwin"
242 ],
243 "engines": {
244 "node": "^20.19.0 || >=22.12.0"
245 }
246 },
247 "node_modules/@rolldown/binding-darwin-x64": {
248 "version": "1.0.3",
249 "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
250 "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
251 "cpu": [
252 "x64"
253 ],
254 "dev": true,
255 "license": "MIT",
256 "optional": true,
257 "os": [
258 "darwin"
259 ],
260 "engines": {
261 "node": "^20.19.0 || >=22.12.0"
262 }
263 },
264 "node_modules/@rolldown/binding-freebsd-x64": {
265 "version": "1.0.3",
266 "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
267 "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
268 "cpu": [
269 "x64"
270 ],
271 "dev": true,
272 "license": "MIT",
273 "optional": true,
274 "os": [
275 "freebsd"
276 ],
277 "engines": {
278 "node": "^20.19.0 || >=22.12.0"
279 }
280 },
281 "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
282 "version": "1.0.3",
283 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
284 "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
285 "cpu": [
286 "arm"
287 ],
288 "dev": true,
289 "license": "MIT",
290 "optional": true,
291 "os": [
292 "linux"
293 ],
294 "engines": {
295 "node": "^20.19.0 || >=22.12.0"
296 }
297 },
298 "node_modules/@rolldown/binding-linux-arm64-gnu": {
299 "version": "1.0.3",
300 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
301 "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
302 "cpu": [
303 "arm64"
304 ],
305 "dev": true,
306 "libc": [
307 "glibc"
308 ],
309 "license": "MIT",
310 "optional": true,
311 "os": [
312 "linux"
313 ],
314 "engines": {
315 "node": "^20.19.0 || >=22.12.0"
316 }
317 },
318 "node_modules/@rolldown/binding-linux-arm64-musl": {
319 "version": "1.0.3",
320 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
321 "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
322 "cpu": [
323 "arm64"
324 ],
325 "dev": true,
326 "libc": [
327 "musl"
328 ],
329 "license": "MIT",
330 "optional": true,
331 "os": [
332 "linux"
333 ],
334 "engines": {
335 "node": "^20.19.0 || >=22.12.0"
336 }
337 },
338 "node_modules/@rolldown/binding-linux-ppc64-gnu": {
339 "version": "1.0.3",
340 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
341 "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
342 "cpu": [
343 "ppc64"
344 ],
345 "dev": true,
346 "libc": [
347 "glibc"
348 ],
349 "license": "MIT",
350 "optional": true,
351 "os": [
352 "linux"
353 ],
354 "engines": {
355 "node": "^20.19.0 || >=22.12.0"
356 }
357 },
358 "node_modules/@rolldown/binding-linux-s390x-gnu": {
359 "version": "1.0.3",
360 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
361 "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
362 "cpu": [
363 "s390x"
364 ],
365 "dev": true,
366 "libc": [
367 "glibc"
368 ],
369 "license": "MIT",
370 "optional": true,
371 "os": [
372 "linux"
373 ],
374 "engines": {
375 "node": "^20.19.0 || >=22.12.0"
376 }
377 },
378 "node_modules/@rolldown/binding-linux-x64-gnu": {
379 "version": "1.0.3",
380 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
381 "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
382 "cpu": [
383 "x64"
384 ],
385 "dev": true,
386 "libc": [
387 "glibc"
388 ],
389 "license": "MIT",
390 "optional": true,
391 "os": [
392 "linux"
393 ],
394 "engines": {
395 "node": "^20.19.0 || >=22.12.0"
396 }
397 },
398 "node_modules/@rolldown/binding-linux-x64-musl": {
399 "version": "1.0.3",
400 "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
401 "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
402 "cpu": [
403 "x64"
404 ],
405 "dev": true,
406 "libc": [
407 "musl"
408 ],
409 "license": "MIT",
410 "optional": true,
411 "os": [
412 "linux"
413 ],
414 "engines": {
415 "node": "^20.19.0 || >=22.12.0"
416 }
417 },
418 "node_modules/@rolldown/binding-openharmony-arm64": {
419 "version": "1.0.3",
420 "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
421 "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
422 "cpu": [
423 "arm64"
424 ],
425 "dev": true,
426 "license": "MIT",
427 "optional": true,
428 "os": [
429 "openharmony"
430 ],
431 "engines": {
432 "node": "^20.19.0 || >=22.12.0"
433 }
434 },
435 "node_modules/@rolldown/binding-wasm32-wasi": {
436 "version": "1.0.3",
437 "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
438 "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
439 "cpu": [
440 "wasm32"
441 ],
442 "dev": true,
443 "license": "MIT",
444 "optional": true,
445 "dependencies": {
446 "@emnapi/core": "1.10.0",
447 "@emnapi/runtime": "1.10.0",
448 "@napi-rs/wasm-runtime": "^1.1.4"
449 },
450 "engines": {
451 "node": "^20.19.0 || >=22.12.0"
452 }
453 },
454 "node_modules/@rolldown/binding-win32-arm64-msvc": {
455 "version": "1.0.3",
456 "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
457 "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
458 "cpu": [
459 "arm64"
460 ],
461 "dev": true,
462 "license": "MIT",
463 "optional": true,
464 "os": [
465 "win32"
466 ],
467 "engines": {
468 "node": "^20.19.0 || >=22.12.0"
469 }
470 },
471 "node_modules/@rolldown/binding-win32-x64-msvc": {
472 "version": "1.0.3",
473 "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
474 "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
475 "cpu": [
476 "x64"
477 ],
478 "dev": true,
479 "license": "MIT",
480 "optional": true,
481 "os": [
482 "win32"
483 ],
484 "engines": {
485 "node": "^20.19.0 || >=22.12.0"
486 }
487 },
488 "node_modules/@rolldown/pluginutils": {
489 "version": "1.0.1",
490 "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
491 "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
492 "dev": true,
493 "license": "MIT"
494 },
495 "node_modules/@standard-schema/spec": {
496 "version": "1.1.0",
497 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
498 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
499 "dev": true,
500 "license": "MIT"
501 },
502 "node_modules/@sveltejs/acorn-typescript": {
503 "version": "1.0.10",
504 "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
505 "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
506 "dev": true,
507 "license": "MIT",
508 "peerDependencies": {
509 "acorn": "^8.9.0"
510 }
511 },
512 "node_modules/@sveltejs/adapter-auto": {
513 "version": "7.0.1",
514 "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
515 "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
516 "dev": true,
517 "license": "MIT",
518 "peerDependencies": {
519 "@sveltejs/kit": "^2.0.0"
520 }
521 },
522 "node_modules/@sveltejs/adapter-static": {
523 "version": "3.0.10",
524 "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
525 "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
526 "dev": true,
527 "license": "MIT",
528 "peerDependencies": {
529 "@sveltejs/kit": "^2.0.0"
530 }
531 },
532 "node_modules/@sveltejs/kit": {
533 "version": "2.65.0",
534 "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.65.0.tgz",
535 "integrity": "sha512-nUWJ4dSKNo8mIOh+HTL+XyRj8FX9Dyb1ayBxj4q9+WrTJfn4jfTt21p3WUFTnnmdnt9xAXpBKLQ+H9y41x0X7Q==",
536 "dev": true,
537 "license": "MIT",
538 "dependencies": {
539 "@standard-schema/spec": "^1.0.0",
540 "@sveltejs/acorn-typescript": "^1.0.9",
541 "@types/cookie": "^0.6.0",
542 "acorn": "^8.16.0",
543 "cookie": "^0.6.0",
544 "devalue": "^5.8.1",
545 "esm-env": "^1.2.2",
546 "kleur": "^4.1.5",
547 "magic-string": "^0.30.5",
548 "mrmime": "^2.0.0",
549 "set-cookie-parser": "^3.0.0",
550 "sirv": "^3.0.0"
551 },
552 "bin": {
553 "svelte-kit": "svelte-kit.js"
554 },
555 "engines": {
556 "node": ">=18.13"
557 },
558 "peerDependencies": {
559 "@opentelemetry/api": "^1.0.0",
560 "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
561 "svelte": "^4.0.0 || ^5.0.0-next.0",
562 "typescript": "^5.3.3 || ^6.0.0",
563 "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
564 },
565 "peerDependenciesMeta": {
566 "@opentelemetry/api": {
567 "optional": true
568 },
569 "typescript": {
570 "optional": true
571 }
572 }
573 },
574 "node_modules/@sveltejs/load-config": {
575 "version": "0.1.1",
576 "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz",
577 "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==",
578 "dev": true,
579 "license": "MIT",
580 "engines": {
581 "node": ">= 18.0.0"
582 }
583 },
584 "node_modules/@sveltejs/vite-plugin-svelte": {
585 "version": "7.1.2",
586 "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
587 "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
588 "dev": true,
589 "license": "MIT",
590 "dependencies": {
591 "deepmerge": "^4.3.1",
592 "magic-string": "^0.30.21",
593 "obug": "^2.1.0",
594 "vitefu": "^1.1.2"
595 },
596 "engines": {
597 "node": "^20.19 || ^22.12 || >=24"
598 },
599 "peerDependencies": {
600 "svelte": "^5.46.4",
601 "vite": "^8.0.0-beta.7 || ^8.0.0"
602 }
603 },
604 "node_modules/@tybys/wasm-util": {
605 "version": "0.10.2",
606 "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
607 "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
608 "dev": true,
609 "license": "MIT",
610 "optional": true,
611 "dependencies": {
612 "tslib": "^2.4.0"
613 }
614 },
615 "node_modules/@types/cookie": {
616 "version": "0.6.0",
617 "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
618 "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
619 "dev": true,
620 "license": "MIT"
621 },
622 "node_modules/@types/estree": {
623 "version": "1.0.9",
624 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
625 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
626 "dev": true,
627 "license": "MIT"
628 },
629 "node_modules/@types/trusted-types": {
630 "version": "2.0.7",
631 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
632 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
633 "dev": true,
634 "license": "MIT"
635 },
636 "node_modules/acorn": {
637 "version": "8.16.0",
638 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
639 "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
640 "dev": true,
641 "license": "MIT",
642 "bin": {
643 "acorn": "bin/acorn"
644 },
645 "engines": {
646 "node": ">=0.4.0"
647 }
648 },
649 "node_modules/agent-base": {
650 "version": "7.1.4",
651 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
652 "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
653 "dev": true,
654 "license": "MIT",
655 "engines": {
656 "node": ">= 14"
657 }
658 },
659 "node_modules/ansi-colors": {
660 "version": "4.1.3",
661 "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
662 "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
663 "dev": true,
664 "license": "MIT",
665 "engines": {
666 "node": ">=6"
667 }
668 },
669 "node_modules/argparse": {
670 "version": "2.0.1",
671 "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
672 "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
673 "dev": true,
674 "license": "Python-2.0"
675 },
676 "node_modules/aria-query": {
677 "version": "5.3.1",
678 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
679 "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
680 "dev": true,
681 "license": "Apache-2.0",
682 "engines": {
683 "node": ">= 0.4"
684 }
685 },
686 "node_modules/axobject-query": {
687 "version": "4.1.0",
688 "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
689 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
690 "dev": true,
691 "license": "Apache-2.0",
692 "engines": {
693 "node": ">= 0.4"
694 }
695 },
696 "node_modules/balanced-match": {
697 "version": "1.0.2",
698 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
699 "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
700 "dev": true,
701 "license": "MIT"
702 },
703 "node_modules/brace-expansion": {
704 "version": "2.1.2",
705 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
706 "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
707 "dev": true,
708 "license": "MIT",
709 "dependencies": {
710 "balanced-match": "^1.0.0"
711 }
712 },
713 "node_modules/change-case": {
714 "version": "5.4.4",
715 "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
716 "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
717 "dev": true,
718 "license": "MIT"
719 },
720 "node_modules/chokidar": {
721 "version": "4.0.3",
722 "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
723 "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
724 "dev": true,
725 "license": "MIT",
726 "dependencies": {
727 "readdirp": "^4.0.1"
728 },
729 "engines": {
730 "node": ">= 14.16.0"
731 },
732 "funding": {
733 "url": "https://paulmillr.com/funding/"
734 }
735 },
736 "node_modules/clsx": {
737 "version": "2.1.1",
738 "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
739 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
740 "dev": true,
741 "license": "MIT",
742 "engines": {
743 "node": ">=6"
744 }
745 },
746 "node_modules/colorette": {
747 "version": "1.4.0",
748 "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
749 "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
750 "dev": true,
751 "license": "MIT"
752 },
753 "node_modules/cookie": {
754 "version": "0.6.0",
755 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
756 "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
757 "dev": true,
758 "license": "MIT",
759 "engines": {
760 "node": ">= 0.6"
761 }
762 },
763 "node_modules/debug": {
764 "version": "4.4.3",
765 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
766 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
767 "dev": true,
768 "license": "MIT",
769 "dependencies": {
770 "ms": "^2.1.3"
771 },
772 "engines": {
773 "node": ">=6.0"
774 },
775 "peerDependenciesMeta": {
776 "supports-color": {
777 "optional": true
778 }
779 }
780 },
781 "node_modules/deepmerge": {
782 "version": "4.3.1",
783 "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
784 "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
785 "dev": true,
786 "license": "MIT",
787 "engines": {
788 "node": ">=0.10.0"
789 }
790 },
791 "node_modules/detect-libc": {
792 "version": "2.1.2",
793 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
794 "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
795 "dev": true,
796 "license": "Apache-2.0",
797 "engines": {
798 "node": ">=8"
799 }
800 },
801 "node_modules/devalue": {
802 "version": "5.8.1",
803 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
804 "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
805 "dev": true,
806 "license": "MIT"
807 },
808 "node_modules/esm-env": {
809 "version": "1.2.2",
810 "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
811 "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
812 "dev": true,
813 "license": "MIT"
814 },
815 "node_modules/esrap": {
816 "version": "2.2.11",
817 "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz",
818 "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==",
819 "dev": true,
820 "license": "MIT",
821 "dependencies": {
822 "@jridgewell/sourcemap-codec": "^1.4.15"
823 },
824 "peerDependencies": {
825 "@typescript-eslint/types": "^8.2.0"
826 },
827 "peerDependenciesMeta": {
828 "@typescript-eslint/types": {
829 "optional": true
830 }
831 }
832 },
833 "node_modules/fast-deep-equal": {
834 "version": "3.1.3",
835 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
836 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
837 "dev": true,
838 "license": "MIT"
839 },
840 "node_modules/fdir": {
841 "version": "6.5.0",
842 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
843 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
844 "dev": true,
845 "license": "MIT",
846 "engines": {
847 "node": ">=12.0.0"
848 },
849 "peerDependencies": {
850 "picomatch": "^3 || ^4"
851 },
852 "peerDependenciesMeta": {
853 "picomatch": {
854 "optional": true
855 }
856 }
857 },
858 "node_modules/fsevents": {
859 "version": "2.3.3",
860 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
861 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
862 "dev": true,
863 "hasInstallScript": true,
864 "license": "MIT",
865 "optional": true,
866 "os": [
867 "darwin"
868 ],
869 "engines": {
870 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
871 }
872 },
873 "node_modules/https-proxy-agent": {
874 "version": "7.0.6",
875 "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
876 "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
877 "dev": true,
878 "license": "MIT",
879 "dependencies": {
880 "agent-base": "^7.1.2",
881 "debug": "4"
882 },
883 "engines": {
884 "node": ">= 14"
885 }
886 },
887 "node_modules/index-to-position": {
888 "version": "1.2.0",
889 "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
890 "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
891 "dev": true,
892 "license": "MIT",
893 "engines": {
894 "node": ">=18"
895 },
896 "funding": {
897 "url": "https://github.com/sponsors/sindresorhus"
898 }
899 },
900 "node_modules/is-reference": {
901 "version": "3.0.3",
902 "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
903 "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
904 "dev": true,
905 "license": "MIT",
906 "dependencies": {
907 "@types/estree": "^1.0.6"
908 }
909 },
910 "node_modules/js-levenshtein": {
911 "version": "1.1.6",
912 "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
913 "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
914 "dev": true,
915 "license": "MIT",
916 "engines": {
917 "node": ">=0.10.0"
918 }
919 },
920 "node_modules/js-tokens": {
921 "version": "4.0.0",
922 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
923 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
924 "dev": true,
925 "license": "MIT"
926 },
927 "node_modules/js-yaml": {
928 "version": "4.2.0",
929 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
930 "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
931 "dev": true,
932 "funding": [
933 {
934 "type": "github",
935 "url": "https://github.com/sponsors/puzrin"
936 },
937 {
938 "type": "github",
939 "url": "https://github.com/sponsors/nodeca"
940 }
941 ],
942 "license": "MIT",
943 "dependencies": {
944 "argparse": "^2.0.1"
945 },
946 "bin": {
947 "js-yaml": "bin/js-yaml.js"
948 }
949 },
950 "node_modules/json-schema-traverse": {
951 "version": "1.0.0",
952 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
953 "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
954 "dev": true,
955 "license": "MIT"
956 },
957 "node_modules/kleur": {
958 "version": "4.1.5",
959 "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
960 "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
961 "dev": true,
962 "license": "MIT",
963 "engines": {
964 "node": ">=6"
965 }
966 },
967 "node_modules/lightningcss": {
968 "version": "1.32.0",
969 "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
970 "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
971 "dev": true,
972 "license": "MPL-2.0",
973 "dependencies": {
974 "detect-libc": "^2.0.3"
975 },
976 "engines": {
977 "node": ">= 12.0.0"
978 },
979 "funding": {
980 "type": "opencollective",
981 "url": "https://opencollective.com/parcel"
982 },
983 "optionalDependencies": {
984 "lightningcss-android-arm64": "1.32.0",
985 "lightningcss-darwin-arm64": "1.32.0",
986 "lightningcss-darwin-x64": "1.32.0",
987 "lightningcss-freebsd-x64": "1.32.0",
988 "lightningcss-linux-arm-gnueabihf": "1.32.0",
989 "lightningcss-linux-arm64-gnu": "1.32.0",
990 "lightningcss-linux-arm64-musl": "1.32.0",
991 "lightningcss-linux-x64-gnu": "1.32.0",
992 "lightningcss-linux-x64-musl": "1.32.0",
993 "lightningcss-win32-arm64-msvc": "1.32.0",
994 "lightningcss-win32-x64-msvc": "1.32.0"
995 }
996 },
997 "node_modules/lightningcss-android-arm64": {
998 "version": "1.32.0",
999 "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
1000 "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
1001 "cpu": [
1002 "arm64"
1003 ],
1004 "dev": true,
1005 "license": "MPL-2.0",
1006 "optional": true,
1007 "os": [
1008 "android"
1009 ],
1010 "engines": {
1011 "node": ">= 12.0.0"
1012 },
1013 "funding": {
1014 "type": "opencollective",
1015 "url": "https://opencollective.com/parcel"
1016 }
1017 },
1018 "node_modules/lightningcss-darwin-arm64": {
1019 "version": "1.32.0",
1020 "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
1021 "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
1022 "cpu": [
1023 "arm64"
1024 ],
1025 "dev": true,
1026 "license": "MPL-2.0",
1027 "optional": true,
1028 "os": [
1029 "darwin"
1030 ],
1031 "engines": {
1032 "node": ">= 12.0.0"
1033 },
1034 "funding": {
1035 "type": "opencollective",
1036 "url": "https://opencollective.com/parcel"
1037 }
1038 },
1039 "node_modules/lightningcss-darwin-x64": {
1040 "version": "1.32.0",
1041 "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
1042 "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
1043 "cpu": [
1044 "x64"
1045 ],
1046 "dev": true,
1047 "license": "MPL-2.0",
1048 "optional": true,
1049 "os": [
1050 "darwin"
1051 ],
1052 "engines": {
1053 "node": ">= 12.0.0"
1054 },
1055 "funding": {
1056 "type": "opencollective",
1057 "url": "https://opencollective.com/parcel"
1058 }
1059 },
1060 "node_modules/lightningcss-freebsd-x64": {
1061 "version": "1.32.0",
1062 "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
1063 "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
1064 "cpu": [
1065 "x64"
1066 ],
1067 "dev": true,
1068 "license": "MPL-2.0",
1069 "optional": true,
1070 "os": [
1071 "freebsd"
1072 ],
1073 "engines": {
1074 "node": ">= 12.0.0"
1075 },
1076 "funding": {
1077 "type": "opencollective",
1078 "url": "https://opencollective.com/parcel"
1079 }
1080 },
1081 "node_modules/lightningcss-linux-arm-gnueabihf": {
1082 "version": "1.32.0",
1083 "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1084 "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
1085 "cpu": [
1086 "arm"
1087 ],
1088 "dev": true,
1089 "license": "MPL-2.0",
1090 "optional": true,
1091 "os": [
1092 "linux"
1093 ],
1094 "engines": {
1095 "node": ">= 12.0.0"
1096 },
1097 "funding": {
1098 "type": "opencollective",
1099 "url": "https://opencollective.com/parcel"
1100 }
1101 },
1102 "node_modules/lightningcss-linux-arm64-gnu": {
1103 "version": "1.32.0",
1104 "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
1105 "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
1106 "cpu": [
1107 "arm64"
1108 ],
1109 "dev": true,
1110 "libc": [
1111 "glibc"
1112 ],
1113 "license": "MPL-2.0",
1114 "optional": true,
1115 "os": [
1116 "linux"
1117 ],
1118 "engines": {
1119 "node": ">= 12.0.0"
1120 },
1121 "funding": {
1122 "type": "opencollective",
1123 "url": "https://opencollective.com/parcel"
1124 }
1125 },
1126 "node_modules/lightningcss-linux-arm64-musl": {
1127 "version": "1.32.0",
1128 "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
1129 "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
1130 "cpu": [
1131 "arm64"
1132 ],
1133 "dev": true,
1134 "libc": [
1135 "musl"
1136 ],
1137 "license": "MPL-2.0",
1138 "optional": true,
1139 "os": [
1140 "linux"
1141 ],
1142 "engines": {
1143 "node": ">= 12.0.0"
1144 },
1145 "funding": {
1146 "type": "opencollective",
1147 "url": "https://opencollective.com/parcel"
1148 }
1149 },
1150 "node_modules/lightningcss-linux-x64-gnu": {
1151 "version": "1.32.0",
1152 "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
1153 "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
1154 "cpu": [
1155 "x64"
1156 ],
1157 "dev": true,
1158 "libc": [
1159 "glibc"
1160 ],
1161 "license": "MPL-2.0",
1162 "optional": true,
1163 "os": [
1164 "linux"
1165 ],
1166 "engines": {
1167 "node": ">= 12.0.0"
1168 },
1169 "funding": {
1170 "type": "opencollective",
1171 "url": "https://opencollective.com/parcel"
1172 }
1173 },
1174 "node_modules/lightningcss-linux-x64-musl": {
1175 "version": "1.32.0",
1176 "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
1177 "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
1178 "cpu": [
1179 "x64"
1180 ],
1181 "dev": true,
1182 "libc": [
1183 "musl"
1184 ],
1185 "license": "MPL-2.0",
1186 "optional": true,
1187 "os": [
1188 "linux"
1189 ],
1190 "engines": {
1191 "node": ">= 12.0.0"
1192 },
1193 "funding": {
1194 "type": "opencollective",
1195 "url": "https://opencollective.com/parcel"
1196 }
1197 },
1198 "node_modules/lightningcss-win32-arm64-msvc": {
1199 "version": "1.32.0",
1200 "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
1201 "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
1202 "cpu": [
1203 "arm64"
1204 ],
1205 "dev": true,
1206 "license": "MPL-2.0",
1207 "optional": true,
1208 "os": [
1209 "win32"
1210 ],
1211 "engines": {
1212 "node": ">= 12.0.0"
1213 },
1214 "funding": {
1215 "type": "opencollective",
1216 "url": "https://opencollective.com/parcel"
1217 }
1218 },
1219 "node_modules/lightningcss-win32-x64-msvc": {
1220 "version": "1.32.0",
1221 "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
1222 "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
1223 "cpu": [
1224 "x64"
1225 ],
1226 "dev": true,
1227 "license": "MPL-2.0",
1228 "optional": true,
1229 "os": [
1230 "win32"
1231 ],
1232 "engines": {
1233 "node": ">= 12.0.0"
1234 },
1235 "funding": {
1236 "type": "opencollective",
1237 "url": "https://opencollective.com/parcel"
1238 }
1239 },
1240 "node_modules/locate-character": {
1241 "version": "3.0.0",
1242 "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
1243 "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
1244 "dev": true,
1245 "license": "MIT"
1246 },
1247 "node_modules/magic-string": {
1248 "version": "0.30.21",
1249 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1250 "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1251 "dev": true,
1252 "license": "MIT",
1253 "dependencies": {
1254 "@jridgewell/sourcemap-codec": "^1.5.5"
1255 }
1256 },
1257 "node_modules/minimatch": {
1258 "version": "5.1.9",
1259 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
1260 "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
1261 "dev": true,
1262 "license": "ISC",
1263 "dependencies": {
1264 "brace-expansion": "^2.0.1"
1265 },
1266 "engines": {
1267 "node": ">=10"
1268 }
1269 },
1270 "node_modules/mri": {
1271 "version": "1.2.0",
1272 "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
1273 "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
1274 "dev": true,
1275 "license": "MIT",
1276 "engines": {
1277 "node": ">=4"
1278 }
1279 },
1280 "node_modules/mrmime": {
1281 "version": "2.0.1",
1282 "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
1283 "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
1284 "dev": true,
1285 "license": "MIT",
1286 "engines": {
1287 "node": ">=10"
1288 }
1289 },
1290 "node_modules/ms": {
1291 "version": "2.1.3",
1292 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1293 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1294 "dev": true,
1295 "license": "MIT"
1296 },
1297 "node_modules/nanoid": {
1298 "version": "3.3.12",
1299 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1300 "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1301 "dev": true,
1302 "funding": [
1303 {
1304 "type": "github",
1305 "url": "https://github.com/sponsors/ai"
1306 }
1307 ],
1308 "license": "MIT",
1309 "bin": {
1310 "nanoid": "bin/nanoid.cjs"
1311 },
1312 "engines": {
1313 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1314 }
1315 },
1316 "node_modules/obug": {
1317 "version": "2.1.2",
1318 "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
1319 "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
1320 "dev": true,
1321 "funding": [
1322 "https://github.com/sponsors/sxzz",
1323 "https://opencollective.com/debug"
1324 ],
1325 "license": "MIT",
1326 "engines": {
1327 "node": ">=12.20.0"
1328 }
1329 },
1330 "node_modules/openapi-typescript": {
1331 "version": "7.13.0",
1332 "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
1333 "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
1334 "dev": true,
1335 "license": "MIT",
1336 "dependencies": {
1337 "@redocly/openapi-core": "^1.34.6",
1338 "ansi-colors": "^4.1.3",
1339 "change-case": "^5.4.4",
1340 "parse-json": "^8.3.0",
1341 "supports-color": "^10.2.2",
1342 "yargs-parser": "^21.1.1"
1343 },
1344 "bin": {
1345 "openapi-typescript": "bin/cli.js"
1346 },
1347 "peerDependencies": {
1348 "typescript": "^5.x"
1349 }
1350 },
1351 "node_modules/parse-json": {
1352 "version": "8.3.0",
1353 "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
1354 "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
1355 "dev": true,
1356 "license": "MIT",
1357 "dependencies": {
1358 "@babel/code-frame": "^7.26.2",
1359 "index-to-position": "^1.1.0",
1360 "type-fest": "^4.39.1"
1361 },
1362 "engines": {
1363 "node": ">=18"
1364 },
1365 "funding": {
1366 "url": "https://github.com/sponsors/sindresorhus"
1367 }
1368 },
1369 "node_modules/picocolors": {
1370 "version": "1.1.1",
1371 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1372 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1373 "dev": true,
1374 "license": "ISC"
1375 },
1376 "node_modules/picomatch": {
1377 "version": "4.0.4",
1378 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
1379 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
1380 "dev": true,
1381 "license": "MIT",
1382 "engines": {
1383 "node": ">=12"
1384 },
1385 "funding": {
1386 "url": "https://github.com/sponsors/jonschlinkert"
1387 }
1388 },
1389 "node_modules/pluralize": {
1390 "version": "8.0.0",
1391 "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
1392 "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
1393 "dev": true,
1394 "license": "MIT",
1395 "engines": {
1396 "node": ">=4"
1397 }
1398 },
1399 "node_modules/postcss": {
1400 "version": "8.5.15",
1401 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
1402 "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
1403 "dev": true,
1404 "funding": [
1405 {
1406 "type": "opencollective",
1407 "url": "https://opencollective.com/postcss/"
1408 },
1409 {
1410 "type": "tidelift",
1411 "url": "https://tidelift.com/funding/github/npm/postcss"
1412 },
1413 {
1414 "type": "github",
1415 "url": "https://github.com/sponsors/ai"
1416 }
1417 ],
1418 "license": "MIT",
1419 "dependencies": {
1420 "nanoid": "^3.3.12",
1421 "picocolors": "^1.1.1",
1422 "source-map-js": "^1.2.1"
1423 },
1424 "engines": {
1425 "node": "^10 || ^12 || >=14"
1426 }
1427 },
1428 "node_modules/readdirp": {
1429 "version": "4.1.2",
1430 "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
1431 "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
1432 "dev": true,
1433 "license": "MIT",
1434 "engines": {
1435 "node": ">= 14.18.0"
1436 },
1437 "funding": {
1438 "type": "individual",
1439 "url": "https://paulmillr.com/funding/"
1440 }
1441 },
1442 "node_modules/require-from-string": {
1443 "version": "2.0.2",
1444 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
1445 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
1446 "dev": true,
1447 "license": "MIT",
1448 "engines": {
1449 "node": ">=0.10.0"
1450 }
1451 },
1452 "node_modules/rolldown": {
1453 "version": "1.0.3",
1454 "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
1455 "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
1456 "dev": true,
1457 "license": "MIT",
1458 "dependencies": {
1459 "@oxc-project/types": "=0.133.0",
1460 "@rolldown/pluginutils": "^1.0.0"
1461 },
1462 "bin": {
1463 "rolldown": "bin/cli.mjs"
1464 },
1465 "engines": {
1466 "node": "^20.19.0 || >=22.12.0"
1467 },
1468 "optionalDependencies": {
1469 "@rolldown/binding-android-arm64": "1.0.3",
1470 "@rolldown/binding-darwin-arm64": "1.0.3",
1471 "@rolldown/binding-darwin-x64": "1.0.3",
1472 "@rolldown/binding-freebsd-x64": "1.0.3",
1473 "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
1474 "@rolldown/binding-linux-arm64-gnu": "1.0.3",
1475 "@rolldown/binding-linux-arm64-musl": "1.0.3",
1476 "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
1477 "@rolldown/binding-linux-s390x-gnu": "1.0.3",
1478 "@rolldown/binding-linux-x64-gnu": "1.0.3",
1479 "@rolldown/binding-linux-x64-musl": "1.0.3",
1480 "@rolldown/binding-openharmony-arm64": "1.0.3",
1481 "@rolldown/binding-wasm32-wasi": "1.0.3",
1482 "@rolldown/binding-win32-arm64-msvc": "1.0.3",
1483 "@rolldown/binding-win32-x64-msvc": "1.0.3"
1484 }
1485 },
1486 "node_modules/sade": {
1487 "version": "1.8.1",
1488 "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
1489 "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
1490 "dev": true,
1491 "license": "MIT",
1492 "dependencies": {
1493 "mri": "^1.1.0"
1494 },
1495 "engines": {
1496 "node": ">=6"
1497 }
1498 },
1499 "node_modules/set-cookie-parser": {
1500 "version": "3.1.0",
1501 "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
1502 "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
1503 "dev": true,
1504 "license": "MIT"
1505 },
1506 "node_modules/sirv": {
1507 "version": "3.0.2",
1508 "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
1509 "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
1510 "dev": true,
1511 "license": "MIT",
1512 "dependencies": {
1513 "@polka/url": "^1.0.0-next.24",
1514 "mrmime": "^2.0.0",
1515 "totalist": "^3.0.0"
1516 },
1517 "engines": {
1518 "node": ">=18"
1519 }
1520 },
1521 "node_modules/source-map-js": {
1522 "version": "1.2.1",
1523 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1524 "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1525 "dev": true,
1526 "license": "BSD-3-Clause",
1527 "engines": {
1528 "node": ">=0.10.0"
1529 }
1530 },
1531 "node_modules/supports-color": {
1532 "version": "10.2.2",
1533 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
1534 "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
1535 "dev": true,
1536 "license": "MIT",
1537 "engines": {
1538 "node": ">=18"
1539 },
1540 "funding": {
1541 "url": "https://github.com/chalk/supports-color?sponsor=1"
1542 }
1543 },
1544 "node_modules/svelte": {
1545 "version": "5.56.3",
1546 "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz",
1547 "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==",
1548 "dev": true,
1549 "license": "MIT",
1550 "dependencies": {
1551 "@jridgewell/remapping": "^2.3.4",
1552 "@jridgewell/sourcemap-codec": "^1.5.0",
1553 "@sveltejs/acorn-typescript": "^1.0.10",
1554 "@types/estree": "^1.0.5",
1555 "@types/trusted-types": "^2.0.7",
1556 "acorn": "^8.12.1",
1557 "aria-query": "5.3.1",
1558 "axobject-query": "^4.1.0",
1559 "clsx": "^2.1.1",
1560 "devalue": "^5.8.1",
1561 "esm-env": "^1.2.1",
1562 "esrap": "^2.2.11",
1563 "is-reference": "^3.0.3",
1564 "locate-character": "^3.0.0",
1565 "magic-string": "^0.30.11",
1566 "zimmerframe": "^1.1.2"
1567 },
1568 "engines": {
1569 "node": ">=18"
1570 }
1571 },
1572 "node_modules/svelte-check": {
1573 "version": "4.6.0",
1574 "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz",
1575 "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==",
1576 "dev": true,
1577 "license": "MIT",
1578 "dependencies": {
1579 "@jridgewell/trace-mapping": "^0.3.25",
1580 "@sveltejs/load-config": "0.1.1",
1581 "chokidar": "^4.0.1",
1582 "fdir": "^6.2.0",
1583 "picocolors": "^1.0.0",
1584 "sade": "^1.7.4"
1585 },
1586 "bin": {
1587 "svelte-check": "bin/svelte-check"
1588 },
1589 "engines": {
1590 "node": ">= 18.0.0"
1591 },
1592 "peerDependencies": {
1593 "svelte": "^4.0.0 || ^5.0.0-next.0",
1594 "typescript": ">=5.0.0"
1595 }
1596 },
1597 "node_modules/tinyglobby": {
1598 "version": "0.2.17",
1599 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1600 "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1601 "dev": true,
1602 "license": "MIT",
1603 "dependencies": {
1604 "fdir": "^6.5.0",
1605 "picomatch": "^4.0.4"
1606 },
1607 "engines": {
1608 "node": ">=12.0.0"
1609 },
1610 "funding": {
1611 "url": "https://github.com/sponsors/SuperchupuDev"
1612 }
1613 },
1614 "node_modules/totalist": {
1615 "version": "3.0.1",
1616 "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
1617 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
1618 "dev": true,
1619 "license": "MIT",
1620 "engines": {
1621 "node": ">=6"
1622 }
1623 },
1624 "node_modules/tslib": {
1625 "version": "2.8.1",
1626 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1627 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1628 "dev": true,
1629 "license": "0BSD",
1630 "optional": true
1631 },
1632 "node_modules/type-fest": {
1633 "version": "4.41.0",
1634 "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
1635 "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
1636 "dev": true,
1637 "license": "(MIT OR CC0-1.0)",
1638 "engines": {
1639 "node": ">=16"
1640 },
1641 "funding": {
1642 "url": "https://github.com/sponsors/sindresorhus"
1643 }
1644 },
1645 "node_modules/typescript": {
1646 "version": "6.0.3",
1647 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
1648 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
1649 "dev": true,
1650 "license": "Apache-2.0",
1651 "bin": {
1652 "tsc": "bin/tsc",
1653 "tsserver": "bin/tsserver"
1654 },
1655 "engines": {
1656 "node": ">=14.17"
1657 }
1658 },
1659 "node_modules/uri-js-replace": {
1660 "version": "1.0.1",
1661 "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
1662 "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
1663 "dev": true,
1664 "license": "MIT"
1665 },
1666 "node_modules/vite": {
1667 "version": "8.0.16",
1668 "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
1669 "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
1670 "dev": true,
1671 "license": "MIT",
1672 "dependencies": {
1673 "lightningcss": "^1.32.0",
1674 "picomatch": "^4.0.4",
1675 "postcss": "^8.5.15",
1676 "rolldown": "1.0.3",
1677 "tinyglobby": "^0.2.17"
1678 },
1679 "bin": {
1680 "vite": "bin/vite.js"
1681 },
1682 "engines": {
1683 "node": "^20.19.0 || >=22.12.0"
1684 },
1685 "funding": {
1686 "url": "https://github.com/vitejs/vite?sponsor=1"
1687 },
1688 "optionalDependencies": {
1689 "fsevents": "~2.3.3"
1690 },
1691 "peerDependencies": {
1692 "@types/node": "^20.19.0 || >=22.12.0",
1693 "@vitejs/devtools": "^0.1.18",
1694 "esbuild": "^0.27.0 || ^0.28.0",
1695 "jiti": ">=1.21.0",
1696 "less": "^4.0.0",
1697 "sass": "^1.70.0",
1698 "sass-embedded": "^1.70.0",
1699 "stylus": ">=0.54.8",
1700 "sugarss": "^5.0.0",
1701 "terser": "^5.16.0",
1702 "tsx": "^4.8.1",
1703 "yaml": "^2.4.2"
1704 },
1705 "peerDependenciesMeta": {
1706 "@types/node": {
1707 "optional": true
1708 },
1709 "@vitejs/devtools": {
1710 "optional": true
1711 },
1712 "esbuild": {
1713 "optional": true
1714 },
1715 "jiti": {
1716 "optional": true
1717 },
1718 "less": {
1719 "optional": true
1720 },
1721 "sass": {
1722 "optional": true
1723 },
1724 "sass-embedded": {
1725 "optional": true
1726 },
1727 "stylus": {
1728 "optional": true
1729 },
1730 "sugarss": {
1731 "optional": true
1732 },
1733 "terser": {
1734 "optional": true
1735 },
1736 "tsx": {
1737 "optional": true
1738 },
1739 "yaml": {
1740 "optional": true
1741 }
1742 }
1743 },
1744 "node_modules/vitefu": {
1745 "version": "1.1.3",
1746 "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
1747 "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
1748 "dev": true,
1749 "license": "MIT",
1750 "workspaces": [
1751 "tests/deps/*",
1752 "tests/projects/*",
1753 "tests/projects/workspace/packages/*"
1754 ],
1755 "peerDependencies": {
1756 "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
1757 },
1758 "peerDependenciesMeta": {
1759 "vite": {
1760 "optional": true
1761 }
1762 }
1763 },
1764 "node_modules/yaml-ast-parser": {
1765 "version": "0.0.43",
1766 "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
1767 "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
1768 "dev": true,
1769 "license": "Apache-2.0"
1770 },
1771 "node_modules/yargs-parser": {
1772 "version": "21.1.1",
1773 "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
1774 "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
1775 "dev": true,
1776 "license": "ISC",
1777 "engines": {
1778 "node": ">=12"
1779 }
1780 },
1781 "node_modules/zimmerframe": {
1782 "version": "1.1.4",
1783 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
1784 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
1785 "dev": true,
1786 "license": "MIT"
1787 }
1788 }
1789 }
web/package.json
Old New
@@ -0,0 +1,31 @@
1 {
2 "name": "web",
3 "private": true,
4 "version": "0.0.1",
5 "type": "module",
6 "scripts": {
7 "dev": "vite dev",
8 "build": "vite build",
9 "preview": "vite preview",
10 "prepare": "svelte-kit sync || echo ''",
11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13 "gen:api": "openapi-typescript ../docs/openapi.json -o src/lib/api-types.ts"
14 },
15 "overrides": {
16 "openapi-typescript": {
17 "typescript": "$typescript"
18 }
19 },
20 "devDependencies": {
21 "@sveltejs/adapter-auto": "^7.0.1",
22 "@sveltejs/adapter-static": "^3.0.10",
23 "@sveltejs/kit": "^2.63.0",
24 "@sveltejs/vite-plugin-svelte": "^7.1.2",
25 "openapi-typescript": "^7.13.0",
26 "svelte": "^5.56.1",
27 "svelte-check": "^4.6.0",
28 "typescript": "^6.0.3",
29 "vite": "^8.0.16"
30 }
31 }
web/src/app.d.ts
Old New
@@ -0,0 +1,13 @@
1 // See https://svelte.dev/docs/kit/types#app.d.ts
2 // for information about these interfaces
3 declare global {
4 namespace App {
5 // interface Error {}
6 // interface Locals {}
7 // interface PageData {}
8 // interface PageState {}
9 // interface Platform {}
10 }
11 }
12
13 export {};
web/src/app.html
Old New
@@ -0,0 +1,12 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <meta name="text-scale" content="scale" />
7 %sveltekit.head%
8 </head>
9 <body data-sveltekit-preload-data="hover">
10 <div style="display: contents">%sveltekit.body%</div>
11 </body>
12 </html>
web/src/lib/ResourceBar.svelte
Old New
@@ -0,0 +1,69 @@
1 <script lang="ts">
2 let {
3 label,
4 used,
5 total,
6 unit = ''
7 }: { label: string; used: number; total: number; unit?: string } = $props();
8
9 const pct = $derived(total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0);
10 const free = $derived(total > 0 ? total - used : 0);
11 const level = $derived(pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : 'ok');
12 </script>
13
14 <div class="bar">
15 <div class="head">
16 <span class="label">{label}</span>
17 {#if total > 0}
18 <span class="nums">{used} / {total}{unit} <span class="free">· {free}{unit} free</span></span>
19 {:else}
20 <span class="nums dim">— (offline)</span>
21 {/if}
22 </div>
23 <div class="track">
24 <div class="fill {level}" style="width: {pct}%"></div>
25 </div>
26 </div>
27
28 <style>
29 .bar {
30 margin: 0.35rem 0;
31 }
32 .head {
33 display: flex;
34 justify-content: space-between;
35 margin-bottom: 2px;
36 }
37 .label {
38 color: #8b919c;
39 }
40 .nums {
41 color: #d6d9df;
42 }
43 .free {
44 color: #6b7280;
45 }
46 .dim {
47 color: #6b7280;
48 }
49 .track {
50 height: 8px;
51 background: #20242c;
52 border-radius: 4px;
53 overflow: hidden;
54 }
55 .fill {
56 height: 100%;
57 border-radius: 4px;
58 transition: width 0.3s;
59 }
60 .fill.ok {
61 background: #34d399;
62 }
63 .fill.warm {
64 background: #f5b945;
65 }
66 .fill.hot {
67 background: #f87171;
68 }
69 </style>
web/src/lib/api-types.ts
Old New
@@ -0,0 +1,488 @@
1 /**
2 * This file was auto-generated by openapi-typescript.
3 * Do not make direct changes to the file.
4 */
5
6 export interface paths {
7 "/api/v1/enroll": {
8 parameters: {
9 query?: never;
10 header?: never;
11 path?: never;
12 cookie?: never;
13 };
14 get?: never;
15 put?: never;
16 /** Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof. */
17 post: {
18 parameters: {
19 query?: never;
20 header?: never;
21 path?: never;
22 cookie?: never;
23 };
24 requestBody: {
25 content: {
26 "application/json": components["schemas"]["EnrollRequest"];
27 };
28 };
29 responses: {
30 /** @description success */
31 201: {
32 headers: {
33 [name: string]: unknown;
34 };
35 content: {
36 "application/json": components["schemas"]["EnrollResponse"];
37 };
38 };
39 /** @description error (plain text) */
40 default: {
41 headers: {
42 [name: string]: unknown;
43 };
44 content: {
45 "text/plain": string;
46 };
47 };
48 };
49 };
50 delete?: never;
51 options?: never;
52 head?: never;
53 patch?: never;
54 trace?: never;
55 };
56 "/api/v1/enroll-tokens": {
57 parameters: {
58 query?: never;
59 header?: never;
60 path?: never;
61 cookie?: never;
62 };
63 get?: never;
64 put?: never;
65 /** Mint a one-time host enrollment token. */
66 post: {
67 parameters: {
68 query?: never;
69 header?: never;
70 path?: never;
71 cookie?: never;
72 };
73 requestBody?: never;
74 responses: {
75 /** @description success */
76 201: {
77 headers: {
78 [name: string]: unknown;
79 };
80 content: {
81 "application/json": components["schemas"]["EnrollTokenResponse"];
82 };
83 };
84 /** @description error (plain text) */
85 default: {
86 headers: {
87 [name: string]: unknown;
88 };
89 content: {
90 "text/plain": string;
91 };
92 };
93 };
94 };
95 delete?: never;
96 options?: never;
97 head?: never;
98 patch?: never;
99 trace?: never;
100 };
101 "/api/v1/events": {
102 parameters: {
103 query?: never;
104 header?: never;
105 path?: never;
106 cookie?: never;
107 };
108 /** Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot. */
109 get: {
110 parameters: {
111 query?: {
112 /** @description admin token */
113 token?: string;
114 };
115 header?: never;
116 path?: never;
117 cookie?: never;
118 };
119 requestBody?: never;
120 responses: {
121 /** @description success */
122 200: {
123 headers: {
124 [name: string]: unknown;
125 };
126 content: {
127 "text/event-stream": components["schemas"]["StateSnapshot"];
128 };
129 };
130 /** @description error (plain text) */
131 default: {
132 headers: {
133 [name: string]: unknown;
134 };
135 content: {
136 "text/plain": string;
137 };
138 };
139 };
140 };
141 put?: never;
142 post?: never;
143 delete?: never;
144 options?: never;
145 head?: never;
146 patch?: never;
147 trace?: never;
148 };
149 "/api/v1/hosts": {
150 parameters: {
151 query?: never;
152 header?: never;
153 path?: never;
154 cookie?: never;
155 };
156 /** List fleet hosts: durable rows merged with live agent state and allocation. */
157 get: {
158 parameters: {
159 query?: never;
160 header?: never;
161 path?: never;
162 cookie?: never;
163 };
164 requestBody?: never;
165 responses: {
166 /** @description success */
167 200: {
168 headers: {
169 [name: string]: unknown;
170 };
171 content: {
172 "application/json": components["schemas"]["Host"][];
173 };
174 };
175 /** @description error (plain text) */
176 default: {
177 headers: {
178 [name: string]: unknown;
179 };
180 content: {
181 "text/plain": string;
182 };
183 };
184 };
185 };
186 put?: never;
187 post?: never;
188 delete?: never;
189 options?: never;
190 head?: never;
191 patch?: never;
192 trace?: never;
193 };
194 "/api/v1/hosts/{id}": {
195 parameters: {
196 query?: never;
197 header?: never;
198 path?: never;
199 cookie?: never;
200 };
201 get?: never;
202 put?: never;
203 post?: never;
204 /** Decommission a host: tombstone its VMs and drain gracefully (202). */
205 delete: {
206 parameters: {
207 query?: never;
208 header?: never;
209 path: {
210 id: string;
211 };
212 cookie?: never;
213 };
214 requestBody?: never;
215 responses: {
216 /** @description success */
217 202: {
218 headers: {
219 [name: string]: unknown;
220 };
221 content?: never;
222 };
223 /** @description error (plain text) */
224 default: {
225 headers: {
226 [name: string]: unknown;
227 };
228 content: {
229 "text/plain": string;
230 };
231 };
232 };
233 };
234 options?: never;
235 head?: never;
236 patch?: never;
237 trace?: never;
238 };
239 "/api/v1/vms": {
240 parameters: {
241 query?: never;
242 header?: never;
243 path?: never;
244 cookie?: never;
245 };
246 /** List VMs: durable rows merged with live agent-reported actual state. */
247 get: {
248 parameters: {
249 query?: never;
250 header?: never;
251 path?: never;
252 cookie?: never;
253 };
254 requestBody?: never;
255 responses: {
256 /** @description success */
257 200: {
258 headers: {
259 [name: string]: unknown;
260 };
261 content: {
262 "application/json": components["schemas"]["VM"][];
263 };
264 };
265 /** @description error (plain text) */
266 default: {
267 headers: {
268 [name: string]: unknown;
269 };
270 content: {
271 "text/plain": string;
272 };
273 };
274 };
275 };
276 put?: never;
277 /** Create a VM on a host. Omitted fields get one-click defaults. */
278 post: {
279 parameters: {
280 query?: never;
281 header?: never;
282 path?: never;
283 cookie?: never;
284 };
285 requestBody: {
286 content: {
287 "application/json": components["schemas"]["CreateVMRequest"];
288 };
289 };
290 responses: {
291 /** @description success */
292 201: {
293 headers: {
294 [name: string]: unknown;
295 };
296 content: {
297 "application/json": components["schemas"]["CreateVMResponse"];
298 };
299 };
300 /** @description error (plain text) */
301 default: {
302 headers: {
303 [name: string]: unknown;
304 };
305 content: {
306 "text/plain": string;
307 };
308 };
309 };
310 };
311 delete?: never;
312 options?: never;
313 head?: never;
314 patch?: never;
315 trace?: never;
316 };
317 "/api/v1/vms/{id}": {
318 parameters: {
319 query?: never;
320 header?: never;
321 path?: never;
322 cookie?: never;
323 };
324 get?: never;
325 put?: never;
326 post?: never;
327 /** Tombstone a VM for teardown. */
328 delete: {
329 parameters: {
330 query?: never;
331 header?: never;
332 path: {
333 id: string;
334 };
335 cookie?: never;
336 };
337 requestBody?: never;
338 responses: {
339 /** @description success */
340 204: {
341 headers: {
342 [name: string]: unknown;
343 };
344 content?: never;
345 };
346 /** @description error (plain text) */
347 default: {
348 headers: {
349 [name: string]: unknown;
350 };
351 content: {
352 "text/plain": string;
353 };
354 };
355 };
356 };
357 options?: never;
358 head?: never;
359 /** Set a VM's desired power state (running or stopped). */
360 patch: {
361 parameters: {
362 query?: never;
363 header?: never;
364 path: {
365 id: string;
366 };
367 cookie?: never;
368 };
369 requestBody: {
370 content: {
371 "application/json": components["schemas"]["PatchVMRequest"];
372 };
373 };
374 responses: {
375 /** @description success */
376 204: {
377 headers: {
378 [name: string]: unknown;
379 };
380 content?: never;
381 };
382 /** @description error (plain text) */
383 default: {
384 headers: {
385 [name: string]: unknown;
386 };
387 content: {
388 "text/plain": string;
389 };
390 };
391 };
392 };
393 trace?: never;
394 };
395 }
396 export type webhooks = Record<string, never>;
397 export interface components {
398 schemas: {
399 Capacity: {
400 disk_gb: number;
401 mem_mb: number;
402 vcpus: number;
403 };
404 CreateVMRequest: {
405 cloud_init?: string;
406 disk_gb?: number;
407 host_id?: string;
408 image_sha256?: string;
409 image_url?: string;
410 mem_mb?: number;
411 name?: string;
412 persistent?: boolean;
413 power_state?: string;
414 ssh_authorized_key?: string;
415 vcpus?: number;
416 };
417 CreateVMResponse: {
418 id: string;
419 name: string;
420 };
421 EnrollRequest: {
422 arch?: string;
423 name?: string;
424 os?: string;
425 overlay?: string;
426 provisioner?: string;
427 token?: string;
428 };
429 EnrollResponse: {
430 bridge_cidr: string;
431 credential: string;
432 host_id: string;
433 overlay: string;
434 server_cert_sha256: string;
435 };
436 EnrollTokenResponse: {
437 token: string;
438 };
439 Host: {
440 allocated: components["schemas"]["Capacity"];
441 arch: string;
442 bridge_cidr: string;
443 capacity: components["schemas"]["Capacity"];
444 /** Format: date-time */
445 enrolled_at: string;
446 id: string;
447 name: string;
448 online: boolean;
449 os: string;
450 overlay: string;
451 provisioner: string;
452 status: string;
453 };
454 PatchVMRequest: {
455 power_state?: string;
456 };
457 StateSnapshot: {
458 hosts: components["schemas"]["Host"][];
459 vms: components["schemas"]["VM"][];
460 };
461 VM: {
462 actual_power: string;
463 assigned_ip: string;
464 /** Format: date-time */
465 created_at: string;
466 deleted: boolean;
467 disk_gb: number;
468 host_id: string;
469 id: string;
470 image_url: string;
471 last_error: string;
472 mem_mb: number;
473 name: string;
474 persistent: boolean;
475 phase: string;
476 power_state: string;
477 status: string;
478 vcpus: number;
479 };
480 };
481 responses: never;
482 parameters: never;
483 requestBodies: never;
484 headers: never;
485 pathItems: never;
486 }
487 export type $defs = Record<string, never>;
488 export type operations = Record<string, never>;
web/src/lib/assets/favicon.svg
Old New
@@ -0,0 +1 @@
1 <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
\ No newline at end of file 1 \ No newline at end of file
web/src/lib/fleet.svelte.ts
Old New
@@ -0,0 +1,137 @@
1 // Shared reactive fleet state: live host/VM snapshot over SSE, plus the admin
2 // API client. The Go server serves this SPA and the API same-origin, so all
3 // requests are relative.
4
5 // Wire types are generated from the server's OpenAPI spec (docs/openapi.json →
6 // api-types.ts via `npm run gen:api`); re-exported here so the rest of the app
7 // keeps one import point and cannot drift from the API.
8 import type { components } from './api-types';
9
10 export type Capacity = components['schemas']['Capacity'];
11 export type Host = components['schemas']['Host'];
12 export type VM = components['schemas']['VM'];
13
14 export type CreateVMRequest = components['schemas']['CreateVMRequest'];
15
16 const TOKEN_KEY = 'eitri_token';
17
18 export const fleet = $state({
19 token: typeof localStorage !== 'undefined' ? (localStorage.getItem(TOKEN_KEY) ?? '') : '',
20 hosts: [] as Host[],
21 vms: [] as VM[],
22 connected: false,
23 error: ''
24 });
25
26 let es: EventSource | null = null;
27
28 function authHeaders(): HeadersInit {
29 return { Authorization: `Bearer ${fleet.token}`, 'Content-Type': 'application/json' };
30 }
31
32 async function req(method: string, path: string, body?: unknown): Promise<Response> {
33 const res = await fetch(path, {
34 method,
35 headers: authHeaders(),
36 body: body === undefined ? undefined : JSON.stringify(body)
37 });
38 if (!res.ok) {
39 const text = await res.text();
40 throw new Error(`${res.status}: ${text.trim() || res.statusText}`);
41 }
42 return res;
43 }
44
45 /** setToken persists the admin token and (re)connects the live stream. */
46 export function setToken(t: string) {
47 fleet.token = t.trim();
48 if (typeof localStorage !== 'undefined') localStorage.setItem(TOKEN_KEY, fleet.token);
49 connect();
50 }
51
52 /** connect opens the SSE stream; on error it falls back to a one-shot refresh. */
53 export function connect() {
54 if (!fleet.token) return;
55 es?.close();
56 es = new EventSource(`/api/v1/events?token=${encodeURIComponent(fleet.token)}`);
57 es.addEventListener('state', (e) => {
58 try {
59 const snap = JSON.parse((e as MessageEvent).data);
60 fleet.hosts = snap.hosts ?? [];
61 fleet.vms = snap.vms ?? [];
62 fleet.connected = true;
63 fleet.error = '';
64 } catch (err) {
65 fleet.error = String(err);
66 }
67 });
68 es.onerror = () => {
69 fleet.connected = false;
70 // EventSource auto-reconnects; surface the state but keep last data.
71 };
72 }
73
74 /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */
75 export async function refresh() {
76 try {
77 const [h, v] = await Promise.all([
78 req('GET', '/api/v1/hosts').then((r) => r.json()),
79 req('GET', '/api/v1/vms').then((r) => r.json())
80 ]);
81 fleet.hosts = h;
82 fleet.vms = v;
83 fleet.error = '';
84 } catch (err) {
85 fleet.error = String(err);
86 }
87 }
88
89 export async function createVM(body: CreateVMRequest): Promise<{ id: string; name: string }> {
90 return (await req('POST', '/api/v1/vms', body)).json();
91 }
92
93 export async function setPower(id: string, power: 'running' | 'stopped') {
94 await req('PATCH', `/api/v1/vms/${id}`, { power_state: power });
95 }
96
97 export async function deleteVM(id: string) {
98 await req('DELETE', `/api/v1/vms/${id}`);
99 }
100
101 export async function decommissionHost(id: string) {
102 await req('DELETE', `/api/v1/hosts/${id}`);
103 }
104
105 export async function createEnrollToken(): Promise<string> {
106 const r = await (await req('POST', '/api/v1/enroll-tokens')).json();
107 return r.token;
108 }
109
110 export function vmsForHost(id: string): VM[] {
111 return fleet.vms.filter((v) => v.host_id === id);
112 }
113
114 // VM display derivations — the canonical "what phase / power / address is this
115 // VM" rules, shared by every view so they cannot drift.
116
117 /** vmPhase is the phase to display: "deleting" once tombstoned, else the
118 * reported phase falling back to the desired status. */
119 export function vmPhase(vm: VM): string {
120 return vm.deleted ? 'deleting' : vm.phase || vm.status;
121 }
122
123 /** vmPower is the power to display: the agent-observed power, falling back to
124 * the desired power_state when no actual is reported yet. */
125 export function vmPower(vm: VM): string {
126 return vm.actual_power || vm.power_state;
127 }
128
129 /** vmIP is the assigned IP, or an em-dash placeholder when unassigned. */
130 export function vmIP(vm: VM): string {
131 return vm.assigned_ip || '—';
132 }
133
134 /** vmIsRunning reports whether the VM is actually running (gates Start/Stop). */
135 export function vmIsRunning(vm: VM): boolean {
136 return vm.actual_power === 'running';
137 }
web/src/lib/index.ts
Old New
@@ -0,0 +1 @@
1 // place files you want to import through the `$lib` alias in this folder.
web/src/routes/+layout.svelte
Old New
@@ -0,0 +1,164 @@
1 <script lang="ts">
2 import favicon from '$lib/assets/favicon.svg';
3 import { onMount } from 'svelte';
4 import { fleet, setToken, connect, refresh } from '$lib/fleet.svelte';
5 let { children } = $props();
6 let tokenInput = $state('');
7
8 onMount(() => {
9 if (fleet.token) {
10 tokenInput = fleet.token;
11 refresh();
12 connect();
13 }
14 });
15
16 function saveToken(e: Event) {
17 e.preventDefault();
18 setToken(tokenInput);
19 refresh();
20 }
21 </script>
22
23 <svelte:head>
24 <link rel="icon" href={favicon} />
25 <title>eitri fleet</title>
26 </svelte:head>
27
28 <header>
29 <a href="/" class="brand">eitri</a>
30 <span class="fleet-label">fleet</span>
31 <span class="spacer"></span>
32 {#if fleet.token}
33 <span class="dot {fleet.connected ? 'on' : 'off'}"></span>
34 <span class="conn">{fleet.connected ? 'live' : 'disconnected'}</span>
35 {/if}
36 <form onsubmit={saveToken} class="tokenform">
37 <input type="password" placeholder="admin token" bind:value={tokenInput} autocomplete="off" />
38 <button type="submit">set</button>
39 </form>
40 </header>
41
42 {#if fleet.error}
43 <div class="error">{fleet.error}</div>
44 {/if}
45
46 <main>
47 {#if !fleet.token}
48 <p class="hint">Enter your admin token above to manage the fleet.</p>
49 {:else}
50 {@render children()}
51 {/if}
52 </main>
53
54 <style>
55 :global(body) {
56 margin: 0;
57 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
58 background: #0c0d10;
59 color: #d6d9df;
60 font-size: 13px;
61 }
62 :global(a) {
63 color: #6cb6ff;
64 text-decoration: none;
65 }
66 :global(a:hover) {
67 text-decoration: underline;
68 }
69 :global(table) {
70 border-collapse: collapse;
71 width: 100%;
72 margin: 0.5rem 0 1.5rem;
73 }
74 :global(th, td) {
75 text-align: left;
76 padding: 0.4rem 0.6rem;
77 border-bottom: 1px solid #20242c;
78 white-space: nowrap;
79 }
80 :global(th) {
81 color: #8b919c;
82 font-weight: 600;
83 border-bottom: 1px solid #2a2e37;
84 }
85 header {
86 display: flex;
87 align-items: center;
88 gap: 0.6rem;
89 padding: 0.6rem 1rem;
90 background: #15171c;
91 border-bottom: 1px solid #23262d;
92 }
93 .brand {
94 font-weight: 700;
95 color: #fff;
96 font-size: 15px;
97 }
98 .fleet-label {
99 color: #6b7280;
100 }
101 .spacer {
102 flex: 1;
103 }
104 .dot {
105 width: 8px;
106 height: 8px;
107 border-radius: 50%;
108 display: inline-block;
109 }
110 .dot.on {
111 background: #34d399;
112 }
113 .dot.off {
114 background: #f87171;
115 }
116 .conn {
117 color: #9aa0aa;
118 margin-right: 0.5rem;
119 }
120 .tokenform {
121 display: flex;
122 gap: 0.3rem;
123 }
124 :global(input, button, select, textarea) {
125 background: #0c0d10;
126 color: #d6d9df;
127 border: 1px solid #2a2e37;
128 border-radius: 4px;
129 padding: 0.3rem 0.5rem;
130 font-family: inherit;
131 font-size: 12px;
132 }
133 :global(button) {
134 cursor: pointer;
135 background: #1f6feb;
136 border-color: #1f6feb;
137 color: #fff;
138 }
139 :global(button:hover) {
140 background: #2b7bf5;
141 }
142 :global(button.ghost) {
143 background: transparent;
144 border-color: #2a2e37;
145 color: #d6d9df;
146 }
147 :global(button.danger) {
148 background: #b9333f;
149 border-color: #b9333f;
150 }
151 main {
152 padding: 1rem;
153 max-width: 1100px;
154 }
155 .error {
156 background: #3a1518;
157 color: #ffb4b4;
158 padding: 0.5rem 1rem;
159 border-bottom: 1px solid #5a2025;
160 }
161 .hint {
162 color: #6b7280;
163 }
164 </style>
web/src/routes/+layout.ts
Old New
@@ -0,0 +1,4 @@
1 // Pure client-side SPA: the Go server serves index.html for all routes and the
2 // app fetches the API at runtime. No SSR, no prerender.
3 export const ssr = false;
4 export const prerender = false;
web/src/routes/+page.svelte
Old New
@@ -0,0 +1,302 @@
1 <script lang="ts">
2 import {
3 fleet,
4 createVM,
5 setPower,
6 deleteVM,
7 decommissionHost,
8 createEnrollToken,
9 vmsForHost,
10 vmPhase,
11 vmPower,
12 vmIP,
13 vmIsRunning,
14 type CreateVMRequest
15 } from '$lib/fleet.svelte';
16
17 let showCreate = $state(false);
18 let advanced = $state(false);
19 let enrollToken = $state('');
20 let busy = $state('');
21
22 let form = $state<CreateVMRequest>({ host_id: '' });
23
24 function openCreate() {
25 form = { host_id: fleet.hosts[0]?.id ?? '' };
26 showCreate = true;
27 }
28
29 async function submitCreate(e: Event) {
30 e.preventDefault();
31 busy = 'create';
32 try {
33 await createVM(stripEmpty(form));
34 showCreate = false;
35 } catch (err) {
36 fleet.error = String(err);
37 } finally {
38 busy = '';
39 }
40 }
41
42 function stripEmpty(f: CreateVMRequest): CreateVMRequest {
43 const out: Record<string, unknown> = {};
44 for (const [k, v] of Object.entries(f)) {
45 if (v !== '' && v !== undefined && v !== null) out[k] = v;
46 }
47 return out as CreateVMRequest;
48 }
49
50 async function power(id: string, p: 'running' | 'stopped') {
51 try {
52 await setPower(id, p);
53 } catch (err) {
54 fleet.error = String(err);
55 }
56 }
57
58 async function remove(id: string) {
59 if (!confirm(`Delete VM ${id}?`)) return;
60 try {
61 await deleteVM(id);
62 } catch (err) {
63 fleet.error = String(err);
64 }
65 }
66
67 async function decommission(id: string, name: string) {
68 if (!confirm(`Decommission host ${name}? Its VMs will be drained and removed.`)) return;
69 try {
70 await decommissionHost(id);
71 } catch (err) {
72 fleet.error = String(err);
73 }
74 }
75
76 async function addHost() {
77 try {
78 enrollToken = await createEnrollToken();
79 } catch (err) {
80 fleet.error = String(err);
81 }
82 }
83 </script>
84
85 <section>
86 <div class="row">
87 <h2>Hosts ({fleet.hosts.length})</h2>
88 <button class="ghost" onclick={addHost}>+ Add host</button>
89 </div>
90
91 {#if enrollToken}
92 <div class="enroll">
93 Run on the new host:
94 <code>eitri-agent --server &lt;url&gt; --quic-addr &lt;addr&gt; --token {enrollToken} enroll</code>
95 </div>
96 {/if}
97
98 {#if fleet.hosts.length === 0}
99 <p class="hint">No hosts enrolled yet.</p>
100 {:else}
101 <table>
102 <thead>
103 <tr><th>Name</th><th>Status</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr>
104 </thead>
105 <tbody>
106 {#each fleet.hosts as h (h.id)}
107 <tr>
108 <td><a href="/hosts/{h.id}">{h.name}</a></td>
109 <td>
110 <span class="dot {h.online ? 'on' : 'off'}"></span>
111 {h.status}{h.online ? '' : ' · offline'}
112 </td>
113 <td>{vmsForHost(h.id).length}</td>
114 <td>{h.bridge_cidr}</td>
115 <td>
116 {h.allocated.vcpus}/{h.capacity.vcpus || '?'}c ·
117 {h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}MB ·
118 {h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}GB
119 </td>
120 <td>
121 {#if h.status !== 'decommissioning'}
122 <button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button>
123 {:else}
124 <span class="hint">draining…</span>
125 {/if}
126 </td>
127 </tr>
128 {/each}
129 </tbody>
130 </table>
131 {/if}
132 </section>
133
134 <section>
135 <div class="row">
136 <h2>VMs ({fleet.vms.length})</h2>
137 <button onclick={openCreate} disabled={fleet.hosts.length === 0}>+ Create VM</button>
138 </div>
139
140 {#if fleet.vms.length === 0}
141 <p class="hint">No VMs.</p>
142 {:else}
143 <table>
144 <thead>
145 <tr><th>Name</th><th>Host</th><th>Phase</th><th>Power</th><th>IP</th><th></th></tr>
146 </thead>
147 <tbody>
148 {#each fleet.vms as v (v.id)}
149 <tr>
150 <td><a href="/vms/{v.id}">{v.name}</a></td>
151 <td>{fleet.hosts.find((h) => h.id === v.host_id)?.name ?? v.host_id.slice(0, 8)}</td>
152 <td>{vmPhase(v)}{v.last_error ? ` · ${v.last_error}` : ''}</td>
153 <td>{vmPower(v)}</td>
154 <td>{vmIP(v)}</td>
155 <td class="actions">
156 {#if vmIsRunning(v)}
157 <button class="ghost" onclick={() => power(v.id, 'stopped')}>Stop</button>
158 {:else}
159 <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button>
160 {/if}
161 <button class="danger" onclick={() => remove(v.id)}>Delete</button>
162 </td>
163 </tr>
164 {/each}
165 </tbody>
166 </table>
167 {/if}
168 </section>
169
170 {#if showCreate}
171 <div class="modal" role="dialog">
172 <form class="card" onsubmit={submitCreate}>
173 <h3>Create VM</h3>
174 <label>
175 Host
176 <select bind:value={form.host_id} required>
177 {#each fleet.hosts as h (h.id)}
178 <option value={h.id}>{h.name}</option>
179 {/each}
180 </select>
181 </label>
182 <label>
183 Name (optional)
184 <input bind:value={form.name} placeholder="auto: sandbox-xxxx" />
185 </label>
186
187 <label class="checkbox">
188 <input type="checkbox" bind:checked={advanced} /> Advanced
189 </label>
190
191 {#if advanced}
192 <div class="grid">
193 <label>vCPUs<input type="number" bind:value={form.vcpus} placeholder="2" /></label>
194 <label>Mem MB<input type="number" bind:value={form.mem_mb} placeholder="2048" /></label>
195 <label>Disk GB<input type="number" bind:value={form.disk_gb} placeholder="10" /></label>
196 </div>
197 <label>Image URL<input bind:value={form.image_url} placeholder="server default" /></label>
198 <label>Image sha256<input bind:value={form.image_sha256} placeholder="paired with URL" /></label>
199 <label>SSH key<input bind:value={form.ssh_authorized_key} placeholder="ssh-ed25519 …" /></label>
200 <label
201 >cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …"
202 ></textarea></label
203 >
204 <label class="checkbox"><input type="checkbox" bind:checked={form.persistent} /> Persistent</label>
205 {/if}
206
207 <div class="row end">
208 <button type="button" class="ghost" onclick={() => (showCreate = false)}>Cancel</button>
209 <button type="submit" disabled={busy === 'create'}
210 >{busy === 'create' ? 'Creating…' : 'Create'}</button
211 >
212 </div>
213 </form>
214 </div>
215 {/if}
216
217 <style>
218 .row {
219 display: flex;
220 align-items: center;
221 gap: 0.8rem;
222 }
223 .row.end {
224 justify-content: flex-end;
225 margin-top: 0.5rem;
226 }
227 h2 {
228 font-size: 14px;
229 margin: 1rem 0 0;
230 }
231 .dot {
232 width: 8px;
233 height: 8px;
234 border-radius: 50%;
235 display: inline-block;
236 margin-right: 3px;
237 }
238 .dot.on {
239 background: #34d399;
240 }
241 .dot.off {
242 background: #f87171;
243 }
244 .actions {
245 display: flex;
246 gap: 0.3rem;
247 }
248 .enroll {
249 background: #15171c;
250 border: 1px solid #2a2e37;
251 border-radius: 4px;
252 padding: 0.5rem;
253 margin: 0.5rem 0;
254 }
255 .enroll code {
256 display: block;
257 margin-top: 0.3rem;
258 color: #8fe3a0;
259 word-break: break-all;
260 }
261 .modal {
262 position: fixed;
263 inset: 0;
264 background: rgba(0, 0, 0, 0.6);
265 display: flex;
266 align-items: center;
267 justify-content: center;
268 }
269 .card {
270 background: #15171c;
271 border: 1px solid #2a2e37;
272 border-radius: 8px;
273 padding: 1.2rem;
274 width: 420px;
275 max-width: 92vw;
276 display: flex;
277 flex-direction: column;
278 gap: 0.5rem;
279 }
280 .card h3 {
281 margin: 0 0 0.3rem;
282 }
283 label {
284 display: flex;
285 flex-direction: column;
286 gap: 0.2rem;
287 color: #9aa0aa;
288 }
289 label.checkbox {
290 flex-direction: row;
291 align-items: center;
292 gap: 0.4rem;
293 }
294 .grid {
295 display: grid;
296 grid-template-columns: 1fr 1fr 1fr;
297 gap: 0.5rem;
298 }
299 .hint {
300 color: #6b7280;
301 }
302 </style>
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -0,0 +1,112 @@
1 <script lang="ts">
2 import { page } from '$app/state';
3 import { fleet, decommissionHost, vmsForHost, vmPhase, vmPower, vmIP } from '$lib/fleet.svelte';
4 import ResourceBar from '$lib/ResourceBar.svelte';
5
6 const id = $derived(page.params.id);
7 const host = $derived(fleet.hosts.find((h) => h.id === id));
8 const vms = $derived(vmsForHost(id));
9
10 async function decommission() {
11 if (!host) return;
12 if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return;
13 try {
14 await decommissionHost(host.id);
15 } catch (err) {
16 fleet.error = String(err);
17 }
18 }
19 </script>
20
21 <p><a href="/">← fleet</a></p>
22
23 {#if !host}
24 <p class="hint">Host not found (it may have been decommissioned).</p>
25 {:else}
26 <h2>{host.name}</h2>
27 <table class="kv">
28 <tbody>
29 <tr><th>ID</th><td>{host.id}</td></tr>
30 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr>
31 <tr><th>OS / Arch</th><td>{host.os} / {host.arch}</td></tr>
32 <tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
33 <tr><th>Overlay</th><td>{host.overlay}</td></tr>
34 <tr><th>Bridge CIDR</th><td>{host.bridge_cidr}</td></tr>
35 <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
36 <tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr>
37 </tbody>
38 </table>
39
40 <h2>Resources</h2>
41 <div class="resources">
42 <ResourceBar label="vCPU" used={host.allocated.vcpus} total={host.capacity.vcpus} />
43 <ResourceBar label="Memory" used={host.allocated.mem_mb} total={host.capacity.mem_mb} unit="MB" />
44 <ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" />
45 </div>
46 <p class="note">
47 Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online).
48 vCPU is commonly oversubscribed; memory and disk are hard limits.
49 </p>
50
51 {#if host.status !== 'decommissioning'}
52 <button class="danger" onclick={decommission}>Decommission host</button>
53 {:else}
54 <p class="hint">Decommissioning — draining {vms.length} VM(s)…</p>
55 {/if}
56
57 <h2>VMs on this host ({vms.length})</h2>
58 {#if vms.length === 0}
59 <p class="hint">None.</p>
60 {:else}
61 <table>
62 <thead><tr><th>Name</th><th>vCPU</th><th>Mem</th><th>Disk</th><th>Phase</th><th>Power</th><th>IP</th></tr></thead>
63 <tbody>
64 {#each vms as v (v.id)}
65 <tr>
66 <td><a href="/vms/{v.id}">{v.name}</a></td>
67 <td>{v.vcpus}</td>
68 <td>{v.mem_mb}MB</td>
69 <td>{v.disk_gb}GB</td>
70 <td>{vmPhase(v)}</td>
71 <td>{vmPower(v)}</td>
72 <td>{vmIP(v)}</td>
73 </tr>
74 {/each}
75 </tbody>
76 </table>
77 {/if}
78 {/if}
79
80 <style>
81 h2 {
82 font-size: 14px;
83 }
84 .resources {
85 max-width: 460px;
86 }
87 .note {
88 color: #6b7280;
89 max-width: 600px;
90 margin: 0.3rem 0 1rem;
91 }
92 .kv th {
93 color: #8b919c;
94 width: 140px;
95 }
96 .dot {
97 width: 8px;
98 height: 8px;
99 border-radius: 50%;
100 display: inline-block;
101 margin-right: 4px;
102 }
103 .dot.on {
104 background: #34d399;
105 }
106 .dot.off {
107 background: #f87171;
108 }
109 .hint {
110 color: #6b7280;
111 }
112 </style>
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -0,0 +1,91 @@
1 <script lang="ts">
2 import { page } from '$app/state';
3 import { fleet, setPower, deleteVM, vmPhase, vmPower, vmIP, vmIsRunning } from '$lib/fleet.svelte';
4
5 const id = $derived(page.params.id);
6 const vm = $derived(fleet.vms.find((v) => v.id === id));
7 const host = $derived(vm ? fleet.hosts.find((h) => h.id === vm.host_id) : undefined);
8
9 async function power(p: 'running' | 'stopped') {
10 if (!vm) return;
11 try {
12 await setPower(vm.id, p);
13 } catch (err) {
14 fleet.error = String(err);
15 }
16 }
17
18 async function remove() {
19 if (!vm) return;
20 if (!confirm(`Delete VM ${vm.name}?`)) return;
21 try {
22 await deleteVM(vm.id);
23 } catch (err) {
24 fleet.error = String(err);
25 }
26 }
27 </script>
28
29 <p><a href="/">← fleet</a></p>
30
31 {#if !vm}
32 <p class="hint">VM not found (it may have been deleted).</p>
33 {:else}
34 <h2>{vm.name}</h2>
35 <table class="kv">
36 <tbody>
37 <tr><th>ID</th><td>{vm.id}</td></tr>
38 <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr>
39 <tr><th>Phase</th><td>{vmPhase(vm)}</td></tr>
40 <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr>
41 <tr><th>IP</th><td>{vmIP(vm)}</td></tr>
42 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
43 <tr><th>Persistent</th><td>{vm.persistent}</td></tr>
44 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr>
45 {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if}
46 <tr><th>Created</th><td>{vm.created_at}</td></tr>
47 </tbody>
48 </table>
49
50 {#if vm.assigned_ip}
51 <p class="ssh">SSH: <code>ssh ubuntu@{vm.assigned_ip}</code> <span class="hint">(over the host's overlay)</span></p>
52 {/if}
53
54 <div class="actions">
55 {#if vmIsRunning(vm)}
56 <button class="ghost" onclick={() => power('stopped')}>Stop</button>
57 {:else}
58 <button class="ghost" onclick={() => power('running')}>Start</button>
59 {/if}
60 <button class="danger" onclick={remove}>Delete</button>
61 </div>
62 {/if}
63
64 <style>
65 h2 {
66 font-size: 14px;
67 }
68 .kv th {
69 color: #8b919c;
70 width: 140px;
71 vertical-align: top;
72 }
73 .wrap {
74 word-break: break-all;
75 white-space: normal;
76 }
77 .err {
78 color: #ffb4b4;
79 }
80 .ssh code {
81 color: #8fe3a0;
82 }
83 .actions {
84 display: flex;
85 gap: 0.4rem;
86 margin-top: 0.8rem;
87 }
88 .hint {
89 color: #6b7280;
90 }
91 </style>
web/static/robots.txt
Old New
@@ -0,0 +1,3 @@
1 # allow crawling everything by default
2 User-agent: *
3 Disallow:
web/tsconfig.json
Old New
@@ -0,0 +1,20 @@
1 {
2 "extends": "./.svelte-kit/tsconfig.json",
3 "compilerOptions": {
4 "rewriteRelativeImportExtensions": true,
5 "allowJs": true,
6 "checkJs": true,
7 "esModuleInterop": true,
8 "forceConsistentCasingInFileNames": true,
9 "resolveJsonModule": true,
10 "skipLibCheck": true,
11 "sourceMap": true,
12 "strict": true,
13 "moduleResolution": "bundler"
14 }
15 // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
16 // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
17 //
18 // To make changes to top-level options such as include and exclude, we recommend extending
19 // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
20 }
web/vite.config.ts
Old New
@@ -0,0 +1,24 @@
1 import adapter from '@sveltejs/adapter-static';
2 import { sveltekit } from '@sveltejs/kit/vite';
3 import { defineConfig } from 'vite';
4
5 export default defineConfig({
6 plugins: [
7 sveltekit({
8 compilerOptions: {
9 // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
10 runes: ({ filename }) =>
11 filename.split(/[/\\]/).includes('node_modules') ? undefined : true
12 },
13
14 // Static SPA: the Go server embeds this build and serves index.html
15 // for all client-routed paths (see internal/server/web).
16 adapter: adapter({ fallback: 'index.html' })
17 })
18 ],
19 server: {
20 proxy: {
21 '/api': 'http://localhost:8080'
22 }
23 }
24 });