cdae8e89
feat: coverage-instrumented boot-gate (eitri-smoke) with live SSH-CA gate check
a73x 2026-07-26 09:11
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,5 @@ | |||
| 1 | /eitri-server | 1 | /eitri-server |
| 2 | /eitri-agent | 2 | /eitri-agent |
| 3 | /eitri-devstack | ||
| 4 | /eitri-sandbox | ||
| 5 | /eitri-smoketest | ||
| 6 | *.db | 3 | *.db |
| 7 | *.db-wal | 4 | *.db-wal |
| 8 | *.db-shm | 5 | *.db-shm |
| @@ -21,3 +18,13 @@ web/build/ | |||
| 21 | 18 | ||
| 22 | # Site-specific deploy config (may hold secrets); see scripts/deploy.env.example | 19 | # Site-specific deploy config (may hold secrets); see scripts/deploy.env.example |
| 23 | deploy.env | 20 | deploy.env |
| 21 | |||
| 22 | # Integration coverage collected by the deploy boot-gate (cmd/eitri-smoke). | ||
| 23 | /coverage/ | ||
| 24 | |||
| 25 | # Stray `go build ./cmd/eitri-smoke` output at the repo root (use bin/). | ||
| 26 | /eitri-smoke | ||
| 27 | |||
| 28 | # Local dev-process working docs — kept OUT of the repo by convention. | ||
| 29 | /docs/superpowers/ | ||
| 30 | /scratchpad/ | ||
.golangci.yml
| Old | New | ||
|---|---|---|---|
| @@ -11,10 +11,6 @@ version: "2" | |||
| 11 | 11 | ||
| 12 | run: | 12 | run: |
| 13 | timeout: 5m | 13 | timeout: 5m |
| 14 | # Lint the integration tiers too, not just the default build. | ||
| 15 | build-tags: | ||
| 16 | - smoke | ||
| 17 | - sandbox | ||
| 18 | 14 | ||
| 19 | linters: | 15 | linters: |
| 20 | default: none | 16 | default: none |
| @@ -131,11 +127,8 @@ linters: | |||
| 131 | - funlen | 127 | - funlen |
| 132 | - containedctx | 128 | - containedctx |
| 133 | - bodyclose | 129 | - bodyclose |
| 134 | # contextcheck adds value in the core libraries; the CLI entrypoints and | 130 | # contextcheck adds value in the core libraries; the CLI entrypoints use |
| 135 | # the integration harness use context.Background in shutdown paths by design. | 131 | # context.Background in shutdown paths by design. |
| 136 | - path: (^|/)cmd/ | 132 | - path: (^|/)cmd/ |
| 137 | linters: | 133 | linters: |
| 138 | - contextcheck | 134 | - contextcheck |
| 139 | - path: (^|/)internal/integration/ | ||
| 140 | linters: | ||
| 141 | - contextcheck | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,7 @@ DEADCODE_VERSION := v0.48.0 | |||
| 10 | # .golangci.yml's enable list once a linter's baseline is clean. | 10 | # .golangci.yml's enable list once a linter's baseline is clean. |
| 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret |
| 12 | 12 | ||
| 13 | .PHONY: build build-go web test vet proto smoke smoke-go devstack sandbox clean \ | 13 | .PHONY: build build-go web test vet proto clean \ |
| 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode \ | 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode \ |
| 15 | deploy hooks | 15 | deploy hooks |
| 16 | 16 | ||
| @@ -30,11 +30,10 @@ web: | |||
| 30 | cp -r web/build/. $(WEB_DIST)/ | 30 | cp -r web/build/. $(WEB_DIST)/ |
| 31 | 31 | ||
| 32 | build: web | 32 | build: web |
| 33 | go build -o $(BIN)/eitri-server ./cmd/eitri-server | 33 | go build -o $(BIN)/eitri-server ./cmd/eitri-server |
| 34 | go build -o $(BIN)/eitri-agent ./cmd/eitri-agent | 34 | go build -o $(BIN)/eitri-agent ./cmd/eitri-agent |
| 35 | go build -o $(BIN)/eitri-devstack ./cmd/eitri-devstack | 35 | go build -o $(BIN)/eitri-mcp ./cmd/eitri-mcp |
| 36 | go build -o $(BIN)/eitri-sandbox ./cmd/eitri-sandbox | 36 | go build -o $(BIN)/eitri-smoke ./cmd/eitri-smoke |
| 37 | go build -o $(BIN)/eitri-mcp ./cmd/eitri-mcp | ||
| 38 | 37 | ||
| 39 | test: | 38 | test: |
| 40 | go test -race ./... | 39 | go test -race ./... |
| @@ -62,35 +61,15 @@ api-check: | |||
| 62 | @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \ | 61 | @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \ |
| 63 | { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; } | 62 | { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; } |
| 64 | 63 | ||
| 65 | # Build as the invoking user, then run the smoke script, which elevates | ||
| 66 | # (sudo) ONLY for the agent — the one process that needs CAP_NET_ADMIN. | ||
| 67 | smoke: build | ||
| 68 | ./scripts/smoke.sh | ||
| 69 | |||
| 70 | # Go real-VM harness: boots actual cloud-hypervisor VMs through the real stack | ||
| 71 | # and asserts on the full control loop. Skips cleanly without KVM/CH/firmware. | ||
| 72 | # Prime sudo first (`sudo -v`) — the agent runs under sudo for CAP_NET_ADMIN. | ||
| 73 | smoke-go: build | ||
| 74 | sudo -v | ||
| 75 | go test -tags=smoke -timeout=20m -count=1 ./internal/integration -run TestSmoke -v | ||
| 76 | |||
| 77 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote | 64 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote |
| 78 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from | 65 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from |
| 79 | # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. | 66 | # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. |
| 67 | # The deploy runs a real-VM boot-gate (cmd/eitri-smoke): create a throwaway VM, | ||
| 68 | # prove it boots under UEFI, then reap it — the fleet's one automated real-VM | ||
| 69 | # check, which also emits merged server+agent integration coverage. | ||
| 80 | deploy: | 70 | deploy: |
| 81 | ./scripts/deploy.sh | 71 | ./scripts/deploy.sh |
| 82 | 72 | ||
| 83 | # Bring up the real stack interactively for feature development; Ctrl-C to stop. | ||
| 84 | devstack: build | ||
| 85 | sudo -v | ||
| 86 | ./$(BIN)/eitri-devstack | ||
| 87 | |||
| 88 | # Nested-VM sandbox: runs the whole eitri stack — including real guest boots — | ||
| 89 | # inside disposable QEMU VMs, never touching the host. Needs qemu + nested KVM. | ||
| 90 | # No sudo required (qemu uses /dev/kvm directly). Heavy: downloads guest images. | ||
| 91 | sandbox: | ||
| 92 | go test -tags=sandbox -timeout=40m -count=1 ./internal/integration/sandbox -run TestSandbox -v | ||
| 93 | |||
| 94 | # --- quality gates ----------------------------------------------------------- | 73 | # --- quality gates ----------------------------------------------------------- |
| 95 | 74 | ||
| 96 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests | 75 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests |
| @@ -145,15 +124,9 @@ shape-check: | |||
| 145 | # Whole-program dead-code gate: fails on any function unreachable from a real | 124 | # Whole-program dead-code gate: fails on any function unreachable from a real |
| 146 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what | 125 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what |
| 147 | # catches production code kept alive only by its own tests; the fix is to remove | 126 | # catches production code kept alive only by its own tests; the fix is to remove |
| 148 | # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags | 127 | # it, wire it into a real path, or move it into a _test.go. |
| 149 | # compile the tag-gated code so it is analysed too. Two sanctioned exceptions, | ||
| 150 | # both production code that only a CROSS-package test can reach (so neither can be | ||
| 151 | # a _test.go): internal/integration (the e2e/harness tree) and reconcile.Engine.Stop | ||
| 152 | # (terminal teardown that must not run in production — it would report every VM as | ||
| 153 | # vanished — used only by an integration test's cleanup). | ||
| 154 | deadcode: | 128 | deadcode: |
| 155 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ | 129 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) ./...); \ |
| 156 | | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$' || true; }); \ | ||
| 157 | if [ -n "$$out" ]; then \ | 130 | if [ -n "$$out" ]; then \ |
| 158 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ | 131 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ |
| 159 | echo "$$out"; exit 1; \ | 132 | echo "$$out"; exit 1; \ |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -45,10 +45,7 @@ create), and a content-addressed image cache (each base image is downloaded and | |||
| 45 | | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. | | 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. | | 46 | | `hack/eitri-ssh` | Client that signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | |
| 47 | 47 | ||
| 48 | Development tooling lives alongside them: `eitri-devstack` (bring the whole stack | 48 | `eitri-shape` (regenerate the architecture graph) rounds out the binaries. |
| 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 | 49 | ||
| 53 | ## Access model | 50 | ## Access model |
| 54 | 51 | ||
| @@ -73,19 +70,6 @@ Build everything: | |||
| 73 | make build # binaries into ./bin | 70 | make build # binaries into ./bin |
| 74 | ``` | 71 | ``` |
| 75 | 72 | ||
| 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, | 73 | **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 | 74 | `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 | 75 | server certificate from the blob, and persists its identity. From then on the |
| @@ -126,7 +110,6 @@ Common loops: | |||
| 126 | 110 | ||
| 127 | ```sh | 111 | ```sh |
| 128 | make test # go test -race | 112 | make test # go test -race |
| 129 | make smoke # scripted end-to-end smoke | 113 | make deploy # roll HEAD to the fleet (local server + remote agents) — runs a real-VM boot-gate |
| 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 | 114 | make shape # regenerate docs/shape.{json,html} after a package change |
| 132 | ``` | 115 | ``` |
cmd/eitri-agent/main.go
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | "github.com/a73x/eitri/internal/agent/serialpump" | 24 | "github.com/a73x/eitri/internal/agent/serialpump" |
| 25 | "github.com/a73x/eitri/internal/agent/state" | 25 | "github.com/a73x/eitri/internal/agent/state" |
| 26 | "github.com/a73x/eitri/internal/agent/syncclient" | 26 | "github.com/a73x/eitri/internal/agent/syncclient" |
| 27 | "github.com/a73x/eitri/internal/covsnap" | ||
| 27 | "github.com/a73x/eitri/internal/joinblob" | 28 | "github.com/a73x/eitri/internal/joinblob" |
| 28 | ) | 29 | ) |
| 29 | 30 | ||
| @@ -156,6 +157,11 @@ func runAgent(st *state.Store, cfg agentConfig) { | |||
| 156 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | 157 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) |
| 157 | defer cancel() | 158 | defer cancel() |
| 158 | 159 | ||
| 160 | // Flush integration-coverage counters on SIGUSR1 (no-op unless built with | ||
| 161 | // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage | ||
| 162 | // from the live agent without bouncing the process. | ||
| 163 | covsnap.Install(ctx) | ||
| 164 | |||
| 159 | net, err := netenv.New(realRunner, id.BridgeCIDR) | 165 | net, err := netenv.New(realRunner, id.BridgeCIDR) |
| 160 | if err != nil { | 166 | if err != nil { |
| 161 | slog.Error("netenv init", "err", err) | 167 | slog.Error("netenv init", "err", err) |
cmd/eitri-mcp/main.go
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | "os/signal" | 18 | "os/signal" |
| 19 | "syscall" | 19 | "syscall" |
| 20 | 20 | ||
| 21 | "github.com/a73x/eitri/internal/gateclient" | ||
| 21 | "github.com/a73x/eitri/internal/mcpserver" | 22 | "github.com/a73x/eitri/internal/mcpserver" |
| 22 | "github.com/a73x/eitri/internal/server/api/client" | 23 | "github.com/a73x/eitri/internal/server/api/client" |
| 23 | "github.com/modelcontextprotocol/go-sdk/mcp" | 24 | "github.com/modelcontextprotocol/go-sdk/mcp" |
| @@ -55,7 +56,7 @@ func run(cfgPath string) error { | |||
| 55 | // The user CA's public key is uploaded to the tenant once (Register, below) | 56 | // The user CA's public key is uploaded to the tenant once (Register, below) |
| 56 | // so VMs trust those certs. GateAuth is backed by the same API client. | 57 | // so VMs trust those certs. GateAuth is backed by the same API client. |
| 57 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken} | 58 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken} |
| 58 | gateAuth := mcpserver.NewGateAuth(api, userCA, cfg.Tenant, nil) | 59 | gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil) |
| 59 | tools := &mcpserver.Tools{ | 60 | tools := &mcpserver.Tools{ |
| 60 | API: mcpserver.API{Client: api}, | 61 | API: mcpserver.API{Client: api}, |
| 61 | Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{ | 62 | Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{ |
cmd/eitri-server/main.go
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | "syscall" | 15 | "syscall" |
| 16 | "time" | 16 | "time" |
| 17 | 17 | ||
| 18 | "github.com/a73x/eitri/internal/covsnap" | ||
| 18 | "github.com/a73x/eitri/internal/joinblob" | 19 | "github.com/a73x/eitri/internal/joinblob" |
| 19 | "github.com/a73x/eitri/internal/server/api" | 20 | "github.com/a73x/eitri/internal/server/api" |
| 20 | serverconfig "github.com/a73x/eitri/internal/server/config" | 21 | serverconfig "github.com/a73x/eitri/internal/server/config" |
| @@ -234,6 +235,11 @@ func main() { | |||
| 234 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | 235 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) |
| 235 | defer stop() | 236 | defer stop() |
| 236 | 237 | ||
| 238 | // Flush integration-coverage counters on SIGUSR1 (no-op unless built with | ||
| 239 | // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage | ||
| 240 | // from the live server without bouncing the process. | ||
| 241 | covsnap.Install(ctx) | ||
| 242 | |||
| 237 | srv := &http.Server{Addr: cfg.HTTPListen, Handler: root} | 243 | srv := &http.Server{Addr: cfg.HTTPListen, Handler: root} |
| 238 | go func() { | 244 | go func() { |
| 239 | slog.Info("http listening", "addr", cfg.HTTPListen) | 245 | slog.Info("http listening", "addr", cfg.HTTPListen) |
cmd/eitri-smoke/config.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,100 @@ | |||
| 1 | // Command eitri-smoke drives the live eitri fleet through create -> boot-proof | ||
| 2 | // -> reap of one throwaway VM, exiting non-zero on any failure. It is a client | ||
| 3 | // of the eitri API plus SSH; it embeds no control-plane or agent code. | ||
| 4 | package main | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "fmt" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Config holds the environment-sourced settings for one smoke run. It mirrors | ||
| 13 | // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV. | ||
| 14 | type Config struct { | ||
| 15 | ServerURL string | ||
| 16 | TokenFile string | ||
| 17 | AgentUserHost string | ||
| 18 | AgentPort int | ||
| 19 | AgentStateDir string | ||
| 20 | |||
| 21 | // Carried for a later coverage-collection task; optional here. | ||
| 22 | ServerGocoverdir string | ||
| 23 | AgentGocoverdir string | ||
| 24 | CoverOut string | ||
| 25 | |||
| 26 | // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the | ||
| 27 | // scenario proves guest access through the gate (a hard gate). Optional. | ||
| 28 | SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>" | ||
| 29 | SmokeTenant string // SMOKE_TENANT, default "default" | ||
| 30 | SmokeVMUser string // SMOKE_VM_USER, default "ubuntu" | ||
| 31 | SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key | ||
| 32 | } | ||
| 33 | |||
| 34 | // loadConfig reads the required smoke settings via getenv (never the real | ||
| 35 | // process environment directly, so callers can inject a fake for tests). It | ||
| 36 | // returns an error naming the first missing required variable. | ||
| 37 | func loadConfig(getenv func(string) string) (Config, error) { | ||
| 38 | serverURL := getenv("SERVER_URL") | ||
| 39 | tokenFile := getenv("ADMIN_TOKEN_FILE") | ||
| 40 | agentHosts := getenv("AGENT_HOSTS") | ||
| 41 | agentStateDir := getenv("AGENT_STATE_DIR") | ||
| 42 | |||
| 43 | var missing []string | ||
| 44 | if serverURL == "" { | ||
| 45 | missing = append(missing, "SERVER_URL") | ||
| 46 | } | ||
| 47 | if tokenFile == "" { | ||
| 48 | missing = append(missing, "ADMIN_TOKEN_FILE") | ||
| 49 | } | ||
| 50 | if agentHosts == "" { | ||
| 51 | missing = append(missing, "AGENT_HOSTS") | ||
| 52 | } | ||
| 53 | if agentStateDir == "" { | ||
| 54 | missing = append(missing, "AGENT_STATE_DIR") | ||
| 55 | } | ||
| 56 | if len(missing) > 0 { | ||
| 57 | return Config{}, fmt.Errorf("missing required env var(s): %s", strings.Join(missing, ", ")) | ||
| 58 | } | ||
| 59 | |||
| 60 | userHost, port := parseAgentHost(agentHosts) | ||
| 61 | |||
| 62 | smokeTenant := getenv("SMOKE_TENANT") | ||
| 63 | if smokeTenant == "" { | ||
| 64 | smokeTenant = "default" | ||
| 65 | } | ||
| 66 | smokeVMUser := getenv("SMOKE_VM_USER") | ||
| 67 | if smokeVMUser == "" { | ||
| 68 | smokeVMUser = "ubuntu" | ||
| 69 | } | ||
| 70 | |||
| 71 | return Config{ | ||
| 72 | ServerURL: serverURL, | ||
| 73 | TokenFile: tokenFile, | ||
| 74 | AgentUserHost: userHost, | ||
| 75 | AgentPort: port, | ||
| 76 | AgentStateDir: agentStateDir, | ||
| 77 | ServerGocoverdir: getenv("SERVER_GOCOVERDIR"), | ||
| 78 | AgentGocoverdir: getenv("AGENT_GOCOVERDIR"), | ||
| 79 | CoverOut: getenv("COVER_OUT"), | ||
| 80 | SmokeGate: getenv("SMOKE_GATE"), | ||
| 81 | SmokeTenant: smokeTenant, | ||
| 82 | SmokeVMUser: smokeVMUser, | ||
| 83 | SmokeUserCAFile: getenv("SMOKE_USER_CA_FILE"), | ||
| 84 | }, nil | ||
| 85 | } | ||
| 86 | |||
| 87 | // parseAgentHost takes the AGENT_HOSTS value (space-separated "user@host[:port]" | ||
| 88 | // entries) and returns the first entry's user@host plus its port. The port is | ||
| 89 | // the substring after the LAST colon when that substring is entirely digits; | ||
| 90 | // otherwise there is no port and it defaults to 22. | ||
| 91 | func parseAgentHost(agentHosts string) (userHost string, port int) { | ||
| 92 | entry := strings.Fields(agentHosts)[0] | ||
| 93 | |||
| 94 | if idx := strings.LastIndex(entry, ":"); idx != -1 { | ||
| 95 | if p, err := strconv.Atoi(entry[idx+1:]); err == nil { | ||
| 96 | return entry[:idx], p | ||
| 97 | } | ||
| 98 | } | ||
| 99 | return entry, 22 | ||
| 100 | } | ||
cmd/eitri-smoke/config_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,156 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // fakeGetenv returns a getenv func backed by a map, so tests never touch the | ||
| 9 | // real process environment. | ||
| 10 | func fakeGetenv(vals map[string]string) func(string) string { | ||
| 11 | return func(key string) string { return vals[key] } | ||
| 12 | } | ||
| 13 | |||
| 14 | func requiredVals() map[string]string { | ||
| 15 | return map[string]string{ | ||
| 16 | "SERVER_URL": "https://server.example:8443", | ||
| 17 | "ADMIN_TOKEN_FILE": "/etc/eitri/admin.token", | ||
| 18 | "AGENT_HOSTS": "ubuntu@10.0.0.5:2222", | ||
| 19 | "AGENT_STATE_DIR": "/var/lib/eitri-agent", | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | func TestLoadConfigParsesAgentHostWithPort(t *testing.T) { | ||
| 24 | cfg, err := loadConfig(fakeGetenv(requiredVals())) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatalf("loadConfig: %v", err) | ||
| 27 | } | ||
| 28 | if cfg.AgentUserHost != "ubuntu@10.0.0.5" { | ||
| 29 | t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost) | ||
| 30 | } | ||
| 31 | if cfg.AgentPort != 2222 { | ||
| 32 | t.Errorf("AgentPort = %d, want 2222", cfg.AgentPort) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | func TestLoadConfigDefaultsPortWithoutColon(t *testing.T) { | ||
| 37 | vals := requiredVals() | ||
| 38 | vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5" | ||
| 39 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 40 | if err != nil { | ||
| 41 | t.Fatalf("loadConfig: %v", err) | ||
| 42 | } | ||
| 43 | if cfg.AgentUserHost != "ubuntu@10.0.0.5" { | ||
| 44 | t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost) | ||
| 45 | } | ||
| 46 | if cfg.AgentPort != 22 { | ||
| 47 | t.Errorf("AgentPort = %d, want 22", cfg.AgentPort) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | func TestLoadConfigMultiEntryTakesFirst(t *testing.T) { | ||
| 52 | vals := requiredVals() | ||
| 53 | vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5:2200 ubuntu@10.0.0.6:2201" | ||
| 54 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatalf("loadConfig: %v", err) | ||
| 57 | } | ||
| 58 | if cfg.AgentUserHost != "ubuntu@10.0.0.5" || cfg.AgentPort != 2200 { | ||
| 59 | t.Errorf("got %q:%d, want ubuntu@10.0.0.5:2200", cfg.AgentUserHost, cfg.AgentPort) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | func TestLoadConfigMissingRequiredVars(t *testing.T) { | ||
| 64 | cases := []struct { | ||
| 65 | name string | ||
| 66 | unset string | ||
| 67 | wantErr string | ||
| 68 | }{ | ||
| 69 | {"missing server url", "SERVER_URL", "SERVER_URL"}, | ||
| 70 | {"missing token file", "ADMIN_TOKEN_FILE", "ADMIN_TOKEN_FILE"}, | ||
| 71 | {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"}, | ||
| 72 | {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"}, | ||
| 73 | } | ||
| 74 | for _, tc := range cases { | ||
| 75 | t.Run(tc.name, func(t *testing.T) { | ||
| 76 | vals := requiredVals() | ||
| 77 | delete(vals, tc.unset) | ||
| 78 | _, err := loadConfig(fakeGetenv(vals)) | ||
| 79 | if err == nil { | ||
| 80 | t.Fatal("loadConfig: want error, got nil") | ||
| 81 | } | ||
| 82 | if !strings.Contains(err.Error(), tc.wantErr) { | ||
| 83 | t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr) | ||
| 84 | } | ||
| 85 | }) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | func TestLoadConfigMissingAllRequiredVars(t *testing.T) { | ||
| 90 | _, err := loadConfig(fakeGetenv(nil)) | ||
| 91 | if err == nil { | ||
| 92 | t.Fatal("loadConfig: want error, got nil") | ||
| 93 | } | ||
| 94 | for _, want := range []string{"SERVER_URL", "ADMIN_TOKEN_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} { | ||
| 95 | if !strings.Contains(err.Error(), want) { | ||
| 96 | t.Errorf("error = %q, missing %q", err.Error(), want) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | func TestLoadConfigSmokeGateDefaults(t *testing.T) { | ||
| 102 | cfg, err := loadConfig(fakeGetenv(requiredVals())) | ||
| 103 | if err != nil { | ||
| 104 | t.Fatalf("loadConfig: %v", err) | ||
| 105 | } | ||
| 106 | if cfg.SmokeGate != "" { | ||
| 107 | t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate) | ||
| 108 | } | ||
| 109 | if cfg.SmokeTenant != "default" { | ||
| 110 | t.Errorf("SmokeTenant = %q, want default", cfg.SmokeTenant) | ||
| 111 | } | ||
| 112 | if cfg.SmokeVMUser != "ubuntu" { | ||
| 113 | t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser) | ||
| 114 | } | ||
| 115 | if cfg.SmokeUserCAFile != "" { | ||
| 116 | t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | func TestLoadConfigSmokeGatePassThrough(t *testing.T) { | ||
| 121 | vals := requiredVals() | ||
| 122 | vals["SMOKE_GATE"] = "gate.example:2222" | ||
| 123 | vals["SMOKE_TENANT"] = "acme" | ||
| 124 | vals["SMOKE_VM_USER"] = "debian" | ||
| 125 | vals["SMOKE_USER_CA_FILE"] = "/etc/eitri-smoke/user_ca" | ||
| 126 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 127 | if err != nil { | ||
| 128 | t.Fatalf("loadConfig: %v", err) | ||
| 129 | } | ||
| 130 | if cfg.SmokeGate != "gate.example:2222" { | ||
| 131 | t.Errorf("SmokeGate = %q, want gate.example:2222", cfg.SmokeGate) | ||
| 132 | } | ||
| 133 | if cfg.SmokeTenant != "acme" { | ||
| 134 | t.Errorf("SmokeTenant = %q, want acme", cfg.SmokeTenant) | ||
| 135 | } | ||
| 136 | if cfg.SmokeVMUser != "debian" { | ||
| 137 | t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser) | ||
| 138 | } | ||
| 139 | if cfg.SmokeUserCAFile != "/etc/eitri-smoke/user_ca" { | ||
| 140 | t.Errorf("SmokeUserCAFile = %q, want /etc/eitri-smoke/user_ca", cfg.SmokeUserCAFile) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | func TestLoadConfigOptionalCoverageVarsPassThrough(t *testing.T) { | ||
| 145 | vals := requiredVals() | ||
| 146 | vals["SERVER_GOCOVERDIR"] = "/tmp/server-cover" | ||
| 147 | vals["AGENT_GOCOVERDIR"] = "/tmp/agent-cover" | ||
| 148 | vals["COVER_OUT"] = "/tmp/out" | ||
| 149 | cfg, err := loadConfig(fakeGetenv(vals)) | ||
| 150 | if err != nil { | ||
| 151 | t.Fatalf("loadConfig: %v", err) | ||
| 152 | } | ||
| 153 | if cfg.ServerGocoverdir != "/tmp/server-cover" || cfg.AgentGocoverdir != "/tmp/agent-cover" || cfg.CoverOut != "/tmp/out" { | ||
| 154 | t.Errorf("optional coverage vars not passed through: %+v", cfg) | ||
| 155 | } | ||
| 156 | } | ||
cmd/eitri-smoke/coverage.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,139 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | "path/filepath" | ||
| 10 | "strconv" | ||
| 11 | "strings" | ||
| 12 | "time" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // covdataMergeArgs builds the `go tool covdata merge` argument list that unions | ||
| 16 | // the per-binary coverage directories in inputDirs into outDir. Pure so the | ||
| 17 | // command assembly is unit-testable without running the toolchain. | ||
| 18 | func covdataMergeArgs(inputDirs []string, outDir string) []string { | ||
| 19 | return []string{"tool", "covdata", "merge", | ||
| 20 | "-i=" + strings.Join(inputDirs, ","), | ||
| 21 | "-o=" + outDir} | ||
| 22 | } | ||
| 23 | |||
| 24 | // covdataTextfmtArgs builds the `go tool covdata textfmt` argument list that | ||
| 25 | // renders the merged coverage in inDir as a textual profile at outFile. | ||
| 26 | func covdataTextfmtArgs(inDir, outFile string) []string { | ||
| 27 | return []string{"tool", "covdata", "textfmt", | ||
| 28 | "-i=" + inDir, | ||
| 29 | "-o=" + outFile} | ||
| 30 | } | ||
| 31 | |||
| 32 | // collectCoverage snapshots live coverage from the running server and agent, | ||
| 33 | // pulls both GOCOVERDIRs together, and merges them into cfg.CoverOut, printing | ||
| 34 | // the total. It needs both cfg.ServerGocoverdir and cfg.AgentGocoverdir; when | ||
| 35 | // either is empty it is a no-op with an explanatory note. Any failure here is | ||
| 36 | // the caller's to treat as non-fatal — the boot gate is the authoritative check. | ||
| 37 | func collectCoverage(ctx context.Context, cfg Config) error { | ||
| 38 | if cfg.ServerGocoverdir == "" || cfg.AgentGocoverdir == "" { | ||
| 39 | fmt.Println("coverage: SERVER_GOCOVERDIR/AGENT_GOCOVERDIR not both set — skipping collection") | ||
| 40 | return nil | ||
| 41 | } | ||
| 42 | |||
| 43 | // 1. Snapshot: SIGUSR1 makes each covsnap handler flush counters into its | ||
| 44 | // GOCOVERDIR. The server is local; the agent runs as root on the remote, so | ||
| 45 | // its pkill needs sudo over SSH. | ||
| 46 | if err := runCmd(exec.CommandContext(ctx, "pkill", "-USR1", "-x", "eitri-server")); err != nil { | ||
| 47 | fmt.Fprintln(os.Stderr, "coverage: signalling local eitri-server:", err) | ||
| 48 | } | ||
| 49 | if err := runCmd(sshCommand(ctx, cfg, "sudo pkill -USR1 -x eitri-agent")); err != nil { | ||
| 50 | fmt.Fprintln(os.Stderr, "coverage: signalling remote eitri-agent:", err) | ||
| 51 | } | ||
| 52 | // Let both handlers finish writing before we read their dirs. | ||
| 53 | time.Sleep(2 * time.Second) | ||
| 54 | |||
| 55 | // 2. Collect: the server dir is local; stream the agent's over SSH via a | ||
| 56 | // sudo tar pipe (its files are root-owned, so a plain scp can't read them). | ||
| 57 | agentDir, err := os.MkdirTemp("", "eitri-smoke-agentcov-") | ||
| 58 | if err != nil { | ||
| 59 | return fmt.Errorf("temp dir for agent coverage: %w", err) | ||
| 60 | } | ||
| 61 | defer os.RemoveAll(agentDir) | ||
| 62 | if err := pullAgentCoverage(ctx, cfg, agentDir); err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | |||
| 66 | // 3. Merge both binaries' data, render a textual profile, and read the total. | ||
| 67 | // Start from a clean CoverOut: covdata refuses to read a directory that | ||
| 68 | // mixes covermodes, so a stale profile from an earlier deploy would clash | ||
| 69 | // with this run's data. | ||
| 70 | if err := os.RemoveAll(cfg.CoverOut); err != nil { | ||
| 71 | return fmt.Errorf("clean cover out %q: %w", cfg.CoverOut, err) | ||
| 72 | } | ||
| 73 | if err := os.MkdirAll(cfg.CoverOut, 0o755); err != nil { | ||
| 74 | return fmt.Errorf("mkdir cover out %q: %w", cfg.CoverOut, err) | ||
| 75 | } | ||
| 76 | if err := runCmd(exec.CommandContext(ctx, "go", covdataMergeArgs([]string{cfg.ServerGocoverdir, agentDir}, cfg.CoverOut)...)); err != nil { | ||
| 77 | return fmt.Errorf("covdata merge: %w", err) | ||
| 78 | } | ||
| 79 | textOut := filepath.Join(cfg.CoverOut, "coverage.txt") | ||
| 80 | if err := runCmd(exec.CommandContext(ctx, "go", covdataTextfmtArgs(cfg.CoverOut, textOut)...)); err != nil { | ||
| 81 | return fmt.Errorf("covdata textfmt: %w", err) | ||
| 82 | } | ||
| 83 | |||
| 84 | total, err := coverageTotal(ctx, textOut) | ||
| 85 | if err != nil { | ||
| 86 | return err | ||
| 87 | } | ||
| 88 | fmt.Printf("coverage: %s (profile: %s)\n", total, textOut) | ||
| 89 | return nil | ||
| 90 | } | ||
| 91 | |||
| 92 | // pullAgentCoverage streams the agent's GOCOVERDIR contents into destDir over a | ||
| 93 | // sudo tar pipe (the coverage files are root-owned on the remote). | ||
| 94 | func pullAgentCoverage(ctx context.Context, cfg Config, destDir string) error { | ||
| 95 | tarball, err := sshCommand(ctx, cfg, "sudo tar -C '"+cfg.AgentGocoverdir+"' -cf - .").Output() | ||
| 96 | if err != nil { | ||
| 97 | return fmt.Errorf("stream agent coverage over ssh: %w", err) | ||
| 98 | } | ||
| 99 | untar := exec.CommandContext(ctx, "tar", "-C", destDir, "-xf", "-") | ||
| 100 | untar.Stdin = bytes.NewReader(tarball) | ||
| 101 | if err := runCmd(untar); err != nil { | ||
| 102 | return fmt.Errorf("extract agent coverage: %w", err) | ||
| 103 | } | ||
| 104 | return nil | ||
| 105 | } | ||
| 106 | |||
| 107 | // coverageTotal returns the final "total:" line of `go tool cover -func`. | ||
| 108 | func coverageTotal(ctx context.Context, profile string) (string, error) { | ||
| 109 | out, err := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+profile).Output() | ||
| 110 | if err != nil { | ||
| 111 | return "", fmt.Errorf("go tool cover -func: %w", err) | ||
| 112 | } | ||
| 113 | lines := strings.Split(strings.TrimSpace(string(out)), "\n") | ||
| 114 | return lines[len(lines)-1], nil | ||
| 115 | } | ||
| 116 | |||
| 117 | // sshCommand builds the ssh invocation used to reach the agent host, matching | ||
| 118 | // the flags the deploy boot-gate uses to reach the agent host. | ||
| 119 | func sshCommand(ctx context.Context, cfg Config, remoteCmd string) *exec.Cmd { | ||
| 120 | return exec.CommandContext(ctx, "ssh", | ||
| 121 | "-p", strconv.Itoa(cfg.AgentPort), | ||
| 122 | "-o", "BatchMode=yes", | ||
| 123 | "-o", "ConnectTimeout=10", | ||
| 124 | cfg.AgentUserHost, remoteCmd) | ||
| 125 | } | ||
| 126 | |||
| 127 | // runCmd runs cmd, capturing stderr so a failure carries the command's own | ||
| 128 | // diagnostic rather than a bare exit code. | ||
| 129 | func runCmd(cmd *exec.Cmd) error { | ||
| 130 | var errb bytes.Buffer | ||
| 131 | cmd.Stderr = &errb | ||
| 132 | if err := cmd.Run(); err != nil { | ||
| 133 | if msg := strings.TrimSpace(errb.String()); msg != "" { | ||
| 134 | return fmt.Errorf("%s: %w: %s", cmd.Args[0], err, msg) | ||
| 135 | } | ||
| 136 | return fmt.Errorf("%s: %w", cmd.Args[0], err) | ||
| 137 | } | ||
| 138 | return nil | ||
| 139 | } | ||
cmd/eitri-smoke/coverage_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,30 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestCovdataMergeArgs(t *testing.T) { | ||
| 9 | got := covdataMergeArgs([]string{"/cov/server", "/cov/agent"}, "/cov/out") | ||
| 10 | want := []string{"tool", "covdata", "merge", "-i=/cov/server,/cov/agent", "-o=/cov/out"} | ||
| 11 | if !slices.Equal(got, want) { | ||
| 12 | t.Fatalf("covdataMergeArgs = %v, want %v", got, want) | ||
| 13 | } | ||
| 14 | } | ||
| 15 | |||
| 16 | func TestCovdataMergeArgsSingleInput(t *testing.T) { | ||
| 17 | got := covdataMergeArgs([]string{"/only"}, "/out") | ||
| 18 | want := []string{"tool", "covdata", "merge", "-i=/only", "-o=/out"} | ||
| 19 | if !slices.Equal(got, want) { | ||
| 20 | t.Fatalf("covdataMergeArgs = %v, want %v", got, want) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | func TestCovdataTextfmtArgs(t *testing.T) { | ||
| 25 | got := covdataTextfmtArgs("/cov/out", "/cov/out/coverage.txt") | ||
| 26 | want := []string{"tool", "covdata", "textfmt", "-i=/cov/out", "-o=/cov/out/coverage.txt"} | ||
| 27 | if !slices.Equal(got, want) { | ||
| 28 | t.Fatalf("covdataTextfmtArgs = %v, want %v", got, want) | ||
| 29 | } | ||
| 30 | } | ||
cmd/eitri-smoke/gatecheck.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,76 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/gateclient" | ||
| 11 | "golang.org/x/crypto/ssh" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // realGateHooks builds the live SSH-CA gate steps: register the smoke user CA | ||
| 15 | // with the tenant, and reach the guest through the gate to prove access. | ||
| 16 | func realGateHooks(cfg Config, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks { | ||
| 17 | auth := gateclient.NewGateAuth(ca, userCA, cfg.SmokeTenant, now) | ||
| 18 | return &gateHooks{ | ||
| 19 | register: func(ctx context.Context) error { return auth.Register(ctx) }, | ||
| 20 | exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) }, | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | // gateExec proves the guest is reachable through the SSH-CA gate: it retries | ||
| 25 | // dial+login over the guest's pre-sshd boot window (a dial or session error | ||
| 26 | // mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser and | ||
| 27 | // confirms `id -un` echoes that same user, or the 120s deadline expires. | ||
| 28 | func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error { | ||
| 29 | var lastErr error | ||
| 30 | err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | ||
| 31 | client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{ | ||
| 32 | Gate: cfg.SmokeGate, | ||
| 33 | VMUser: cfg.SmokeVMUser, | ||
| 34 | Auth: auth, | ||
| 35 | }, vmName) | ||
| 36 | if dialErr != nil { | ||
| 37 | lastErr = dialErr | ||
| 38 | return false, nil | ||
| 39 | } | ||
| 40 | defer client.Close() | ||
| 41 | |||
| 42 | out, runErr := runGuestCommand(client, "id -un") | ||
| 43 | if runErr != nil { | ||
| 44 | lastErr = runErr | ||
| 45 | return false, nil | ||
| 46 | } | ||
| 47 | |||
| 48 | got := strings.TrimSpace(out) | ||
| 49 | if got != cfg.SmokeVMUser { | ||
| 50 | return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser) | ||
| 51 | } | ||
| 52 | return true, nil | ||
| 53 | }) | ||
| 54 | if err != nil { | ||
| 55 | if errors.Is(err, errPollTimeout) { | ||
| 56 | return fmt.Errorf("FAIL: could not reach guest %q through the gate within 120s: %w", vmName, lastErr) | ||
| 57 | } | ||
| 58 | return err | ||
| 59 | } | ||
| 60 | return nil | ||
| 61 | } | ||
| 62 | |||
| 63 | // runGuestCommand runs cmd in a new session on client and returns its stdout. | ||
| 64 | func runGuestCommand(client *ssh.Client, cmd string) (string, error) { | ||
| 65 | session, err := client.NewSession() | ||
| 66 | if err != nil { | ||
| 67 | return "", fmt.Errorf("open ssh session: %w", err) | ||
| 68 | } | ||
| 69 | defer session.Close() | ||
| 70 | |||
| 71 | out, err := session.Output(cmd) | ||
| 72 | if err != nil { | ||
| 73 | return "", fmt.Errorf("run %q: %w", cmd, err) | ||
| 74 | } | ||
| 75 | return string(out), nil | ||
| 76 | } | ||
cmd/eitri-smoke/main.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,119 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "crypto/rand" | ||
| 6 | "encoding/hex" | ||
| 7 | "fmt" | ||
| 8 | "net/http" | ||
| 9 | "os" | ||
| 10 | "os/exec" | ||
| 11 | "path/filepath" | ||
| 12 | "strconv" | ||
| 13 | "strings" | ||
| 14 | "time" | ||
| 15 | |||
| 16 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 17 | ) | ||
| 18 | |||
| 19 | func main() { | ||
| 20 | if err := run(); err != nil { | ||
| 21 | fmt.Fprintln(os.Stderr, "eitri-smoke:", err) | ||
| 22 | os.Exit(1) | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | func run() error { | ||
| 27 | cfg, err := loadConfig(os.Getenv) | ||
| 28 | if err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | |||
| 32 | tokenBytes, err := os.ReadFile(cfg.TokenFile) | ||
| 33 | if err != nil { | ||
| 34 | return fmt.Errorf("read admin token file %q: %w", cfg.TokenFile, err) | ||
| 35 | } | ||
| 36 | token := strings.TrimRight(string(tokenBytes), "\n") | ||
| 37 | |||
| 38 | api := &client.Client{ | ||
| 39 | BaseURL: cfg.ServerURL, | ||
| 40 | Token: token, | ||
| 41 | UserCALabel: "eitri-smoke", | ||
| 42 | HTTP: &http.Client{Timeout: 30 * time.Second}, | ||
| 43 | } | ||
| 44 | |||
| 45 | vmName, err := randVMName() | ||
| 46 | if err != nil { | ||
| 47 | return fmt.Errorf("generate vm name: %w", err) | ||
| 48 | } | ||
| 49 | |||
| 50 | var gate *gateHooks | ||
| 51 | if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" { | ||
| 52 | userCA, err := loadOrCreateUserCA(cfg.SmokeUserCAFile) | ||
| 53 | if err != nil { | ||
| 54 | return fmt.Errorf("load smoke user CA: %w", err) | ||
| 55 | } | ||
| 56 | gate = realGateHooks(cfg, api, userCA, time.Now, time.Sleep) | ||
| 57 | } else { | ||
| 58 | fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)") | ||
| 59 | } | ||
| 60 | |||
| 61 | ctx := context.Background() | ||
| 62 | msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey) | ||
| 63 | if err != nil { | ||
| 64 | return err | ||
| 65 | } | ||
| 66 | fmt.Println(msg) | ||
| 67 | |||
| 68 | // The boot gate has passed. Collect integration coverage as a by-product | ||
| 69 | // when COVER_OUT is set; a failure here must NOT fail the deploy, so warn | ||
| 70 | // and carry on — the gate above is the authoritative check. | ||
| 71 | if cfg.CoverOut != "" { | ||
| 72 | if err := collectCoverage(ctx, cfg); err != nil { | ||
| 73 | fmt.Fprintln(os.Stderr, "eitri-smoke: coverage collection failed (boot gate still passed):", err) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return nil | ||
| 77 | } | ||
| 78 | |||
| 79 | // randVMName generates a throwaway VM name of the form "smoke-<8 hex digits>", | ||
| 80 | // unique enough that concurrent smoke runs don't collide. | ||
| 81 | func randVMName() (string, error) { | ||
| 82 | var b [4]byte | ||
| 83 | if _, err := rand.Read(b[:]); err != nil { | ||
| 84 | return "", err | ||
| 85 | } | ||
| 86 | return "smoke-" + hex.EncodeToString(b[:]), nil | ||
| 87 | } | ||
| 88 | |||
| 89 | // realRunSSH returns the real sshFunc: it shells out to the ssh binary | ||
| 90 | // against userHost:port with the deploy boot-gate's ssh flags, running | ||
| 91 | // remoteCmd non-interactively and returning its stdout. | ||
| 92 | func realRunSSH(userHost string, port int) sshFunc { | ||
| 93 | return func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 94 | cmd := exec.CommandContext(ctx, "ssh", | ||
| 95 | "-p", strconv.Itoa(port), | ||
| 96 | "-o", "BatchMode=yes", | ||
| 97 | "-o", "ConnectTimeout=10", | ||
| 98 | userHost, remoteCmd) | ||
| 99 | out, err := cmd.Output() | ||
| 100 | return string(out), err | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | // realReadPubKey reads the local operator's SSH public key, trying | ||
| 105 | // ~/.ssh/id_ed25519.pub then ~/.ssh/id_rsa.pub. It returns "" (not an error) | ||
| 106 | // if neither is present — the scenario tolerates a keyless VM. | ||
| 107 | func realReadPubKey() string { | ||
| 108 | home, err := os.UserHomeDir() | ||
| 109 | if err != nil { | ||
| 110 | return "" | ||
| 111 | } | ||
| 112 | for _, name := range []string{"id_ed25519.pub", "id_rsa.pub"} { | ||
| 113 | data, err := os.ReadFile(filepath.Join(home, ".ssh", name)) | ||
| 114 | if err == nil { | ||
| 115 | return strings.TrimSpace(string(data)) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | return "" | ||
| 119 | } | ||
cmd/eitri-smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,196 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "regexp" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // bootedPattern / panickedPattern classify a guest's serial console text. They | ||
| 14 | // mirror the boot-gate's grep -aiE serial-console patterns exactly. | ||
| 15 | var ( | ||
| 16 | bootedPattern = regexp.MustCompile(`(?i)Welcome to.*Ubuntu|login:|Reached target.*Multi-User`) | ||
| 17 | panickedPattern = regexp.MustCompile(`(?i)Kernel panic|Cannot open root`) | ||
| 18 | errPollTimeout = errors.New("poll timeout") | ||
| 19 | ) | ||
| 20 | |||
| 21 | // classifySerial inspects a guest's serial console text and reports whether it | ||
| 22 | // shows evidence of a successful userspace boot and/or a kernel panic / | ||
| 23 | // root-mount failure. It is pure — no I/O — so the boot-proof classification | ||
| 24 | // logic is fully unit-testable. | ||
| 25 | func classifySerial(text string) (booted, panicked bool) { | ||
| 26 | return bootedPattern.MatchString(text), panickedPattern.MatchString(text) | ||
| 27 | } | ||
| 28 | |||
| 29 | // bootProofCommand builds the remote shell command run over SSH on the agent | ||
| 30 | // host to read and sanitize a VM's serial console log. | ||
| 31 | func bootProofCommand(agentStateDir, vmID string) string { | ||
| 32 | return fmt.Sprintf(`sudo cat '%s/vms/%s/serial.log' 2>/dev/null | tr -cd '\11\12\15\40-\176'`, agentStateDir, vmID) | ||
| 33 | } | ||
| 34 | |||
| 35 | // vmAPI is the subset of the shared API client the scenario needs. Declaring | ||
| 36 | // it lets tests supply a fake instead of a real HTTP-backed *client.Client. | ||
| 37 | type vmAPI interface { | ||
| 38 | ListHosts(ctx context.Context) ([]client.Host, error) | ||
| 39 | CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | ||
| 40 | ListVMs(ctx context.Context) ([]client.VM, error) | ||
| 41 | DeleteVM(ctx context.Context, id string) error | ||
| 42 | } | ||
| 43 | |||
| 44 | // getVM finds the VM with the given id in the current listing. The bool | ||
| 45 | // return reports whether it was present. There is deliberately no GET-one | ||
| 46 | // endpoint, so this list-filter is the smoke's lookup. | ||
| 47 | func getVM(ctx context.Context, c vmAPI, id string) (client.VM, bool, error) { | ||
| 48 | vms, err := c.ListVMs(ctx) | ||
| 49 | if err != nil { | ||
| 50 | return client.VM{}, false, err | ||
| 51 | } | ||
| 52 | for _, vm := range vms { | ||
| 53 | if vm.ID == id { | ||
| 54 | return vm, true, nil | ||
| 55 | } | ||
| 56 | } | ||
| 57 | return client.VM{}, false, nil | ||
| 58 | } | ||
| 59 | |||
| 60 | // sshFunc runs remoteCmd on the agent host over SSH and returns its stdout. | ||
| 61 | type sshFunc func(ctx context.Context, remoteCmd string) (string, error) | ||
| 62 | |||
| 63 | // gateHooks bundles the optional SSH-CA gate steps. nil means "skip the gate". | ||
| 64 | type gateHooks struct { | ||
| 65 | register func(ctx context.Context) error // upload the smoke user CA to the tenant (before create) | ||
| 66 | exec func(ctx context.Context, vmName string) error // reach the guest through the gate (after boot) | ||
| 67 | } | ||
| 68 | |||
| 69 | // pollLoop calls attempt repeatedly (with sleep between calls) until attempt | ||
| 70 | // reports done, returns a non-nil error, or the deadline (now()+timeout) is | ||
| 71 | // reached, in which case it returns errPollTimeout. now and sleep are | ||
| 72 | // injected so callers can drive the deadline logic with a virtual clock. | ||
| 73 | func pollLoop(ctx context.Context, now func() time.Time, sleep func(time.Duration), timeout, interval time.Duration, attempt func() (done bool, err error)) error { | ||
| 74 | deadline := now().Add(timeout) | ||
| 75 | for { | ||
| 76 | if err := ctx.Err(); err != nil { | ||
| 77 | return err | ||
| 78 | } | ||
| 79 | done, err := attempt() | ||
| 80 | if err != nil { | ||
| 81 | return err | ||
| 82 | } | ||
| 83 | if done { | ||
| 84 | return nil | ||
| 85 | } | ||
| 86 | if !now().Before(deadline) { | ||
| 87 | return errPollTimeout | ||
| 88 | } | ||
| 89 | sleep(interval) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // runScenario drives the full register -> create -> ready -> boot-proof -> | ||
| 94 | // gate-exec -> reap sequence against c (the API), runSSH (the boot-proof | ||
| 95 | // transport), and readPubKey (the local SSH key source). vmName is the | ||
| 96 | // pre-generated name for the throwaway VM. gate, when non-nil, registers the | ||
| 97 | // smoke's user CA with the tenant before create (the guest bakes its trusted | ||
| 98 | // CAs at boot, so registration MUST happen first) and proves gate SSH access | ||
| 99 | // after the boot-proof — a hard gate, so a failure there fails the scenario. | ||
| 100 | // now/sleep are the injected clock so the poll deadlines are unit-testable | ||
| 101 | // without real waiting. On success it returns the human-readable COMPLETE | ||
| 102 | // line; on any failure it returns a descriptive error. | ||
| 103 | func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH sshFunc, gate *gateHooks, now func() time.Time, sleep func(time.Duration), readPubKey func() string) (string, error) { | ||
| 104 | if gate != nil { | ||
| 105 | if err := gate.register(ctx); err != nil { | ||
| 106 | return "", fmt.Errorf("register smoke user CA: %w", err) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | hostList, err := c.ListHosts(ctx) | ||
| 111 | if err != nil { | ||
| 112 | return "", fmt.Errorf("list hosts: %w", err) | ||
| 113 | } | ||
| 114 | if len(hostList) == 0 { | ||
| 115 | return "", errors.New("no hosts available") | ||
| 116 | } | ||
| 117 | hostID := hostList[0].ID | ||
| 118 | |||
| 119 | sshKey := readPubKey() | ||
| 120 | |||
| 121 | start := now() | ||
| 122 | created, err := c.CreateVM(ctx, client.CreateVMRequest{HostID: hostID, Name: vmName, SSHAuthorizedKey: sshKey}) | ||
| 123 | if err != nil { | ||
| 124 | return "", fmt.Errorf("create vm: %w", err) | ||
| 125 | } | ||
| 126 | vmID := created.ID | ||
| 127 | |||
| 128 | var lastPhase string | ||
| 129 | err = pollLoop(ctx, now, sleep, 600*time.Second, 5*time.Second, func() (bool, error) { | ||
| 130 | vm, _, err := getVM(ctx, c, vmID) | ||
| 131 | if err != nil { | ||
| 132 | return false, fmt.Errorf("poll vm ready: %w", err) | ||
| 133 | } | ||
| 134 | lastPhase = vm.Phase | ||
| 135 | return vm.Phase == "ready" && vm.AssignedIP != "", nil | ||
| 136 | }) | ||
| 137 | if err != nil { | ||
| 138 | if errors.Is(err, errPollTimeout) { | ||
| 139 | return "", fmt.Errorf("FAIL: VM not ready within 600s (phase=%s)", lastPhase) | ||
| 140 | } | ||
| 141 | return "", err | ||
| 142 | } | ||
| 143 | coldStart := now().Sub(start) | ||
| 144 | |||
| 145 | remoteCmd := bootProofCommand(cfg.AgentStateDir, vmID) | ||
| 146 | err = pollLoop(ctx, now, sleep, 180*time.Second, 6*time.Second, func() (bool, error) { | ||
| 147 | serial, sshErr := runSSH(ctx, remoteCmd) | ||
| 148 | if sshErr != nil { | ||
| 149 | // Match the bash scenario's "|| true": an SSH hiccup mid-boot is | ||
| 150 | // not fatal on its own, just an empty-serial iteration. | ||
| 151 | serial = "" | ||
| 152 | } | ||
| 153 | booted, panicked := classifySerial(serial) | ||
| 154 | if panicked { | ||
| 155 | return false, errors.New("FAIL: guest panic / root-mount failure in serial log") | ||
| 156 | } | ||
| 157 | return booted, nil | ||
| 158 | }) | ||
| 159 | if err != nil { | ||
| 160 | if errors.Is(err, errPollTimeout) { | ||
| 161 | return "", errors.New("FAIL: no userspace boot evidence in serial within 180s") | ||
| 162 | } | ||
| 163 | return "", err | ||
| 164 | } | ||
| 165 | |||
| 166 | gateOK := false | ||
| 167 | if gate != nil { | ||
| 168 | if err := gate.exec(ctx, vmName); err != nil { | ||
| 169 | return "", err | ||
| 170 | } | ||
| 171 | gateOK = true | ||
| 172 | } | ||
| 173 | |||
| 174 | if err := c.DeleteVM(ctx, vmID); err != nil { | ||
| 175 | return "", fmt.Errorf("delete vm: %w", err) | ||
| 176 | } | ||
| 177 | err = pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | ||
| 178 | _, present, err := getVM(ctx, c, vmID) | ||
| 179 | if err != nil { | ||
| 180 | return false, fmt.Errorf("poll vm reaped: %w", err) | ||
| 181 | } | ||
| 182 | return !present, nil | ||
| 183 | }) | ||
| 184 | if err != nil { | ||
| 185 | if errors.Is(err, errPollTimeout) { | ||
| 186 | return "", errors.New("FAIL: VM not hard-deleted within 120s of tombstone") | ||
| 187 | } | ||
| 188 | return "", err | ||
| 189 | } | ||
| 190 | |||
| 191 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reaped OK", int64(coldStart.Seconds())) | ||
| 192 | if gateOK { | ||
| 193 | msg += ", gate SSH: ok" | ||
| 194 | } | ||
| 195 | return msg, nil | ||
| 196 | } | ||
cmd/eitri-smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,455 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // --- classifySerial ------------------------------------------------------- | ||
| 14 | |||
| 15 | func TestClassifySerialBooted(t *testing.T) { | ||
| 16 | text := "[ 5.123456] Ubuntu 22.04.3 LTS ubuntu-vm ttyS0\n\nubuntu-vm login: " | ||
| 17 | booted, panicked := classifySerial(text) | ||
| 18 | if !booted { | ||
| 19 | t.Error("booted = false, want true") | ||
| 20 | } | ||
| 21 | if panicked { | ||
| 22 | t.Error("panicked = true, want false") | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestClassifySerialPanic(t *testing.T) { | ||
| 27 | text := "[ 2.345678] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)" | ||
| 28 | booted, panicked := classifySerial(text) | ||
| 29 | if booted { | ||
| 30 | t.Error("booted = true, want false") | ||
| 31 | } | ||
| 32 | if !panicked { | ||
| 33 | t.Error("panicked = false, want true") | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | func TestClassifySerialStillBooting(t *testing.T) { | ||
| 38 | text := "[ 0.123456] Booting Linux on physical CPU 0x0\n[ 0.234567] Linux version 6.5.0" | ||
| 39 | booted, panicked := classifySerial(text) | ||
| 40 | if booted { | ||
| 41 | t.Error("booted = true, want false") | ||
| 42 | } | ||
| 43 | if panicked { | ||
| 44 | t.Error("panicked = true, want false") | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | // --- fakes for runScenario ------------------------------------------------- | ||
| 49 | |||
| 50 | // fakeClock is a controllable now()/sleep() pair: sleep advances the virtual | ||
| 51 | // clock instead of waiting, so deadline logic runs at test speed. | ||
| 52 | type fakeClock struct{ t time.Time } | ||
| 53 | |||
| 54 | func (c *fakeClock) now() time.Time { return c.t } | ||
| 55 | func (c *fakeClock) sleep(d time.Duration) { c.t = c.t.Add(d) } | ||
| 56 | |||
| 57 | // testAPI implements vmAPI by delegating to per-test closures. | ||
| 58 | type testAPI struct { | ||
| 59 | listHostsFunc func(ctx context.Context) ([]client.Host, error) | ||
| 60 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | ||
| 61 | listVMsFunc func(ctx context.Context) ([]client.VM, error) | ||
| 62 | deleteVMFunc func(ctx context.Context, id string) error | ||
| 63 | } | ||
| 64 | |||
| 65 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { | ||
| 66 | return a.listHostsFunc(ctx) | ||
| 67 | } | ||
| 68 | func (a *testAPI) CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 69 | return a.createVMFunc(ctx, req) | ||
| 70 | } | ||
| 71 | func (a *testAPI) ListVMs(ctx context.Context) ([]client.VM, error) { | ||
| 72 | return a.listVMsFunc(ctx) | ||
| 73 | } | ||
| 74 | func (a *testAPI) DeleteVM(ctx context.Context, id string) error { return a.deleteVMFunc(ctx, id) } | ||
| 75 | |||
| 76 | func baseCfg() Config { | ||
| 77 | return Config{AgentStateDir: "/var/lib/eitri-agent", AgentUserHost: "ubuntu@10.0.0.5", AgentPort: 22} | ||
| 78 | } | ||
| 79 | |||
| 80 | func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } | ||
| 81 | |||
| 82 | // --- runScenario: success path --------------------------------------------- | ||
| 83 | |||
| 84 | func TestRunScenarioSuccess(t *testing.T) { | ||
| 85 | listVMsCalls := 0 | ||
| 86 | api := &testAPI{ | ||
| 87 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 88 | return []client.Host{{ID: "host-1"}}, nil | ||
| 89 | }, | ||
| 90 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 91 | if req.HostID != "host-1" { | ||
| 92 | t.Errorf("CreateVM HostID = %q, want host-1", req.HostID) | ||
| 93 | } | ||
| 94 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 95 | }, | ||
| 96 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 97 | listVMsCalls++ | ||
| 98 | switch { | ||
| 99 | case listVMsCalls < 3: | ||
| 100 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | ||
| 101 | case listVMsCalls == 3: | ||
| 102 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 103 | default: | ||
| 104 | // Reap poll: absent from the first check. | ||
| 105 | return nil, nil | ||
| 106 | } | ||
| 107 | }, | ||
| 108 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 109 | if id != "vm-1" { | ||
| 110 | t.Errorf("deleteVM id = %q, want vm-1", id) | ||
| 111 | } | ||
| 112 | return nil | ||
| 113 | }, | ||
| 114 | } | ||
| 115 | |||
| 116 | sshCalls := 0 | ||
| 117 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 118 | sshCalls++ | ||
| 119 | if !strings.Contains(remoteCmd, "vm-1") { | ||
| 120 | t.Errorf("remoteCmd = %q, want it to reference vm-1", remoteCmd) | ||
| 121 | } | ||
| 122 | if sshCalls < 2 { | ||
| 123 | return "[ 0.1] Booting Linux...", nil | ||
| 124 | } | ||
| 125 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil | ||
| 126 | } | ||
| 127 | |||
| 128 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 129 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 130 | if err != nil { | ||
| 131 | t.Fatalf("runScenario: %v", err) | ||
| 132 | } | ||
| 133 | if !strings.Contains(msg, "SMOKE COMPLETE") || !strings.Contains(msg, "reaped OK") { | ||
| 134 | t.Errorf("message = %q, want SMOKE COMPLETE ... reaped OK", msg) | ||
| 135 | } | ||
| 136 | if !strings.Contains(msg, "cold_start=") { | ||
| 137 | t.Errorf("message = %q, want cold_start=", msg) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | // --- runScenario: serial panic ----------------------------------------- | ||
| 142 | |||
| 143 | func TestRunScenarioSerialPanicFails(t *testing.T) { | ||
| 144 | api := &testAPI{ | ||
| 145 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 146 | return []client.Host{{ID: "host-1"}}, nil | ||
| 147 | }, | ||
| 148 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 149 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 150 | }, | ||
| 151 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 152 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 153 | }, | ||
| 154 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 155 | t.Fatal("DeleteVM should not be called after a panic") | ||
| 156 | return nil | ||
| 157 | }, | ||
| 158 | } | ||
| 159 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 160 | return "Kernel panic - not syncing: VFS: Unable to mount root fs", nil | ||
| 161 | } | ||
| 162 | |||
| 163 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 164 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 165 | if err == nil { | ||
| 166 | t.Fatal("runScenario: want error, got nil") | ||
| 167 | } | ||
| 168 | if !strings.Contains(err.Error(), "panic") { | ||
| 169 | t.Errorf("error = %q, want it to mention panic", err.Error()) | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | // --- runScenario: never-ready timeout --------------------------------- | ||
| 174 | |||
| 175 | func TestRunScenarioNeverReadyTimesOut(t *testing.T) { | ||
| 176 | api := &testAPI{ | ||
| 177 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 178 | return []client.Host{{ID: "host-1"}}, nil | ||
| 179 | }, | ||
| 180 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 181 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 182 | }, | ||
| 183 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 184 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | ||
| 185 | }, | ||
| 186 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 187 | t.Fatal("DeleteVM should not be called when the VM never becomes ready") | ||
| 188 | return nil | ||
| 189 | }, | ||
| 190 | } | ||
| 191 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 192 | t.Fatal("runSSH should not be called when the VM never becomes ready") | ||
| 193 | return "", nil | ||
| 194 | } | ||
| 195 | |||
| 196 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 197 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 198 | if err == nil { | ||
| 199 | t.Fatal("runScenario: want error, got nil") | ||
| 200 | } | ||
| 201 | if !strings.Contains(err.Error(), "FAIL: VM not ready within 600s") { | ||
| 202 | t.Errorf("error = %q, want FAIL: VM not ready within 600s ...", err.Error()) | ||
| 203 | } | ||
| 204 | if !strings.Contains(err.Error(), "phase=booting") { | ||
| 205 | t.Errorf("error = %q, want it to include phase=booting", err.Error()) | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | // --- runScenario: never-reaped timeout --------------------------------- | ||
| 210 | |||
| 211 | func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | ||
| 212 | api := &testAPI{ | ||
| 213 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 214 | return []client.Host{{ID: "host-1"}}, nil | ||
| 215 | }, | ||
| 216 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 217 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 218 | }, | ||
| 219 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 220 | // Always present, even after delete — models a stuck reap. | ||
| 221 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 222 | }, | ||
| 223 | deleteVMFunc: func(ctx context.Context, id string) error { return nil }, | ||
| 224 | } | ||
| 225 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 226 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil | ||
| 227 | } | ||
| 228 | |||
| 229 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 230 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 231 | if err == nil { | ||
| 232 | t.Fatal("runScenario: want error, got nil") | ||
| 233 | } | ||
| 234 | if !strings.Contains(err.Error(), "FAIL: VM not hard-deleted within 120s") { | ||
| 235 | t.Errorf("error = %q, want FAIL: VM not hard-deleted within 120s ...", err.Error()) | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | // --- runScenario: no hosts ------------------------------------------------ | ||
| 240 | |||
| 241 | func TestRunScenarioNoHosts(t *testing.T) { | ||
| 242 | api := &testAPI{ | ||
| 243 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { return nil, nil }, | ||
| 244 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 245 | t.Fatal("CreateVM should not be called with no hosts") | ||
| 246 | return client.CreateVMResponse{}, nil | ||
| 247 | }, | ||
| 248 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 249 | t.Fatal("ListVMs should not be called with no hosts") | ||
| 250 | return nil, nil | ||
| 251 | }, | ||
| 252 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 253 | t.Fatal("DeleteVM should not be called with no hosts") | ||
| 254 | return nil | ||
| 255 | }, | ||
| 256 | } | ||
| 257 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 258 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 259 | if err == nil { | ||
| 260 | t.Fatal("runScenario: want error, got nil") | ||
| 261 | } | ||
| 262 | } | ||
| 263 | |||
| 264 | // --- runScenario: gate hooks ----------------------------------------------- | ||
| 265 | |||
| 266 | // happyPathAPI returns a testAPI that succeeds all the way through reap, | ||
| 267 | // tracking call order in calls (a shared slice each hook also appends to). | ||
| 268 | func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | ||
| 269 | t.Helper() | ||
| 270 | listVMsCalls := 0 | ||
| 271 | return &testAPI{ | ||
| 272 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 273 | return []client.Host{{ID: "host-1"}}, nil | ||
| 274 | }, | ||
| 275 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 276 | *calls = append(*calls, "createVM") | ||
| 277 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 278 | }, | ||
| 279 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 280 | listVMsCalls++ | ||
| 281 | switch { | ||
| 282 | case listVMsCalls < 3: | ||
| 283 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | ||
| 284 | case listVMsCalls == 3: | ||
| 285 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 286 | default: | ||
| 287 | return nil, nil | ||
| 288 | } | ||
| 289 | }, | ||
| 290 | deleteVMFunc: func(ctx context.Context, id string) error { return nil }, | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | func happyPathRunSSH() sshFunc { | ||
| 295 | sshCalls := 0 | ||
| 296 | return func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 297 | sshCalls++ | ||
| 298 | if sshCalls < 2 { | ||
| 299 | return "[ 0.1] Booting Linux...", nil | ||
| 300 | } | ||
| 301 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil | ||
| 302 | } | ||
| 303 | } | ||
| 304 | |||
| 305 | func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | ||
| 306 | var calls []string | ||
| 307 | api := happyPathAPI(t, &calls) | ||
| 308 | gate := &gateHooks{ | ||
| 309 | register: func(ctx context.Context) error { | ||
| 310 | calls = append(calls, "register") | ||
| 311 | return nil | ||
| 312 | }, | ||
| 313 | exec: func(ctx context.Context, vmName string) error { | ||
| 314 | if vmName != "smoke-test" { | ||
| 315 | t.Errorf("gate.exec vmName = %q, want smoke-test", vmName) | ||
| 316 | } | ||
| 317 | calls = append(calls, "exec") | ||
| 318 | return nil | ||
| 319 | }, | ||
| 320 | } | ||
| 321 | |||
| 322 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 323 | msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey) | ||
| 324 | if err != nil { | ||
| 325 | t.Fatalf("runScenario: %v", err) | ||
| 326 | } | ||
| 327 | if !strings.Contains(msg, "gate SSH: ok") { | ||
| 328 | t.Errorf("message = %q, want it to mention gate SSH: ok", msg) | ||
| 329 | } | ||
| 330 | |||
| 331 | want := []string{"register", "createVM", "exec"} | ||
| 332 | if len(calls) != len(want) { | ||
| 333 | t.Fatalf("call order = %v, want %v", calls, want) | ||
| 334 | } | ||
| 335 | for i, c := range want { | ||
| 336 | if calls[i] != c { | ||
| 337 | t.Errorf("call order = %v, want %v", calls, want) | ||
| 338 | break | ||
| 339 | } | ||
| 340 | } | ||
| 341 | } | ||
| 342 | |||
| 343 | func TestRunScenarioGateRegisterErrorAbortsBeforeCreate(t *testing.T) { | ||
| 344 | api := &testAPI{ | ||
| 345 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 346 | t.Fatal("ListHosts should not be called when register fails") | ||
| 347 | return nil, nil | ||
| 348 | }, | ||
| 349 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 350 | t.Fatal("CreateVM should not be called when register fails") | ||
| 351 | return client.CreateVMResponse{}, nil | ||
| 352 | }, | ||
| 353 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 354 | t.Fatal("ListVMs should not be called when register fails") | ||
| 355 | return nil, nil | ||
| 356 | }, | ||
| 357 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 358 | t.Fatal("DeleteVM should not be called when register fails") | ||
| 359 | return nil | ||
| 360 | }, | ||
| 361 | } | ||
| 362 | gate := &gateHooks{ | ||
| 363 | register: func(ctx context.Context) error { return errors.New("upload boom") }, | ||
| 364 | exec: func(ctx context.Context, vmName string) error { | ||
| 365 | t.Fatal("exec should not be called when register fails") | ||
| 366 | return nil | ||
| 367 | }, | ||
| 368 | } | ||
| 369 | |||
| 370 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 371 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, gate, clock.now, clock.sleep, noopReadPubKey) | ||
| 372 | if err == nil { | ||
| 373 | t.Fatal("runScenario: want error, got nil") | ||
| 374 | } | ||
| 375 | if !strings.Contains(err.Error(), "register smoke user CA") { | ||
| 376 | t.Errorf("error = %q, want it to mention register smoke user CA", err.Error()) | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 380 | func TestRunScenarioGateExecErrorFails(t *testing.T) { | ||
| 381 | var calls []string | ||
| 382 | api := happyPathAPI(t, &calls) | ||
| 383 | api.deleteVMFunc = func(ctx context.Context, id string) error { | ||
| 384 | t.Fatal("DeleteVM should not be called when gate exec fails") | ||
| 385 | return nil | ||
| 386 | } | ||
| 387 | gate := &gateHooks{ | ||
| 388 | register: func(ctx context.Context) error { return nil }, | ||
| 389 | exec: func(ctx context.Context, vmName string) error { | ||
| 390 | return errors.New("FAIL: gate SSH boom") | ||
| 391 | }, | ||
| 392 | } | ||
| 393 | |||
| 394 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 395 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey) | ||
| 396 | if err == nil { | ||
| 397 | t.Fatal("runScenario: want error, got nil") | ||
| 398 | } | ||
| 399 | if !strings.Contains(err.Error(), "gate SSH boom") { | ||
| 400 | t.Errorf("error = %q, want it to mention gate SSH boom", err.Error()) | ||
| 401 | } | ||
| 402 | } | ||
| 403 | |||
| 404 | // --- pollLoop --------------------------------------------------------- | ||
| 405 | |||
| 406 | func TestPollLoopReturnsErrPollTimeoutAtDeadline(t *testing.T) { | ||
| 407 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 408 | calls := 0 | ||
| 409 | err := pollLoop(context.Background(), clock.now, clock.sleep, 10*time.Second, 5*time.Second, func() (bool, error) { | ||
| 410 | calls++ | ||
| 411 | return false, nil | ||
| 412 | }) | ||
| 413 | if !errors.Is(err, errPollTimeout) { | ||
| 414 | t.Errorf("err = %v, want errPollTimeout", err) | ||
| 415 | } | ||
| 416 | if calls == 0 { | ||
| 417 | t.Error("attempt was never called") | ||
| 418 | } | ||
| 419 | } | ||
| 420 | |||
| 421 | func TestPollLoopPropagatesAttemptError(t *testing.T) { | ||
| 422 | wantErr := errors.New("boom") | ||
| 423 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 424 | err := pollLoop(context.Background(), clock.now, clock.sleep, 10*time.Second, 5*time.Second, func() (bool, error) { | ||
| 425 | return false, wantErr | ||
| 426 | }) | ||
| 427 | if !errors.Is(err, wantErr) { | ||
| 428 | t.Errorf("err = %v, want %v", err, wantErr) | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 432 | // TestGetVMFiltersById pins that getVM discriminates by ID within a listing | ||
| 433 | // that contains other VMs — on the live fleet the list always does (eitri-dev | ||
| 434 | // at minimum), so a match-first regression would poll the wrong VM's | ||
| 435 | // phase/IP during the ready wait. | ||
| 436 | func TestGetVMFiltersById(t *testing.T) { | ||
| 437 | api := &testAPI{listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 438 | return []client.VM{ | ||
| 439 | {ID: "vm-1", Phase: "creating"}, | ||
| 440 | {ID: "vm-2", Phase: "ready", AssignedIP: "10.77.1.9"}, | ||
| 441 | }, nil | ||
| 442 | }} | ||
| 443 | |||
| 444 | vm, present, err := getVM(context.Background(), api, "vm-2") | ||
| 445 | if err != nil || !present { | ||
| 446 | t.Fatalf("getVM(vm-2) = present %v, err %v; want present", present, err) | ||
| 447 | } | ||
| 448 | if vm.ID != "vm-2" || vm.Phase != "ready" || vm.AssignedIP != "10.77.1.9" { | ||
| 449 | t.Errorf("getVM(vm-2) returned the wrong row: %+v", vm) | ||
| 450 | } | ||
| 451 | |||
| 452 | if _, present, err := getVM(context.Background(), api, "vm-3"); err != nil || present { | ||
| 453 | t.Errorf("getVM(vm-3) against a non-empty list = present %v, err %v; want absent", present, err) | ||
| 454 | } | ||
| 455 | } | ||
cmd/eitri-smoke/userca.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,56 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/ed25519" | ||
| 5 | "crypto/rand" | ||
| 6 | "encoding/pem" | ||
| 7 | "errors" | ||
| 8 | "fmt" | ||
| 9 | "io/fs" | ||
| 10 | "os" | ||
| 11 | "path/filepath" | ||
| 12 | |||
| 13 | "golang.org/x/crypto/ssh" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // loadOrCreateUserCA loads the smoke's persistent user CA key from path, | ||
| 17 | // generating and persisting a fresh ed25519 key on first run so subsequent | ||
| 18 | // smoke runs reuse the same CA identity (the gate check registers this key's | ||
| 19 | // public half with the tenant once; a fresh key every run would mean an | ||
| 20 | // ever-growing set of trusted, never-reused CAs). | ||
| 21 | func loadOrCreateUserCA(path string) (ssh.Signer, error) { | ||
| 22 | data, err := os.ReadFile(path) | ||
| 23 | if err == nil { | ||
| 24 | signer, err := ssh.ParsePrivateKey(data) | ||
| 25 | if err != nil { | ||
| 26 | return nil, fmt.Errorf("parse user CA key %q: %w", path, err) | ||
| 27 | } | ||
| 28 | return signer, nil | ||
| 29 | } | ||
| 30 | if !errors.Is(err, fs.ErrNotExist) { | ||
| 31 | return nil, fmt.Errorf("read user CA key %q: %w", path, err) | ||
| 32 | } | ||
| 33 | |||
| 34 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 35 | if err != nil { | ||
| 36 | return nil, fmt.Errorf("generate user CA key: %w", err) | ||
| 37 | } | ||
| 38 | block, err := ssh.MarshalPrivateKey(priv, "") | ||
| 39 | if err != nil { | ||
| 40 | return nil, fmt.Errorf("marshal user CA key: %w", err) | ||
| 41 | } | ||
| 42 | pemBytes := pem.EncodeToMemory(block) | ||
| 43 | |||
| 44 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 45 | return nil, fmt.Errorf("mkdir user CA key dir: %w", err) | ||
| 46 | } | ||
| 47 | if err := os.WriteFile(path, pemBytes, 0o600); err != nil { | ||
| 48 | return nil, fmt.Errorf("write user CA key %q: %w", path, err) | ||
| 49 | } | ||
| 50 | |||
| 51 | signer, err := ssh.NewSignerFromSigner(priv) | ||
| 52 | if err != nil { | ||
| 53 | return nil, fmt.Errorf("build user CA signer: %w", err) | ||
| 54 | } | ||
| 55 | return signer, nil | ||
| 56 | } | ||
cmd/eitri-smoke/userca_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,37 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "golang.org/x/crypto/ssh" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func TestLoadOrCreateUserCACreatesThenReuses(t *testing.T) { | ||
| 12 | dir := t.TempDir() | ||
| 13 | path := filepath.Join(dir, "nested", "user_ca") | ||
| 14 | |||
| 15 | s1, err := loadOrCreateUserCA(path) | ||
| 16 | if err != nil { | ||
| 17 | t.Fatalf("loadOrCreateUserCA (create): %v", err) | ||
| 18 | } | ||
| 19 | s2, err := loadOrCreateUserCA(path) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatalf("loadOrCreateUserCA (reuse): %v", err) | ||
| 22 | } | ||
| 23 | |||
| 24 | line1 := string(ssh.MarshalAuthorizedKey(s1.PublicKey())) | ||
| 25 | line2 := string(ssh.MarshalAuthorizedKey(s2.PublicKey())) | ||
| 26 | if line1 != line2 { | ||
| 27 | t.Errorf("public keys differ across calls: %q vs %q", line1, line2) | ||
| 28 | } | ||
| 29 | |||
| 30 | info, err := os.Stat(path) | ||
| 31 | if err != nil { | ||
| 32 | t.Fatalf("stat key file: %v", err) | ||
| 33 | } | ||
| 34 | if mode := info.Mode().Perm(); mode != 0o600 { | ||
| 35 | t.Errorf("file mode = %o, want 0600", mode) | ||
| 36 | } | ||
| 37 | } | ||
docs/architecture.md
| Old | New | ||
|---|---|---|---|
| @@ -39,7 +39,6 @@ bridge IP (`assigned_ip`) via the agent. | |||
| 39 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | | 39 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | |
| 40 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | | 40 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | |
| 41 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | | 41 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | |
| 42 | | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). | | ||
| 43 | 42 | ||
| 44 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see | 43 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see |
| 45 | > edges changing elsewhere in the module. Always run them with `-count=1` | 44 | > edges changing elsewhere in the module. Always run them with `-count=1` |
| @@ -90,9 +89,11 @@ why the invariants hold: | |||
| 90 | | Architecture-shape diagram drift | `make shape-check` | yes | | 89 | | Architecture-shape diagram drift | `make shape-check` | yes | |
| 91 | | Complexity/style lint | `make lint-extra` | **no** (informational) | | 90 | | Complexity/style lint | `make lint-extra` | **no** (informational) | |
| 92 | 91 | ||
| 93 | Heavy integration tiers (`make smoke-go`, `make sandbox`) need KVM / | 92 | Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate |
| 94 | cloud-hypervisor / nested QEMU and are not part of the per-PR gate; run them on | 93 | (`cmd/eitri-smoke`) against the live fleet — create a throwaway VM, prove it boots |
| 95 | privileged runners or locally. | 94 | under UEFI, reap it — which is the fleet's one automated real-VM check. Because the |
| 95 | fleet binaries are deployed with `-cover`, the same run also collects merged | ||
| 96 | server+agent integration coverage (flushed on SIGUSR1 via `internal/covsnap`). | ||
| 96 | 97 | ||
| 97 | ### Linting tiers | 98 | ### Linting tiers |
| 98 | 99 | ||
| @@ -110,8 +111,7 @@ once its baseline is clean. | |||
| 110 | coverage. CI fails if any package drops below its floor; raise the floor when | 111 | coverage. CI fails if any package drops below its floor; raise the floor when |
| 111 | you raise coverage. Aspirational targets (not yet enforced): logic/domain | 112 | you raise coverage. Aspirational targets (not yet enforced): logic/domain |
| 112 | packages → 80%, host-touching effectful packages → 50%. Generated code | 113 | packages → 80%, host-touching effectful packages → 50%. Generated code |
| 113 | (`internal/pb`), thin `cmd/*` mains, and the tag-gated integration tiers are not | 114 | (`internal/pb`) and thin `cmd/*` mains are not gated here. |
| 114 | gated here. | ||
| 115 | 115 | ||
| 116 | ### The shape diagram | 116 | ### The shape diagram |
| 117 | 117 | ||
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -115,11 +115,5 @@ itself is never surfaced in a tool result or error. | |||
| 115 | ## Testing this yourself | 115 | ## Testing this yourself |
| 116 | 116 | ||
| 117 | Unit tests (`internal/mcpserver/*_test.go`) cover the tools against fake API | 117 | Unit tests (`internal/mcpserver/*_test.go`) cover the tools against fake API |
| 118 | and SSH seams. A tag-gated integration test | 118 | and SSH seams. End-to-end behaviour against a real VM is exercised by driving |
| 119 | (`internal/integration/mcp_smoke_test.go`) launches the real `eitri-mcp` | 119 | the tools through a VM created on the fleet via `make deploy`. |
| 120 | binary as a subprocess speaking stdio MCP and drives create → exec → | ||
| 121 | write/read file → destroy against a real VM: | ||
| 122 | |||
| 123 | ``` | ||
| 124 | sudo -v && go test -tags=smoke -timeout=25m -count=1 ./internal/integration -run TestMCPSmoke -v | ||
| 125 | ``` | ||
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -66,6 +66,7 @@ | |||
| 66 | "internal/agent/serialpump", | 66 | "internal/agent/serialpump", |
| 67 | "internal/agent/state", | 67 | "internal/agent/state", |
| 68 | "internal/agent/syncclient", | 68 | "internal/agent/syncclient", |
| 69 | "internal/covsnap", | ||
| 69 | "internal/joinblob" | 70 | "internal/joinblob" |
| 70 | ] | 71 | ] |
| 71 | }, | 72 | }, |
| @@ -82,6 +83,7 @@ | |||
| 82 | "plane": "binaries", | 83 | "plane": "binaries", |
| 83 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", | 84 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", |
| 84 | "imports": [ | 85 | "imports": [ |
| 86 | "internal/gateclient", | ||
| 85 | "internal/mcpserver", | 87 | "internal/mcpserver", |
| 86 | "internal/server/api/client" | 88 | "internal/server/api/client" |
| 87 | ] | 89 | ] |
| @@ -91,6 +93,7 @@ | |||
| 91 | "plane": "binaries", | 93 | "plane": "binaries", |
| 92 | "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", | 94 | "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", |
| 93 | "imports": [ | 95 | "imports": [ |
| 96 | "internal/covsnap", | ||
| 94 | "internal/joinblob", | 97 | "internal/joinblob", |
| 95 | "internal/server/api", | 98 | "internal/server/api", |
| 96 | "internal/server/config", | 99 | "internal/server/config", |
| @@ -114,6 +117,15 @@ | |||
| 114 | ] | 117 | ] |
| 115 | }, | 118 | }, |
| 116 | { | 119 | { |
| 120 | "importPath": "cmd/eitri-smoke", | ||
| 121 | "plane": "binaries", | ||
| 122 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", | ||
| 123 | "imports": [ | ||
| 124 | "internal/gateclient", | ||
| 125 | "internal/server/api/client" | ||
| 126 | ] | ||
| 127 | }, | ||
| 128 | { | ||
| 117 | "importPath": "internal/agent/cloudhv", | 129 | "importPath": "internal/agent/cloudhv", |
| 118 | "plane": "data", | 130 | "plane": "data", |
| 119 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", | 131 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", |
| @@ -229,6 +241,18 @@ | |||
| 229 | "imports": [] | 241 | "imports": [] |
| 230 | }, | 242 | }, |
| 231 | { | 243 | { |
| 244 | "importPath": "internal/covsnap", | ||
| 245 | "plane": "tooling", | ||
| 246 | "synopsis": "Package covsnap lets a long-running binary flush integration-coverage counters on demand.", | ||
| 247 | "imports": [] | ||
| 248 | }, | ||
| 249 | { | ||
| 250 | "importPath": "internal/gateclient", | ||
| 251 | "plane": "tooling", | ||
| 252 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", | ||
| 253 | "imports": [] | ||
| 254 | }, | ||
| 255 | { | ||
| 232 | "importPath": "internal/joinblob", | 256 | "importPath": "internal/joinblob", |
| 233 | "plane": "wire", | 257 | "plane": "wire", |
| 234 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", | 258 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", |
| @@ -239,6 +263,7 @@ | |||
| 239 | "plane": "tooling", | 263 | "plane": "tooling", |
| 240 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", | 264 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", |
| 241 | "imports": [ | 265 | "imports": [ |
| 266 | "internal/gateclient", | ||
| 242 | "internal/random", | 267 | "internal/random", |
| 243 | "internal/server/api/client" | 268 | "internal/server/api/client" |
| 244 | ] | 269 | ] |
| @@ -281,7 +306,7 @@ | |||
| 281 | { | 306 | { |
| 282 | "importPath": "internal/server/api/client", | 307 | "importPath": "internal/server/api/client", |
| 283 | "plane": "control", | 308 | "plane": "control", |
| 284 | "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server) goes through.", | 309 | "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate) goes through.", |
| 285 | "imports": [ | 310 | "imports": [ |
| 286 | "internal/server/api/types" | 311 | "internal/server/api/types" |
| 287 | ] | 312 | ] |
| @@ -304,7 +329,7 @@ | |||
| 304 | { | 329 | { |
| 305 | "importPath": "internal/server/config", | 330 | "importPath": "internal/server/config", |
| 306 | "plane": "control", | 331 | "plane": "control", |
| 307 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema.", | 332 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", |
| 308 | "imports": [] | 333 | "imports": [] |
| 309 | }, | 334 | }, |
| 310 | { | 335 | { |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ | |||
| 15 | "internal/agent/serialpump", | 15 | "internal/agent/serialpump", |
| 16 | "internal/agent/state", | 16 | "internal/agent/state", |
| 17 | "internal/agent/syncclient", | 17 | "internal/agent/syncclient", |
| 18 | "internal/covsnap", | ||
| 18 | "internal/joinblob" | 19 | "internal/joinblob" |
| 19 | ] | 20 | ] |
| 20 | }, | 21 | }, |
| @@ -31,6 +32,7 @@ | |||
| 31 | "plane": "binaries", | 32 | "plane": "binaries", |
| 32 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", | 33 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", |
| 33 | "imports": [ | 34 | "imports": [ |
| 35 | "internal/gateclient", | ||
| 34 | "internal/mcpserver", | 36 | "internal/mcpserver", |
| 35 | "internal/server/api/client" | 37 | "internal/server/api/client" |
| 36 | ] | 38 | ] |
| @@ -40,6 +42,7 @@ | |||
| 40 | "plane": "binaries", | 42 | "plane": "binaries", |
| 41 | "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", | 43 | "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", |
| 42 | "imports": [ | 44 | "imports": [ |
| 45 | "internal/covsnap", | ||
| 43 | "internal/joinblob", | 46 | "internal/joinblob", |
| 44 | "internal/server/api", | 47 | "internal/server/api", |
| 45 | "internal/server/config", | 48 | "internal/server/config", |
| @@ -63,6 +66,15 @@ | |||
| 63 | ] | 66 | ] |
| 64 | }, | 67 | }, |
| 65 | { | 68 | { |
| 69 | "importPath": "cmd/eitri-smoke", | ||
| 70 | "plane": "binaries", | ||
| 71 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", | ||
| 72 | "imports": [ | ||
| 73 | "internal/gateclient", | ||
| 74 | "internal/server/api/client" | ||
| 75 | ] | ||
| 76 | }, | ||
| 77 | { | ||
| 66 | "importPath": "internal/agent/cloudhv", | 78 | "importPath": "internal/agent/cloudhv", |
| 67 | "plane": "data", | 79 | "plane": "data", |
| 68 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", | 80 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", |
| @@ -178,6 +190,18 @@ | |||
| 178 | "imports": [] | 190 | "imports": [] |
| 179 | }, | 191 | }, |
| 180 | { | 192 | { |
| 193 | "importPath": "internal/covsnap", | ||
| 194 | "plane": "tooling", | ||
| 195 | "synopsis": "Package covsnap lets a long-running binary flush integration-coverage counters on demand.", | ||
| 196 | "imports": [] | ||
| 197 | }, | ||
| 198 | { | ||
| 199 | "importPath": "internal/gateclient", | ||
| 200 | "plane": "tooling", | ||
| 201 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", | ||
| 202 | "imports": [] | ||
| 203 | }, | ||
| 204 | { | ||
| 181 | "importPath": "internal/joinblob", | 205 | "importPath": "internal/joinblob", |
| 182 | "plane": "wire", | 206 | "plane": "wire", |
| 183 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", | 207 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", |
| @@ -188,6 +212,7 @@ | |||
| 188 | "plane": "tooling", | 212 | "plane": "tooling", |
| 189 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", | 213 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", |
| 190 | "imports": [ | 214 | "imports": [ |
| 215 | "internal/gateclient", | ||
| 191 | "internal/random", | 216 | "internal/random", |
| 192 | "internal/server/api/client" | 217 | "internal/server/api/client" |
| 193 | ] | 218 | ] |
| @@ -230,7 +255,7 @@ | |||
| 230 | { | 255 | { |
| 231 | "importPath": "internal/server/api/client", | 256 | "importPath": "internal/server/api/client", |
| 232 | "plane": "control", | 257 | "plane": "control", |
| 233 | "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server) goes through.", | 258 | "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate) goes through.", |
| 234 | "imports": [ | 259 | "imports": [ |
| 235 | "internal/server/api/types" | 260 | "internal/server/api/types" |
| 236 | ] | 261 | ] |
| @@ -253,7 +278,7 @@ | |||
| 253 | { | 278 | { |
| 254 | "importPath": "internal/server/config", | 279 | "importPath": "internal/server/config", |
| 255 | "plane": "control", | 280 | "plane": "control", |
| 256 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema.", | 281 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", |
| 257 | "imports": [] | 282 | "imports": [] |
| 258 | }, | 283 | }, |
| 259 | { | 284 | { |
internal/agent/reconcile/managerhelpers_test.go
| Old | New | ||
|---|---|---|---|
| @@ -27,3 +27,22 @@ func (m *manager) waitIdle() { | |||
| 27 | w.mu.Unlock() | 27 | w.mu.Unlock() |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
| 30 | |||
| 31 | // Stop shuts down every per-VM worker. It signals and returns without waiting: a | ||
| 32 | // worker mid-pass exits when that pass ends (bounded by VMTimeout). | ||
| 33 | // | ||
| 34 | // TERMINAL: an Engine cannot be restarted, and after Stop, Step dispatches | ||
| 35 | // nothing and reports empty actual state — which the control plane reads as every | ||
| 36 | // VM on this host having vanished. Production therefore never calls it | ||
| 37 | // (cmd/eitri-agent lets the process exit and the OS reclaim the goroutines); it | ||
| 38 | // exists only so a test's cleanup can tear workers down between cases. | ||
| 39 | func (e *Engine) Stop() { | ||
| 40 | m := e.manager() | ||
| 41 | m.mu.Lock() | ||
| 42 | defer m.mu.Unlock() | ||
| 43 | m.stopped = true | ||
| 44 | for id, w := range m.workers { | ||
| 45 | delete(m.workers, id) | ||
| 46 | w.stop() | ||
| 47 | } | ||
| 48 | } | ||
internal/agent/reconcile/worker.go
| Old | New | ||
|---|---|---|---|
| @@ -151,28 +151,6 @@ func (m *manager) snapshot() []*worker { | |||
| 151 | return out | 151 | return out |
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | // Stop shuts down every per-VM worker. It signals and returns without waiting: a | ||
| 155 | // worker mid-pass exits when that pass ends (bounded by VMTimeout). | ||
| 156 | // | ||
| 157 | // TERMINAL: an Engine cannot be restarted. After Stop, Step still advances the | ||
| 158 | // persisted epoch and returns a report, but dispatches nothing and reports EMPTY | ||
| 159 | // actual state — which the control plane reads as every VM on this host having | ||
| 160 | // vanished. Production never calls it (cmd/eitri-agent lets the process exit and | ||
| 161 | // the OS reclaim the goroutines); it exists so a test's cleanup can tear workers | ||
| 162 | // down between cases without leaking goroutines. Because that sole caller is a | ||
| 163 | // cross-package test (internal/integration), it cannot live in a _test.go, so | ||
| 164 | // the deadcode gate allowlists it. | ||
| 165 | func (e *Engine) Stop() { | ||
| 166 | m := e.manager() | ||
| 167 | m.mu.Lock() | ||
| 168 | defer m.mu.Unlock() | ||
| 169 | m.stopped = true | ||
| 170 | for id, w := range m.workers { | ||
| 171 | delete(m.workers, id) | ||
| 172 | w.stop() | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | // worker owns exactly one VM's reconcile. Its goroutine is the serialization | 154 | // worker owns exactly one VM's reconcile. Its goroutine is the serialization |
| 177 | // primitive: at most one operation on that VM is ever in flight. | 155 | // primitive: at most one operation on that VM is ever in flight. |
| 178 | type worker struct { | 156 | type worker struct { |
internal/arch/arch_test.go
| Old | New | ||
|---|---|---|---|
| @@ -230,38 +230,6 @@ func TestAPITypesIsALeaf(t *testing.T) { | |||
| 230 | } | 230 | } |
| 231 | } | 231 | } |
| 232 | 232 | ||
| 233 | // R7: internal/integration/testutil is test infrastructure only (its package | ||
| 234 | // doc says so) — it exists to support the in-process harness and the QEMU | ||
| 235 | // sandbox, not to be a dependency of anything shipped. Nothing under | ||
| 236 | // internal/server/*, internal/agent/*, or cmd/* may import | ||
| 237 | // internal/integration/* — checked transitively, so a wrapper package can't | ||
| 238 | // smuggle the dependency in either. The sanctioned exceptions are the | ||
| 239 | // test-tooling launcher binaries that ARE the integration infrastructure's | ||
| 240 | // entry points (smoketest, devstack, sandbox); the shipped binaries | ||
| 241 | // (eitri-server, eitri-agent) and everything else stay clean. | ||
| 242 | func TestProductionPlanesDoNotImportIntegrationTestInfra(t *testing.T) { | ||
| 243 | g := internalImports(t) | ||
| 244 | allowed := map[string]bool{ | ||
| 245 | module + "/cmd/eitri-smoketest": true, // drives internal/integration/harness | ||
| 246 | module + "/cmd/eitri-devstack": true, // drives internal/integration/harness | ||
| 247 | module + "/cmd/eitri-sandbox": true, // drives internal/integration/sandbox + testutil | ||
| 248 | } | ||
| 249 | for pkg := range g { | ||
| 250 | if allowed[pkg] { | ||
| 251 | continue | ||
| 252 | } | ||
| 253 | if !has(pkg, "internal/server/") && !has(pkg, "internal/agent/") && !has(pkg, "cmd/") { | ||
| 254 | continue | ||
| 255 | } | ||
| 256 | deps := transitiveDeps(g, pkg) | ||
| 257 | for d := range deps { | ||
| 258 | if has(d, "internal/integration/") { | ||
| 259 | t.Errorf("production package %s must not import test-infrastructure package %s", short(pkg), short(d)) | ||
| 260 | } | ||
| 261 | } | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | // assertNotImported fails if pkg directly imports any path in forbidden. | 233 | // assertNotImported fails if pkg directly imports any path in forbidden. |
| 266 | func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) { | 234 | func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) { |
| 267 | t.Helper() | 235 | t.Helper() |
internal/covsnap/covsnap.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,47 @@ | |||
| 1 | // Package covsnap lets a long-running binary flush integration-coverage | ||
| 2 | // counters on demand. It is a no-op unless built with `go build -cover` AND | ||
| 3 | // GOCOVERDIR is set, so it costs nothing in a normal build. | ||
| 4 | package covsnap | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "context" | ||
| 8 | "log/slog" | ||
| 9 | "os" | ||
| 10 | "os/signal" | ||
| 11 | "runtime/coverage" | ||
| 12 | "syscall" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // write is a seam for tests; production writes real coverage data. | ||
| 16 | var write = func(dir string) error { | ||
| 17 | if err := coverage.WriteMetaDir(dir); err != nil { | ||
| 18 | return err | ||
| 19 | } | ||
| 20 | return coverage.WriteCountersDir(dir) | ||
| 21 | } | ||
| 22 | |||
| 23 | // Install starts a goroutine that, on each SIGUSR1, snapshots coverage into | ||
| 24 | // $GOCOVERDIR. Unset GOCOVERDIR -> returns without registering anything. | ||
| 25 | func Install(ctx context.Context) { | ||
| 26 | dir := os.Getenv("GOCOVERDIR") | ||
| 27 | if dir == "" { | ||
| 28 | return | ||
| 29 | } | ||
| 30 | ch := make(chan os.Signal, 1) | ||
| 31 | signal.Notify(ch, syscall.SIGUSR1) | ||
| 32 | go func() { | ||
| 33 | defer signal.Stop(ch) | ||
| 34 | for { | ||
| 35 | select { | ||
| 36 | case <-ctx.Done(): | ||
| 37 | return | ||
| 38 | case <-ch: | ||
| 39 | if err := write(dir); err != nil { | ||
| 40 | slog.Warn("covsnap: write failed", "dir", dir, "err", err) | ||
| 41 | continue | ||
| 42 | } | ||
| 43 | slog.Info("covsnap: coverage written", "dir", dir) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | }() | ||
| 47 | } | ||
internal/covsnap/covsnap_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,76 @@ | |||
| 1 | package covsnap | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "os" | ||
| 6 | "syscall" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // TestInstall_SignalTriggersWrite verifies that once Install is called with | ||
| 12 | // GOCOVERDIR set, sending SIGUSR1 to the process invokes the write seam with | ||
| 13 | // the configured directory. | ||
| 14 | func TestInstall_SignalTriggersWrite(t *testing.T) { | ||
| 15 | dir := t.TempDir() | ||
| 16 | t.Setenv("GOCOVERDIR", dir) | ||
| 17 | |||
| 18 | origWrite := write | ||
| 19 | t.Cleanup(func() { write = origWrite }) | ||
| 20 | |||
| 21 | calls := make(chan string, 1) | ||
| 22 | write = func(d string) error { | ||
| 23 | calls <- d | ||
| 24 | return nil | ||
| 25 | } | ||
| 26 | |||
| 27 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 28 | t.Cleanup(cancel) | ||
| 29 | |||
| 30 | Install(ctx) | ||
| 31 | |||
| 32 | if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { | ||
| 33 | t.Fatalf("failed to send SIGUSR1: %v", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | select { | ||
| 37 | case got := <-calls: | ||
| 38 | if got != dir { | ||
| 39 | t.Fatalf("write called with dir %q, want %q", got, dir) | ||
| 40 | } | ||
| 41 | case <-time.After(2 * time.Second): | ||
| 42 | t.Fatal("timed out waiting for write to be called after SIGUSR1") | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // TestInstall_NoGOCOVERDIR verifies that Install is a no-op when GOCOVERDIR | ||
| 47 | // is unset: no handler is registered, so sending SIGUSR1 never calls write | ||
| 48 | // and never panics. | ||
| 49 | func TestInstall_NoGOCOVERDIR(t *testing.T) { | ||
| 50 | t.Setenv("GOCOVERDIR", "") | ||
| 51 | |||
| 52 | origWrite := write | ||
| 53 | t.Cleanup(func() { write = origWrite }) | ||
| 54 | |||
| 55 | calls := make(chan string, 1) | ||
| 56 | write = func(d string) error { | ||
| 57 | calls <- d | ||
| 58 | return nil | ||
| 59 | } | ||
| 60 | |||
| 61 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 62 | t.Cleanup(cancel) | ||
| 63 | |||
| 64 | Install(ctx) | ||
| 65 | |||
| 66 | if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { | ||
| 67 | t.Fatalf("failed to send SIGUSR1: %v", err) | ||
| 68 | } | ||
| 69 | |||
| 70 | select { | ||
| 71 | case got := <-calls: | ||
| 72 | t.Fatalf("write unexpectedly called with dir %q", got) | ||
| 73 | case <-time.After(200 * time.Millisecond): | ||
| 74 | // expected: nothing fired | ||
| 75 | } | ||
| 76 | } | ||
internal/gateclient/auth.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,217 @@ | |||
| 1 | // Package gateclient is a client of the eitri SSH-CA jump gate: it holds the | ||
| 2 | // credential cache (GateAuth) that self-signs short-lived user certs and | ||
| 3 | // verifies host certs against the eitri CA, and the two-hop dial (Dial) that | ||
| 4 | // reaches a VM by name through the gate. It's shared by internal/mcpserver | ||
| 5 | // and cmd/eitri-smoke. | ||
| 6 | package gateclient | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "bytes" | ||
| 10 | "context" | ||
| 11 | "crypto/ed25519" | ||
| 12 | "crypto/rand" | ||
| 13 | "encoding/binary" | ||
| 14 | "fmt" | ||
| 15 | "strings" | ||
| 16 | "sync" | ||
| 17 | "time" | ||
| 18 | |||
| 19 | "golang.org/x/crypto/ssh" | ||
| 20 | ) | ||
| 21 | |||
| 22 | // CertAuthority is the subset of the eitri API client GateAuth needs: enough | ||
| 23 | // to fetch the (host) SSH CA's public key and register this client's own user | ||
| 24 | // CA with its tenant. It's declared here (rather than depending on the | ||
| 25 | // shared API client directly) so tests can fake it in-memory without | ||
| 26 | // spinning up an httptest server. | ||
| 27 | type CertAuthority interface { | ||
| 28 | FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) | ||
| 29 | UploadUserCA(ctx context.Context, tenant, caLine string) error | ||
| 30 | } | ||
| 31 | |||
| 32 | // GateAuth is a concurrency-safe credential cache for authenticating to the | ||
| 33 | // eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral | ||
| 34 | // (never-persisted) ed25519 keypair generated once at first use and LOCALLY | ||
| 35 | // self-signs a short-lived user certificate for it on demand (refreshing | ||
| 36 | // shortly before expiry) using this client's own persistent user CA. It | ||
| 37 | // verifies host certificates against the eitri host CA. The user CA's public | ||
| 38 | // key must be registered with the tenant (see Register) so VMs trust the | ||
| 39 | // certs this client signs. | ||
| 40 | type GateAuth struct { | ||
| 41 | api CertAuthority | ||
| 42 | userCA ssh.Signer // this client's persistent user CA; signs user certs locally | ||
| 43 | tenant string // this client's tenant; connect names are <tenant>.<vm> | ||
| 44 | now func() time.Time | ||
| 45 | |||
| 46 | mu sync.Mutex | ||
| 47 | ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once | ||
| 48 | ca ssh.PublicKey // eitri host CA; fetched lazily, once | ||
| 49 | cert *ssh.Certificate | ||
| 50 | certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert | ||
| 51 | registered bool // true once the user CA has been uploaded (once-guard) | ||
| 52 | } | ||
| 53 | |||
| 54 | // NewGateAuth constructs a GateAuth backed by api, self-signing user certs with | ||
| 55 | // userCA and dialing VMs under tenant. If now is nil, time.Now is used. The | ||
| 56 | // ephemeral keypair and host CA key are NOT fetched here; both are established | ||
| 57 | // lazily on first use so construction cannot fail. | ||
| 58 | func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant string, now func() time.Time) *GateAuth { | ||
| 59 | if now == nil { | ||
| 60 | now = time.Now | ||
| 61 | } | ||
| 62 | return &GateAuth{api: api, userCA: userCA, tenant: tenant, now: now} | ||
| 63 | } | ||
| 64 | |||
| 65 | // Register uploads this client's user-CA public key to its tenant so VMs trust | ||
| 66 | // certs it signs. Idempotent; safe to call at startup before creating VMs. | ||
| 67 | func (g *GateAuth) Register(ctx context.Context) error { | ||
| 68 | g.mu.Lock() | ||
| 69 | defer g.mu.Unlock() | ||
| 70 | if g.registered { | ||
| 71 | return nil | ||
| 72 | } | ||
| 73 | line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(g.userCA.PublicKey()))) | ||
| 74 | if err := g.api.UploadUserCA(ctx, g.tenant, line); err != nil { | ||
| 75 | return fmt.Errorf("registering user CA: %w", err) | ||
| 76 | } | ||
| 77 | g.registered = true | ||
| 78 | return nil | ||
| 79 | } | ||
| 80 | |||
| 81 | // Signer returns an ssh.Signer backed by a cached, cert-signed identity, | ||
| 82 | // self-signing (or re-signing, if the cached cert is missing or expires within | ||
| 83 | // a minute) as needed. | ||
| 84 | func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) { | ||
| 85 | g.mu.Lock() | ||
| 86 | defer g.mu.Unlock() | ||
| 87 | |||
| 88 | if g.ephemeral == nil { | ||
| 89 | signer, err := newEphemeralSigner() | ||
| 90 | if err != nil { | ||
| 91 | return nil, fmt.Errorf("generating ephemeral SSH key: %w", err) | ||
| 92 | } | ||
| 93 | g.ephemeral = signer | ||
| 94 | } | ||
| 95 | |||
| 96 | if g.needsMintLocked() { | ||
| 97 | cert, err := g.signCertLocked() | ||
| 98 | if err != nil { | ||
| 99 | return nil, fmt.Errorf("signing user certificate: %w", err) | ||
| 100 | } | ||
| 101 | certSigner, err := ssh.NewCertSigner(cert, g.ephemeral) | ||
| 102 | if err != nil { | ||
| 103 | return nil, fmt.Errorf("wrapping signed certificate: %w", err) | ||
| 104 | } | ||
| 105 | g.cert = cert | ||
| 106 | g.certSigner = certSigner | ||
| 107 | } | ||
| 108 | |||
| 109 | return g.certSigner, nil | ||
| 110 | } | ||
| 111 | |||
| 112 | // signCertLocked self-signs a short-lived user certificate for g.ephemeral's | ||
| 113 | // public key using g.userCA. Callers must hold g.mu. Mirrors the (removed) | ||
| 114 | // server-side minter's cert shape. | ||
| 115 | func (g *GateAuth) signCertLocked() (*ssh.Certificate, error) { | ||
| 116 | var serial uint64 | ||
| 117 | if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { | ||
| 118 | return nil, err | ||
| 119 | } | ||
| 120 | now := g.now() | ||
| 121 | cert := &ssh.Certificate{ | ||
| 122 | Key: g.ephemeral.PublicKey(), | ||
| 123 | Serial: serial, | ||
| 124 | CertType: ssh.UserCert, | ||
| 125 | KeyId: "ubuntu", | ||
| 126 | ValidPrincipals: []string{"ubuntu"}, | ||
| 127 | ValidAfter: uint64(now.Add(-time.Minute).Unix()), // small skew backdate | ||
| 128 | ValidBefore: uint64(now.Add(30 * time.Minute).Unix()), | ||
| 129 | Permissions: ssh.Permissions{Extensions: map[string]string{ | ||
| 130 | "permit-pty": "", "permit-port-forwarding": "", "permit-user-rc": "", "permit-agent-forwarding": "", | ||
| 131 | }}, | ||
| 132 | } | ||
| 133 | if err := cert.SignCert(rand.Reader, g.userCA); err != nil { | ||
| 134 | return nil, err | ||
| 135 | } | ||
| 136 | return cert, nil | ||
| 137 | } | ||
| 138 | |||
| 139 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", | ||
| 140 | // the form the gate resolves and the VM's host-cert principal matches. g.tenant | ||
| 141 | // is non-empty by construction (the caller validates it). | ||
| 142 | func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 143 | if _, err := g.Signer(ctx); err != nil { | ||
| 144 | return "", err | ||
| 145 | } | ||
| 146 | g.mu.Lock() | ||
| 147 | defer g.mu.Unlock() | ||
| 148 | return g.tenant + "." + vmName, nil | ||
| 149 | } | ||
| 150 | |||
| 151 | // needsMintLocked reports whether the cached cert is absent or expires | ||
| 152 | // within a minute of now(). Callers must hold g.mu. | ||
| 153 | func (g *GateAuth) needsMintLocked() bool { | ||
| 154 | if g.cert == nil { | ||
| 155 | return true | ||
| 156 | } | ||
| 157 | if g.cert.ValidBefore == ssh.CertTimeInfinity { | ||
| 158 | return false | ||
| 159 | } | ||
| 160 | return g.now().Add(time.Minute).Unix() >= int64(g.cert.ValidBefore) | ||
| 161 | } | ||
| 162 | |||
| 163 | // HostKeyCallback returns an ssh.HostKeyCallback that accepts only host | ||
| 164 | // certificates signed by the eitri CA, lazily fetching the CA (once) on | ||
| 165 | // first invocation. | ||
| 166 | func (g *GateAuth) HostKeyCallback() ssh.HostKeyCallback { | ||
| 167 | checker := &ssh.CertChecker{ | ||
| 168 | IsHostAuthority: func(auth ssh.PublicKey, address string) bool { | ||
| 169 | // ssh.HostKeyCallback has no ctx param, so caller cancellation | ||
| 170 | // cannot reach here; the fetch deadline is the API client's HTTP | ||
| 171 | // timeout (30s), not the SSH handshake context. | ||
| 172 | ca, err := g.caKey(context.Background()) | ||
| 173 | if err != nil { | ||
| 174 | return false | ||
| 175 | } | ||
| 176 | return caEquals(auth, ca) | ||
| 177 | }, | ||
| 178 | } | ||
| 179 | return checker.CheckHostKey | ||
| 180 | } | ||
| 181 | |||
| 182 | // caKey returns the cached eitri CA public key, fetching it (once) if not | ||
| 183 | // already cached. | ||
| 184 | func (g *GateAuth) caKey(ctx context.Context) (ssh.PublicKey, error) { | ||
| 185 | g.mu.Lock() | ||
| 186 | defer g.mu.Unlock() | ||
| 187 | |||
| 188 | if g.ca != nil { | ||
| 189 | return g.ca, nil | ||
| 190 | } | ||
| 191 | // g.mu is deliberately held across this network call: single-flight fetch | ||
| 192 | // so concurrent callbacks share one FetchSSHCA rather than stampeding. | ||
| 193 | ca, err := g.api.FetchSSHCA(ctx) | ||
| 194 | if err != nil { | ||
| 195 | return nil, fmt.Errorf("fetching SSH CA: %w", err) | ||
| 196 | } | ||
| 197 | g.ca = ca | ||
| 198 | return g.ca, nil | ||
| 199 | } | ||
| 200 | |||
| 201 | // caEquals reports whether two SSH public keys are the same key, by | ||
| 202 | // comparing their wire encodings. | ||
| 203 | func caEquals(a, b ssh.PublicKey) bool { | ||
| 204 | if a == nil || b == nil { | ||
| 205 | return false | ||
| 206 | } | ||
| 207 | return bytes.Equal(a.Marshal(), b.Marshal()) | ||
| 208 | } | ||
| 209 | |||
| 210 | // newEphemeralSigner generates a fresh, never-persisted ed25519 SSH signer. | ||
| 211 | func newEphemeralSigner() (ssh.Signer, error) { | ||
| 212 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 213 | if err != nil { | ||
| 214 | return nil, err | ||
| 215 | } | ||
| 216 | return ssh.NewSignerFromSigner(priv) | ||
| 217 | } | ||
internal/gateclient/auth_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,258 @@ | |||
| 1 | package gateclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "errors" | ||
| 8 | "net" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | ||
| 15 | "golang.org/x/crypto/ssh" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519 | ||
| 19 | // host-CA signer, so tests exercise real host-cert verification without an | ||
| 20 | // httptest server. User certs are now self-signed by GateAuth locally, so this | ||
| 21 | // fake only serves the host CA (FetchSSHCA) and records user-CA uploads. | ||
| 22 | type fakeCertAuthority struct { | ||
| 23 | caSigner ssh.Signer // host CA served by FetchSSHCA | ||
| 24 | |||
| 25 | mu sync.Mutex | ||
| 26 | fetchCACalls int | ||
| 27 | uploadCalls int | ||
| 28 | lastTenant string | ||
| 29 | lastUploadLine string | ||
| 30 | fetchErr error // if set, FetchSSHCA returns it | ||
| 31 | uploadErr error // if set, UploadUserCA returns it | ||
| 32 | } | ||
| 33 | |||
| 34 | func newFakeCertAuthority(t *testing.T) *fakeCertAuthority { | ||
| 35 | t.Helper() | ||
| 36 | return &fakeCertAuthority{caSigner: newTestSigner(t)} | ||
| 37 | } | ||
| 38 | |||
| 39 | func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { | ||
| 40 | f.mu.Lock() | ||
| 41 | defer f.mu.Unlock() | ||
| 42 | f.fetchCACalls++ | ||
| 43 | if f.fetchErr != nil { | ||
| 44 | return nil, f.fetchErr | ||
| 45 | } | ||
| 46 | return f.caSigner.PublicKey(), nil | ||
| 47 | } | ||
| 48 | |||
| 49 | func (f *fakeCertAuthority) UploadUserCA(ctx context.Context, tenant, caLine string) error { | ||
| 50 | f.mu.Lock() | ||
| 51 | defer f.mu.Unlock() | ||
| 52 | f.uploadCalls++ | ||
| 53 | f.lastTenant = tenant | ||
| 54 | f.lastUploadLine = caLine | ||
| 55 | return f.uploadErr | ||
| 56 | } | ||
| 57 | |||
| 58 | func (f *fakeCertAuthority) setFetchErr(err error) { | ||
| 59 | f.mu.Lock() | ||
| 60 | defer f.mu.Unlock() | ||
| 61 | f.fetchErr = err | ||
| 62 | } | ||
| 63 | |||
| 64 | func (f *fakeCertAuthority) counts() (fetchCA, upload int) { | ||
| 65 | f.mu.Lock() | ||
| 66 | defer f.mu.Unlock() | ||
| 67 | return f.fetchCACalls, f.uploadCalls | ||
| 68 | } | ||
| 69 | |||
| 70 | // newTestSigner generates a fresh ed25519 ssh.Signer for use as a test CA. | ||
| 71 | func newTestSigner(t *testing.T) ssh.Signer { | ||
| 72 | t.Helper() | ||
| 73 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 74 | require.NoError(t, err) | ||
| 75 | signer, err := ssh.NewSignerFromSigner(priv) | ||
| 76 | require.NoError(t, err) | ||
| 77 | return signer | ||
| 78 | } | ||
| 79 | |||
| 80 | // newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA | ||
| 81 | // under tenant "default" and driven by clock (nil = time.Now). | ||
| 82 | func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth { | ||
| 83 | return NewGateAuth(fake, userCA, "default", clock) | ||
| 84 | } | ||
| 85 | |||
| 86 | // certOf returns the *ssh.Certificate a cert-signer's public key carries. | ||
| 87 | func certOf(t *testing.T, s ssh.Signer) *ssh.Certificate { | ||
| 88 | t.Helper() | ||
| 89 | cert, ok := s.PublicKey().(*ssh.Certificate) | ||
| 90 | require.True(t, ok, "signer public key is not a certificate") | ||
| 91 | return cert | ||
| 92 | } | ||
| 93 | |||
| 94 | // hostCert builds and signs a host certificate for a fresh ephemeral host | ||
| 95 | // key, using ca as the signing authority. | ||
| 96 | func hostCert(t *testing.T, ca ssh.Signer) *ssh.Certificate { | ||
| 97 | t.Helper() | ||
| 98 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 99 | require.NoError(t, err) | ||
| 100 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 101 | require.NoError(t, err) | ||
| 102 | cert := &ssh.Certificate{ | ||
| 103 | Key: sshPub, | ||
| 104 | CertType: ssh.HostCert, | ||
| 105 | ValidPrincipals: []string{"vm-name"}, | ||
| 106 | ValidBefore: ssh.CertTimeInfinity, | ||
| 107 | } | ||
| 108 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 109 | return cert | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestGateAuthSignerSelfSignsOnceAndReuses(t *testing.T) { | ||
| 113 | fake := newFakeCertAuthority(t) | ||
| 114 | userCA := newTestSigner(t) | ||
| 115 | ga := newTestGateAuth(fake, userCA, nil) | ||
| 116 | |||
| 117 | s1, err := ga.Signer(t.Context()) | ||
| 118 | require.NoError(t, err) | ||
| 119 | s2, err := ga.Signer(t.Context()) | ||
| 120 | require.NoError(t, err) | ||
| 121 | |||
| 122 | assert.Same(t, s1, s2, "expected the same cached signer to be returned") | ||
| 123 | assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type()) | ||
| 124 | |||
| 125 | // The cert is self-signed by our own user CA, for principal "ubuntu". | ||
| 126 | cert := certOf(t, s1) | ||
| 127 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals) | ||
| 128 | assert.Equal(t, ssh.UserCert, int(cert.CertType)) | ||
| 129 | assert.Equal(t, userCA.PublicKey().Marshal(), cert.SignatureKey.Marshal(), | ||
| 130 | "cert must be signed by our own user CA") | ||
| 131 | } | ||
| 132 | |||
| 133 | func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) { | ||
| 134 | fake := newFakeCertAuthority(t) | ||
| 135 | userCA := newTestSigner(t) | ||
| 136 | current := time.Unix(1_700_000_000, 0) | ||
| 137 | clock := func() time.Time { return current } | ||
| 138 | ga := newTestGateAuth(fake, userCA, clock) | ||
| 139 | |||
| 140 | // First sign: cert is valid until now+30m. | ||
| 141 | s1, err := ga.Signer(t.Context()) | ||
| 142 | require.NoError(t, err) | ||
| 143 | serial1 := certOf(t, s1).Serial | ||
| 144 | |||
| 145 | // Advance the clock to within a minute of expiry: this must re-sign. | ||
| 146 | current = current.Add(30 * time.Minute) | ||
| 147 | s2, err := ga.Signer(t.Context()) | ||
| 148 | require.NoError(t, err) | ||
| 149 | serial2 := certOf(t, s2).Serial | ||
| 150 | assert.NotEqual(t, serial1, serial2, "expected re-sign when cached cert expires within a minute") | ||
| 151 | |||
| 152 | // Same "now" again: the fresh cert has ~30m left, so this must NOT re-sign. | ||
| 153 | s3, err := ga.Signer(t.Context()) | ||
| 154 | require.NoError(t, err) | ||
| 155 | assert.Same(t, s2, s3, "expected no re-sign when cached cert has >1min remaining") | ||
| 156 | } | ||
| 157 | |||
| 158 | func TestGateAuthRegisterUploadsOnce(t *testing.T) { | ||
| 159 | fake := newFakeCertAuthority(t) | ||
| 160 | userCA := newTestSigner(t) | ||
| 161 | ga := newTestGateAuth(fake, userCA, nil) | ||
| 162 | |||
| 163 | require.NoError(t, ga.Register(t.Context())) | ||
| 164 | require.NoError(t, ga.Register(t.Context())) | ||
| 165 | |||
| 166 | _, uploads := fake.counts() | ||
| 167 | assert.Equal(t, 1, uploads, "Register must be idempotent (upload once)") | ||
| 168 | assert.Equal(t, "default", fake.lastTenant) | ||
| 169 | assert.Equal(t, string(ssh.MarshalAuthorizedKey(userCA.PublicKey())), fake.lastUploadLine+"\n", | ||
| 170 | "uploaded line must be our user CA public key") | ||
| 171 | } | ||
| 172 | |||
| 173 | func TestGateAuthRegisterSurfacesUploadError(t *testing.T) { | ||
| 174 | fake := newFakeCertAuthority(t) | ||
| 175 | fake.uploadErr = errors.New("upload boom") | ||
| 176 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 177 | |||
| 178 | err := ga.Register(t.Context()) | ||
| 179 | require.Error(t, err) | ||
| 180 | assert.Contains(t, err.Error(), "registering user CA") | ||
| 181 | |||
| 182 | // A failed upload must not flip the once-guard: a retry re-attempts. | ||
| 183 | fake.uploadErr = nil | ||
| 184 | require.NoError(t, ga.Register(t.Context())) | ||
| 185 | _, uploads := fake.counts() | ||
| 186 | assert.Equal(t, 2, uploads, "failed upload must be retried, not swallowed") | ||
| 187 | } | ||
| 188 | |||
| 189 | func TestGateAuthConnectName(t *testing.T) { | ||
| 190 | fake := newFakeCertAuthority(t) | ||
| 191 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 192 | |||
| 193 | name, err := ga.ConnectName(t.Context(), "web-1") | ||
| 194 | require.NoError(t, err) | ||
| 195 | assert.Equal(t, "default.web-1", name) | ||
| 196 | } | ||
| 197 | |||
| 198 | func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) { | ||
| 199 | fake := newFakeCertAuthority(t) | ||
| 200 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 201 | |||
| 202 | cert := hostCert(t, fake.caSigner) | ||
| 203 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 204 | assert.NoError(t, err) | ||
| 205 | } | ||
| 206 | |||
| 207 | func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) { | ||
| 208 | fake := newFakeCertAuthority(t) | ||
| 209 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 210 | |||
| 211 | foreignCA := newTestSigner(t) | ||
| 212 | cert := hostCert(t, foreignCA) | ||
| 213 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 214 | assert.Error(t, err) | ||
| 215 | } | ||
| 216 | |||
| 217 | func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) { | ||
| 218 | fake := newFakeCertAuthority(t) | ||
| 219 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 220 | |||
| 221 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 222 | require.NoError(t, err) | ||
| 223 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 224 | require.NoError(t, err) | ||
| 225 | |||
| 226 | err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, sshPub) | ||
| 227 | assert.Error(t, err) | ||
| 228 | } | ||
| 229 | |||
| 230 | func TestGateAuthFetchesCAOnlyOnce(t *testing.T) { | ||
| 231 | fake := newFakeCertAuthority(t) | ||
| 232 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 233 | |||
| 234 | cb := ga.HostKeyCallback() | ||
| 235 | cert := hostCert(t, fake.caSigner) | ||
| 236 | require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert)) | ||
| 237 | require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert)) | ||
| 238 | |||
| 239 | _, err := ga.Signer(t.Context()) | ||
| 240 | require.NoError(t, err) | ||
| 241 | _, err = ga.Signer(t.Context()) | ||
| 242 | require.NoError(t, err) | ||
| 243 | |||
| 244 | fetchCA, _ := fake.counts() | ||
| 245 | assert.Equal(t, 1, fetchCA, "expected the CA to be fetched exactly once") | ||
| 246 | } | ||
| 247 | |||
| 248 | func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) { | ||
| 249 | fake := newFakeCertAuthority(t) | ||
| 250 | fake.setFetchErr(errors.New("ssh-ca gate is not enabled")) | ||
| 251 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 252 | |||
| 253 | // A perfectly valid, CA-signed host cert must STILL be rejected when we | ||
| 254 | // cannot fetch the CA to verify against it — failing closed, never open. | ||
| 255 | cert := hostCert(t, fake.caSigner) | ||
| 256 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 257 | assert.Error(t, err, "host must be rejected when the CA cannot be fetched") | ||
| 258 | } | ||
internal/gateclient/dial.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,97 @@ | |||
| 1 | package gateclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "fmt" | ||
| 6 | "net" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "golang.org/x/crypto/ssh" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Credentials provides the cert-backed client signer, the CA host-key | ||
| 13 | // verifier, and the gate connect name a dialer authenticates with. *GateAuth | ||
| 14 | // satisfies it. | ||
| 15 | type Credentials interface { | ||
| 16 | Signer(ctx context.Context) (ssh.Signer, error) | ||
| 17 | HostKeyCallback() ssh.HostKeyCallback | ||
| 18 | // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name. | ||
| 19 | ConnectName(ctx context.Context, vmName string) (string, error) | ||
| 20 | } | ||
| 21 | |||
| 22 | // DialConfig configures SSH access to VMs through the eitri SSH-CA jump gate. | ||
| 23 | type DialConfig struct { | ||
| 24 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) | ||
| 25 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) | ||
| 26 | Auth Credentials // minted user-cert signer + CA host verifier | ||
| 27 | } | ||
| 28 | |||
| 29 | // Dial reaches the VM named vmName through the eitri SSH-CA gate: a client | ||
| 30 | // handshake with the gate (CA-verified host cert, CA-signed user cert), a | ||
| 31 | // direct-tcpip tunnel to <tenant>.<vmName>:22 (the only port the gate permits), | ||
| 32 | // then a second handshake directly with the VM's sshd over that tunnel. Error | ||
| 33 | // messages distinguish gate-unreachable/gate-handshake from | ||
| 34 | // VM-unreachable/VM-handshake. The caller must Close the returned *ssh.Client. | ||
| 35 | func Dial(ctx context.Context, cfg DialConfig, vmName string) (*ssh.Client, error) { | ||
| 36 | signer, err := cfg.Auth.Signer(ctx) | ||
| 37 | if err != nil { | ||
| 38 | return nil, fmt.Errorf("minting gate credentials: %w", err) | ||
| 39 | } | ||
| 40 | hostCB := cfg.Auth.HostKeyCallback() | ||
| 41 | // The gate resolves <tenant>.<name> and each VM's host-cert principal is that | ||
| 42 | // same namespaced name, so both the tunnel target and the VM host-cert | ||
| 43 | // verification address use it. | ||
| 44 | target, err := cfg.Auth.ConnectName(ctx, vmName) | ||
| 45 | if err != nil { | ||
| 46 | return nil, fmt.Errorf("building gate connect name: %w", err) | ||
| 47 | } | ||
| 48 | |||
| 49 | // Both hops share the same client config: the same CA-signed user cert | ||
| 50 | // authenticates to the gate and to the VM, and the same callback verifies | ||
| 51 | // both host certs against the eitri CA. The gate ignores the outer username, | ||
| 52 | // so using the VM login user throughout is harmless. | ||
| 53 | clientConf := &ssh.ClientConfig{ | ||
| 54 | User: cfg.VMUser, | ||
| 55 | Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, | ||
| 56 | HostKeyCallback: hostCB, | ||
| 57 | Timeout: 15 * time.Second, | ||
| 58 | } | ||
| 59 | |||
| 60 | // Gate hop. Dial the gate verbatim and verify its host cert under the SAME | ||
| 61 | // address: the operator sets Gate to "<gate-domain>:<port>", and the gate's | ||
| 62 | // host cert principal is that domain, so ssh.CertChecker (in hostCB) matches. | ||
| 63 | dialer := net.Dialer{Timeout: 15 * time.Second} | ||
| 64 | conn, err := dialer.DialContext(ctx, "tcp", cfg.Gate) | ||
| 65 | if err != nil { | ||
| 66 | return nil, fmt.Errorf("gate %s unreachable: %w", cfg.Gate, err) | ||
| 67 | } | ||
| 68 | gnc, gchans, greqs, err := ssh.NewClientConn(conn, cfg.Gate, clientConf) | ||
| 69 | if err != nil { | ||
| 70 | _ = conn.Close() | ||
| 71 | return nil, fmt.Errorf("gate %s ssh handshake: %w", cfg.Gate, err) | ||
| 72 | } | ||
| 73 | gateClient := ssh.NewClient(gnc, gchans, greqs) | ||
| 74 | |||
| 75 | // VM hop. Open the direct-tcpip tunnel to <tenant>.<vmName>:22 through the | ||
| 76 | // gate. A connection failure here is expected during the vm_create pre-sshd | ||
| 77 | // boot window (the guest hasn't started sshd yet), and the caller retries. | ||
| 78 | vmAddr := target + ":22" | ||
| 79 | vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr) | ||
| 80 | if err != nil { | ||
| 81 | gateClient.Close() | ||
| 82 | return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err) | ||
| 83 | } | ||
| 84 | // Verify the VM's host cert under <tenant>.<vmName>:22: its host-cert | ||
| 85 | // principal is that namespaced name, so ssh.CertChecker matches on the host | ||
| 86 | // portion of this address. | ||
| 87 | nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf) | ||
| 88 | if err != nil { | ||
| 89 | gateClient.Close() | ||
| 90 | return nil, fmt.Errorf("vm %s ssh handshake: %w", vmName, err) | ||
| 91 | } | ||
| 92 | client := ssh.NewClient(nc, chans, reqs) | ||
| 93 | // Tie the gate client's lifetime to the VM client's: when the VM client | ||
| 94 | // closes (or the VM drops), tear down the tunnel and the gate connection. | ||
| 95 | go func() { _ = client.Wait(); gateClient.Close() }() | ||
| 96 | return client, nil | ||
| 97 | } | ||
internal/mcpserver/gateauth.go
| Old | New | ||
|---|---|---|---|
| @@ -1,212 +0,0 @@ | |||
| 1 | package mcpserver | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "crypto/ed25519" | ||
| 7 | "crypto/rand" | ||
| 8 | "encoding/binary" | ||
| 9 | "fmt" | ||
| 10 | "strings" | ||
| 11 | "sync" | ||
| 12 | "time" | ||
| 13 | |||
| 14 | "golang.org/x/crypto/ssh" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // CertAuthority is the subset of the eitri API client GateAuth needs: enough | ||
| 18 | // to fetch the (host) SSH CA's public key and register this client's own user | ||
| 19 | // CA with its tenant. It's declared here (rather than depending on the | ||
| 20 | // shared API client directly) so tests can fake it in-memory without | ||
| 21 | // spinning up an httptest server. | ||
| 22 | type CertAuthority interface { | ||
| 23 | FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) | ||
| 24 | UploadUserCA(ctx context.Context, tenant, caLine string) error | ||
| 25 | } | ||
| 26 | |||
| 27 | // GateAuth is a concurrency-safe credential cache for authenticating to the | ||
| 28 | // eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral | ||
| 29 | // (never-persisted) ed25519 keypair generated once at first use and LOCALLY | ||
| 30 | // self-signs a short-lived user certificate for it on demand (refreshing | ||
| 31 | // shortly before expiry) using this client's own persistent user CA. It | ||
| 32 | // verifies host certificates against the eitri host CA. The user CA's public | ||
| 33 | // key must be registered with the tenant (see Register) so VMs trust the | ||
| 34 | // certs this client signs. | ||
| 35 | type GateAuth struct { | ||
| 36 | api CertAuthority | ||
| 37 | userCA ssh.Signer // this client's persistent user CA; signs user certs locally | ||
| 38 | tenant string // this client's tenant; connect names are <tenant>.<vm> | ||
| 39 | now func() time.Time | ||
| 40 | |||
| 41 | mu sync.Mutex | ||
| 42 | ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once | ||
| 43 | ca ssh.PublicKey // eitri host CA; fetched lazily, once | ||
| 44 | cert *ssh.Certificate | ||
| 45 | certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert | ||
| 46 | registered bool // true once the user CA has been uploaded (once-guard) | ||
| 47 | } | ||
| 48 | |||
| 49 | // NewGateAuth constructs a GateAuth backed by api, self-signing user certs with | ||
| 50 | // userCA and dialing VMs under tenant. If now is nil, time.Now is used. The | ||
| 51 | // ephemeral keypair and host CA key are NOT fetched here; both are established | ||
| 52 | // lazily on first use so construction cannot fail. | ||
| 53 | func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant string, now func() time.Time) *GateAuth { | ||
| 54 | if now == nil { | ||
| 55 | now = time.Now | ||
| 56 | } | ||
| 57 | return &GateAuth{api: api, userCA: userCA, tenant: tenant, now: now} | ||
| 58 | } | ||
| 59 | |||
| 60 | // Register uploads this client's user-CA public key to its tenant so VMs trust | ||
| 61 | // certs it signs. Idempotent; safe to call at startup before creating VMs. | ||
| 62 | func (g *GateAuth) Register(ctx context.Context) error { | ||
| 63 | g.mu.Lock() | ||
| 64 | defer g.mu.Unlock() | ||
| 65 | if g.registered { | ||
| 66 | return nil | ||
| 67 | } | ||
| 68 | line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(g.userCA.PublicKey()))) | ||
| 69 | if err := g.api.UploadUserCA(ctx, g.tenant, line); err != nil { | ||
| 70 | return fmt.Errorf("registering user CA: %w", err) | ||
| 71 | } | ||
| 72 | g.registered = true | ||
| 73 | return nil | ||
| 74 | } | ||
| 75 | |||
| 76 | // Signer returns an ssh.Signer backed by a cached, cert-signed identity, | ||
| 77 | // self-signing (or re-signing, if the cached cert is missing or expires within | ||
| 78 | // a minute) as needed. | ||
| 79 | func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) { | ||
| 80 | g.mu.Lock() | ||
| 81 | defer g.mu.Unlock() | ||
| 82 | |||
| 83 | if g.ephemeral == nil { | ||
| 84 | signer, err := newEphemeralSigner() | ||
| 85 | if err != nil { | ||
| 86 | return nil, fmt.Errorf("generating ephemeral SSH key: %w", err) | ||
| 87 | } | ||
| 88 | g.ephemeral = signer | ||
| 89 | } | ||
| 90 | |||
| 91 | if g.needsMintLocked() { | ||
| 92 | cert, err := g.signCertLocked() | ||
| 93 | if err != nil { | ||
| 94 | return nil, fmt.Errorf("signing user certificate: %w", err) | ||
| 95 | } | ||
| 96 | certSigner, err := ssh.NewCertSigner(cert, g.ephemeral) | ||
| 97 | if err != nil { | ||
| 98 | return nil, fmt.Errorf("wrapping signed certificate: %w", err) | ||
| 99 | } | ||
| 100 | g.cert = cert | ||
| 101 | g.certSigner = certSigner | ||
| 102 | } | ||
| 103 | |||
| 104 | return g.certSigner, nil | ||
| 105 | } | ||
| 106 | |||
| 107 | // signCertLocked self-signs a short-lived user certificate for g.ephemeral's | ||
| 108 | // public key using g.userCA. Callers must hold g.mu. Mirrors the (removed) | ||
| 109 | // server-side minter's cert shape. | ||
| 110 | func (g *GateAuth) signCertLocked() (*ssh.Certificate, error) { | ||
| 111 | var serial uint64 | ||
| 112 | if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { | ||
| 113 | return nil, err | ||
| 114 | } | ||
| 115 | now := g.now() | ||
| 116 | cert := &ssh.Certificate{ | ||
| 117 | Key: g.ephemeral.PublicKey(), | ||
| 118 | Serial: serial, | ||
| 119 | CertType: ssh.UserCert, | ||
| 120 | KeyId: "ubuntu", | ||
| 121 | ValidPrincipals: []string{"ubuntu"}, | ||
| 122 | ValidAfter: uint64(now.Add(-time.Minute).Unix()), // small skew backdate | ||
| 123 | ValidBefore: uint64(now.Add(30 * time.Minute).Unix()), | ||
| 124 | Permissions: ssh.Permissions{Extensions: map[string]string{ | ||
| 125 | "permit-pty": "", "permit-port-forwarding": "", "permit-user-rc": "", "permit-agent-forwarding": "", | ||
| 126 | }}, | ||
| 127 | } | ||
| 128 | if err := cert.SignCert(rand.Reader, g.userCA); err != nil { | ||
| 129 | return nil, err | ||
| 130 | } | ||
| 131 | return cert, nil | ||
| 132 | } | ||
| 133 | |||
| 134 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", | ||
| 135 | // the form the gate resolves and the VM's host-cert principal matches. g.tenant | ||
| 136 | // is non-empty by construction (the caller validates it). | ||
| 137 | func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 138 | if _, err := g.Signer(ctx); err != nil { | ||
| 139 | return "", err | ||
| 140 | } | ||
| 141 | g.mu.Lock() | ||
| 142 | defer g.mu.Unlock() | ||
| 143 | return g.tenant + "." + vmName, nil | ||
| 144 | } | ||
| 145 | |||
| 146 | // needsMintLocked reports whether the cached cert is absent or expires | ||
| 147 | // within a minute of now(). Callers must hold g.mu. | ||
| 148 | func (g *GateAuth) needsMintLocked() bool { | ||
| 149 | if g.cert == nil { | ||
| 150 | return true | ||
| 151 | } | ||
| 152 | if g.cert.ValidBefore == ssh.CertTimeInfinity { | ||
| 153 | return false | ||
| 154 | } | ||
| 155 | return g.now().Add(time.Minute).Unix() >= int64(g.cert.ValidBefore) | ||
| 156 | } | ||
| 157 | |||
| 158 | // HostKeyCallback returns an ssh.HostKeyCallback that accepts only host | ||
| 159 | // certificates signed by the eitri CA, lazily fetching the CA (once) on | ||
| 160 | // first invocation. | ||
| 161 | func (g *GateAuth) HostKeyCallback() ssh.HostKeyCallback { | ||
| 162 | checker := &ssh.CertChecker{ | ||
| 163 | IsHostAuthority: func(auth ssh.PublicKey, address string) bool { | ||
| 164 | // ssh.HostKeyCallback has no ctx param, so caller cancellation | ||
| 165 | // cannot reach here; the fetch deadline is the API client's HTTP | ||
| 166 | // client timeout (30s), not the SSH handshake context. | ||
| 167 | ca, err := g.caKey(context.Background()) | ||
| 168 | if err != nil { | ||
| 169 | return false | ||
| 170 | } | ||
| 171 | return caEquals(auth, ca) | ||
| 172 | }, | ||
| 173 | } | ||
| 174 | return checker.CheckHostKey | ||
| 175 | } | ||
| 176 | |||
| 177 | // caKey returns the cached eitri CA public key, fetching it (once) if not | ||
| 178 | // already cached. | ||
| 179 | func (g *GateAuth) caKey(ctx context.Context) (ssh.PublicKey, error) { | ||
| 180 | g.mu.Lock() | ||
| 181 | defer g.mu.Unlock() | ||
| 182 | |||
| 183 | if g.ca != nil { | ||
| 184 | return g.ca, nil | ||
| 185 | } | ||
| 186 | // g.mu is deliberately held across this network call: single-flight fetch | ||
| 187 | // so concurrent callbacks share one FetchSSHCA rather than stampeding. | ||
| 188 | ca, err := g.api.FetchSSHCA(ctx) | ||
| 189 | if err != nil { | ||
| 190 | return nil, fmt.Errorf("fetching SSH CA: %w", err) | ||
| 191 | } | ||
| 192 | g.ca = ca | ||
| 193 | return g.ca, nil | ||
| 194 | } | ||
| 195 | |||
| 196 | // caEquals reports whether two SSH public keys are the same key, by | ||
| 197 | // comparing their wire encodings. | ||
| 198 | func caEquals(a, b ssh.PublicKey) bool { | ||
| 199 | if a == nil || b == nil { | ||
| 200 | return false | ||
| 201 | } | ||
| 202 | return bytes.Equal(a.Marshal(), b.Marshal()) | ||
| 203 | } | ||
| 204 | |||
| 205 | // newEphemeralSigner generates a fresh, never-persisted ed25519 SSH signer. | ||
| 206 | func newEphemeralSigner() (ssh.Signer, error) { | ||
| 207 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 208 | if err != nil { | ||
| 209 | return nil, err | ||
| 210 | } | ||
| 211 | return ssh.NewSignerFromSigner(priv) | ||
| 212 | } | ||
internal/mcpserver/gateauth_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,258 +0,0 @@ | |||
| 1 | package mcpserver | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "errors" | ||
| 8 | "net" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | ||
| 15 | "golang.org/x/crypto/ssh" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519 | ||
| 19 | // host-CA signer, so tests exercise real host-cert verification without an | ||
| 20 | // httptest server. User certs are now self-signed by GateAuth locally, so this | ||
| 21 | // fake only serves the host CA (FetchSSHCA) and records user-CA uploads. | ||
| 22 | type fakeCertAuthority struct { | ||
| 23 | caSigner ssh.Signer // host CA served by FetchSSHCA | ||
| 24 | |||
| 25 | mu sync.Mutex | ||
| 26 | fetchCACalls int | ||
| 27 | uploadCalls int | ||
| 28 | lastTenant string | ||
| 29 | lastUploadLine string | ||
| 30 | fetchErr error // if set, FetchSSHCA returns it | ||
| 31 | uploadErr error // if set, UploadUserCA returns it | ||
| 32 | } | ||
| 33 | |||
| 34 | func newFakeCertAuthority(t *testing.T) *fakeCertAuthority { | ||
| 35 | t.Helper() | ||
| 36 | return &fakeCertAuthority{caSigner: newTestSigner(t)} | ||
| 37 | } | ||
| 38 | |||
| 39 | func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { | ||
| 40 | f.mu.Lock() | ||
| 41 | defer f.mu.Unlock() | ||
| 42 | f.fetchCACalls++ | ||
| 43 | if f.fetchErr != nil { | ||
| 44 | return nil, f.fetchErr | ||
| 45 | } | ||
| 46 | return f.caSigner.PublicKey(), nil | ||
| 47 | } | ||
| 48 | |||
| 49 | func (f *fakeCertAuthority) UploadUserCA(ctx context.Context, tenant, caLine string) error { | ||
| 50 | f.mu.Lock() | ||
| 51 | defer f.mu.Unlock() | ||
| 52 | f.uploadCalls++ | ||
| 53 | f.lastTenant = tenant | ||
| 54 | f.lastUploadLine = caLine | ||
| 55 | return f.uploadErr | ||
| 56 | } | ||
| 57 | |||
| 58 | func (f *fakeCertAuthority) setFetchErr(err error) { | ||
| 59 | f.mu.Lock() | ||
| 60 | defer f.mu.Unlock() | ||
| 61 | f.fetchErr = err | ||
| 62 | } | ||
| 63 | |||
| 64 | func (f *fakeCertAuthority) counts() (fetchCA, upload int) { | ||
| 65 | f.mu.Lock() | ||
| 66 | defer f.mu.Unlock() | ||
| 67 | return f.fetchCACalls, f.uploadCalls | ||
| 68 | } | ||
| 69 | |||
| 70 | // newTestSigner generates a fresh ed25519 ssh.Signer for use as a test CA. | ||
| 71 | func newTestSigner(t *testing.T) ssh.Signer { | ||
| 72 | t.Helper() | ||
| 73 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 74 | require.NoError(t, err) | ||
| 75 | signer, err := ssh.NewSignerFromSigner(priv) | ||
| 76 | require.NoError(t, err) | ||
| 77 | return signer | ||
| 78 | } | ||
| 79 | |||
| 80 | // newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA | ||
| 81 | // under tenant "default" and driven by clock (nil = time.Now). | ||
| 82 | func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth { | ||
| 83 | return NewGateAuth(fake, userCA, "default", clock) | ||
| 84 | } | ||
| 85 | |||
| 86 | // certOf returns the *ssh.Certificate a cert-signer's public key carries. | ||
| 87 | func certOf(t *testing.T, s ssh.Signer) *ssh.Certificate { | ||
| 88 | t.Helper() | ||
| 89 | cert, ok := s.PublicKey().(*ssh.Certificate) | ||
| 90 | require.True(t, ok, "signer public key is not a certificate") | ||
| 91 | return cert | ||
| 92 | } | ||
| 93 | |||
| 94 | // hostCert builds and signs a host certificate for a fresh ephemeral host | ||
| 95 | // key, using ca as the signing authority. | ||
| 96 | func hostCert(t *testing.T, ca ssh.Signer) *ssh.Certificate { | ||
| 97 | t.Helper() | ||
| 98 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 99 | require.NoError(t, err) | ||
| 100 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 101 | require.NoError(t, err) | ||
| 102 | cert := &ssh.Certificate{ | ||
| 103 | Key: sshPub, | ||
| 104 | CertType: ssh.HostCert, | ||
| 105 | ValidPrincipals: []string{"vm-name"}, | ||
| 106 | ValidBefore: ssh.CertTimeInfinity, | ||
| 107 | } | ||
| 108 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 109 | return cert | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestGateAuthSignerSelfSignsOnceAndReuses(t *testing.T) { | ||
| 113 | fake := newFakeCertAuthority(t) | ||
| 114 | userCA := newTestSigner(t) | ||
| 115 | ga := newTestGateAuth(fake, userCA, nil) | ||
| 116 | |||
| 117 | s1, err := ga.Signer(t.Context()) | ||
| 118 | require.NoError(t, err) | ||
| 119 | s2, err := ga.Signer(t.Context()) | ||
| 120 | require.NoError(t, err) | ||
| 121 | |||
| 122 | assert.Same(t, s1, s2, "expected the same cached signer to be returned") | ||
| 123 | assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type()) | ||
| 124 | |||
| 125 | // The cert is self-signed by our own user CA, for principal "ubuntu". | ||
| 126 | cert := certOf(t, s1) | ||
| 127 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals) | ||
| 128 | assert.Equal(t, ssh.UserCert, int(cert.CertType)) | ||
| 129 | assert.Equal(t, userCA.PublicKey().Marshal(), cert.SignatureKey.Marshal(), | ||
| 130 | "cert must be signed by our own user CA") | ||
| 131 | } | ||
| 132 | |||
| 133 | func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) { | ||
| 134 | fake := newFakeCertAuthority(t) | ||
| 135 | userCA := newTestSigner(t) | ||
| 136 | current := time.Unix(1_700_000_000, 0) | ||
| 137 | clock := func() time.Time { return current } | ||
| 138 | ga := newTestGateAuth(fake, userCA, clock) | ||
| 139 | |||
| 140 | // First sign: cert is valid until now+30m. | ||
| 141 | s1, err := ga.Signer(t.Context()) | ||
| 142 | require.NoError(t, err) | ||
| 143 | serial1 := certOf(t, s1).Serial | ||
| 144 | |||
| 145 | // Advance the clock to within a minute of expiry: this must re-sign. | ||
| 146 | current = current.Add(30 * time.Minute) | ||
| 147 | s2, err := ga.Signer(t.Context()) | ||
| 148 | require.NoError(t, err) | ||
| 149 | serial2 := certOf(t, s2).Serial | ||
| 150 | assert.NotEqual(t, serial1, serial2, "expected re-sign when cached cert expires within a minute") | ||
| 151 | |||
| 152 | // Same "now" again: the fresh cert has ~30m left, so this must NOT re-sign. | ||
| 153 | s3, err := ga.Signer(t.Context()) | ||
| 154 | require.NoError(t, err) | ||
| 155 | assert.Same(t, s2, s3, "expected no re-sign when cached cert has >1min remaining") | ||
| 156 | } | ||
| 157 | |||
| 158 | func TestGateAuthRegisterUploadsOnce(t *testing.T) { | ||
| 159 | fake := newFakeCertAuthority(t) | ||
| 160 | userCA := newTestSigner(t) | ||
| 161 | ga := newTestGateAuth(fake, userCA, nil) | ||
| 162 | |||
| 163 | require.NoError(t, ga.Register(t.Context())) | ||
| 164 | require.NoError(t, ga.Register(t.Context())) | ||
| 165 | |||
| 166 | _, uploads := fake.counts() | ||
| 167 | assert.Equal(t, 1, uploads, "Register must be idempotent (upload once)") | ||
| 168 | assert.Equal(t, "default", fake.lastTenant) | ||
| 169 | assert.Equal(t, string(ssh.MarshalAuthorizedKey(userCA.PublicKey())), fake.lastUploadLine+"\n", | ||
| 170 | "uploaded line must be our user CA public key") | ||
| 171 | } | ||
| 172 | |||
| 173 | func TestGateAuthRegisterSurfacesUploadError(t *testing.T) { | ||
| 174 | fake := newFakeCertAuthority(t) | ||
| 175 | fake.uploadErr = errors.New("upload boom") | ||
| 176 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 177 | |||
| 178 | err := ga.Register(t.Context()) | ||
| 179 | require.Error(t, err) | ||
| 180 | assert.Contains(t, err.Error(), "registering user CA") | ||
| 181 | |||
| 182 | // A failed upload must not flip the once-guard: a retry re-attempts. | ||
| 183 | fake.uploadErr = nil | ||
| 184 | require.NoError(t, ga.Register(t.Context())) | ||
| 185 | _, uploads := fake.counts() | ||
| 186 | assert.Equal(t, 2, uploads, "failed upload must be retried, not swallowed") | ||
| 187 | } | ||
| 188 | |||
| 189 | func TestGateAuthConnectName(t *testing.T) { | ||
| 190 | fake := newFakeCertAuthority(t) | ||
| 191 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 192 | |||
| 193 | name, err := ga.ConnectName(t.Context(), "web-1") | ||
| 194 | require.NoError(t, err) | ||
| 195 | assert.Equal(t, "default.web-1", name) | ||
| 196 | } | ||
| 197 | |||
| 198 | func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) { | ||
| 199 | fake := newFakeCertAuthority(t) | ||
| 200 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 201 | |||
| 202 | cert := hostCert(t, fake.caSigner) | ||
| 203 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 204 | assert.NoError(t, err) | ||
| 205 | } | ||
| 206 | |||
| 207 | func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) { | ||
| 208 | fake := newFakeCertAuthority(t) | ||
| 209 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 210 | |||
| 211 | foreignCA := newTestSigner(t) | ||
| 212 | cert := hostCert(t, foreignCA) | ||
| 213 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 214 | assert.Error(t, err) | ||
| 215 | } | ||
| 216 | |||
| 217 | func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) { | ||
| 218 | fake := newFakeCertAuthority(t) | ||
| 219 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 220 | |||
| 221 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 222 | require.NoError(t, err) | ||
| 223 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 224 | require.NoError(t, err) | ||
| 225 | |||
| 226 | err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, sshPub) | ||
| 227 | assert.Error(t, err) | ||
| 228 | } | ||
| 229 | |||
| 230 | func TestGateAuthFetchesCAOnlyOnce(t *testing.T) { | ||
| 231 | fake := newFakeCertAuthority(t) | ||
| 232 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 233 | |||
| 234 | cb := ga.HostKeyCallback() | ||
| 235 | cert := hostCert(t, fake.caSigner) | ||
| 236 | require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert)) | ||
| 237 | require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert)) | ||
| 238 | |||
| 239 | _, err := ga.Signer(t.Context()) | ||
| 240 | require.NoError(t, err) | ||
| 241 | _, err = ga.Signer(t.Context()) | ||
| 242 | require.NoError(t, err) | ||
| 243 | |||
| 244 | fetchCA, _ := fake.counts() | ||
| 245 | assert.Equal(t, 1, fetchCA, "expected the CA to be fetched exactly once") | ||
| 246 | } | ||
| 247 | |||
| 248 | func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) { | ||
| 249 | fake := newFakeCertAuthority(t) | ||
| 250 | fake.setFetchErr(errors.New("ssh-ca gate is not enabled")) | ||
| 251 | ga := newTestGateAuth(fake, newTestSigner(t), nil) | ||
| 252 | |||
| 253 | // A perfectly valid, CA-signed host cert must STILL be rejected when we | ||
| 254 | // cannot fetch the CA to verify against it — failing closed, never open. | ||
| 255 | cert := hostCert(t, fake.caSigner) | ||
| 256 | err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) | ||
| 257 | assert.Error(t, err, "host must be rejected when the CA cannot be fetched") | ||
| 258 | } | ||
internal/mcpserver/sshrun.go
| Old | New | ||
|---|---|---|---|
| @@ -7,11 +7,11 @@ import ( | |||
| 7 | "fmt" | 7 | "fmt" |
| 8 | "io" | 8 | "io" |
| 9 | "io/fs" | 9 | "io/fs" |
| 10 | "net" | ||
| 11 | "os" | 10 | "os" |
| 12 | "path" | 11 | "path" |
| 13 | "time" | 12 | "time" |
| 14 | 13 | ||
| 14 | "github.com/a73x/eitri/internal/gateclient" | ||
| 15 | "github.com/pkg/sftp" | 15 | "github.com/pkg/sftp" |
| 16 | "golang.org/x/crypto/ssh" | 16 | "golang.org/x/crypto/ssh" |
| 17 | ) | 17 | ) |
| @@ -19,21 +19,11 @@ import ( | |||
| 19 | // outputCap bounds captured exec/file bytes returned to the model. | 19 | // outputCap bounds captured exec/file bytes returned to the model. |
| 20 | const outputCap = 1 << 20 // 1 MiB | 20 | const outputCap = 1 << 20 // 1 MiB |
| 21 | 21 | ||
| 22 | // GateCredentials provides the cert-backed client signer, the CA host-key | ||
| 23 | // verifier, and the gate connect name the Runner authenticates with. *GateAuth | ||
| 24 | // satisfies it. | ||
| 25 | type GateCredentials interface { | ||
| 26 | Signer(ctx context.Context) (ssh.Signer, error) | ||
| 27 | HostKeyCallback() ssh.HostKeyCallback | ||
| 28 | // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name. | ||
| 29 | ConnectName(ctx context.Context, vmName string) (string, error) | ||
| 30 | } | ||
| 31 | |||
| 32 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. | 22 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. |
| 33 | type RunnerConfig struct { | 23 | type RunnerConfig struct { |
| 34 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) | 24 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) |
| 35 | Auth GateCredentials // minted user-cert signer + CA host verifier | 25 | Auth gateclient.Credentials // minted user-cert signer + CA host verifier |
| 36 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) | 26 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) |
| 37 | } | 27 | } |
| 38 | 28 | ||
| 39 | // ExecResult is a completed remote command. | 29 | // ExecResult is a completed remote command. |
| @@ -104,74 +94,10 @@ func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Dura | |||
| 104 | return res, nil | 94 | return res, nil |
| 105 | } | 95 | } |
| 106 | 96 | ||
| 107 | // dial reaches the VM named vmName through the eitri SSH-CA gate: a client | 97 | // dial reaches the VM named vmName through the eitri SSH-CA gate. See |
| 108 | // handshake with the gate (CA-verified host cert, CA-signed user cert), a | 98 | // gateclient.Dial for the two-hop dial logic this delegates to. |
| 109 | // direct-tcpip tunnel to <tenant>.<vmName>:22 (the only port the gate permits), | ||
| 110 | // then a second handshake directly with the VM's sshd over that tunnel. Error | ||
| 111 | // messages distinguish gate-unreachable/gate-handshake from | ||
| 112 | // VM-unreachable/VM-handshake. The caller must Close the returned *ssh.Client. | ||
| 113 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { | 99 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { |
| 114 | signer, err := r.cfg.Auth.Signer(ctx) | 100 | return gateclient.Dial(ctx, gateclient.DialConfig{Gate: r.cfg.Gate, VMUser: r.cfg.VMUser, Auth: r.cfg.Auth}, vmName) |
| 115 | if err != nil { | ||
| 116 | return nil, fmt.Errorf("minting gate credentials: %w", err) | ||
| 117 | } | ||
| 118 | hostCB := r.cfg.Auth.HostKeyCallback() | ||
| 119 | // The gate resolves <tenant>.<name> and each VM's host-cert principal is that | ||
| 120 | // same namespaced name, so both the tunnel target and the VM host-cert | ||
| 121 | // verification address use it. | ||
| 122 | target, err := r.cfg.Auth.ConnectName(ctx, vmName) | ||
| 123 | if err != nil { | ||
| 124 | return nil, fmt.Errorf("building gate connect name: %w", err) | ||
| 125 | } | ||
| 126 | |||
| 127 | // Both hops share the same client config: the same CA-signed user cert | ||
| 128 | // authenticates to the gate and to the VM, and the same callback verifies | ||
| 129 | // both host certs against the eitri CA. The gate ignores the outer username, | ||
| 130 | // so using the VM login user throughout is harmless. | ||
| 131 | clientConf := &ssh.ClientConfig{ | ||
| 132 | User: r.cfg.VMUser, | ||
| 133 | Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, | ||
| 134 | HostKeyCallback: hostCB, | ||
| 135 | Timeout: 15 * time.Second, | ||
| 136 | } | ||
| 137 | |||
| 138 | // Gate hop. Dial the gate verbatim and verify its host cert under the SAME | ||
| 139 | // address: the operator sets Gate to "<gate-domain>:<port>", and the gate's | ||
| 140 | // host cert principal is that domain, so ssh.CertChecker (in hostCB) matches. | ||
| 141 | dialer := net.Dialer{Timeout: 15 * time.Second} | ||
| 142 | conn, err := dialer.DialContext(ctx, "tcp", r.cfg.Gate) | ||
| 143 | if err != nil { | ||
| 144 | return nil, fmt.Errorf("gate %s unreachable: %w", r.cfg.Gate, err) | ||
| 145 | } | ||
| 146 | gnc, gchans, greqs, err := ssh.NewClientConn(conn, r.cfg.Gate, clientConf) | ||
| 147 | if err != nil { | ||
| 148 | _ = conn.Close() | ||
| 149 | return nil, fmt.Errorf("gate %s ssh handshake: %w", r.cfg.Gate, err) | ||
| 150 | } | ||
| 151 | gateClient := ssh.NewClient(gnc, gchans, greqs) | ||
| 152 | |||
| 153 | // VM hop. Open the direct-tcpip tunnel to <tenant>.<vmName>:22 through the | ||
| 154 | // gate. A connection failure here is expected during the vm_create pre-sshd | ||
| 155 | // boot window (the guest hasn't started sshd yet), and the caller retries. | ||
| 156 | vmAddr := target + ":22" | ||
| 157 | vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr) | ||
| 158 | if err != nil { | ||
| 159 | gateClient.Close() | ||
| 160 | return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err) | ||
| 161 | } | ||
| 162 | // Verify the VM's host cert under <tenant>.<vmName>:22: its host-cert | ||
| 163 | // principal is that namespaced name, so ssh.CertChecker matches on the host | ||
| 164 | // portion of this address. | ||
| 165 | nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf) | ||
| 166 | if err != nil { | ||
| 167 | gateClient.Close() | ||
| 168 | return nil, fmt.Errorf("vm %s ssh handshake: %w", vmName, err) | ||
| 169 | } | ||
| 170 | client := ssh.NewClient(nc, chans, reqs) | ||
| 171 | // Tie the gate client's lifetime to the VM client's: when the VM client | ||
| 172 | // closes (or the VM drops), tear down the tunnel and the gate connection. | ||
| 173 | go func() { _ = client.Wait(); gateClient.Close() }() | ||
| 174 | return client, nil | ||
| 175 | } | 101 | } |
| 176 | 102 | ||
| 177 | // cappedBuf captures at most outputCap bytes and records truncation. | 103 | // cappedBuf captures at most outputCap bytes and records truncation. |
internal/mcpserver/sshrun_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | package mcpserver | 1 | package mcpserver |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 4 | "context" | 5 | "context" |
| 5 | "crypto/ed25519" | 6 | "crypto/ed25519" |
| 6 | "crypto/rand" | 7 | "crypto/rand" |
| @@ -10,6 +11,7 @@ import ( | |||
| 10 | "testing" | 11 | "testing" |
| 11 | "time" | 12 | "time" |
| 12 | 13 | ||
| 14 | "github.com/a73x/eitri/internal/gateclient" | ||
| 13 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" |
| 14 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" |
| 15 | "golang.org/x/crypto/ssh" | 17 | "golang.org/x/crypto/ssh" |
| @@ -48,7 +50,9 @@ func hostCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer { | |||
| 48 | // mirroring the gate/VM sshd's TrustedUserCAKeys policy. | 50 | // mirroring the gate/VM sshd's TrustedUserCAKeys policy. |
| 49 | func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { | 51 | func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { |
| 50 | checker := &ssh.CertChecker{ | 52 | checker := &ssh.CertChecker{ |
| 51 | IsUserAuthority: func(auth ssh.PublicKey) bool { return caEquals(auth, ca) }, | 53 | IsUserAuthority: func(auth ssh.PublicKey) bool { |
| 54 | return auth != nil && ca != nil && bytes.Equal(auth.Marshal(), ca.Marshal()) | ||
| 55 | }, | ||
| 52 | } | 56 | } |
| 53 | return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { | 57 | return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { |
| 54 | cert, ok := key.(*ssh.Certificate) | 58 | cert, ok := key.(*ssh.Certificate) |
| @@ -205,8 +209,8 @@ func gatePipe(newCh ssh.NewChannel, vmAddr string) { | |||
| 205 | go func() { io.Copy(target, ch); target.Close() }() | 209 | go func() { io.Copy(target, ch); target.Close() }() |
| 206 | } | 210 | } |
| 207 | 211 | ||
| 208 | // fakeGateCreds is a minimal GateCredentials with a caller-chosen client signer | 212 | // fakeGateCreds is a minimal gateclient.Credentials with a caller-chosen |
| 209 | // and host verifier, for exercising client-auth rejection paths. | 213 | // client signer and host verifier, for exercising client-auth rejection paths. |
| 210 | type fakeGateCreds struct { | 214 | type fakeGateCreds struct { |
| 211 | signer ssh.Signer | 215 | signer ssh.Signer |
| 212 | hostCB ssh.HostKeyCallback | 216 | hostCB ssh.HostKeyCallback |
| @@ -218,12 +222,22 @@ func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, er | |||
| 218 | return "default." + vmName, nil | 222 | return "default." + vmName, nil |
| 219 | } | 223 | } |
| 220 | 224 | ||
| 225 | // fakeCA is a minimal gateclient.CertAuthority whose FetchSSHCA serves a | ||
| 226 | // fixed host-CA signer's public key; UploadUserCA is unused by these dial | ||
| 227 | // tests. The same signer doubles as the user CA passed to | ||
| 228 | // gateclient.NewGateAuth, mirroring gateauth_test.go's fakeCertAuthority. | ||
| 229 | type fakeCA struct{ caSigner ssh.Signer } | ||
| 230 | |||
| 231 | func (f fakeCA) FetchSSHCA(context.Context) (ssh.PublicKey, error) { | ||
| 232 | return f.caSigner.PublicKey(), nil | ||
| 233 | } | ||
| 234 | func (f fakeCA) UploadUserCA(context.Context, string, string) error { return nil } | ||
| 235 | |||
| 221 | // ── tests ──────────────────────────────────────────────────────────────────── | 236 | // ── tests ──────────────────────────────────────────────────────────────────── |
| 222 | 237 | ||
| 223 | func TestExecThroughGate(t *testing.T) { | 238 | func TestExecThroughGate(t *testing.T) { |
| 224 | fake := newFakeCertAuthority(t) | 239 | ca := newSigner(t) |
| 225 | ga := NewGateAuth(fake, fake.caSigner, "default", nil) | 240 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) |
| 226 | ca := fake.caSigner | ||
| 227 | 241 | ||
| 228 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) | 242 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 229 | // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, | 243 | // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, |
| @@ -238,9 +252,8 @@ func TestExecThroughGate(t *testing.T) { | |||
| 238 | } | 252 | } |
| 239 | 253 | ||
| 240 | func TestExecVMForeignCAHostCertRejected(t *testing.T) { | 254 | func TestExecVMForeignCAHostCertRejected(t *testing.T) { |
| 241 | fake := newFakeCertAuthority(t) | 255 | ca := newSigner(t) |
| 242 | ga := NewGateAuth(fake, fake.caSigner, "default", nil) | 256 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) |
| 243 | ca := fake.caSigner | ||
| 244 | 257 | ||
| 245 | // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy | 258 | // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy |
| 246 | // still trusts the real CA, so the gate hop and client auth both succeed and | 259 | // still trusts the real CA, so the gate hop and client auth both succeed and |
| @@ -256,9 +269,8 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) { | |||
| 256 | } | 269 | } |
| 257 | 270 | ||
| 258 | func TestExecGateRejectsNonCAUserKey(t *testing.T) { | 271 | func TestExecGateRejectsNonCAUserKey(t *testing.T) { |
| 259 | fake := newFakeCertAuthority(t) | 272 | ca := newSigner(t) |
| 260 | ga := NewGateAuth(fake, fake.caSigner, "default", nil) | 273 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) |
| 261 | ca := fake.caSigner | ||
| 262 | 274 | ||
| 263 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) | 275 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 264 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 276 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "io/fs" | 7 | "io/fs" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
| 10 | "github.com/a73x/eitri/internal/gateclient" | ||
| 10 | "github.com/a73x/eitri/internal/random" | 11 | "github.com/a73x/eitri/internal/random" |
| 11 | "github.com/a73x/eitri/internal/server/api/client" | 12 | "github.com/a73x/eitri/internal/server/api/client" |
| 12 | ) | 13 | ) |
| @@ -54,9 +55,9 @@ func (a API) FirstOnlineHost(ctx context.Context) (client.Host, error) { | |||
| 54 | // The real client and runner must satisfy the seams unchanged, and the shared | 55 | // The real client and runner must satisfy the seams unchanged, and the shared |
| 55 | // API client must keep satisfying the gate's cert-authority seam. | 56 | // API client must keep satisfying the gate's cert-authority seam. |
| 56 | var ( | 57 | var ( |
| 57 | _ api = API{} | 58 | _ api = API{} |
| 58 | _ runner = (*Runner)(nil) | 59 | _ runner = (*Runner)(nil) |
| 59 | _ CertAuthority = (*client.Client)(nil) | 60 | _ gateclient.CertAuthority = (*client.Client)(nil) |
| 60 | ) | 61 | ) |
| 61 | 62 | ||
| 62 | // Tools implements the seven eitri-mcp tools over the API and SSH seams. | 63 | // Tools implements the seven eitri-mcp tools over the API and SSH seams. |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,8 @@ | |||
| 1 | // Package client is THE Go client for the eitri control-plane HTTP API — the | 1 | // Package client is THE Go client for the eitri control-plane HTTP API — the |
| 2 | // one consumer every in-repo caller (MCP server) goes through. Its method set | 2 | // one consumer every in-repo caller (MCP server, smoke gate) goes through. |
| 3 | // is exactly what those consumers use, nothing more: a new endpoint call | 3 | // Its method set is exactly what those consumers use, nothing more: a new |
| 4 | // starts by adding a method here (an arch fitness rule enforces that no other | 4 | // endpoint call starts by adding a method here (an arch fitness rule enforces |
| 5 | // package speaks the API's HTTP directly). | 5 | // that no other package speaks the API's HTTP directly). |
| 6 | // | 6 | // |
| 7 | // The wire shapes come from internal/server/api/types; the ones consumers | 7 | // The wire shapes come from internal/server/api/types; the ones consumers |
| 8 | // need are re-exported as aliases so callers import only this package. | 8 | // need are re-exported as aliases so callers import only this package. |
| @@ -156,7 +156,8 @@ func (c *Client) FetchSSHCALine(ctx context.Context) (string, error) { | |||
| 156 | } | 156 | } |
| 157 | 157 | ||
| 158 | // FetchSSHCA is FetchSSHCALine, parsed: the host CA as an ssh.PublicKey, for | 158 | // FetchSSHCA is FetchSSHCALine, parsed: the host CA as an ssh.PublicKey, for |
| 159 | // callers that verify host certs. | 159 | // callers that verify host certs (it satisfies gateclient.CertAuthority |
| 160 | // together with UploadUserCA). | ||
| 160 | func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { | 161 | func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { |
| 161 | line, err := c.FetchSSHCALine(ctx) | 162 | line, err := c.FetchSSHCALine(ctx) |
| 162 | if err != nil { | 163 | if err != nil { |
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -12,10 +12,15 @@ import ( | |||
| 12 | 12 | ||
| 13 | "golang.org/x/crypto/ssh" | 13 | "golang.org/x/crypto/ssh" |
| 14 | 14 | ||
| 15 | "github.com/a73x/eitri/internal/gateclient" | ||
| 15 | "github.com/a73x/eitri/internal/server/api/client" | 16 | "github.com/a73x/eitri/internal/server/api/client" |
| 16 | "github.com/a73x/eitri/internal/server/api/types" | 17 | "github.com/a73x/eitri/internal/server/api/types" |
| 17 | ) | 18 | ) |
| 18 | 19 | ||
| 20 | // The client must satisfy gateclient's CertAuthority so GateAuth can use it | ||
| 21 | // directly (FetchSSHCA + UploadUserCA). | ||
| 22 | var _ gateclient.CertAuthority = (*client.Client)(nil) | ||
| 23 | |||
| 19 | // testCALine is a valid ed25519 authorized_keys line WITH a trailing comment; | 24 | // testCALine is a valid ed25519 authorized_keys line WITH a trailing comment; |
| 20 | // FetchSSHCALine must return it verbatim. | 25 | // FetchSSHCALine must return it verbatim. |
| 21 | const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca" | 26 | const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca" |
internal/server/config/config.go
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,5 @@ | |||
| 1 | // Package config defines the eitri-server on-disk JSON configuration schema. | 1 | // Package config defines the eitri-server on-disk JSON configuration schema, |
| 2 | // It is shared between the server binary (cmd/eitri-server) and the | 2 | // loaded by the server binary (cmd/eitri-server) at startup. |
| 3 | // integration harness (internal/integration/harness), which renders a config | ||
| 4 | // file for the server subprocess it launches — sharing the type makes the | ||
| 5 | // compiler, not a comment, keep the two in agreement. | ||
| 6 | package config | 3 | package config |
| 7 | 4 | ||
| 8 | // Config is the eitri-server config file schema (decoded from JSON). | 5 | // Config is the eitri-server config file schema (decoded from JSON). |
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -39,8 +39,9 @@ func classify(rel string) Plane { | |||
| 39 | case strings.HasPrefix(rel, "cmd/"): | 39 | case strings.HasPrefix(rel, "cmd/"): |
| 40 | return PlaneBinaries | 40 | return PlaneBinaries |
| 41 | case strings.HasPrefix(rel, "internal/arch"), | 41 | case strings.HasPrefix(rel, "internal/arch"), |
| 42 | strings.HasPrefix(rel, "internal/integration"), | ||
| 43 | strings.HasPrefix(rel, "internal/mcpserver"), | 42 | strings.HasPrefix(rel, "internal/mcpserver"), |
| 43 | strings.HasPrefix(rel, "internal/covsnap"), | ||
| 44 | strings.HasPrefix(rel, "internal/gateclient"), | ||
| 44 | strings.HasPrefix(rel, "internal/shape"): | 45 | strings.HasPrefix(rel, "internal/shape"): |
| 45 | return PlaneTooling | 46 | return PlaneTooling |
| 46 | default: | 47 | default: |
internal/shape/classify_test.go
| Old | New | ||
|---|---|---|---|
| @@ -9,9 +9,8 @@ func TestSynopsisTakesFirstSentence(t *testing.T) { | |||
| 9 | "First sentence. Second one.": "First sentence.", | 9 | "First sentence. Second one.": "First sentence.", |
| 10 | "Two\nlines collapsed.": "Two lines collapsed.", | 10 | "Two\nlines collapsed.": "Two lines collapsed.", |
| 11 | "Para one.\n\nPara two.": "Para one.", | 11 | "Para one.\n\nPara two.": "Para one.", |
| 12 | // A garbled run-on like devstack's: cut at the first ". " boundary, | 12 | // A run-on synopsis: cut at the first ". " boundary, dropping the tail. |
| 13 | // dropping the trailing concatenated section. | 13 | "Brings up the stack until Ctrl-C. It is the companion.": "Brings up the stack until Ctrl-C.", |
| 14 | "Brings up the stack until Ctrl-C. It is the companion — both ride harness.Stack blocks.": "Brings up the stack until Ctrl-C.", | ||
| 15 | } | 14 | } |
| 16 | for in, want := range cases { | 15 | for in, want := range cases { |
| 17 | if got := synopsis(in); got != want { | 16 | if got := synopsis(in); got != want { |
| @@ -22,23 +21,22 @@ func TestSynopsisTakesFirstSentence(t *testing.T) { | |||
| 22 | 21 | ||
| 23 | func TestClassifyAssignsPlaneByPrefix(t *testing.T) { | 22 | func TestClassifyAssignsPlaneByPrefix(t *testing.T) { |
| 24 | cases := map[string]Plane{ | 23 | cases := map[string]Plane{ |
| 25 | "internal/server/api": PlaneControl, | 24 | "internal/server/api": PlaneControl, |
| 26 | "internal/server/store": PlaneControl, | 25 | "internal/server/store": PlaneControl, |
| 27 | "internal/agent/reconcile": PlaneData, | 26 | "internal/agent/reconcile": PlaneData, |
| 28 | "internal/agent/exec": PlaneData, | 27 | "internal/agent/exec": PlaneData, |
| 29 | "internal/pb": PlaneWire, | 28 | "internal/pb": PlaneWire, |
| 30 | "internal/transport": PlaneWire, | 29 | "internal/transport": PlaneWire, |
| 31 | "internal/names": PlaneWire, | 30 | "internal/names": PlaneWire, |
| 32 | "internal/random": PlaneWire, | 31 | "internal/random": PlaneWire, |
| 33 | "cmd/eitri-server": PlaneBinaries, | 32 | "cmd/eitri-server": PlaneBinaries, |
| 34 | "cmd/eitri-shape": PlaneBinaries, | 33 | "cmd/eitri-shape": PlaneBinaries, |
| 35 | "internal/arch": PlaneTooling, | 34 | "internal/arch": PlaneTooling, |
| 36 | "internal/integration": PlaneTooling, | 35 | "internal/covsnap": PlaneTooling, |
| 37 | "internal/integration/harness": PlaneTooling, | 36 | "internal/gateclient": PlaneTooling, |
| 38 | "internal/integration/sandbox": PlaneTooling, | 37 | "internal/shape": PlaneTooling, |
| 39 | "internal/shape": PlaneTooling, | 38 | "internal/somethingnew": PlaneUnclassified, |
| 40 | "internal/somethingnew": PlaneUnclassified, | 39 | "pkg/whatever": PlaneUnclassified, |
| 41 | "pkg/whatever": PlaneUnclassified, | ||
| 42 | } | 40 | } |
| 43 | for rel, want := range cases { | 41 | for rel, want := range cases { |
| 44 | if got := classify(rel); got != want { | 42 | if got := classify(rel); got != want { |
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -4,9 +4,10 @@ | |||
| 4 | # Rather than a single flat number (which lets a well-tested package rot while a | 4 | # Rather than a single flat number (which lets a well-tested package rot while a |
| 5 | # poorly-tested one drags the average), each package has its own floor set a few | 5 | # poorly-tested one drags the average), each package has its own floor set a few |
| 6 | # points below today's coverage. CI fails if any package drops below its floor; | 6 | # points below today's coverage. CI fails if any package drops below its floor; |
| 7 | # raise a floor whenever you raise the coverage. Generated code (internal/pb), | 7 | # raise a floor whenever you raise the coverage. Generated code (internal/pb) and |
| 8 | # thin main packages (cmd/*), and the tag-gated integration tiers are not gated | 8 | # thin main packages (cmd/*) are not gated here; real-VM behaviour is covered by |
| 9 | # here — the integration tiers are exercised by `make smoke-go` / `make sandbox`. | 9 | # the deploy boot-gate (cmd/eitri-smoke) against the live fleet, not by unit |
| 10 | # coverage. | ||
| 10 | # | 11 | # |
| 11 | # Aspirational targets (not yet enforced): logic/domain packages → 80%, | 12 | # Aspirational targets (not yet enforced): logic/domain packages → 80%, |
| 12 | # host-touching effectful packages → 50%. Ratchet the floors toward those. | 13 | # host-touching effectful packages → 50%. Ratchet the floors toward those. |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,27 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd" | |||
| 29 | # leave unset to keep whatever firmware already exists on each host. | 29 | # leave unset to keep whatever firmware already exists on each host. |
| 30 | # FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd" | 30 | # FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd" |
| 31 | 31 | ||
| 32 | # ── SSH-CA gate boot-check (optional) ───────────────────────────────────────── | ||
| 33 | # After proving the guest boots (serial console), the boot-gate also proves | ||
| 34 | # guest ACCESS by reaching the VM through eitri's SSH-CA jump gate and running a | ||
| 35 | # command as the guest user — a hard gate. It self-signs a short-lived cert with | ||
| 36 | # a smoke user CA (load-or-created below) that it registers with the tenant. | ||
| 37 | # SMOKE_GATE defaults to the server's ssh_listen; set it if that host:port isn't | ||
| 38 | # dialable from the deploy host (its host must match the gate's host-cert | ||
| 39 | # principal). Leave the rest unset to use the defaults shown. | ||
| 40 | # SMOKE_GATE="127.0.0.1:2223" | ||
| 41 | # SMOKE_TENANT="default" | ||
| 42 | # SMOKE_VM_USER="ubuntu" | ||
| 43 | # SMOKE_USER_CA_FILE="$HOME/eitri-deploy/smoke_user_ca" | ||
| 44 | |||
| 45 | # ── Integration coverage (optional) ─────────────────────────────────────────── | ||
| 46 | # The fleet is always deployed coverage-instrumented; the boot-gate flushes and | ||
| 47 | # merges server+agent coverage into ./coverage/integration. These override where | ||
| 48 | # each binary writes its raw GOCOVERDIR data. SERVER_GOCOVERDIR is local; the | ||
| 49 | # AGENT one is a path ON each host. Defaults shown — leave unset to use them. | ||
| 50 | # SERVER_GOCOVERDIR="$PWD/coverage/server" | ||
| 51 | # AGENT_GOCOVERDIR="$AGENT_STATE_DIR/coverage" | ||
| 52 | |||
| 32 | # Optional overrides (leave unset to use the agent's built-in defaults). | 53 | # Optional overrides (leave unset to use the agent's built-in defaults). |
| 33 | # TOMBSTONE_GRACE — how long a deleted VM lingers (stopped) before destroy. | 54 | # TOMBSTONE_GRACE — how long a deleted VM lingers (stopped) before destroy. |
| 34 | # Agent default is 5m; lower it for a snappier dashboard teardown. | 55 | # Agent default is 5m; lower it for a snappier dashboard teardown. |
scripts/deploy.sh
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,23 @@ source "$ENV_FILE" | |||
| 32 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | 32 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 33 | cd "$REPO_ROOT" | 33 | cd "$REPO_ROOT" |
| 34 | 34 | ||
| 35 | # Coverage output dirs. The fleet is always built with coverage instrumentation, | ||
| 36 | # so the boot-gate below can flush + merge server+agent coverage. Defaults live | ||
| 37 | # under the repo's gitignored coverage/ tree (server) and the agent's state dir | ||
| 38 | # (agent, a path ON the remote host); override either in deploy.env. | ||
| 39 | SERVER_GOCOVERDIR="${SERVER_GOCOVERDIR:-$REPO_ROOT/coverage/server}" | ||
| 40 | AGENT_GOCOVERDIR="${AGENT_GOCOVERDIR:-$AGENT_STATE_DIR/coverage}" | ||
| 41 | |||
| 42 | # SSH-CA gate boot-check config. The smoke proves guest access through the jump | ||
| 43 | # gate (a hard gate). SMOKE_GATE defaults to the server's ssh_listen (the | ||
| 44 | # host:port the gate accepts, whose host is the gate host-cert principal); | ||
| 45 | # override in deploy.env if that address isn't dialable from here. SMOKE_TENANT | ||
| 46 | # and SMOKE_VM_USER fall back to the smoke's own defaults (default / ubuntu) when | ||
| 47 | # unset. The smoke's user CA is load-or-created at SMOKE_USER_CA_FILE and | ||
| 48 | # registered with the tenant before the throwaway VM is created. | ||
| 49 | SMOKE_GATE="${SMOKE_GATE:-$(python3 -c "import json; print(json.load(open('$SERVER_CONFIG')).get('ssh_listen',''))" 2>/dev/null || true)}" | ||
| 50 | SMOKE_USER_CA_FILE="${SMOKE_USER_CA_FILE:-$HOME/eitri-deploy/smoke_user_ca}" | ||
| 51 | |||
| 35 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } | 52 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } |
| 36 | short() { sha256sum "$1" | cut -c1-12; } | 53 | short() { sha256sum "$1" | cut -c1-12; } |
| 37 | 54 | ||
| @@ -40,8 +57,19 @@ git diff --quiet || SHA="$SHA-dirty" | |||
| 40 | bold "Deploying HEAD $SHA to fleet" | 57 | bold "Deploying HEAD $SHA to fleet" |
| 41 | 58 | ||
| 42 | # ── 0. Build ────────────────────────────────────────────────────────────────── | 59 | # ── 0. Build ────────────────────────────────────────────────────────────────── |
| 43 | bold "Building binaries (make build)" | 60 | # The fleet binaries are ALWAYS built with coverage instrumentation. -coverpkg |
| 44 | make build | 61 | # spans the whole module so internal/* (not just main) is measured through the |
| 62 | # real binaries. -covermode=atomic is REQUIRED: runtime/coverage.WriteCountersDir | ||
| 63 | # (the mid-run SIGUSR1 flush in internal/covsnap) refuses to run under the default | ||
| 64 | # 'set' mode, and atomic is correct anyway for a live server flushed across | ||
| 65 | # concurrent goroutines. Coverage is a by-product of the boot-gate below and a | ||
| 66 | # no-op unless GOCOVERDIR is set at launch. The gate driver (eitri-smoke) builds | ||
| 67 | # normally. | ||
| 68 | bold "Building binaries (coverage-instrumented server+agent)" | ||
| 69 | make web | ||
| 70 | go build -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-server ./cmd/eitri-server | ||
| 71 | go build -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-agent ./cmd/eitri-agent | ||
| 72 | go build -o bin/eitri-smoke ./cmd/eitri-smoke | ||
| 45 | 73 | ||
| 46 | # ── 1. Control plane (local eitri-server) ───────────────────────────────────── | 74 | # ── 1. Control plane (local eitri-server) ───────────────────────────────────── |
| 47 | bold "Rolling server -> $SERVER_BIN" | 75 | bold "Rolling server -> $SERVER_BIN" |
| @@ -54,7 +82,10 @@ if pgrep -x eitri-server >/dev/null; then | |||
| 54 | exit 1 | 82 | exit 1 |
| 55 | fi | 83 | fi |
| 56 | install -m 0755 bin/eitri-server "$SERVER_BIN" | 84 | install -m 0755 bin/eitri-server "$SERVER_BIN" |
| 57 | setsid "$SERVER_BIN" --config "$SERVER_CONFIG" </dev/null >>"$SERVER_LOG" 2>&1 & | 85 | # Fresh coverage dir per deploy: covdata rejects a dir mixing covermodes, and |
| 86 | # coverage should reflect only this deploy's run. | ||
| 87 | rm -rf "$SERVER_GOCOVERDIR"; mkdir -p "$SERVER_GOCOVERDIR" | ||
| 88 | GOCOVERDIR="$SERVER_GOCOVERDIR" setsid "$SERVER_BIN" --config "$SERVER_CONFIG" </dev/null >>"$SERVER_LOG" 2>&1 & | ||
| 58 | # Liveness gate: /livez is 200 as soon as the HTTP mux serves — an honest | 89 | # Liveness gate: /livez is 200 as soon as the HTTP mux serves — an honest |
| 59 | # "process is up" signal (unlike GET /, which returns the SPA even before the | 90 | # "process is up" signal (unlike GET /, which returns the SPA even before the |
| 60 | # app is wired). Readiness (DB) is gated separately below, after the agents | 91 | # app is wired). Readiness (DB) is gated separately below, after the agents |
| @@ -99,7 +130,8 @@ if pgrep -x eitri-agent >/dev/null; then echo "agent did not stop on \$(hostname | |||
| 99 | sudo install -m 0755 /tmp/eitri-agent-new "$AGENT_BIN" | 130 | sudo install -m 0755 /tmp/eitri-agent-new "$AGENT_BIN" |
| 100 | rm -f /tmp/eitri-agent-new | 131 | rm -f /tmp/eitri-agent-new |
| 101 | $fw_install | 132 | $fw_install |
| 102 | sudo bash -c "setsid $AGENT_BIN $agent_flags </dev/null >>$AGENT_LOG 2>&1 &" | 133 | sudo rm -rf "$AGENT_GOCOVERDIR"; sudo mkdir -p "$AGENT_GOCOVERDIR" |
| 134 | sudo bash -c "GOCOVERDIR=$AGENT_GOCOVERDIR setsid $AGENT_BIN $agent_flags </dev/null >>$AGENT_LOG 2>&1 &" | ||
| 103 | sleep 2 | 135 | sleep 2 |
| 104 | pgrep -x eitri-agent >/dev/null || { echo "agent FAILED to start on \$(hostname) (see $AGENT_LOG)" >&2; exit 1; } | 136 | pgrep -x eitri-agent >/dev/null || { echo "agent FAILED to start on \$(hostname) (see $AGENT_LOG)" >&2; exit 1; } |
| 105 | echo "agent up: \$(sha256sum $AGENT_BIN | cut -c1-12) on \$(hostname)" | 137 | echo "agent up: \$(sha256sum $AGENT_BIN | cut -c1-12) on \$(hostname)" |
| @@ -156,7 +188,11 @@ if [[ "${SKIP_BOOT_SMOKE:-0}" == "1" ]]; then | |||
| 156 | echo "(boot gate skipped: SKIP_BOOT_SMOKE=1)" | 188 | echo "(boot gate skipped: SKIP_BOOT_SMOKE=1)" |
| 157 | elif [[ -n "${ADMIN_TOKEN_FILE:-}" ]]; then | 189 | elif [[ -n "${ADMIN_TOKEN_FILE:-}" ]]; then |
| 158 | bold "Boot gate: create a throwaway VM and confirm it actually boots" | 190 | bold "Boot gate: create a throwaway VM and confirm it actually boots" |
| 159 | if EITRI_DEPLOY_ENV="$ENV_FILE" "$REPO_ROOT/scripts/remote-smoke.sh"; then | 191 | # eitri-smoke reads its config from the environment, so export what it needs; |
| 192 | # COVER_OUT triggers the post-gate coverage merge (server+agent). | ||
| 193 | export SERVER_URL ADMIN_TOKEN_FILE AGENT_HOSTS AGENT_STATE_DIR SERVER_GOCOVERDIR AGENT_GOCOVERDIR | ||
| 194 | export SMOKE_GATE SMOKE_TENANT SMOKE_VM_USER SMOKE_USER_CA_FILE | ||
| 195 | if COVER_OUT="$REPO_ROOT/coverage/integration" "$REPO_ROOT/bin/eitri-smoke"; then | ||
| 160 | echo "boot gate: PASS" | 196 | echo "boot gate: PASS" |
| 161 | else | 197 | else |
| 162 | echo "DEPLOY FAILED boot gate: a VM did not boot after this deploy." >&2 | 198 | echo "DEPLOY FAILED boot gate: a VM did not boot after this deploy." >&2 |
scripts/remote-smoke.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,101 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Remote smoke: validate the LIVE fleet end-to-end. Creates a VM through the real | ||
| 3 | # API, confirms the GUEST actually booted (reads its serial console on the host), | ||
| 4 | # optionally checks reachability, then reaps it and verifies the hard-delete. | ||
| 5 | # | ||
| 6 | # Unlike scripts/smoke.sh this needs NO local privilege — the agent host does the | ||
| 7 | # privileged tap/nft work. Config is sourced from $EITRI_DEPLOY_ENV (the same | ||
| 8 | # file deploy.sh uses); the admin token is read from $ADMIN_TOKEN_FILE and never | ||
| 9 | # printed. | ||
| 10 | set -euo pipefail | ||
| 11 | |||
| 12 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 13 | [[ -f "$ENV_FILE" ]] || { echo "remote-smoke: config not found: $ENV_FILE" >&2; exit 1; } | ||
| 14 | # shellcheck disable=SC1090 | ||
| 15 | source "$ENV_FILE" | ||
| 16 | : "${SERVER_URL:?}" "${ADMIN_TOKEN_FILE:?}" "${AGENT_HOSTS:?}" "${AGENT_STATE_DIR:?}" | ||
| 17 | |||
| 18 | tok="$(cat "$ADMIN_TOKEN_FILE")" | ||
| 19 | api() { curl -fsS -H "Authorization: Bearer $tok" "$@"; } | ||
| 20 | say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; } | ||
| 21 | |||
| 22 | # First agent host = where we read the serial console. | ||
| 23 | entry="${AGENT_HOSTS%% *}" | ||
| 24 | userhost="${entry%%:*}"; port="${entry##*:}"; [[ "$port" == "$entry" ]] && port=22 | ||
| 25 | ssh_host() { ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=10 "$userhost" "$@"; } | ||
| 26 | |||
| 27 | # Echo the requested fields (space-separated) for THIS VM from the /vms list in a | ||
| 28 | # single query; each field is '' when the VM is absent or the value is null. Used | ||
| 29 | # by the ready-poll (phase + ip together, so no second fetch on the ready | ||
| 30 | # iteration) and the reap-poll (presence check via the id field). | ||
| 31 | vm_fields() { | ||
| 32 | api "$SERVER_URL/api/v1/vms" | python3 -c " | ||
| 33 | import sys,json | ||
| 34 | want='$*'.split() | ||
| 35 | vm=next((v for v in json.load(sys.stdin) if v['id']=='$VM_ID'), {}) | ||
| 36 | print(' '.join('' if vm.get(f) is None else str(vm.get(f)) for f in want)) | ||
| 37 | " | ||
| 38 | } | ||
| 39 | |||
| 40 | say "Fleet" | ||
| 41 | api "$SERVER_URL/api/v1/hosts" | python3 -m json.tool | ||
| 42 | HOST_ID=$(api "$SERVER_URL/api/v1/hosts" \ | ||
| 43 | | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["id"])') | ||
| 44 | |||
| 45 | say "Create VM" | ||
| 46 | SSH_KEY="" | ||
| 47 | for f in ~/.ssh/id_ed25519.pub ~/.ssh/id_rsa.pub; do | ||
| 48 | [[ -f "$f" ]] && { SSH_KEY=$(cat "$f"); break; } | ||
| 49 | done | ||
| 50 | START=$(date +%s) | ||
| 51 | VM_ID=$(api -X POST "$SERVER_URL/api/v1/vms" -H 'Content-Type: application/json' \ | ||
| 52 | -d "{\"host_id\":\"$HOST_ID\",\"ssh_authorized_key\":\"$SSH_KEY\"}" \ | ||
| 53 | | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') | ||
| 54 | echo "VM id: $VM_ID" | ||
| 55 | |||
| 56 | say "Wait for ready (first run downloads the image — allow a few minutes)" | ||
| 57 | DEADLINE=$(( $(date +%s) + 600 )); IP=""; PHASE="" | ||
| 58 | while [[ "$(date +%s)" -lt "$DEADLINE" ]]; do | ||
| 59 | read -r PHASE IP <<<"$(vm_fields phase assigned_ip)" | ||
| 60 | [[ "$PHASE" == "ready" ]] && break | ||
| 61 | echo " phase=$PHASE — waiting..." | ||
| 62 | sleep 5 | ||
| 63 | done | ||
| 64 | COLD=$(( $(date +%s) - START )) | ||
| 65 | [[ "$PHASE" == "ready" && -n "$IP" ]] || { echo "FAIL: VM not ready within 600s (phase=$PHASE)"; exit 1; } | ||
| 66 | echo "ready: ip=$IP cold_start=${COLD}s" | ||
| 67 | |||
| 68 | # Boot GATE — 'ready' only means CH is up + IP assigned; it does NOT mean the | ||
| 69 | # guest booted (a bad firmware/image leaves CH running but the guest dead). So | ||
| 70 | # poll the guest serial console until we see real userspace, and FAIL on a | ||
| 71 | # kernel panic or a 180s timeout. This is the assertion that the UEFI -> | ||
| 72 | # guest-GRUB -> guest-kernel chain actually worked. | ||
| 73 | say "Boot proof — poll guest serial on $userhost until userspace" | ||
| 74 | SERIAL="$AGENT_STATE_DIR/vms/$VM_ID/serial.log" | ||
| 75 | BOOT_DEADLINE=$(( $(date +%s) + 180 )); BOOTED="" | ||
| 76 | while [[ "$(date +%s)" -lt "$BOOT_DEADLINE" ]]; do | ||
| 77 | serial="$(ssh_host "sudo cat '$SERIAL' 2>/dev/null | tr -cd '\\11\\12\\15\\40-\\176'" 2>/dev/null || true)" | ||
| 78 | if grep -qaiE 'Kernel panic|Cannot open root' <<<"$serial"; then | ||
| 79 | echo "FAIL: guest panic / root-mount failure:"; grep -aiE 'panic|Cannot open root' <<<"$serial" | tail -5 | ||
| 80 | exit 1 | ||
| 81 | fi | ||
| 82 | if grep -qaiE 'Welcome to.*Ubuntu|login:|Reached target.*Multi-User' <<<"$serial"; then | ||
| 83 | BOOTED=1 | ||
| 84 | grep -aiE 'BdsDxe|Linux version|Welcome to.*Ubuntu|login:' <<<"$serial" | head -6 | ||
| 85 | break | ||
| 86 | fi | ||
| 87 | echo " guest still booting..."; sleep 6 | ||
| 88 | done | ||
| 89 | [[ -n "$BOOTED" ]] || { echo "FAIL: no userspace boot evidence in serial within 180s"; exit 1; } | ||
| 90 | |||
| 91 | say "Reap — verify quarantine -> destroy -> hard-delete" | ||
| 92 | api -X DELETE "$SERVER_URL/api/v1/vms/$VM_ID" >/dev/null | ||
| 93 | REAP_DEADLINE=$(( $(date +%s) + 120 )); REAPED="" | ||
| 94 | while [[ "$(date +%s)" -lt "$REAP_DEADLINE" ]]; do | ||
| 95 | [[ -z "$(vm_fields id)" ]] && { REAPED=1; break; } | ||
| 96 | echo " vm still present — waiting for destroy+ack..." | ||
| 97 | sleep 5 | ||
| 98 | done | ||
| 99 | [[ -n "$REAPED" ]] || { echo "FAIL: VM not hard-deleted within 120s of tombstone"; exit 1; } | ||
| 100 | |||
| 101 | say "REMOTE SMOKE COMPLETE — booted under UEFI, cold_start=${COLD}s, reaped OK" | ||