a73x

cdae8e89

feat: coverage-instrumented boot-gate (eitri-smoke) with live SSH-CA gate check

a73x   2026-07-26 09:11

Commit message
feat: coverage-instrumented boot-gate (eitri-smoke) with live SSH-CA gate check

Replaces the local integration test tiers — the deploy boot-gate is the
real-VM net.

.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"