c2b35d97
feat: Eitri — BYO-hardware VM fleet control plane
a73x 2026-07-25 08:12
Commit message
.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 ¬ifier{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 <url> --quic-addr <addr> --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 | }); | ||