a73x

15c5a499

feat(arch): main packages are wiring only — R14

a73x   2026-07-29 07:53

Commit message
feat(arch): main packages are wiring only — R14

A cmd/* package may import this module's packages plus a five-entry stdlib
allowlist (errors, fmt, log, log/slog, os): enough to print a version,
dispatch to an internal Run, and exit non-zero. Any other import — HTTP,
JSON, flag, crypto, third-party — is logic outside the coverage gate's
sight (scripts/coverage.sh floors internal/, not cmd/) and belongs behind
a tested Run() in an internal package. The rule's exception list ships
empty and is a ratchet: entries never get added.

Every binary complies, each behind its own floored package:

- eitri-server: boot wiring in internal/server/boot; config loading and
  every startup invariant in internal/server/config (Load/ParseDuration).
  The jump gate's tenant-scoped resolve/authorize and its revocation and
  user-CA lookups are named, unit-tested constructors: resolution and
  authorization honour tenant ownership and refuse tombstoned VMs, and
  both lookups fail closed on a store error.
- eitri-agent: command line in internal/agent/run; the production
  one-shot command runner is cloudhv.RealRunner, keeping the data plane's
  os/exec confined to its one sanctioned site.
- eitri: dispatch in internal/cli (ErrUsage preserves the usage/failure
  exit-code split); eitri-mcp: internal/mcpserver (CA load-or-create
  tested across create, reload, and rejection); eitri-site:
  internal/site; eitri-smoke: internal/smoke (the boot-gate harness and
  its tests, deploy.sh contract unchanged).

Coverage floors: internal/server/config enters at 95, server/boot 17,
agent/run 30, mcpserver 57, smoke 46; site rises to 84, oidcprovider 79,
cli 64.

cmd/eitri-agent/main.go
Old New
@@ -1,293 +1,23 @@
1 // eitri-agent: BYO-hardware agent. Enrolls the host via "join <blob>" and 1 // eitri-agent: BYO-hardware agent. Enrolls the host via "join <blob>" and then
2 // then runs the reconcile + sync loop indefinitely. 2 // runs the reconcile + sync loop indefinitely. All behavior lives in
3 // internal/agent/run (RunCLI); this package is wiring only (arch R14).
3 package main 4 package main
4 5
5 import ( 6 import (
6 "context"
7 "errors"
8 "flag"
9 "fmt" 7 "fmt"
10 "log/slog"
11 "os" 8 "os"
12 "os/exec"
13 "os/signal"
14 "runtime"
15 "syscall"
16 "time"
17 9
18 "github.com/a73x/eitri/internal/agent/bootstrap" 10 "github.com/a73x/eitri/internal/agent/run"
19 "github.com/a73x/eitri/internal/agent/cloudhv"
20 "github.com/a73x/eitri/internal/agent/enrollclient"
21 "github.com/a73x/eitri/internal/agent/imagecache"
22 "github.com/a73x/eitri/internal/agent/netenv"
23 "github.com/a73x/eitri/internal/agent/reconcile"
24 "github.com/a73x/eitri/internal/agent/seed"
25 "github.com/a73x/eitri/internal/agent/serialpump"
26 "github.com/a73x/eitri/internal/agent/state"
27 "github.com/a73x/eitri/internal/agent/syncclient"
28 "github.com/a73x/eitri/internal/covsnap"
29 "github.com/a73x/eitri/internal/joinblob"
30 "github.com/a73x/eitri/internal/version" 11 "github.com/a73x/eitri/internal/version"
31 ) 12 )
32 13
33 // agentConfig carries runAgent's wiring, replacing a long positional list.
34 type agentConfig struct {
35 StateDir, CHBin, Firmware string
36 BootstrapURL string
37 TombstoneGrace, VanishGrace time.Duration
38 VMTimeout time.Duration
39 ImageCacheMaxGB int64
40 // MaxConcurrentCreates bounds how much of the per-VM workers' create
41 // concurrency reaches the disk at once (0 = unlimited).
42 MaxConcurrentCreates int
43 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent offers the
44 // fleet (0 = unlimited): advertised to the server AND enforced at VM boot.
45 MaxVCPUs, MaxMemMB, MaxDiskGB int64
46 }
47
48 func main() { 14 func main() {
49 if len(os.Args) > 1 && os.Args[1] == "--version" { 15 if len(os.Args) > 1 && os.Args[1] == "--version" {
50 fmt.Println(version.Version) 16 fmt.Println(version.Version)
51 return 17 return
52 } 18 }
53 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 19 if err := run.RunCLI(os.Args[1:]); err != nil {
54 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") 20 fmt.Fprintln(os.Stderr, "eitri-agent:", err)
55 firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
56 bootstrapURL := flag.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)")
57 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
58 vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone")
59 vmTimeout := flag.Duration("vm-timeout", 15*time.Minute, "watchdog bound on ONE VM's reconcile pass (0 disables); keep above the 10m image-download timeout")
60 imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)")
61 maxConcurrentCreates := flag.Int("max-concurrent-creates", 4, "cap how many VMs may be inside the I/O-heavy part of create at once (image fetch + disk copy); 0 = unlimited")
62 maxVCPUs := flag.Int64("max-vcpus", 0, "cap the total vCPUs this host offers the fleet (0 = unlimited; reserves headroom, advertised + enforced at boot)")
63 maxMemMB := flag.Int64("max-mem-mb", 0, "cap the total memory (MB) this host offers the fleet (0 = unlimited)")
64 maxDiskGB := flag.Int64("max-disk-gb", 0, "cap the total disk (GB) this host offers the fleet (0 = unlimited)")
65 flag.Parse()
66
67 for name, v := range map[string]int64{"max-vcpus": *maxVCPUs, "max-mem-mb": *maxMemMB, "max-disk-gb": *maxDiskGB} {
68 if v < 0 {
69 slog.Error("resource cap must be >= 0 (0 = unlimited)", "flag", "--"+name, "value", v)
70 os.Exit(1)
71 }
72 }
73
74 st, err := state.Open(*stateDir)
75 if err != nil {
76 slog.Error("open state dir", "err", err)
77 os.Exit(1)
78 }
79
80 if flag.Arg(0) == "join" {
81 runJoin(st, flag.Arg(1))
82 return
83 }
84
85 runAgent(st, agentConfig{
86 StateDir: *stateDir,
87 CHBin: *chBin,
88 Firmware: *firmware,
89 BootstrapURL: *bootstrapURL,
90 TombstoneGrace: *tombstoneGrace,
91 VanishGrace: *vanishGrace,
92 VMTimeout: *vmTimeout,
93 ImageCacheMaxGB: *imageCacheMaxGB,
94 MaxConcurrentCreates: *maxConcurrentCreates,
95 MaxVCPUs: *maxVCPUs,
96 MaxMemMB: *maxMemMB,
97 MaxDiskGB: *maxDiskGB,
98 })
99 }
100
101 // runJoin handles the "join <blob>" subcommand: decode the join blob, enroll,
102 // and persist identity — pinning the server cert from the blob (the enroll
103 // response's fingerprint is ignored, so the blob is the sole trust root).
104 func runJoin(st *state.Store, blob string) {
105 if blob == "" {
106 fmt.Fprintln(os.Stderr, "usage: eitri-agent join <join-blob>")
107 os.Exit(1)
108 }
109 f, err := joinblob.Decode(blob)
110 if err != nil {
111 // Never echo the blob itself — it carries a bearer token.
112 fmt.Fprintf(os.Stderr, "invalid join blob: %v\n", err)
113 os.Exit(1)
114 }
115
116 hostname, err := os.Hostname()
117 if err != nil {
118 hostname = "unknown"
119 }
120
121 result, err := enrollclient.New(f.HTTPURL).Enroll(context.Background(), enrollclient.Request{
122 Token: f.Token,
123 Name: hostname,
124 OS: runtime.GOOS,
125 Arch: runtime.GOARCH,
126 Provisioner: "cloudhv",
127 })
128 if errors.Is(err, enrollclient.ErrTokenRejected) {
129 fmt.Fprintln(os.Stderr, "enroll rejected: token already used or expired — mint a new join token")
130 os.Exit(1)
131 }
132 if err != nil {
133 fmt.Fprintf(os.Stderr, "enroll failed: %v\n", err)
134 os.Exit(1)
135 }
136
137 id := state.Identity{
138 HostID: result.HostID,
139 Credential: result.Credential,
140 BridgeCIDR: result.BridgeCIDR,
141 ServerQUICAddr: f.QUICAddr,
142 ServerCertSHA256: f.CertFP, // authoritative; response fingerprint ignored
143 }
144 if err := st.SaveIdentity(id); err != nil {
145 slog.Error("save identity", "err", err)
146 os.Exit(1)
147 }
148 fmt.Printf("Enrolled: host_id=%s bridge_cidr=%s\n", result.HostID, result.BridgeCIDR)
149 }
150
151 // realRunner creates a subprocess and returns its combined output.
152 func realRunner(ctx context.Context, name string, args ...string) (string, error) {
153 cmd := exec.CommandContext(ctx, name, args...)
154 out, err := cmd.CombinedOutput()
155 return string(out), err
156 }
157
158 // runAgent handles the normal (no subcommand) run mode.
159 func runAgent(st *state.Store, cfg agentConfig) {
160 id, ok := st.Identity()
161 if !ok {
162 fmt.Fprintln(os.Stderr, "not enrolled — run with 'join <blob>' subcommand first")
163 os.Exit(1)
164 }
165
166 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
167 defer cancel()
168
169 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with
170 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage
171 // from the live agent without bouncing the process.
172 covsnap.Install(ctx)
173
174 net, err := netenv.New(realRunner, id.BridgeCIDR)
175 if err != nil {
176 slog.Error("netenv init", "err", err)
177 os.Exit(1)
178 }
179
180 if err := net.EnsureBridge(ctx); err != nil {
181 slog.Error("ensure bridge", "err", err)
182 os.Exit(1)
183 }
184
185 // Bootstrap cloud-hypervisor and its UEFI firmware before anything tries
186 // to launch a VM: a bare host that just joined has neither, and the agent
187 // is useless without at least the hypervisor binary. BootstrapDest maps
188 // the --ch-bin value (usually a bare $PATH name) to a real install path.
189 bs := &bootstrap.Bootstrapper{CHPath: cloudhv.BootstrapDest(cfg.CHBin), FirmwarePath: cfg.Firmware, ManifestURL: cfg.BootstrapURL}
190 if err := bs.Ensure(ctx); err != nil {
191 slog.Error("bootstrap runtime", "err", err)
192 os.Exit(1)
193 }
194
195 prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, realRunner)
196
197 // Serial console pumps: one per running VM, started at Boot (cloudhv hook)
198 // and reattached here for VMs that survived an agent restart (CH runs in
199 // its own process group; the pump reconnects to the still-listening
200 // serial socket).
201 pumps := serialpump.NewManager(st.SerialSocketPath, st.SerialLogPath)
202 prov.Pumps = pumps
203 if recs, err := st.LoadVMs(); err == nil {
204 for _, rec := range recs {
205 if rec.IP != "" {
206 net.AddReservation(rec.Spec.VMID, rec.IP)
207 }
208 if prov.Running(rec.Spec.VMID) {
209 pumps.Ensure(rec.Spec.VMID)
210 }
211 }
212 } else {
213 slog.Warn("state load failed; surviving VMs' consoles will be silent AND their DHCP reservations are not rebuilt (they may fail to renew until reconcile recreates them)", "err", err)
214 }
215
216 // Start the embedded DHCP responder AFTER reservations are rebuilt from
217 // state, so a surviving guest's renewal is never answered from an empty
218 // table (fail-closed + reservation preload close the gap).
219 if err := net.StartDHCP(ctx); err != nil {
220 slog.Error("start dhcp", "err", err)
221 os.Exit(1) 21 os.Exit(1)
222 } 22 }
223
224 cache := imagecache.New(st.ImagesDir(), realRunner)
225 // Clamp before shifting: GB<<30 overflows int64 for absurd flag values —
226 // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond
227 // any real cache; anything above disables eviction just like 0 would.
228 imageCacheMaxGB := cfg.ImageCacheMaxGB
229 if imageCacheMaxGB < 0 || imageCacheMaxGB > 1<<20 {
230 slog.Warn("image-cache-max-gb out of range [0, 2^20]; disabling eviction", "value", imageCacheMaxGB)
231 imageCacheMaxGB = 0
232 }
233 cache.MaxBytes = imageCacheMaxGB << 30
234 // Reclaim temps left by a previous agent killed mid-fetch or mid-convert.
235 // Must run here, before the reconcile loop starts fetching: a sweep cannot
236 // tell an abandoned temp from one an in-flight fetch is still writing.
237 cache.SweepTemps()
238
239 engine := &reconcile.Engine{
240 St: st,
241 Prov: prov,
242 Net: net,
243 Images: cache.Ensure,
244 Seed: seed.Build,
245 BootID: syncclient.HostBootID,
246 Now: time.Now,
247 TombstoneGrace: cfg.TombstoneGrace,
248 VanishGrace: cfg.VanishGrace,
249 MaxCreateAttempts: 3,
250 MaxConcurrentCreates: cfg.MaxConcurrentCreates,
251 VMTimeout: cfg.VMTimeout,
252 MaxVCPUs: cfg.MaxVCPUs,
253 MaxMemMB: cfg.MaxMemMB,
254 MaxDiskGB: cfg.MaxDiskGB,
255 }
256
257 // Seed the admission ledger from persisted records so the first reconcile
258 // accounts for VMs that survived the agent restart (their compute must
259 // count against the caps before any new create is admitted).
260 if recs, err := st.LoadVMs(); err == nil {
261 engine.SeedLedger(recs)
262 }
263
264 // The per-VM reconcile workers are deliberately NOT torn down here. Stopping
265 // the engine is terminal — it would make the next report an empty actual state,
266 // which the control plane reads as every VM on this host having vanished — and
267 // it would race an unjoined session worker that can still be mid-Step when ctx
268 // is cancelled (see the note below where pumps are torn down). The process is
269 // exiting; the OS reclaims the goroutines.
270
271 client := &syncclient.Client{
272 Engine: engine,
273 St: st,
274 Identity: id,
275 StateDir: cfg.StateDir,
276 Runner: realRunner,
277 Console: pumps,
278 MaxVCPUs: cfg.MaxVCPUs,
279 MaxMemMB: cfg.MaxMemMB,
280 MaxDiskGB: cfg.MaxDiskGB,
281 }
282
283 slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR)
284 client.Run(ctx)
285
286 // client.Run blocks until ctx is cancelled (SIGINT/SIGTERM) and only then
287 // returns, so the reconcile/sync loop is done driving VMs and no further
288 // Ensure/Stop calls are expected to reach pumps (an unjoined session
289 // worker could in principle still be mid-Engine.Step, but it has nothing
290 // left to drive once client.Run has returned). Tear down every serial
291 // console pump here, at the very end of agent shutdown.
292 pumps.StopAll()
293 } 23 }
cmd/eitri-mcp/main.go
Old New
@@ -3,27 +3,16 @@
3 // eitri API plus SSH; it embeds no control-plane code. It holds its OWN 3 // eitri API plus SSH; it embeds no control-plane code. It holds its OWN
4 // persistent user CA, uploads that CA's public key to its tenant once at 4 // persistent user CA, uploads that CA's public key to its tenant once at
5 // startup, and self-signs short-lived user certs locally (BYO model — the 5 // startup, and self-signs short-lived user certs locally (BYO model — the
6 // server no longer mints user certs). Wiring only — logic lives in 6 // server no longer mints user certs). All behavior lives in internal/mcpserver
7 // internal/mcpserver. 7 // (RunCLI); this package is wiring only (arch R14).
8 package main 8 package main
9 9
10 import ( 10 import (
11 "context"
12 "crypto/ed25519"
13 "crypto/rand"
14 "encoding/pem"
15 "flag"
16 "fmt" 11 "fmt"
17 "os" 12 "os"
18 "os/signal"
19 "syscall"
20 13
21 "github.com/a73x/eitri/internal/gateclient"
22 "github.com/a73x/eitri/internal/mcpserver" 14 "github.com/a73x/eitri/internal/mcpserver"
23 "github.com/a73x/eitri/internal/server/api/client"
24 "github.com/a73x/eitri/internal/version" 15 "github.com/a73x/eitri/internal/version"
25 "github.com/modelcontextprotocol/go-sdk/mcp"
26 "golang.org/x/crypto/ssh"
27 ) 16 )
28 17
29 func main() { 18 func main() {
@@ -31,125 +20,8 @@ func main() {
31 fmt.Println(version.Version) 20 fmt.Println(version.Version)
32 return 21 return
33 } 22 }
34 defaultCfg := "~/.config/eitri-mcp/config.json" 23 if err := mcpserver.RunCLI(os.Args[1:]); err != nil {
35 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" {
36 defaultCfg = env
37 }
38 cfgPath := flag.String("config", defaultCfg, "path to eitri-mcp config.json")
39 flag.Parse()
40
41 if err := run(*cfgPath); err != nil {
42 fmt.Fprintln(os.Stderr, "eitri-mcp:", err) 24 fmt.Fprintln(os.Stderr, "eitri-mcp:", err)
43 os.Exit(1) 25 os.Exit(1)
44 } 26 }
45 } 27 }
46
47 func run(cfgPath string) error {
48 cfg, err := mcpserver.LoadConfig(cfgPath)
49 if err != nil {
50 return err
51 }
52 // This client owns its own persistent user CA (BYO model): it self-signs
53 // short-lived user certs locally rather than asking the server to mint them.
54 userCA, err := loadOrCreateCA(cfg.CAKeyPath)
55 if err != nil {
56 return fmt.Errorf("load mcp user CA: %w", err)
57 }
58 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it
59 // authenticates with short-lived user certs it self-signs on demand with its
60 // own user CA, and verifies both hops' host certs against the eitri host CA.
61 // The user CA's public key is uploaded to the tenant once (Register, below)
62 // so VMs trust those certs. GateAuth is backed by the same API client.
63 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token}
64 gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil)
65 tools := &mcpserver.Tools{
66 API: mcpserver.API{Client: api},
67 Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{
68 Gate: cfg.Gate,
69 Auth: gateAuth,
70 VMUser: cfg.VMUser,
71 }),
72 Gate: cfg.Gate,
73 VMUser: cfg.VMUser,
74 }
75
76 server := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, nil)
77 register(server, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", tools.VMCreate)
78 register(server, "vm_list", "List all VMs on the eitri fleet.", tools.VMList)
79 register(server, "vm_info", "Show one VM's state and how to reach it.", tools.VMInfo)
80 register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec)
81 register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile)
82 register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile)
83 register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy)
84
85 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
86 defer stop()
87
88 // Upload our user CA to the tenant before serving, so vm_create's precondition
89 // (a registered user CA) is satisfied and VMs trust the certs we sign.
90 if err := gateAuth.Register(ctx); err != nil {
91 return fmt.Errorf("register mcp user CA: %w", err)
92 }
93 return server.Run(ctx, &mcp.StdioTransport{})
94 }
95
96 // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file
97 // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a
98 // concurrent creator can't clobber it and a symlink can't be followed), and
99 // returns its signer; if present it parses and returns the existing key. This
100 // is deliberately local to the MCP (a pure API client) and does NOT import the
101 // server's sshca package. Never logs or returns key material in errors.
102 func loadOrCreateCA(path string) (ssh.Signer, error) {
103 pemBytes, err := os.ReadFile(path)
104 if err == nil {
105 signer, perr := ssh.ParsePrivateKey(pemBytes)
106 if perr != nil {
107 return nil, fmt.Errorf("parse ssh key %q: %w", path, perr)
108 }
109 return signer, nil
110 }
111 if !os.IsNotExist(err) {
112 return nil, fmt.Errorf("read ssh key %q: %w", path, err)
113 }
114
115 _, priv, err := ed25519.GenerateKey(rand.Reader)
116 if err != nil {
117 return nil, fmt.Errorf("generate ssh key: %w", err)
118 }
119 block, err := ssh.MarshalPrivateKey(priv, "")
120 if err != nil {
121 return nil, fmt.Errorf("marshal ssh key: %w", err)
122 }
123 signer, err := ssh.NewSignerFromSigner(priv)
124 if err != nil {
125 return nil, fmt.Errorf("new signer: %w", err)
126 }
127 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
128 if err != nil {
129 return nil, fmt.Errorf("create ssh key %q: %w", path, err)
130 }
131 if _, werr := f.Write(pem.EncodeToMemory(block)); werr != nil {
132 f.Close()
133 return nil, fmt.Errorf("write ssh key %q: %w", path, werr)
134 }
135 if cerr := f.Close(); cerr != nil {
136 return nil, fmt.Errorf("close ssh key %q: %w", path, cerr)
137 }
138 return signer, nil
139 }
140
141 // register adapts a Tools method to the SDK. This is the ONLY place that
142 // touches SDK generics; if the SDK's handler signature changes, change it here.
143 //
144 // Note on the hand-off contract: the SDK drops the Out value when the handler
145 // returns a non-nil error — StructuredContent is left unset and only err.Error()
146 // reaches the model (as IsError text content). VMCreate's degraded-path errors
147 // are self-sufficient (they name the VM id+name), so the model can still find
148 // and destroy the VM from the error text.
149 func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
150 mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
151 func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
152 out, err := fn(ctx, in)
153 return nil, out, err
154 })
155 }
cmd/eitri-server/main.go
Old New
@@ -1,324 +1,23 @@
1 // eitri-server: single-node control plane (Phase 1: static admin token, no TLS 1 // eitri-server: single-node control plane (no TLS termination here — front
2 // termination here — front with a reverse proxy for TLS). 2 // with a reverse proxy for TLS). All behavior lives in internal/server/boot
3 // (RunCLI); this package is version-check-and-dispatch wiring only (arch R14).
3 package main 4 package main
4 5
5 import ( 6 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "flag"
10 "fmt" 7 "fmt"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "os" 8 "os"
15 "os/signal"
16 "regexp"
17 "strings"
18 "syscall"
19 "time"
20 9
21 "github.com/a73x/eitri/internal/covsnap" 10 "github.com/a73x/eitri/internal/server/boot"
22 "github.com/a73x/eitri/internal/joinblob"
23 "github.com/a73x/eitri/internal/server/api"
24 serverconfig "github.com/a73x/eitri/internal/server/config"
25 "github.com/a73x/eitri/internal/server/health"
26 "github.com/a73x/eitri/internal/server/hub"
27 "github.com/a73x/eitri/internal/server/registry"
28 "github.com/a73x/eitri/internal/server/release"
29 "github.com/a73x/eitri/internal/server/store"
30 "github.com/a73x/eitri/internal/server/syncsvc"
31 "github.com/a73x/eitri/internal/server/web"
32 "github.com/a73x/eitri/internal/transport"
33 "github.com/a73x/eitri/internal/version" 11 "github.com/a73x/eitri/internal/version"
34 "github.com/quic-go/quic-go"
35 ) 12 )
36 13
37 // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept local
38 // to the control plane rather than shared with the agent's imagecache: R1
39 // forbids the control plane importing the data plane, even transitively.
40 var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`)
41
42 // parseDurationCfg parses raw (a config duration string) for the knob named
43 // name (the JSON field label echoed in error logs). Empty raw keeps def. A
44 // parse failure is fatal. valid, when non-nil, is the knob's range rule; a
45 // value failing it is fatal, with rule (e.g. ">= 0", "> 0") spelling the rule
46 // in the log line. A nil valid skips the range check.
47 func parseDurationCfg(name, raw string, def time.Duration, valid func(time.Duration) bool, rule string) time.Duration {
48 if raw == "" {
49 return def
50 }
51 d, err := time.ParseDuration(raw)
52 if err != nil {
53 slog.Error(name+" invalid", "err", err)
54 os.Exit(1)
55 }
56 if valid != nil && !valid(d) {
57 slog.Error(name+" must be "+rule, "value", raw)
58 os.Exit(1)
59 }
60 return d
61 }
62
63 // config is the on-disk JSON schema, shared with the integration harness
64 // (which renders this file for the server subprocess it launches) via
65 // internal/server/config so the compiler keeps the two in agreement.
66 type config = serverconfig.Config
67
68 func main() { 14 func main() {
69 if len(os.Args) > 1 && os.Args[1] == "--version" { 15 if len(os.Args) > 1 && os.Args[1] == "--version" {
70 fmt.Println(version.Version) 16 fmt.Println(version.Version)
71 return 17 return
72 } 18 }
73 cfgPath := flag.String("config", "/etc/eitri/server.json", "config file") 19 if err := boot.RunCLI(os.Args[1:]); err != nil {
74 flag.Parse() 20 fmt.Fprintln(os.Stderr, "eitri-server:", err)
75 raw, err := os.ReadFile(*cfgPath)
76 if err != nil {
77 slog.Error("read config", "err", err)
78 os.Exit(1)
79 }
80 var cfg config
81 if err := json.Unmarshal(raw, &cfg); err != nil {
82 slog.Error("parse config", "err", err)
83 os.Exit(1)
84 }
85 if cfg.HostSecret == "" {
86 slog.Error("host_secret is required")
87 os.Exit(1)
88 }
89 // The server is a pure OIDC relying party (spec §2): issuer, client_id and
90 // public_url are required. Collect every missing key so the operator fixes
91 // server.json in one pass rather than one restart per key.
92 var missingOIDC []string
93 if cfg.OIDC.Issuer == "" {
94 missingOIDC = append(missingOIDC, "oidc.issuer")
95 }
96 if cfg.OIDC.ClientID == "" {
97 missingOIDC = append(missingOIDC, "oidc.client_id")
98 }
99 if cfg.OIDC.PublicURL == "" {
100 missingOIDC = append(missingOIDC, "oidc.public_url")
101 }
102 if len(missingOIDC) > 0 {
103 slog.Error("server.json: missing required keys (point them at eitri-oidc or your IdP)", "keys", strings.Join(missingOIDC, ", "))
104 os.Exit(1)
105 }
106 // public_url builds the OIDC callback URL, so it must be an absolute
107 // http(s) URL with a host — catch a bare host, missing scheme, or
108 // scheme-only URL at boot, not at the first redirect.
109 if u, err := url.Parse(cfg.OIDC.PublicURL); err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
110 slog.Error("server.json: oidc.public_url must be an absolute http(s) URL", "value", cfg.OIDC.PublicURL)
111 os.Exit(1)
112 }
113 // admin_token is retired (spec §7): ignored if present, still passed to
114 // api.Config until the PAT/session middleware replaces it. Warn once so the
115 // operator prunes the stale key.
116 if cfg.AdminToken != "" {
117 slog.Warn("server.json: admin_token is no longer used and is ignored; remove it")
118 }
119 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
120 slog.Error("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)")
121 os.Exit(1)
122 }
123 // [carry-forward] Fail fast on a malformed default image digest rather
124 // than letting every mint silently propagate a bad hash. A local regex
125 // (not the agent's imagecache) because R1 forbids the control plane
126 // importing the data plane, even transitively.
127 if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) {
128 slog.Error("default_image_sha256 malformed (want 64 lowercase hex chars)", "value", cfg.DefaultImageSHA)
129 os.Exit(1)
130 }
131
132 st, err := store.Open(cfg.DBPath, cfg.CIDRPool)
133 if err != nil {
134 slog.Error("open store", "err", err)
135 os.Exit(1) 21 os.Exit(1)
136 } 22 }
137
138 certPEM, certFP, err := st.ServerCert()
139 if err != nil {
140 slog.Error("server cert", "err", err)
141 os.Exit(1)
142 }
143 keyPEM, err := st.ServerKeyPEM()
144 if err != nil {
145 slog.Error("server key", "err", err)
146 os.Exit(1)
147 }
148
149 // Log the identity agents pin — the operator verifies this out-of-band
150 // during the rotation ceremony (docs/cert-rotation.md step 3).
151 slog.Info("server cert", "fingerprint", certFP)
152
153 // Rotation nudge: the agent pin ignores expiry so nothing breaks at
154 // NotAfter, but a long-lived key is a widening forgery window. Warn while
155 // inside the renewal window — at startup AND daily, because servers here
156 // are long-lived daemons that can cross into the window (or past expiry)
157 // without ever restarting. See docs/cert-rotation.md for the ceremony.
158 warnIfRenewalDue := func() {
159 notAfter, due := transport.CertRenewalDue(certPEM, time.Now())
160 if !due {
161 return
162 }
163 if notAfter.IsZero() {
164 slog.Warn("server cert unparseable — inspect server.crt (docs/cert-rotation.md)")
165 return
166 }
167 slog.Warn("server cert renewal due — rotate and re-enroll agents (docs/cert-rotation.md)",
168 "not_after", notAfter.Format(time.RFC3339))
169 }
170 warnIfRenewalDue()
171
172 // Audit retention: bound the append-only log (default 90 days, 0 disables).
173 // A negative value is almost certainly a typo — refuse rather than silently
174 // keeping the audit log forever ("0" is the explicit disable spelling).
175 auditRetention := parseDurationCfg("audit_retention", cfg.AuditRetention, 90*24*time.Hour,
176 func(d time.Duration) bool { return d >= 0 }, ">= 0")
177 pruneAudit := func() {
178 if auditRetention <= 0 {
179 return
180 }
181 if n, err := st.PruneAudit(auditRetention); err != nil {
182 slog.Warn("audit prune failed", "err", err)
183 } else if n > 0 {
184 slog.Info("audit pruned", "rows", n, "retention", auditRetention)
185 }
186 }
187 pruneAudit()
188
189 // Daily housekeeping: cert-renewal nudge + audit retention.
190 go func() {
191 for range time.Tick(24 * time.Hour) {
192 warnIfRenewalDue()
193 pruneAudit()
194 }
195 }()
196
197 // Fail fast if the advertised addresses are non-empty but malformed (e.g. a
198 // URL with no scheme): otherwise every enroll-token mint would 500 at runtime.
199 if _, err := joinblob.Encode(cfg.AdvertiseHTTP, cfg.AdvertiseQUIC, "startup-probe", certFP); err != nil {
200 slog.Error("advertise_http/advertise_quic invalid", "err", err)
201 os.Exit(1)
202 }
203
204 // SSH jump gate (§B): nil when ssh_listen is unset (gate OFF); see
205 // setupSSHGate. The listener itself is started below, once syncsvc.Service
206 // (the tunnel dialer) exists.
207 sshGate := setupSSHGate(cfg)
208
209 reg := registry.New(time.Now)
210 h := hub.New()
211
212 a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret),
213 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA},
214 ServerCertSHA256: certFP,
215 AdvertiseHTTP: cfg.AdvertiseHTTP,
216 AdvertiseQUIC: cfg.AdvertiseQUIC,
217 OIDC: api.OIDCConfig{
218 Issuer: cfg.OIDC.Issuer,
219 ClientID: cfg.OIDC.ClientID,
220 ClientSecret: cfg.OIDC.ClientSecret,
221 PublicURL: cfg.OIDC.PublicURL,
222 AllowedDomains: cfg.OIDC.AllowedDomains,
223 AllowedIdentities: cfg.OIDC.AllowedIdentities,
224 }},
225 st, reg, h)
226
227 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
228 if err != nil {
229 slog.Error("server tls", "err", err)
230 os.Exit(1)
231 }
232 lis, err := quic.ListenAddr(cfg.QUICListen, tlsConf,
233 // Shared with the agent dialer via transport so the two ends can't drift.
234 transport.SyncQUICConfig())
235 if err != nil {
236 slog.Error("quic listen", "err", err)
237 os.Exit(1)
238 }
239 maxCredAge := parseDurationCfg("credential_max_age", cfg.CredentialMaxAge, 0, nil, "")
240 // SSH cert minters: no-op when the gate is off, so the endpoint 404s.
241 sshGate.wireAPI(a)
242
243 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge)
244 // Console broker: the API bridges browser WebSockets to agent console
245 // streams over the live sync connections the service tracks.
246 a.SetConsoleDialer(svc)
247
248 // SSH jump gate listener (no-op when off); fatal on a failed bind, like
249 // QUIC/HTTP below — a dead gate must not run silently.
250 sshGate.startListener(st, svc)
251
252 go func() {
253 slog.Info("quic listening", "addr", cfg.QUICListen)
254 if err := svc.Serve(context.Background(), lis); err != nil {
255 slog.Error("quic serve", "err", err)
256 os.Exit(1)
257 }
258 }()
259
260 // Background: finalize drained decommissioning hosts.
261 go a.StartBackground(context.Background())
262
263 // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else.
264 root := http.NewServeMux()
265 root.Handle("/api/", a.Handler())
266 // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how
267 // a browser establishes a session in the first place (spec §2).
268 root.Handle("/auth/", a.AuthHandler())
269 // Unauthenticated probes (outside /api/, so a load balancer or the deploy
270 // script needs no token). /livez is process-up; /readyz gates on the
271 // dependencies the server needs to actually serve — the DB. The QUIC
272 // listener bind is a startup invariant: quic.ListenAddr above exits the
273 // process on failure and runs before this HTTP server, so a response here
274 // already implies QUIC bound.
275 root.HandleFunc("/livez", health.Live)
276 root.Handle("/readyz", health.Ready(3*time.Second,
277 health.Check{Name: "db", Probe: st.Ping},
278 ))
279 root.Handle("/", web.Handler())
280
281 // Graceful shutdown: SIGINT/SIGTERM stops accepting, drains in-flight
282 // requests, then closes the API — stopping the SSE snapshot-hub goroutine and
283 // releasing its notifier subscription. The QUIC listener and background worker
284 // die with the process.
285 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
286 defer stop()
287
288 // Release discovery: absent field ⇒ eitri.sh default; explicit "" disables.
289 manifestURL := "https://eitri.sh/dl/latest/manifest.json"
290 if cfg.ReleaseManifestURL != nil {
291 manifestURL = *cfg.ReleaseManifestURL
292 }
293 if manifestURL != "" {
294 rel := release.New(manifestURL)
295 go rel.Poll(ctx, 24*time.Hour, func(err error) {
296 slog.Warn("release manifest refresh failed", "err", err)
297 })
298 a.SetReleaseSource(rel)
299 }
300 a.SetAgentUpgrader(svc)
301
302 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with
303 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage
304 // from the live server without bouncing the process.
305 covsnap.Install(ctx)
306
307 srv := &http.Server{Addr: cfg.HTTPListen, Handler: root}
308 go func() {
309 slog.Info("http listening", "addr", cfg.HTTPListen)
310 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
311 slog.Error("http serve", "err", err)
312 os.Exit(1)
313 }
314 }()
315
316 <-ctx.Done()
317 slog.Info("shutting down")
318 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
319 defer cancel()
320 if err := srv.Shutdown(shutdownCtx); err != nil {
321 slog.Warn("http graceful shutdown", "err", err)
322 }
323 a.Close()
324 } 23 }
cmd/eitri-server/sshgate.go
Old New
@@ -1,159 +0,0 @@
1 package main
2
3 import (
4 "log/slog"
5 "net"
6 "os"
7 "time"
8
9 "github.com/a73x/eitri/internal/server/api"
10 "github.com/a73x/eitri/internal/server/sshca"
11 "github.com/a73x/eitri/internal/server/sshgate"
12 "github.com/a73x/eitri/internal/server/store"
13 "github.com/a73x/eitri/internal/server/syncsvc"
14 "golang.org/x/crypto/ssh"
15 )
16
17 // sshGateSetup carries the jump-gate state from config-time setup to the later
18 // wiring points in startup (API cert minters, sync snapshot, gate listener).
19 // A nil *sshGateSetup means the gate is OFF: every method is a no-op on a nil
20 // receiver, so main holds one value instead of repeating `!= nil` guards.
21 type sshGateSetup struct {
22 ca *sshca.CA
23 listen string // cfg.SSHListen
24 domain string // cfg.SSHGateDomain
25 }
26
27 // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set
28 // (returns nil). When enabled, load or create the persistent user CA + gate
29 // host key (0600, never logged).
30 func setupSSHGate(cfg config) *sshGateSetup {
31 if cfg.SSHListen == "" {
32 return nil
33 }
34 if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" {
35 slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set")
36 os.Exit(1)
37 }
38 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey)
39 if err != nil {
40 slog.Error("ssh ca", "err", err)
41 os.Exit(1)
42 }
43 // Log the HOST CA identity operators pin via @cert-authority for gate + VM
44 // host verification. Only the *public* key is ever logged (private material
45 // never is). eitri holds no user CA — those are BYO per-tenant.
46 slog.Info("ssh jump gate configured", "listen", cfg.SSHListen,
47 "host_ca", string(sshGate.HostCAAuthorizedKey()))
48 // The gate listener itself is started later (startListener), once
49 // syncsvc.Service (the tunnel dialer) exists.
50 return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}
51 }
52
53 // wireAPI installs the per-VM host-cert minter and publishes the HOST CA: when
54 // the jump gate is enabled, eitri signs a persistent host key + cert at each VM
55 // create and serves the host CA pubkey via GET /api/v1/ssh-ca. eitri never
56 // mints user certs — user CAs are BYO per-tenant (uploaded, never held here).
57 // Left unwired when the gate is off, so the ssh-ca endpoint 404s.
58 func (g *sshGateSetup) wireAPI(a *api.API) {
59 if g == nil {
60 return
61 }
62 // Per-VM host certs: sign a persistent host key + cert at each VM create,
63 // so VMs present verifiable host keys (clients accept via @cert-authority).
64 a.SetHostCertMinter(api.NewHostMinter(g.ca.HostCA()))
65 // Publish the HOST CA public key so clients can pin `@cert-authority` for
66 // host verification of both the gate and every VM.
67 a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey()))
68 }
69
70 // startListener starts the SSH jump gate listener: when enabled, front
71 // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names
72 // against the store, tunnels port 22 through the sync connection (svc.OpenTCP),
73 // and trusts only certs signed by the user CA. A failed bind is fatal (like
74 // QUIC/HTTP): a dead gate must not run silently.
75 func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
76 if g == nil {
77 return
78 }
79 // The gate host cert's principal is the name clients dial. Prefer the
80 // configured domain; else the host part of ssh_listen; else "localhost".
81 gateDomain := g.domain
82 if gateDomain == "" {
83 if h, _, err := net.SplitHostPort(g.listen); err == nil {
84 gateDomain = h
85 }
86 }
87 if gateDomain == "" {
88 gateDomain = "localhost"
89 }
90 slog.Info("ssh gate host cert", "principal", gateDomain)
91 // resolve is tenant-scoped: the bare name is looked up WITHIN the
92 // connection's tenant only.
93 resolve := func(tenant, name string) (hostID, vmID string, ok bool) {
94 vm, err := st.VMByTenantName(tenant, name)
95 if err != nil {
96 return "", "", false
97 }
98 return vm.HostID, vm.ID, true
99 }
100 // authorize re-reads the VM row and requires tenant equality: the tenant
101 // that the connection's user cert resolved to (via its per-tenant CA) must
102 // own the VM, or the connection is refused.
103 authorize := func(tenant, vmID string) bool {
104 vm, err := st.GetVM(vmID)
105 return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant
106 }
107 // Sign a long-lived HOST cert for the gate's own host key and present THAT
108 // (via a cert signer) instead of the bare key, so a client verifying with
109 // `@cert-authority` accepts the gate on first connect — no TOFU window.
110 gateCert, err := sshca.SignHostCert(g.ca.HostCA(), g.ca.HostKey().PublicKey(),
111 []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL)
112 if err != nil {
113 slog.Error("sign gate host cert", "err", err)
114 os.Exit(1)
115 }
116 gateHostSigner, err := ssh.NewCertSigner(gateCert, g.ca.HostKey())
117 if err != nil {
118 slog.Error("gate host cert signer", "err", err)
119 os.Exit(1)
120 }
121 // isRevoked gates every cert auth against the revocation list. Fail-CLOSED
122 // for the single connection on a DB error: a store hiccup rejects THAT
123 // login (returns revoked=true) rather than fail-open (which would let a
124 // possibly-revoked cert through) or fail-the-whole-gate (which a global
125 // close would amount to, DoSing every login on any transient error).
126 isRevoked := func(serial uint64) bool {
127 revoked, err := st.IsSSHCertRevoked(serial)
128 if err != nil {
129 slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err)
130 return true
131 }
132 return revoked
133 }
134 // The gate trusts the DB-registered set of tenant user CAs and stamps each
135 // connection with the tenant that registered the signing CA. Look up by the
136 // SAME canonical authorized_keys line the store persists (ca_pubkey), so the
137 // bytes agree. A lookup error fails closed (rejects the cert).
138 userCALookup := func(pub ssh.PublicKey) (string, bool) {
139 tenant, ok, err := st.TenantForUserCA(sshca.AuthorizedKeyLine(pub))
140 if err != nil {
141 slog.Error("tenant user-ca lookup failed; rejecting", "err", err)
142 return "", false
143 }
144 return tenant, ok
145 }
146 gate := sshgate.New(gateHostSigner, userCALookup, resolve, authorize, svc.OpenTCP, isRevoked)
147 ln, err := net.Listen("tcp", g.listen)
148 if err != nil {
149 slog.Error("ssh gate listen", "err", err)
150 os.Exit(1)
151 }
152 go func() {
153 slog.Info("ssh jump gate listening", "addr", g.listen)
154 if err := gate.Serve(ln); err != nil {
155 slog.Error("ssh gate serve", "err", err)
156 os.Exit(1)
157 }
158 }()
159 }
cmd/eitri-site/main.go
Old New
@@ -1,10 +1,9 @@
1 // Command eitri-site generates the eitri.sh static site (default) or the 1 // Command eitri-site generates the eitri.sh static site (default) or the
2 // release manifest (the "manifest" subcommand). See internal/site. 2 // release manifest (the "manifest" subcommand). All behavior lives in
3 // internal/site (RunCLI); this package is wiring only (arch R14).
3 package main 4 package main
4 5
5 import ( 6 import (
6 "encoding/json"
7 "flag"
8 "fmt" 7 "fmt"
9 "os" 8 "os"
10 9
@@ -17,54 +16,8 @@ func main() {
17 fmt.Println(version.Version) 16 fmt.Println(version.Version)
18 return 17 return
19 } 18 }
20 if len(os.Args) > 1 && os.Args[1] == "manifest" { 19 if err := site.RunCLI(os.Args[1:]); err != nil {
21 if err := runManifest(os.Args[2:]); err != nil {
22 fmt.Fprintln(os.Stderr, "eitri-site manifest:", err)
23 os.Exit(1)
24 }
25 return
26 }
27 if err := runBuild(os.Args[1:]); err != nil {
28 fmt.Fprintln(os.Stderr, "eitri-site:", err) 20 fmt.Fprintln(os.Stderr, "eitri-site:", err)
29 os.Exit(1) 21 os.Exit(1)
30 } 22 }
31 } 23 }
32
33 func runBuild(args []string) error {
34 fs := flag.NewFlagSet("eitri-site", flag.ExitOnError)
35 docs := fs.String("docs", "docs", "docs directory (markdown sources)")
36 siteDir := fs.String("site", "site", "site directory (index.md, template.html, style.css)")
37 dist := fs.String("dist", "", "optional dist/<version> dir with release artifacts")
38 out := fs.String("out", "site/dist", "output webroot")
39 if err := fs.Parse(args); err != nil {
40 return err
41 }
42 return site.Build(site.Config{DocsDir: *docs, SiteDir: *siteDir, DistDir: *dist, OutDir: *out})
43 }
44
45 func runManifest(args []string) error {
46 fs := flag.NewFlagSet("eitri-site manifest", flag.ExitOnError)
47 ver := fs.String("version", "", "release version (vX.Y.Z)")
48 dist := fs.String("dist", "", "dist/<version> dir holding bare agent binaries")
49 base := fs.String("base", "", "base URL artifacts are served from")
50 out := fs.String("out", "", "output path (default <dist>/manifest.json)")
51 if err := fs.Parse(args); err != nil {
52 return err
53 }
54 if *ver == "" || *dist == "" || *base == "" {
55 return fmt.Errorf("-version, -dist, and -base are required")
56 }
57 m, err := site.BuildManifest(*ver, *dist, *base)
58 if err != nil {
59 return err
60 }
61 raw, err := json.MarshalIndent(m, "", " ")
62 if err != nil {
63 return err
64 }
65 path := *out
66 if path == "" {
67 path = *dist + "/manifest.json"
68 }
69 return os.WriteFile(path, append(raw, '\n'), 0o644)
70 }
cmd/eitri-smoke/config.go
Old New
@@ -1,106 +0,0 @@
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 CIUser string
17 CIPasswordFile string
18 CIPATFile string
19 AgentUserHost string
20 AgentPort int
21 AgentStateDir string
22
23 // Carried for a later coverage-collection task; optional here.
24 ServerGocoverdir string
25 AgentGocoverdir string
26 CoverOut string
27
28 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the
29 // scenario proves guest access through the gate (a hard gate). Optional.
30 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>"
31 SmokeVMUser string // SMOKE_VM_USER, default "ubuntu"
32 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key
33 }
34
35 // loadConfig reads the required smoke settings via getenv (never the real
36 // process environment directly, so callers can inject a fake for tests). It
37 // returns an error naming the first missing required variable.
38 func loadConfig(getenv func(string) string) (Config, error) {
39 serverURL := getenv("SERVER_URL")
40 ciUser := getenv("CI_USER")
41 ciPasswordFile := getenv("CI_PASSWORD_FILE")
42 ciPATFile := getenv("CI_PAT_FILE")
43 agentHosts := getenv("AGENT_HOSTS")
44 agentStateDir := getenv("AGENT_STATE_DIR")
45
46 var missing []string
47 if serverURL == "" {
48 missing = append(missing, "SERVER_URL")
49 }
50 if ciUser == "" {
51 missing = append(missing, "CI_USER")
52 }
53 if ciPasswordFile == "" {
54 missing = append(missing, "CI_PASSWORD_FILE")
55 }
56 if ciPATFile == "" {
57 missing = append(missing, "CI_PAT_FILE")
58 }
59 if agentHosts == "" {
60 missing = append(missing, "AGENT_HOSTS")
61 }
62 if agentStateDir == "" {
63 missing = append(missing, "AGENT_STATE_DIR")
64 }
65 if len(missing) > 0 {
66 return Config{}, fmt.Errorf("missing required env var(s): %s", strings.Join(missing, ", "))
67 }
68
69 userHost, port := parseAgentHost(agentHosts)
70
71 smokeVMUser := getenv("SMOKE_VM_USER")
72 if smokeVMUser == "" {
73 smokeVMUser = "ubuntu"
74 }
75
76 return Config{
77 ServerURL: serverURL,
78 CIUser: ciUser,
79 CIPasswordFile: ciPasswordFile,
80 CIPATFile: ciPATFile,
81 AgentUserHost: userHost,
82 AgentPort: port,
83 AgentStateDir: agentStateDir,
84 ServerGocoverdir: getenv("SERVER_GOCOVERDIR"),
85 AgentGocoverdir: getenv("AGENT_GOCOVERDIR"),
86 CoverOut: getenv("COVER_OUT"),
87 SmokeGate: getenv("SMOKE_GATE"),
88 SmokeVMUser: smokeVMUser,
89 SmokeUserCAFile: getenv("SMOKE_USER_CA_FILE"),
90 }, nil
91 }
92
93 // parseAgentHost takes the AGENT_HOSTS value (space-separated "user@host[:port]"
94 // entries) and returns the first entry's user@host plus its port. The port is
95 // the substring after the LAST colon when that substring is entirely digits;
96 // otherwise there is no port and it defaults to 22.
97 func parseAgentHost(agentHosts string) (userHost string, port int) {
98 entry := strings.Fields(agentHosts)[0]
99
100 if idx := strings.LastIndex(entry, ":"); idx != -1 {
101 if p, err := strconv.Atoi(entry[idx+1:]); err == nil {
102 return entry[:idx], p
103 }
104 }
105 return entry, 22
106 }
cmd/eitri-smoke/config_test.go
Old New
@@ -1,153 +0,0 @@
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 "CI_USER": "ci@eitri.local",
18 "CI_PASSWORD_FILE": "/etc/eitri/ci-password",
19 "CI_PAT_FILE": "/etc/eitri/ci-pat",
20 "AGENT_HOSTS": "ubuntu@10.0.0.5:2222",
21 "AGENT_STATE_DIR": "/var/lib/eitri-agent",
22 }
23 }
24
25 func TestLoadConfigParsesAgentHostWithPort(t *testing.T) {
26 cfg, err := loadConfig(fakeGetenv(requiredVals()))
27 if err != nil {
28 t.Fatalf("loadConfig: %v", err)
29 }
30 if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
31 t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
32 }
33 if cfg.AgentPort != 2222 {
34 t.Errorf("AgentPort = %d, want 2222", cfg.AgentPort)
35 }
36 }
37
38 func TestLoadConfigDefaultsPortWithoutColon(t *testing.T) {
39 vals := requiredVals()
40 vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5"
41 cfg, err := loadConfig(fakeGetenv(vals))
42 if err != nil {
43 t.Fatalf("loadConfig: %v", err)
44 }
45 if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
46 t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
47 }
48 if cfg.AgentPort != 22 {
49 t.Errorf("AgentPort = %d, want 22", cfg.AgentPort)
50 }
51 }
52
53 func TestLoadConfigMultiEntryTakesFirst(t *testing.T) {
54 vals := requiredVals()
55 vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5:2200 ubuntu@10.0.0.6:2201"
56 cfg, err := loadConfig(fakeGetenv(vals))
57 if err != nil {
58 t.Fatalf("loadConfig: %v", err)
59 }
60 if cfg.AgentUserHost != "ubuntu@10.0.0.5" || cfg.AgentPort != 2200 {
61 t.Errorf("got %q:%d, want ubuntu@10.0.0.5:2200", cfg.AgentUserHost, cfg.AgentPort)
62 }
63 }
64
65 func TestLoadConfigMissingRequiredVars(t *testing.T) {
66 cases := []struct {
67 name string
68 unset string
69 wantErr string
70 }{
71 {"missing server url", "SERVER_URL", "SERVER_URL"},
72 {"missing ci user", "CI_USER", "CI_USER"},
73 {"missing ci password file", "CI_PASSWORD_FILE", "CI_PASSWORD_FILE"},
74 {"missing ci pat file", "CI_PAT_FILE", "CI_PAT_FILE"},
75 {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"},
76 {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"},
77 }
78 for _, tc := range cases {
79 t.Run(tc.name, func(t *testing.T) {
80 vals := requiredVals()
81 delete(vals, tc.unset)
82 _, err := loadConfig(fakeGetenv(vals))
83 if err == nil {
84 t.Fatal("loadConfig: want error, got nil")
85 }
86 if !strings.Contains(err.Error(), tc.wantErr) {
87 t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr)
88 }
89 })
90 }
91 }
92
93 func TestLoadConfigMissingAllRequiredVars(t *testing.T) {
94 _, err := loadConfig(fakeGetenv(nil))
95 if err == nil {
96 t.Fatal("loadConfig: want error, got nil")
97 }
98 for _, want := range []string{"SERVER_URL", "CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} {
99 if !strings.Contains(err.Error(), want) {
100 t.Errorf("error = %q, missing %q", err.Error(), want)
101 }
102 }
103 }
104
105 func TestLoadConfigSmokeGateDefaults(t *testing.T) {
106 cfg, err := loadConfig(fakeGetenv(requiredVals()))
107 if err != nil {
108 t.Fatalf("loadConfig: %v", err)
109 }
110 if cfg.SmokeGate != "" {
111 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate)
112 }
113 if cfg.SmokeVMUser != "ubuntu" {
114 t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser)
115 }
116 if cfg.SmokeUserCAFile != "" {
117 t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile)
118 }
119 }
120
121 func TestLoadConfigSmokeGatePassThrough(t *testing.T) {
122 vals := requiredVals()
123 vals["SMOKE_GATE"] = "gate.example:2222"
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.SmokeVMUser != "debian" {
134 t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser)
135 }
136 if cfg.SmokeUserCAFile != "/etc/eitri-smoke/user_ca" {
137 t.Errorf("SmokeUserCAFile = %q, want /etc/eitri-smoke/user_ca", cfg.SmokeUserCAFile)
138 }
139 }
140
141 func TestLoadConfigOptionalCoverageVarsPassThrough(t *testing.T) {
142 vals := requiredVals()
143 vals["SERVER_GOCOVERDIR"] = "/tmp/server-cover"
144 vals["AGENT_GOCOVERDIR"] = "/tmp/agent-cover"
145 vals["COVER_OUT"] = "/tmp/out"
146 cfg, err := loadConfig(fakeGetenv(vals))
147 if err != nil {
148 t.Fatalf("loadConfig: %v", err)
149 }
150 if cfg.ServerGocoverdir != "/tmp/server-cover" || cfg.AgentGocoverdir != "/tmp/agent-cover" || cfg.CoverOut != "/tmp/out" {
151 t.Errorf("optional coverage vars not passed through: %+v", cfg)
152 }
153 }
cmd/eitri-smoke/coverage.go
Old New
@@ -1,139 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,78 +0,0 @@
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. The
16 // tenant is the operator PAT's own tenant (derived via Me() by the caller), so
17 // the CA registration and connect names match the fleet's real partition.
18 func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks {
19 auth := gateclient.NewGateAuth(ca, userCA, tenant, now)
20 return &gateHooks{
21 register: func(ctx context.Context) error { return auth.Register(ctx) },
22 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) },
23 }
24 }
25
26 // gateExec proves the guest is reachable through the SSH-CA gate: it retries
27 // dial+login over the guest's pre-sshd boot window (a dial or session error
28 // mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser and
29 // confirms `id -un` echoes that same user, or the 120s deadline expires.
30 func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error {
31 var lastErr error
32 err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) {
33 client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{
34 Gate: cfg.SmokeGate,
35 VMUser: cfg.SmokeVMUser,
36 Auth: auth,
37 }, vmName)
38 if dialErr != nil {
39 lastErr = dialErr
40 return false, nil
41 }
42 defer client.Close()
43
44 out, runErr := runGuestCommand(client, "id -un")
45 if runErr != nil {
46 lastErr = runErr
47 return false, nil
48 }
49
50 got := strings.TrimSpace(out)
51 if got != cfg.SmokeVMUser {
52 return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser)
53 }
54 return true, nil
55 })
56 if err != nil {
57 if errors.Is(err, errPollTimeout) {
58 return fmt.Errorf("FAIL: could not reach guest %q through the gate within 120s: %w", vmName, lastErr)
59 }
60 return err
61 }
62 return nil
63 }
64
65 // runGuestCommand runs cmd in a new session on client and returns its stdout.
66 func runGuestCommand(client *ssh.Client, cmd string) (string, error) {
67 session, err := client.NewSession()
68 if err != nil {
69 return "", fmt.Errorf("open ssh session: %w", err)
70 }
71 defer session.Close()
72
73 out, err := session.Output(cmd)
74 if err != nil {
75 return "", fmt.Errorf("run %q: %w", cmd, err)
76 }
77 return string(out), nil
78 }
cmd/eitri-smoke/login.go
Old New
@@ -1,108 +0,0 @@
1 package main
2
3 import (
4 "fmt"
5 "net/http"
6 "net/http/cookiejar"
7 "net/url"
8 "time"
9
10 "github.com/a73x/eitri/internal/server/api/client"
11 )
12
13 // sessionCookie is the name of the console session cookie the callback sets.
14 // Its presence in the jar after the credential POST is the one honest signal
15 // that sign-in succeeded — a failed password re-renders the login form as a
16 // plain 200 without ever setting it.
17 const sessionCookie = "eitri_session"
18
19 // loginPAT signs in through the real OIDC code flow — the same doors a browser
20 // walks — and mints a short-lived PAT for this run. There is no special grant
21 // for machines (spec §3): CI registers a flat-file user and then drives the
22 // standard authorization-code flow headlessly against the plain-HTML login
23 // form, lands an ordinary session, and mints a PAT through the normal route.
24 func loginPAT(serverURL, email, password string) (string, error) {
25 base, err := url.Parse(serverURL)
26 if err != nil {
27 return "", fmt.Errorf("parse server url %q: %w", serverURL, err)
28 }
29 jar, err := cookiejar.New(nil)
30 if err != nil {
31 return "", fmt.Errorf("cookie jar: %w", err)
32 }
33 // A default (redirect-following) client: GET /auth/login bounces through the
34 // issuer's authorize endpoint to the login form, and the credential POST
35 // runs issuer -> /auth/callback -> / — we want every hop followed so the
36 // session cookie lands in the jar and resp.Request.URL is the form's URL.
37 hc := &http.Client{Jar: jar, Timeout: 30 * time.Second}
38
39 // GET /auth/login follows the redirect chain to the issuer's login form.
40 // We POST credentials straight back to resp.Request.URL — the authorize
41 // URL we landed on, query intact — so no HTML parsing is needed and the
42 // form's action attribute is never consulted. (eitri-oidc accepts the
43 // credential POST on that same URL by contract, spec §2.1.)
44 resp, err := hc.Get(serverURL + "/auth/login")
45 if err != nil {
46 return "", fmt.Errorf("GET /auth/login: %w", err)
47 }
48 resp.Body.Close()
49 if resp.StatusCode != http.StatusOK {
50 return "", fmt.Errorf("sign-in: login form GET returned %d (want 200) at %s", resp.StatusCode, resp.Request.URL)
51 }
52 formURL := resp.Request.URL.String()
53
54 // POST credentials to the form URL; the redirects run issuer -> /auth/callback
55 // -> / and the callback plants the session cookie in the jar on success.
56 resp2, err := hc.PostForm(formURL, url.Values{
57 "email": {email},
58 "password": {password},
59 })
60 if err != nil {
61 return "", fmt.Errorf("POST credentials: %w", err)
62 }
63 resp2.Body.Close()
64
65 // A failed sign-in re-renders the login form as a 200 without a session
66 // cookie. Detect success by the cookie's presence in the jar, never by the
67 // status. The password is never included in the error.
68 if cookieByName(jar.Cookies(base), sessionCookie) == nil {
69 return "", fmt.Errorf("sign-in failed for %q: no %s cookie after credential POST "+
70 "(final status %d at %s) — check the CI user exists in eitri-oidc and the password matches",
71 email, sessionCookie, resp2.StatusCode, resp2.Request.URL)
72 }
73
74 // Mint the PAT through the shared client, riding the session cookie in the
75 // jar (Token left empty so no Bearer header is sent).
76 c := &client.Client{BaseURL: serverURL, HTTP: hc}
77 tok, err := c.CreateAPIToken("boot-gate", time.Hour)
78 if err != nil {
79 return "", fmt.Errorf("mint boot-gate PAT: %w", err)
80 }
81 return tok.Token, nil
82 }
83
84 // proveCredentialChain is the boot-gate's phase-1 proof: it confirms the minted
85 // PAT resolves to a non-empty tenant via GET /api/v1/me, exercising the whole
86 // issuer -> login -> session -> PAT-mint chain without touching VMs. It returns
87 // the tenant handle so the caller can log which tenant the ci user landed in.
88 func proveCredentialChain(serverURL, token string) (string, error) {
89 c := &client.Client{BaseURL: serverURL, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}
90 me, err := c.Me()
91 if err != nil {
92 return "", fmt.Errorf("credential-chain proof: Me() with minted PAT: %w", err)
93 }
94 if me.Tenant == "" {
95 return "", fmt.Errorf("credential-chain proof: minted PAT resolved to an empty tenant")
96 }
97 return me.Tenant, nil
98 }
99
100 // cookieByName returns the named cookie from cookies, or nil if absent.
101 func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
102 for _, c := range cookies {
103 if c.Name == name {
104 return c
105 }
106 }
107 return nil
108 }
cmd/eitri-smoke/login_test.go
Old New
@@ -1,171 +0,0 @@
1 package main
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/oidcprovider"
13 "github.com/a73x/eitri/internal/server/api"
14 "github.com/a73x/eitri/internal/server/api/client"
15 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry"
17 "github.com/a73x/eitri/internal/server/store"
18 )
19
20 // smokeLoginEnv is a whole eitri-server /auth + /api stack wired to a real
21 // internal/oidcprovider issuer — the exact credential chain a deploy walks. It
22 // exists to prove loginPAT end-to-end: sign in through the login form, land a
23 // session, mint a PAT, and use it against the API.
24 type smokeLoginEnv struct {
25 serverURL string
26 st *store.Store
27 }
28
29 // newSmokeLoginEnv stands up the issuer and server with one seeded user and
30 // returns the server's base URL. It mirrors internal/server/api/auth_test.go's
31 // fixture: the httptest listener binds on construction so we know the server's
32 // address (hence the OIDC redirect URL) before wiring the two ends.
33 func newSmokeLoginEnv(t *testing.T, email, password string) smokeLoginEnv {
34 t.Helper()
35
36 apiSrv := httptest.NewUnstartedServer(nil)
37 publicURL := "http://" + apiSrv.Listener.Addr().String()
38
39 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
40 if err != nil {
41 t.Fatalf("store.Open: %v", err)
42 }
43 t.Cleanup(func() { st.Close() })
44
45 usersPath := filepath.Join(t.TempDir(), "users.json")
46 if err := oidcprovider.AddUser(usersPath, email, password); err != nil {
47 t.Fatalf("AddUser: %v", err)
48 }
49 prov, err := oidcprovider.New(oidcprovider.Config{
50 UsersFile: usersPath,
51 SigningKey: filepath.Join(t.TempDir(), "signing.key"),
52 Clients: []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
53 })
54 if err != nil {
55 t.Fatalf("oidcprovider.New: %v", err)
56 }
57 oidcSrv := httptest.NewServer(prov.Handler())
58 t.Cleanup(oidcSrv.Close)
59 prov.SetIssuer(oidcSrv.URL)
60
61 a := api.New(api.Config{
62 HostSecret: []byte("hostsecret"),
63 OIDC: api.OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL},
64 }, st, registry.New(time.Now), hub.New())
65 t.Cleanup(a.Close)
66
67 root := http.NewServeMux()
68 root.Handle("/api/", a.Handler())
69 root.Handle("/auth/", a.AuthHandler())
70 apiSrv.Config.Handler = root
71 apiSrv.Start()
72 t.Cleanup(apiSrv.Close)
73
74 return smokeLoginEnv{serverURL: publicURL, st: st}
75 }
76
77 func TestLoginPATMintsUsableToken(t *testing.T) {
78 const (
79 email = "ci@eitri.local"
80 password = "hunter2hunter2"
81 )
82 env := newSmokeLoginEnv(t, email, password)
83
84 token, err := loginPAT(env.serverURL, email, password)
85 if err != nil {
86 t.Fatalf("loginPAT: %v", err)
87 }
88 if token == "" {
89 t.Fatal("loginPAT returned an empty token")
90 }
91
92 // The PAT must actually authenticate an ordinary API call.
93 c := &client.Client{BaseURL: env.serverURL, Token: token, HTTP: &http.Client{Timeout: 10 * time.Second}}
94 me, err := c.Me()
95 if err != nil {
96 t.Fatalf("Me() with minted PAT: %v", err)
97 }
98 if me.Email != email {
99 t.Errorf("Me().Email = %q, want %q", me.Email, email)
100 }
101 if _, err := c.ListHosts(context.Background()); err != nil {
102 t.Errorf("ListHosts() with minted PAT: %v", err)
103 }
104 }
105
106 // TestProveCredentialChain is the phase-1 proof: signing in and minting a PAT
107 // must resolve to a non-empty tenant via Me() (the ci user's JIT tenant),
108 // without any VM involvement.
109 func TestProveCredentialChain(t *testing.T) {
110 const (
111 email = "ci@eitri.local"
112 password = "hunter2hunter2"
113 )
114 env := newSmokeLoginEnv(t, email, password)
115
116 token, err := loginPAT(env.serverURL, email, password)
117 if err != nil {
118 t.Fatalf("loginPAT: %v", err)
119 }
120 tenant, err := proveCredentialChain(env.serverURL, token)
121 if err != nil {
122 t.Fatalf("proveCredentialChain: %v", err)
123 }
124 if tenant == "" {
125 t.Fatal("proveCredentialChain returned an empty tenant")
126 }
127 }
128
129 // TestScenarioPATDerivesTenant is the phase-2 contract: a scenario client built
130 // from an operator-minted PAT derives its tenant via Me() — not from any env or
131 // hardcoded default. The store mints the PAT directly, standing in for the
132 // console-minted "deploy" token a real operator saves to CI_PAT_FILE.
133 func TestScenarioPATDerivesTenant(t *testing.T) {
134 env := newSmokeLoginEnv(t, "ci@eitri.local", "hunter2hunter2")
135
136 // A distinct operator tenant with a row (handleMe 401s on a rowless tenant).
137 tn, err := env.st.CreateTenantForIdentity("https://op.example", "op-subject", "op@example.com")
138 if err != nil {
139 t.Fatalf("CreateTenantForIdentity: %v", err)
140 }
141 secret, _, err := env.st.CreateAPIToken(tn.ID, "deploy", 0)
142 if err != nil {
143 t.Fatalf("CreateAPIToken: %v", err)
144 }
145
146 c := &client.Client{BaseURL: env.serverURL, Token: secret, HTTP: &http.Client{Timeout: 10 * time.Second}}
147 me, err := c.Me()
148 if err != nil {
149 t.Fatalf("Me() with store-minted PAT: %v", err)
150 }
151 if me.Tenant != tn.ID {
152 t.Errorf("Me().Tenant = %q, want %q (derived, not assumed)", me.Tenant, tn.ID)
153 }
154 }
155
156 func TestLoginPATWrongPasswordFails(t *testing.T) {
157 const email = "ci@eitri.local"
158 env := newSmokeLoginEnv(t, email, "the-right-password")
159
160 _, err := loginPAT(env.serverURL, email, "the-wrong-password")
161 if err == nil {
162 t.Fatal("loginPAT: want error on wrong password, got nil")
163 }
164 // The failure must name the sign-in problem and must not echo the password.
165 if !strings.Contains(err.Error(), "sign-in failed") || !strings.Contains(err.Error(), email) {
166 t.Errorf("error = %q, want it to mention the sign-in failure and the user", err)
167 }
168 if strings.Contains(err.Error(), "the-wrong-password") {
169 t.Errorf("error must not echo the password: %q", err)
170 }
171 }
cmd/eitri-smoke/main.go
Old New
@@ -1,19 +1,14 @@
1 // eitri-smoke: the deploy boot-gate harness. It signs in through the real OIDC
2 // flow, then creates a throwaway VM, proves it boots and is reachable through
3 // the SSH-CA gate, and reaps it — failing the deploy on any error. All behavior
4 // lives in internal/smoke (Run); this package is wiring only (arch R14).
1 package main 5 package main
2 6
3 import ( 7 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "fmt" 8 "fmt"
8 "net/http"
9 "os" 9 "os"
10 "os/exec"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15 10
16 "github.com/a73x/eitri/internal/server/api/client" 11 "github.com/a73x/eitri/internal/smoke"
17 "github.com/a73x/eitri/internal/version" 12 "github.com/a73x/eitri/internal/version"
18 ) 13 )
19 14
@@ -22,140 +17,8 @@ func main() {
22 fmt.Println(version.Version) 17 fmt.Println(version.Version)
23 return 18 return
24 } 19 }
25 if err := run(); err != nil { 20 if err := smoke.Run(); err != nil {
26 fmt.Fprintln(os.Stderr, "eitri-smoke:", err) 21 fmt.Fprintln(os.Stderr, "eitri-smoke:", err)
27 os.Exit(1) 22 os.Exit(1)
28 } 23 }
29 } 24 }
30
31 func run() error {
32 cfg, err := loadConfig(os.Getenv)
33 if err != nil {
34 return err
35 }
36
37 // Phase 1 — credential-chain proof. The admin token is gone: the boot-gate
38 // authenticates like a human. Read the machine identity's password (CI_USER,
39 // the deploy identity), sign in through the real OIDC code flow, mint a
40 // short-lived PAT, and confirm it resolves to a tenant (spec §3). This proves
41 // issuer, login form, session, and PAT mint end to end; it deliberately never
42 // touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's
43 // `default` tenant is human-owned), so the lifecycle half runs as the operator
44 // below.
45 pwBytes, err := os.ReadFile(cfg.CIPasswordFile)
46 if err != nil {
47 return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err)
48 }
49 password := strings.TrimRight(string(pwBytes), "\r\n")
50
51 token, err := loginPAT(cfg.ServerURL, cfg.CIUser, password)
52 if err != nil {
53 return err
54 }
55 ciTenant, err := proveCredentialChain(cfg.ServerURL, token)
56 if err != nil {
57 return err
58 }
59 fmt.Printf("credential chain OK (tenant %s)\n", ciTenant)
60
61 // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from
62 // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation
63 // story). The scenario's tenant is DERIVED via Me(), never assumed, and
64 // threaded into the user-CA registration and gate connect name below.
65 patBytes, err := os.ReadFile(cfg.CIPATFile)
66 if err != nil {
67 return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err)
68 }
69 pat := strings.TrimRight(string(patBytes), "\r\n")
70
71 api := &client.Client{
72 BaseURL: cfg.ServerURL,
73 Token: pat,
74 UserCALabel: "eitri-smoke",
75 HTTP: &http.Client{Timeout: 30 * time.Second},
76 }
77 me, err := api.Me()
78 if err != nil {
79 return fmt.Errorf("resolve operator PAT tenant: %w", err)
80 }
81 if me.Tenant == "" {
82 return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", cfg.CIPATFile)
83 }
84 tenant := me.Tenant
85 fmt.Printf("operator PAT tenant: %s\n", tenant)
86
87 vmName, err := randVMName()
88 if err != nil {
89 return fmt.Errorf("generate vm name: %w", err)
90 }
91
92 var gate *gateHooks
93 if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" {
94 userCA, err := loadOrCreateUserCA(cfg.SmokeUserCAFile)
95 if err != nil {
96 return fmt.Errorf("load smoke user CA: %w", err)
97 }
98 gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep)
99 } else {
100 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)")
101 }
102
103 ctx := context.Background()
104 msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey)
105 if err != nil {
106 return err
107 }
108 fmt.Println(msg)
109
110 // The boot gate has passed. Collect integration coverage as a by-product
111 // when COVER_OUT is set; a failure here must NOT fail the deploy, so warn
112 // and carry on — the gate above is the authoritative check.
113 if cfg.CoverOut != "" {
114 if err := collectCoverage(ctx, cfg); err != nil {
115 fmt.Fprintln(os.Stderr, "eitri-smoke: coverage collection failed (boot gate still passed):", err)
116 }
117 }
118 return nil
119 }
120
121 // randVMName generates a throwaway VM name of the form "smoke-<8 hex digits>",
122 // unique enough that concurrent smoke runs don't collide.
123 func randVMName() (string, error) {
124 var b [4]byte
125 if _, err := rand.Read(b[:]); err != nil {
126 return "", err
127 }
128 return "smoke-" + hex.EncodeToString(b[:]), nil
129 }
130
131 // realRunSSH returns the real sshFunc: it shells out to the ssh binary
132 // against userHost:port with the deploy boot-gate's ssh flags, running
133 // remoteCmd non-interactively and returning its stdout.
134 func realRunSSH(userHost string, port int) sshFunc {
135 return func(ctx context.Context, remoteCmd string) (string, error) {
136 cmd := exec.CommandContext(ctx, "ssh",
137 "-p", strconv.Itoa(port),
138 "-o", "BatchMode=yes",
139 "-o", "ConnectTimeout=10",
140 userHost, remoteCmd)
141 out, err := cmd.Output()
142 return string(out), err
143 }
144 }
145
146 // realReadPubKey reads the local operator's SSH public key, trying
147 // ~/.ssh/id_ed25519.pub then ~/.ssh/id_rsa.pub. It returns "" (not an error)
148 // if neither is present — the scenario tolerates a keyless VM.
149 func realReadPubKey() string {
150 home, err := os.UserHomeDir()
151 if err != nil {
152 return ""
153 }
154 for _, name := range []string{"id_ed25519.pub", "id_rsa.pub"} {
155 data, err := os.ReadFile(filepath.Join(home, ".ssh", name))
156 if err == nil {
157 return strings.TrimSpace(string(data))
158 }
159 }
160 return ""
161 }
cmd/eitri-smoke/scenario.go
Old New
@@ -1,196 +0,0 @@
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
@@ -1,455 +0,0 @@
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
@@ -1,56 +0,0 @@
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
@@ -1,37 +0,0 @@
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 }
cmd/eitri/main.go
Old New
@@ -1,10 +1,11 @@
1 // Command eitri is the end-user client: SSH into fleet VMs through the 1 // Command eitri is the end-user client: SSH into fleet VMs through the
2 // jump gate with self-signed short-lived certs (eitri ssh) and register 2 // jump gate with self-signed short-lived certs (eitri ssh) and register
3 // tenant user CAs (eitri ca upload). See internal/cli and docs/ssh-access.md. 3 // tenant user CAs (eitri ca upload). All behavior lives in internal/cli
4 // (Main); this package is wiring only (arch R14). See docs/ssh-access.md.
4 package main 5 package main
5 6
6 import ( 7 import (
7 "context" 8 "errors"
8 "fmt" 9 "fmt"
9 "os" 10 "os"
10 11
@@ -12,31 +13,14 @@ import (
12 "github.com/a73x/eitri/internal/version" 13 "github.com/a73x/eitri/internal/version"
13 ) 14 )
14 15
15 const usage = `usage:
16 eitri ssh <vm> [ssh args / remote command...]
17 eitri ca upload [<tenant>] <ca-public-key-file>
18 eitri --version
19
20 env: EITRI_URL, EITRI_GATE (required for ssh); EITRI_TOKEN (required for ca);
21 EITRI_CA, EITRI_TENANT, EITRI_KEY, EITRI_KNOWN_HOSTS (optional)`
22
23 func main() { 16 func main() {
24 if len(os.Args) > 1 && os.Args[1] == "--version" { 17 if len(os.Args) > 1 && os.Args[1] == "--version" {
25 fmt.Println(version.Version) 18 fmt.Println(version.Version)
26 return 19 return
27 } 20 }
28 if len(os.Args) < 2 { 21 err := cli.Main(os.Args[1:], os.Stdout)
29 fmt.Fprintln(os.Stderr, usage) 22 if errors.Is(err, cli.ErrUsage) {
30 os.Exit(2) 23 fmt.Fprintln(os.Stderr, cli.Usage)
31 }
32 var err error
33 switch os.Args[1] {
34 case "ssh":
35 err = runSSH(os.Args[2:])
36 case "ca":
37 err = runCA(os.Args[2:])
38 default:
39 fmt.Fprintln(os.Stderr, usage)
40 os.Exit(2) 24 os.Exit(2)
41 } 25 }
42 if err != nil { 26 if err != nil {
@@ -44,46 +28,3 @@ func main() {
44 os.Exit(1) 28 os.Exit(1)
45 } 29 }
46 } 30 }
47
48 func runSSH(args []string) error {
49 if len(args) >= 1 && (args[0] == "-h" || args[0] == "--help") {
50 fmt.Println("usage: eitri ssh <vm> [ssh args / remote command...]")
51 return nil
52 }
53 if len(args) < 1 {
54 return fmt.Errorf("usage: eitri ssh <vm> [ssh args / remote command...]")
55 }
56 env, err := cli.FromEnv()
57 if err != nil {
58 return err
59 }
60 return cli.RunSSH(context.Background(), env, args[0], args[1:])
61 }
62
63 func runCA(args []string) error {
64 if len(args) < 1 || args[0] != "upload" {
65 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
66 }
67 rest := args[1:]
68 if len(rest) < 1 || len(rest) > 2 {
69 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
70 }
71 tenant, pub := os.Getenv("EITRI_TENANT"), rest[0]
72 if len(rest) == 2 {
73 tenant, pub = rest[0], rest[1]
74 }
75 if tenant == "" {
76 return fmt.Errorf("no tenant: pass one (eitri ca upload <tenant> <file>) or set EITRI_TENANT")
77 }
78 url := os.Getenv("EITRI_URL")
79 token := os.Getenv("EITRI_TOKEN")
80 if url == "" || token == "" {
81 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (a personal access token)")
82 }
83 out, err := cli.UploadUserCA(context.Background(), url, token, tenant, pub)
84 if err != nil {
85 return err
86 }
87 fmt.Println(out)
88 return nil
89 }
docs/shape.html
Old New
@@ -66,18 +66,7 @@
66 "plane": "binaries", 66 "plane": "binaries",
67 "synopsis": "eitri-agent: BYO-hardware agent.", 67 "synopsis": "eitri-agent: BYO-hardware agent.",
68 "imports": [ 68 "imports": [
69 "internal/agent/bootstrap", 69 "internal/agent/run",
70 "internal/agent/cloudhv",
71 "internal/agent/enrollclient",
72 "internal/agent/imagecache",
73 "internal/agent/netenv",
74 "internal/agent/reconcile",
75 "internal/agent/seed",
76 "internal/agent/serialpump",
77 "internal/agent/state",
78 "internal/agent/syncclient",
79 "internal/covsnap",
80 "internal/joinblob",
81 "internal/version" 70 "internal/version"
82 ] 71 ]
83 }, 72 },
@@ -94,9 +83,7 @@
94 "plane": "binaries", 83 "plane": "binaries",
95 "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.",
96 "imports": [ 85 "imports": [
97 "internal/gateclient",
98 "internal/mcpserver", 86 "internal/mcpserver",
99 "internal/server/api/client",
100 "internal/version" 87 "internal/version"
101 ] 88 ]
102 }, 89 },
@@ -112,22 +99,9 @@
112 { 99 {
113 "importPath": "cmd/eitri-server", 100 "importPath": "cmd/eitri-server",
114 "plane": "binaries", 101 "plane": "binaries",
115 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 102 "synopsis": "eitri-server: single-node control plane (no TLS termination here — front with a reverse proxy for TLS).",
116 "imports": [ 103 "imports": [
117 "internal/covsnap", 104 "internal/server/boot",
118 "internal/joinblob",
119 "internal/server/api",
120 "internal/server/config",
121 "internal/server/health",
122 "internal/server/hub",
123 "internal/server/registry",
124 "internal/server/release",
125 "internal/server/sshca",
126 "internal/server/sshgate",
127 "internal/server/store",
128 "internal/server/syncsvc",
129 "internal/server/web",
130 "internal/transport",
131 "internal/version" 105 "internal/version"
132 ] 106 ]
133 }, 107 },
@@ -151,10 +125,9 @@
151 { 125 {
152 "importPath": "cmd/eitri-smoke", 126 "importPath": "cmd/eitri-smoke",
153 "plane": "binaries", 127 "plane": "binaries",
154 "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.", 128 "synopsis": "eitri-smoke: the deploy boot-gate harness.",
155 "imports": [ 129 "imports": [
156 "internal/gateclient", 130 "internal/smoke",
157 "internal/server/api/client",
158 "internal/version" 131 "internal/version"
159 ] 132 ]
160 }, 133 },
@@ -239,6 +212,25 @@
239 ] 212 ]
240 }, 213 },
241 { 214 {
215 "importPath": "internal/agent/run",
216 "plane": "data",
217 "synopsis": "Package run implements the eitri-agent command line behind a tested RunCLI so cmd/eitri-agent stays thin wiring (arch R14).",
218 "imports": [
219 "internal/agent/bootstrap",
220 "internal/agent/cloudhv",
221 "internal/agent/enrollclient",
222 "internal/agent/imagecache",
223 "internal/agent/netenv",
224 "internal/agent/reconcile",
225 "internal/agent/seed",
226 "internal/agent/serialpump",
227 "internal/agent/state",
228 "internal/agent/syncclient",
229 "internal/covsnap",
230 "internal/joinblob"
231 ]
232 },
233 {
242 "importPath": "internal/agent/seed", 234 "importPath": "internal/agent/seed",
243 "plane": "data", 235 "plane": "data",
244 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", 236 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
@@ -398,6 +390,27 @@
398 "imports": [] 390 "imports": []
399 }, 391 },
400 { 392 {
393 "importPath": "internal/server/boot",
394 "plane": "control",
395 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).",
396 "imports": [
397 "internal/covsnap",
398 "internal/joinblob",
399 "internal/server/api",
400 "internal/server/config",
401 "internal/server/health",
402 "internal/server/hub",
403 "internal/server/registry",
404 "internal/server/release",
405 "internal/server/sshca",
406 "internal/server/sshgate",
407 "internal/server/store",
408 "internal/server/syncsvc",
409 "internal/server/web",
410 "internal/transport"
411 ]
412 },
413 {
401 "importPath": "internal/server/config", 414 "importPath": "internal/server/config",
402 "plane": "control", 415 "plane": "control",
403 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", 416 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.",
@@ -490,6 +503,15 @@
490 ] 503 ]
491 }, 504 },
492 { 505 {
506 "importPath": "internal/smoke",
507 "plane": "tooling",
508 "synopsis": "Package smoke is the deploy boot-gate harness.",
509 "imports": [
510 "internal/gateclient",
511 "internal/server/api/client"
512 ]
513 },
514 {
493 "importPath": "internal/transport", 515 "importPath": "internal/transport",
494 "plane": "wire", 516 "plane": "wire",
495 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 517 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
docs/shape.json
Old New
@@ -15,18 +15,7 @@
15 "plane": "binaries", 15 "plane": "binaries",
16 "synopsis": "eitri-agent: BYO-hardware agent.", 16 "synopsis": "eitri-agent: BYO-hardware agent.",
17 "imports": [ 17 "imports": [
18 "internal/agent/bootstrap", 18 "internal/agent/run",
19 "internal/agent/cloudhv",
20 "internal/agent/enrollclient",
21 "internal/agent/imagecache",
22 "internal/agent/netenv",
23 "internal/agent/reconcile",
24 "internal/agent/seed",
25 "internal/agent/serialpump",
26 "internal/agent/state",
27 "internal/agent/syncclient",
28 "internal/covsnap",
29 "internal/joinblob",
30 "internal/version" 19 "internal/version"
31 ] 20 ]
32 }, 21 },
@@ -43,9 +32,7 @@
43 "plane": "binaries", 32 "plane": "binaries",
44 "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.",
45 "imports": [ 34 "imports": [
46 "internal/gateclient",
47 "internal/mcpserver", 35 "internal/mcpserver",
48 "internal/server/api/client",
49 "internal/version" 36 "internal/version"
50 ] 37 ]
51 }, 38 },
@@ -61,22 +48,9 @@
61 { 48 {
62 "importPath": "cmd/eitri-server", 49 "importPath": "cmd/eitri-server",
63 "plane": "binaries", 50 "plane": "binaries",
64 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 51 "synopsis": "eitri-server: single-node control plane (no TLS termination here — front with a reverse proxy for TLS).",
65 "imports": [ 52 "imports": [
66 "internal/covsnap", 53 "internal/server/boot",
67 "internal/joinblob",
68 "internal/server/api",
69 "internal/server/config",
70 "internal/server/health",
71 "internal/server/hub",
72 "internal/server/registry",
73 "internal/server/release",
74 "internal/server/sshca",
75 "internal/server/sshgate",
76 "internal/server/store",
77 "internal/server/syncsvc",
78 "internal/server/web",
79 "internal/transport",
80 "internal/version" 54 "internal/version"
81 ] 55 ]
82 }, 56 },
@@ -100,10 +74,9 @@
100 { 74 {
101 "importPath": "cmd/eitri-smoke", 75 "importPath": "cmd/eitri-smoke",
102 "plane": "binaries", 76 "plane": "binaries",
103 "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.", 77 "synopsis": "eitri-smoke: the deploy boot-gate harness.",
104 "imports": [ 78 "imports": [
105 "internal/gateclient", 79 "internal/smoke",
106 "internal/server/api/client",
107 "internal/version" 80 "internal/version"
108 ] 81 ]
109 }, 82 },
@@ -188,6 +161,25 @@
188 ] 161 ]
189 }, 162 },
190 { 163 {
164 "importPath": "internal/agent/run",
165 "plane": "data",
166 "synopsis": "Package run implements the eitri-agent command line behind a tested RunCLI so cmd/eitri-agent stays thin wiring (arch R14).",
167 "imports": [
168 "internal/agent/bootstrap",
169 "internal/agent/cloudhv",
170 "internal/agent/enrollclient",
171 "internal/agent/imagecache",
172 "internal/agent/netenv",
173 "internal/agent/reconcile",
174 "internal/agent/seed",
175 "internal/agent/serialpump",
176 "internal/agent/state",
177 "internal/agent/syncclient",
178 "internal/covsnap",
179 "internal/joinblob"
180 ]
181 },
182 {
191 "importPath": "internal/agent/seed", 183 "importPath": "internal/agent/seed",
192 "plane": "data", 184 "plane": "data",
193 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", 185 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
@@ -347,6 +339,27 @@
347 "imports": [] 339 "imports": []
348 }, 340 },
349 { 341 {
342 "importPath": "internal/server/boot",
343 "plane": "control",
344 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).",
345 "imports": [
346 "internal/covsnap",
347 "internal/joinblob",
348 "internal/server/api",
349 "internal/server/config",
350 "internal/server/health",
351 "internal/server/hub",
352 "internal/server/registry",
353 "internal/server/release",
354 "internal/server/sshca",
355 "internal/server/sshgate",
356 "internal/server/store",
357 "internal/server/syncsvc",
358 "internal/server/web",
359 "internal/transport"
360 ]
361 },
362 {
350 "importPath": "internal/server/config", 363 "importPath": "internal/server/config",
351 "plane": "control", 364 "plane": "control",
352 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", 365 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.",
@@ -439,6 +452,15 @@
439 ] 452 ]
440 }, 453 },
441 { 454 {
455 "importPath": "internal/smoke",
456 "plane": "tooling",
457 "synopsis": "Package smoke is the deploy boot-gate harness.",
458 "imports": [
459 "internal/gateclient",
460 "internal/server/api/client"
461 ]
462 },
463 {
442 "importPath": "internal/transport", 464 "importPath": "internal/transport",
443 "plane": "wire", 465 "plane": "wire",
444 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 466 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
internal/agent/cloudhv/cloudhv.go
Old New
@@ -49,6 +49,17 @@ func New(st *state.Store, chBin, firmware string, run agentexec.Runner) *Provisi
49 return &Provisioner{st: st, chBin: chBin, firmware: firmware, run: run} 49 return &Provisioner{st: st, chBin: chBin, firmware: firmware, run: run}
50 } 50 }
51 51
52 // RealRunner is the production one-shot command runner the host-touching agent
53 // packages (netenv, imagecache, cloudhv, syncclient) run with. It spawns
54 // name+args, waits, and returns their combined stdout/stderr. It lives here
55 // because cloudhv is the data plane's one sanctioned os/exec site (arch R6/R7);
56 // every other agent package receives it as an injected agentexec.Runner and so
57 // never imports os/exec itself.
58 func RealRunner(ctx context.Context, name string, args ...string) (string, error) {
59 out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
60 return string(out), err
61 }
62
52 // BootstrapDest maps a --ch-bin value to the filesystem path bootstrap may 63 // BootstrapDest maps a --ch-bin value to the filesystem path bootstrap may
53 // install the binary at. The two are different vocabularies: --ch-bin is 64 // install the binary at. The two are different vocabularies: --ch-bin is
54 // usually a bare command name resolved on $PATH at launch, which names no 65 // usually a bare command name resolved on $PATH at launch, which names no
internal/agent/run/cli.go
Old New
@@ -0,0 +1,309 @@
1 // cli.go is the eitri-agent command line: `eitri-agent join <blob>` enrolls the
2 // host once, and `eitri-agent` (no subcommand) runs the reconcile + sync loop
3 // indefinitely. It lives here rather than in cmd/eitri-agent so the flag/config
4 // assembly, validation, and dispatch are testable and coverage-gated (arch R14:
5 // main packages are wiring only).
6
7 // Package run implements the eitri-agent command line behind a tested RunCLI so
8 // cmd/eitri-agent stays thin wiring (arch R14).
9 package run
10
11 import (
12 "context"
13 "errors"
14 "flag"
15 "fmt"
16 "log/slog"
17 "os"
18 "os/signal"
19 "runtime"
20 "syscall"
21 "time"
22
23 "github.com/a73x/eitri/internal/agent/bootstrap"
24 "github.com/a73x/eitri/internal/agent/cloudhv"
25 "github.com/a73x/eitri/internal/agent/enrollclient"
26 "github.com/a73x/eitri/internal/agent/imagecache"
27 "github.com/a73x/eitri/internal/agent/netenv"
28 "github.com/a73x/eitri/internal/agent/reconcile"
29 "github.com/a73x/eitri/internal/agent/seed"
30 "github.com/a73x/eitri/internal/agent/serialpump"
31 "github.com/a73x/eitri/internal/agent/state"
32 "github.com/a73x/eitri/internal/agent/syncclient"
33 "github.com/a73x/eitri/internal/covsnap"
34 "github.com/a73x/eitri/internal/joinblob"
35 )
36
37 // Config carries serve's wiring, replacing a long positional list.
38 type Config struct {
39 StateDir, CHBin, Firmware string
40 BootstrapURL string
41 TombstoneGrace, VanishGrace time.Duration
42 VMTimeout time.Duration
43 ImageCacheMaxGB int64
44 // MaxConcurrentCreates bounds how much of the per-VM workers' create
45 // concurrency reaches the disk at once (0 = unlimited).
46 MaxConcurrentCreates int
47 // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent offers the
48 // fleet (0 = unlimited): advertised to the server AND enforced at VM boot.
49 MaxVCPUs, MaxMemMB, MaxDiskGB int64
50 }
51
52 // RunCLI dispatches the eitri-agent command line (everything after the binary
53 // name, --version excluded — that stays in cmd/eitri-agent). It parses flags,
54 // opens the state directory, then either enrolls the host ("join <blob>") or
55 // runs the agent.
56 func RunCLI(args []string) error {
57 cfg, rest, err := parseConfig(args)
58 if err != nil {
59 return err
60 }
61
62 st, err := state.Open(cfg.StateDir)
63 if err != nil {
64 return fmt.Errorf("open state dir: %w", err)
65 }
66
67 if len(rest) > 0 && rest[0] == "join" {
68 var blob string
69 if len(rest) > 1 {
70 blob = rest[1]
71 }
72 return join(st, blob)
73 }
74
75 return serve(st, cfg)
76 }
77
78 // parseConfig defines and parses the agent flags, returning the assembled
79 // Config and any positional arguments (the "join <blob>" subcommand). Resource
80 // caps are validated here so a negative cap is rejected before any state is
81 // touched.
82 func parseConfig(args []string) (Config, []string, error) {
83 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError)
84 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
85 chBin := fs.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
86 firmware := fs.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
87 bootstrapURL := fs.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)")
88 tombstoneGrace := fs.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
89 vanishGrace := fs.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone")
90 vmTimeout := fs.Duration("vm-timeout", 15*time.Minute, "watchdog bound on ONE VM's reconcile pass (0 disables); keep above the 10m image-download timeout")
91 imageCacheMaxGB := fs.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)")
92 maxConcurrentCreates := fs.Int("max-concurrent-creates", 4, "cap how many VMs may be inside the I/O-heavy part of create at once (image fetch + disk copy); 0 = unlimited")
93 maxVCPUs := fs.Int64("max-vcpus", 0, "cap the total vCPUs this host offers the fleet (0 = unlimited; reserves headroom, advertised + enforced at boot)")
94 maxMemMB := fs.Int64("max-mem-mb", 0, "cap the total memory (MB) this host offers the fleet (0 = unlimited)")
95 maxDiskGB := fs.Int64("max-disk-gb", 0, "cap the total disk (GB) this host offers the fleet (0 = unlimited)")
96 if err := fs.Parse(args); err != nil {
97 return Config{}, nil, err
98 }
99
100 // Fixed order so the reported cap is deterministic when more than one is bad.
101 caps := []struct {
102 flag string
103 v int64
104 }{
105 {"max-vcpus", *maxVCPUs},
106 {"max-mem-mb", *maxMemMB},
107 {"max-disk-gb", *maxDiskGB},
108 }
109 for _, c := range caps {
110 if c.v < 0 {
111 return Config{}, nil, fmt.Errorf("resource cap --%s must be >= 0 (0 = unlimited), got %d", c.flag, c.v)
112 }
113 }
114
115 return Config{
116 StateDir: *stateDir,
117 CHBin: *chBin,
118 Firmware: *firmware,
119 BootstrapURL: *bootstrapURL,
120 TombstoneGrace: *tombstoneGrace,
121 VanishGrace: *vanishGrace,
122 VMTimeout: *vmTimeout,
123 ImageCacheMaxGB: *imageCacheMaxGB,
124 MaxConcurrentCreates: *maxConcurrentCreates,
125 MaxVCPUs: *maxVCPUs,
126 MaxMemMB: *maxMemMB,
127 MaxDiskGB: *maxDiskGB,
128 }, fs.Args(), nil
129 }
130
131 // join handles the "join <blob>" subcommand: decode the join blob, enroll,
132 // and persist identity — pinning the server cert from the blob (the enroll
133 // response's fingerprint is ignored, so the blob is the sole trust root).
134 func join(st *state.Store, blob string) error {
135 if blob == "" {
136 return errors.New("usage: eitri-agent join <join-blob>")
137 }
138 f, err := joinblob.Decode(blob)
139 if err != nil {
140 // Never echo the blob itself — it carries a bearer token.
141 return fmt.Errorf("invalid join blob: %w", err)
142 }
143
144 hostname, err := os.Hostname()
145 if err != nil {
146 hostname = "unknown"
147 }
148
149 result, err := enrollclient.New(f.HTTPURL).Enroll(context.Background(), enrollclient.Request{
150 Token: f.Token,
151 Name: hostname,
152 OS: runtime.GOOS,
153 Arch: runtime.GOARCH,
154 Provisioner: "cloudhv",
155 })
156 if errors.Is(err, enrollclient.ErrTokenRejected) {
157 return errors.New("enroll rejected: token already used or expired — mint a new join token")
158 }
159 if err != nil {
160 return fmt.Errorf("enroll failed: %w", err)
161 }
162
163 id := state.Identity{
164 HostID: result.HostID,
165 Credential: result.Credential,
166 BridgeCIDR: result.BridgeCIDR,
167 ServerQUICAddr: f.QUICAddr,
168 ServerCertSHA256: f.CertFP, // authoritative; response fingerprint ignored
169 }
170 if err := st.SaveIdentity(id); err != nil {
171 return fmt.Errorf("save identity: %w", err)
172 }
173 fmt.Printf("Enrolled: host_id=%s bridge_cidr=%s\n", result.HostID, result.BridgeCIDR)
174 return nil
175 }
176
177 // serve handles the normal (no subcommand) run mode: it wires the reconcile
178 // engine and sync client and blocks until SIGINT/SIGTERM.
179 func serve(st *state.Store, cfg Config) error {
180 id, ok := st.Identity()
181 if !ok {
182 return errors.New("not enrolled — run with 'join <blob>' subcommand first")
183 }
184
185 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
186 defer cancel()
187
188 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with
189 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage
190 // from the live agent without bouncing the process.
191 covsnap.Install(ctx)
192
193 net, err := netenv.New(cloudhv.RealRunner, id.BridgeCIDR)
194 if err != nil {
195 return fmt.Errorf("netenv init: %w", err)
196 }
197
198 if err := net.EnsureBridge(ctx); err != nil {
199 return fmt.Errorf("ensure bridge: %w", err)
200 }
201
202 // Bootstrap cloud-hypervisor and its UEFI firmware before anything tries
203 // to launch a VM: a bare host that just joined has neither, and the agent
204 // is useless without at least the hypervisor binary. BootstrapDest maps
205 // the --ch-bin value (usually a bare $PATH name) to a real install path.
206 bs := &bootstrap.Bootstrapper{CHPath: cloudhv.BootstrapDest(cfg.CHBin), FirmwarePath: cfg.Firmware, ManifestURL: cfg.BootstrapURL}
207 if err := bs.Ensure(ctx); err != nil {
208 return fmt.Errorf("bootstrap runtime: %w", err)
209 }
210
211 prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, cloudhv.RealRunner)
212
213 // Serial console pumps: one per running VM, started at Boot (cloudhv hook)
214 // and reattached here for VMs that survived an agent restart (CH runs in
215 // its own process group; the pump reconnects to the still-listening
216 // serial socket).
217 pumps := serialpump.NewManager(st.SerialSocketPath, st.SerialLogPath)
218 prov.Pumps = pumps
219 if recs, err := st.LoadVMs(); err == nil {
220 for _, rec := range recs {
221 if rec.IP != "" {
222 net.AddReservation(rec.Spec.VMID, rec.IP)
223 }
224 if prov.Running(rec.Spec.VMID) {
225 pumps.Ensure(rec.Spec.VMID)
226 }
227 }
228 } else {
229 slog.Warn("state load failed; surviving VMs' consoles will be silent AND their DHCP reservations are not rebuilt (they may fail to renew until reconcile recreates them)", "err", err)
230 }
231
232 // Start the embedded DHCP responder AFTER reservations are rebuilt from
233 // state, so a surviving guest's renewal is never answered from an empty
234 // table (fail-closed + reservation preload close the gap).
235 if err := net.StartDHCP(ctx); err != nil {
236 return fmt.Errorf("start dhcp: %w", err)
237 }
238
239 cache := imagecache.New(st.ImagesDir(), cloudhv.RealRunner)
240 // Clamp before shifting: GB<<30 overflows int64 for absurd flag values —
241 // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond
242 // any real cache; anything above disables eviction just like 0 would.
243 imageCacheMaxGB := cfg.ImageCacheMaxGB
244 if imageCacheMaxGB < 0 || imageCacheMaxGB > 1<<20 {
245 slog.Warn("image-cache-max-gb out of range [0, 2^20]; disabling eviction", "value", imageCacheMaxGB)
246 imageCacheMaxGB = 0
247 }
248 cache.MaxBytes = imageCacheMaxGB << 30
249 // Reclaim temps left by a previous agent killed mid-fetch or mid-convert.
250 // Must run here, before the reconcile loop starts fetching: a sweep cannot
251 // tell an abandoned temp from one an in-flight fetch is still writing.
252 cache.SweepTemps()
253
254 engine := &reconcile.Engine{
255 St: st,
256 Prov: prov,
257 Net: net,
258 Images: cache.Ensure,
259 Seed: seed.Build,
260 BootID: syncclient.HostBootID,
261 Now: time.Now,
262 TombstoneGrace: cfg.TombstoneGrace,
263 VanishGrace: cfg.VanishGrace,
264 MaxCreateAttempts: 3,
265 MaxConcurrentCreates: cfg.MaxConcurrentCreates,
266 VMTimeout: cfg.VMTimeout,
267 MaxVCPUs: cfg.MaxVCPUs,
268 MaxMemMB: cfg.MaxMemMB,
269 MaxDiskGB: cfg.MaxDiskGB,
270 }
271
272 // Seed the admission ledger from persisted records so the first reconcile
273 // accounts for VMs that survived the agent restart (their compute must
274 // count against the caps before any new create is admitted).
275 if recs, err := st.LoadVMs(); err == nil {
276 engine.SeedLedger(recs)
277 }
278
279 // The per-VM reconcile workers are deliberately NOT torn down here. Stopping
280 // the engine is terminal — it would make the next report an empty actual state,
281 // which the control plane reads as every VM on this host having vanished — and
282 // it would race an unjoined session worker that can still be mid-Step when ctx
283 // is cancelled (see the note below where pumps are torn down). The process is
284 // exiting; the OS reclaims the goroutines.
285
286 client := &syncclient.Client{
287 Engine: engine,
288 St: st,
289 Identity: id,
290 StateDir: cfg.StateDir,
291 Runner: cloudhv.RealRunner,
292 Console: pumps,
293 MaxVCPUs: cfg.MaxVCPUs,
294 MaxMemMB: cfg.MaxMemMB,
295 MaxDiskGB: cfg.MaxDiskGB,
296 }
297
298 slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR)
299 client.Run(ctx)
300
301 // client.Run blocks until ctx is cancelled (SIGINT/SIGTERM) and only then
302 // returns, so the reconcile/sync loop is done driving VMs and no further
303 // Ensure/Stop calls are expected to reach pumps (an unjoined session
304 // worker could in principle still be mid-Engine.Step, but it has nothing
305 // left to drive once client.Run has returned). Tear down every serial
306 // console pump here, at the very end of agent shutdown.
307 pumps.StopAll()
308 return nil
309 }
internal/agent/run/cli_test.go
Old New
@@ -0,0 +1,116 @@
1 package run
2
3 import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/a73x/eitri/internal/agent/state"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 )
12
13 // TestParseConfigDefaults pins every flag default: this binary runs the fleet,
14 // so a silent change to any default is a production behavior change.
15 func TestParseConfigDefaults(t *testing.T) {
16 cfg, rest, err := parseConfig(nil)
17 require.NoError(t, err)
18 assert.Empty(t, rest)
19 assert.Equal(t, "/var/lib/eitri-agent", cfg.StateDir)
20 assert.Equal(t, "cloud-hypervisor", cfg.CHBin)
21 assert.Equal(t, "/usr/share/eitri/CLOUDHV.fd", cfg.Firmware)
22 assert.Equal(t, "https://eitri.sh/dl/latest/manifest.json", cfg.BootstrapURL)
23 assert.Equal(t, 5*time.Minute, cfg.TombstoneGrace)
24 assert.Equal(t, time.Hour, cfg.VanishGrace)
25 assert.Equal(t, 15*time.Minute, cfg.VMTimeout)
26 assert.Equal(t, int64(20), cfg.ImageCacheMaxGB)
27 assert.Equal(t, 4, cfg.MaxConcurrentCreates)
28 assert.Equal(t, int64(0), cfg.MaxVCPUs)
29 assert.Equal(t, int64(0), cfg.MaxMemMB)
30 assert.Equal(t, int64(0), cfg.MaxDiskGB)
31 }
32
33 // TestParseConfigOverrides confirms every flag threads through to the Config.
34 func TestParseConfigOverrides(t *testing.T) {
35 cfg, rest, err := parseConfig([]string{
36 "--state-dir=/srv/state",
37 "--ch-bin=/opt/ch",
38 "--firmware=/opt/fw.fd",
39 "--bootstrap-url=",
40 "--tombstone-grace=90s",
41 "--vanish-grace=2h",
42 "--vm-timeout=0",
43 "--image-cache-max-gb=50",
44 "--max-concurrent-creates=1",
45 "--max-vcpus=8",
46 "--max-mem-mb=4096",
47 "--max-disk-gb=100",
48 })
49 require.NoError(t, err)
50 assert.Empty(t, rest)
51 assert.Equal(t, "/srv/state", cfg.StateDir)
52 assert.Equal(t, "/opt/ch", cfg.CHBin)
53 assert.Equal(t, "/opt/fw.fd", cfg.Firmware)
54 assert.Equal(t, "", cfg.BootstrapURL)
55 assert.Equal(t, 90*time.Second, cfg.TombstoneGrace)
56 assert.Equal(t, 2*time.Hour, cfg.VanishGrace)
57 assert.Equal(t, time.Duration(0), cfg.VMTimeout)
58 assert.Equal(t, int64(50), cfg.ImageCacheMaxGB)
59 assert.Equal(t, 1, cfg.MaxConcurrentCreates)
60 assert.Equal(t, int64(8), cfg.MaxVCPUs)
61 assert.Equal(t, int64(4096), cfg.MaxMemMB)
62 assert.Equal(t, int64(100), cfg.MaxDiskGB)
63 }
64
65 // TestParseConfigJoinSubcommand returns the positional args so RunCLI can
66 // dispatch the join flow.
67 func TestParseConfigJoinSubcommand(t *testing.T) {
68 _, rest, err := parseConfig([]string{"--state-dir=/srv/state", "join", "the-blob"})
69 require.NoError(t, err)
70 require.Equal(t, []string{"join", "the-blob"}, rest)
71 }
72
73 // TestParseConfigRejectsNegativeCaps rejects a negative resource cap (0 means
74 // unlimited) and names the offending flag deterministically.
75 func TestParseConfigRejectsNegativeCaps(t *testing.T) {
76 for _, flag := range []string{"max-vcpus", "max-mem-mb", "max-disk-gb"} {
77 _, _, err := parseConfig([]string{"--" + flag + "=-1"})
78 require.Error(t, err)
79 assert.Contains(t, err.Error(), "--"+flag)
80 }
81 }
82
83 // TestParseConfigBadFlag surfaces an unknown flag as an error rather than
84 // exiting the process (ContinueOnError).
85 func TestParseConfigBadFlag(t *testing.T) {
86 _, _, err := parseConfig([]string{"--nonesuch"})
87 require.Error(t, err)
88 }
89
90 // TestJoinEmptyBlob rejects a missing blob before any decode or network call;
91 // st is never touched, so nil is safe.
92 func TestJoinEmptyBlob(t *testing.T) {
93 err := join(nil, "")
94 require.Error(t, err)
95 assert.Contains(t, err.Error(), "usage: eitri-agent join")
96 }
97
98 // TestJoinInvalidBlob rejects an undecodable blob and never echoes the blob
99 // itself (it carries a bearer token). Decode fails before st is used.
100 func TestJoinInvalidBlob(t *testing.T) {
101 const secret = "not-a-valid-join-blob-with-secret"
102 err := join(nil, secret)
103 require.Error(t, err)
104 assert.Contains(t, err.Error(), "invalid join blob")
105 assert.NotContains(t, err.Error(), secret, "the blob carries a bearer token and must never appear in errors")
106 }
107
108 // TestServeNotEnrolled refuses to run before the host has enrolled, before any
109 // network or signal handler is set up.
110 func TestServeNotEnrolled(t *testing.T) {
111 st, err := state.Open(t.TempDir())
112 require.NoError(t, err)
113 err = serve(st, Config{StateDir: t.TempDir()})
114 require.Error(t, err)
115 assert.True(t, strings.Contains(err.Error(), "not enrolled"), "got %q", err.Error())
116 }
internal/arch/arch_test.go
Old New
@@ -229,17 +229,19 @@ func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) {
229 229
230 // R7: external process execution anywhere in internal/ is confined to a 230 // R7: external process execution anywhere in internal/ is confined to a
231 // sanctioned allowlist — cloudhv (the data plane's one exec funnel, R6's 231 // sanctioned allowlist — cloudhv (the data plane's one exec funnel, R6's
232 // narrower story), cli (interactive ssh must be the real OpenSSH client), and 232 // narrower story), cli (interactive ssh must be the real OpenSSH client), shape
233 // shape (architecture tooling that shells out to `go list`, the same 233 // (architecture tooling that shells out to `go list`, the same introspection
234 // introspection internal/arch's own tests do). R2/R6 tell the per-plane 234 // internal/arch's own tests do), and smoke (the deploy boot-gate harness, an
235 // stories; R7 is the whole-tree backstop that makes a new exec site anywhere 235 // external test rig that shells out to ssh, pkill, and `go tool covdata`).
236 // an explicit, reviewed decision. 236 // R2/R6 tell the per-plane stories; R7 is the whole-tree backstop that makes a
237 // new exec site anywhere an explicit, reviewed decision.
237 func TestExecIsConfinedToSanctionedPackages(t *testing.T) { 238 func TestExecIsConfinedToSanctionedPackages(t *testing.T) {
238 g := directImports(t) 239 g := directImports(t)
239 allowed := map[string]bool{ 240 allowed := map[string]bool{
240 module + "/internal/agent/cloudhv": true, // the data plane's one sanctioned exec funnel (R6's story) 241 module + "/internal/agent/cloudhv": true, // the data plane's one sanctioned exec funnel (R6's story)
241 module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client 242 module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client
242 module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make 243 module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make
244 module + "/internal/smoke": true, // deploy boot-gate harness: shells out to ssh, pkill, and `go tool covdata` against the live fleet
243 } 245 }
244 for pkg, offenders := range execViolations(g, module, "internal/", allowed) { 246 for pkg, offenders := range execViolations(g, module, "internal/", allowed) {
245 for _, o := range offenders { 247 for _, o := range offenders {
@@ -328,7 +330,7 @@ func TestAPITypesIsALeaf(t *testing.T) {
328 // (or any other) import would relink the issuer into the relying party and 330 // (or any other) import would relink the issuer into the relying party and
329 // silently rebuild the embedded-IdP coupling this split exists to prevent. 331 // silently rebuild the embedded-IdP coupling this split exists to prevent.
330 // Test files anywhere may still import it as an in-process IdP 332 // Test files anywhere may still import it as an in-process IdP
331 // (server/api's auth_test, eitri-smoke's login_test); those edges are 333 // (server/api's auth_test, internal/smoke's login_test); those edges are
332 // test-only and never enter the production graph go list reports here. 334 // test-only and never enter the production graph go list reports here.
333 // (b) the go-oidc verifier module belongs to internal/server/api, the relying 335 // (b) the go-oidc verifier module belongs to internal/server/api, the relying
334 // party, alone. It is the client half of the protocol — anywhere else it 336 // party, alone. It is the client half of the protocol — anywhere else it
@@ -410,3 +412,53 @@ func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidde
410 t.Errorf("domain package %s must not import: %s", short(pkg), strings.Join(hits, ", ")) 412 t.Errorf("domain package %s must not import: %s", short(pkg), strings.Join(hits, ", "))
411 } 413 }
412 } 414 }
415
416 // R14: main packages are wiring, not logic. A cmd/* package may import
417 // packages from this module plus a tiny stdlib allowlist — enough to print a
418 // version, dispatch to an internal Run, and exit non-zero. Every other import
419 // (net/http, encoding/json, flag, crypto, third-party modules) is evidence of
420 // logic living in a package that the coverage gate cannot see (scripts/
421 // coverage.sh floors internal/, not cmd/) — move it behind a tested
422 // `Run(...) error` in an internal package instead.
423 //
424 // r14Grandfathered names the mains that predate the rule and still carry
425 // logic. The list is a ratchet: entries may only be REMOVED (as their logic
426 // is extracted); never add one, and never grow an entry's import surface.
427 func TestMainPackagesAreWiringOnly(t *testing.T) {
428 allowedStd := map[string]bool{
429 "errors": true, // errors.Is on sentinel errors from the internal Run
430 "fmt": true,
431 "log": true,
432 "log/slog": true,
433 "os": true,
434 }
435 r14Grandfathered := map[string]bool{
436 // Empty: every main is now wiring over a tested internal Run. The map
437 // stays as the ratchet mechanism — a new main has nowhere to hide.
438 }
439 g := directImports(t)
440 seen := 0
441 for pkg, deps := range g {
442 if !strings.HasPrefix(pkg, module+"/cmd/") {
443 continue
444 }
445 seen++
446 if r14Grandfathered[pkg] {
447 continue
448 }
449 var bad []string
450 for _, d := range deps {
451 if strings.HasPrefix(d, module+"/") || allowedStd[d] {
452 continue
453 }
454 bad = append(bad, d)
455 }
456 if len(bad) > 0 {
457 sort.Strings(bad)
458 t.Errorf("main package %s imports %s — mains are wiring only (R14): move the logic behind a tested Run() in an internal package", short(pkg), strings.Join(bad, ", "))
459 }
460 }
461 if seen == 0 {
462 t.Error("sweep found no cmd/ packages at all — did the go list pattern break?")
463 }
464 }
internal/cli/main.go
Old New
@@ -0,0 +1,89 @@
1 // main.go is the eitri client's command dispatch. It lives here rather than in
2 // cmd/eitri so it is testable and coverage-gated (arch R14: main packages are
3 // wiring only).
4
5 package cli
6
7 import (
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 )
14
15 // Usage is the top-level help text, printed by cmd/eitri on ErrUsage.
16 const Usage = `usage:
17 eitri ssh <vm> [ssh args / remote command...]
18 eitri ca upload [<tenant>] <ca-public-key-file>
19 eitri --version
20
21 env: EITRI_URL, EITRI_GATE, EITRI_TENANT (required for ssh);
22 EITRI_URL, EITRI_TOKEN and a tenant (argument or EITRI_TENANT) for ca;
23 EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS (optional, default under ~/.ssh)`
24
25 // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather
26 // than treating it as a runtime failure.
27 var ErrUsage = errors.New("bad usage")
28
29 // Main dispatches the eitri command line (everything after the binary name,
30 // --version excluded — that stays in cmd/eitri). stdout carries command
31 // output; prompts and confirmations go to stderr inside the subcommands.
32 func Main(args []string, stdout io.Writer) error {
33 if len(args) < 1 {
34 return ErrUsage
35 }
36 switch args[0] {
37 case "ssh":
38 return runSSH(args[1:], stdout)
39 case "ca":
40 return runCA(args[1:], stdout)
41 default:
42 return ErrUsage
43 }
44 }
45
46 func runSSH(args []string, stdout io.Writer) error {
47 if len(args) >= 1 && (args[0] == "-h" || args[0] == "--help") {
48 fmt.Fprintln(stdout, "usage: eitri ssh <vm> [ssh args / remote command...]")
49 return nil
50 }
51 if len(args) < 1 {
52 return fmt.Errorf("usage: eitri ssh <vm> [ssh args / remote command...]")
53 }
54 env, err := FromEnv()
55 if err != nil {
56 return err
57 }
58 return RunSSH(context.Background(), env, args[0], args[1:])
59 }
60
61 func runCA(args []string, stdout io.Writer) error {
62 if len(args) < 1 || args[0] != "upload" {
63 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
64 }
65 rest := args[1:]
66 if len(rest) < 1 || len(rest) > 2 {
67 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
68 }
69 // The tenant comes from the positional when given, else EITRI_TENANT.
70 // There is no implicit tenant (a guess would surface as an opaque 404).
71 tenant, pub := os.Getenv("EITRI_TENANT"), rest[0]
72 if len(rest) == 2 {
73 tenant, pub = rest[0], rest[1]
74 }
75 if tenant == "" {
76 return fmt.Errorf("no tenant: pass one (eitri ca upload <tenant> <file>) or set EITRI_TENANT")
77 }
78 url := os.Getenv("EITRI_URL")
79 token := os.Getenv("EITRI_TOKEN")
80 if url == "" || token == "" {
81 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (a personal access token)")
82 }
83 out, err := UploadUserCA(context.Background(), url, token, tenant, pub)
84 if err != nil {
85 return err
86 }
87 fmt.Fprintln(stdout, out)
88 return nil
89 }
internal/cli/main_test.go
Old New
@@ -0,0 +1,63 @@
1 package cli
2
3 import (
4 "bytes"
5 "errors"
6 "strings"
7 "testing"
8 )
9
10 func TestMainDispatch(t *testing.T) {
11 var out bytes.Buffer
12 if err := Main(nil, &out); !errors.Is(err, ErrUsage) {
13 t.Errorf("no args: got %v, want ErrUsage", err)
14 }
15 if err := Main([]string{"frobnicate"}, &out); !errors.Is(err, ErrUsage) {
16 t.Errorf("unknown subcommand: got %v, want ErrUsage", err)
17 }
18 // `ssh -h` prints usage and succeeds (exit 0), it is not an error.
19 if err := Main([]string{"ssh", "-h"}, &out); err != nil {
20 t.Errorf("ssh -h: %v", err)
21 }
22 if !strings.Contains(out.String(), "eitri ssh <vm>") {
23 t.Errorf("ssh -h must print usage, got %q", out.String())
24 }
25 if err := Main([]string{"ssh"}, &out); err == nil {
26 t.Error("bare ssh must error with usage")
27 }
28 }
29
30 // TestRunCATenantResolution pins how `eitri ca upload` finds its tenant:
31 // positional wins, EITRI_TENANT is the fallback, and no tenant at all is an
32 // actionable error before anything touches the network.
33 func TestRunCATenantResolution(t *testing.T) {
34 var out bytes.Buffer
35 t.Setenv("EITRI_URL", "")
36 t.Setenv("EITRI_TOKEN", "")
37 t.Setenv("EITRI_TENANT", "")
38
39 if err := Main([]string{"ca"}, &out); err == nil || !strings.Contains(err.Error(), "usage:") {
40 t.Errorf("bare ca: got %v", err)
41 }
42 if err := Main([]string{"ca", "upload"}, &out); err == nil || !strings.Contains(err.Error(), "usage:") {
43 t.Errorf("upload with no args: got %v", err)
44 }
45
46 // No positional tenant, no EITRI_TENANT: the error names both remedies.
47 err := Main([]string{"ca", "upload", "ca.pub"}, &out)
48 if err == nil || !strings.Contains(err.Error(), "EITRI_TENANT") {
49 t.Errorf("tenantless: got %v", err)
50 }
51
52 // A tenant (positional or env) but no URL/token: fails on the env contract
53 // — proving tenant resolution passed.
54 err = Main([]string{"ca", "upload", "acme", "ca.pub"}, &out)
55 if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") {
56 t.Errorf("positional tenant: got %v", err)
57 }
58 t.Setenv("EITRI_TENANT", "acme")
59 err = Main([]string{"ca", "upload", "ca.pub"}, &out)
60 if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") {
61 t.Errorf("env tenant: got %v", err)
62 }
63 }
internal/mcpserver/cli.go
Old New
@@ -0,0 +1,149 @@
1 // cli.go is the eitri-mcp command line: it loads the config, self-signs with a
2 // persistent per-client user CA, uploads that CA to the tenant, and serves the
3 // MCP tools over stdio. It lives here rather than in cmd/eitri-mcp so it is
4 // testable and coverage-gated (arch R14: main packages are wiring only).
5
6 package mcpserver
7
8 import (
9 "context"
10 "crypto/ed25519"
11 "crypto/rand"
12 "encoding/pem"
13 "flag"
14 "fmt"
15 "os"
16 "os/signal"
17 "syscall"
18
19 "github.com/a73x/eitri/internal/gateclient"
20 "github.com/a73x/eitri/internal/server/api/client"
21 "github.com/modelcontextprotocol/go-sdk/mcp"
22 "golang.org/x/crypto/ssh"
23 )
24
25 // RunCLI dispatches the eitri-mcp command line (everything after the binary
26 // name, --version excluded — that stays in cmd/eitri-mcp). The config path
27 // defaults to EITRI_MCP_CONFIG, else ~/.config/eitri-mcp/config.json.
28 func RunCLI(args []string) error {
29 defaultCfg := "~/.config/eitri-mcp/config.json"
30 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" {
31 defaultCfg = env
32 }
33 fs := flag.NewFlagSet("eitri-mcp", flag.ExitOnError)
34 cfgPath := fs.String("config", defaultCfg, "path to eitri-mcp config.json")
35 if err := fs.Parse(args); err != nil {
36 return err
37 }
38 return run(*cfgPath)
39 }
40
41 func run(cfgPath string) error {
42 cfg, err := LoadConfig(cfgPath)
43 if err != nil {
44 return err
45 }
46 // This client owns its own persistent user CA (BYO model): it self-signs
47 // short-lived user certs locally rather than asking the server to mint them.
48 userCA, err := loadOrCreateCA(cfg.CAKeyPath)
49 if err != nil {
50 return fmt.Errorf("load mcp user CA: %w", err)
51 }
52 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it
53 // authenticates with short-lived user certs it self-signs on demand with its
54 // own user CA, and verifies both hops' host certs against the eitri host CA.
55 // 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 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token}
58 gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil)
59 tools := &Tools{
60 API: API{Client: api},
61 Runner: NewRunner(RunnerConfig{
62 Gate: cfg.Gate,
63 Auth: gateAuth,
64 VMUser: cfg.VMUser,
65 }),
66 Gate: cfg.Gate,
67 VMUser: cfg.VMUser,
68 }
69
70 server := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, nil)
71 register(server, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", tools.VMCreate)
72 register(server, "vm_list", "List all VMs on the eitri fleet.", tools.VMList)
73 register(server, "vm_info", "Show one VM's state and how to reach it.", tools.VMInfo)
74 register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec)
75 register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile)
76 register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile)
77 register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy)
78
79 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
80 defer stop()
81
82 // Upload our user CA to the tenant before serving, so vm_create's precondition
83 // (a registered user CA) is satisfied and VMs trust the certs we sign.
84 if err := gateAuth.Register(ctx); err != nil {
85 return fmt.Errorf("register mcp user CA: %w", err)
86 }
87 return server.Run(ctx, &mcp.StdioTransport{})
88 }
89
90 // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file
91 // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a
92 // concurrent creator can't clobber it and a symlink can't be followed), and
93 // returns its signer; if present it parses and returns the existing key. This
94 // is deliberately local to the MCP (a pure API client) and does NOT import the
95 // server's sshca package. Never logs or returns key material in errors.
96 func loadOrCreateCA(path string) (ssh.Signer, error) {
97 pemBytes, err := os.ReadFile(path)
98 if err == nil {
99 signer, perr := ssh.ParsePrivateKey(pemBytes)
100 if perr != nil {
101 return nil, fmt.Errorf("parse ssh key %q: %w", path, perr)
102 }
103 return signer, nil
104 }
105 if !os.IsNotExist(err) {
106 return nil, fmt.Errorf("read ssh key %q: %w", path, err)
107 }
108
109 _, priv, err := ed25519.GenerateKey(rand.Reader)
110 if err != nil {
111 return nil, fmt.Errorf("generate ssh key: %w", err)
112 }
113 block, err := ssh.MarshalPrivateKey(priv, "")
114 if err != nil {
115 return nil, fmt.Errorf("marshal ssh key: %w", err)
116 }
117 signer, err := ssh.NewSignerFromSigner(priv)
118 if err != nil {
119 return nil, fmt.Errorf("new signer: %w", err)
120 }
121 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
122 if err != nil {
123 return nil, fmt.Errorf("create ssh key %q: %w", path, err)
124 }
125 if _, werr := f.Write(pem.EncodeToMemory(block)); werr != nil {
126 f.Close()
127 return nil, fmt.Errorf("write ssh key %q: %w", path, werr)
128 }
129 if cerr := f.Close(); cerr != nil {
130 return nil, fmt.Errorf("close ssh key %q: %w", path, cerr)
131 }
132 return signer, nil
133 }
134
135 // register adapts a Tools method to the SDK. This is the ONLY place that
136 // touches SDK generics; if the SDK's handler signature changes, change it here.
137 //
138 // Note on the hand-off contract: the SDK drops the Out value when the handler
139 // returns a non-nil error — StructuredContent is left unset and only err.Error()
140 // reaches the model (as IsError text content). VMCreate's degraded-path errors
141 // are self-sufficient (they name the VM id+name), so the model can still find
142 // and destroy the VM from the error text.
143 func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
144 mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
145 func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
146 out, err := fn(ctx, in)
147 return nil, out, err
148 })
149 }
internal/mcpserver/cli_test.go
Old New
@@ -0,0 +1,60 @@
1 package mcpserver
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/modelcontextprotocol/go-sdk/mcp"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 // TestLoadOrCreateCACreatesThenLoads covers the two steady-state paths: a fresh
15 // key is generated 0600 when absent, and the identical key loads back when
16 // present.
17 func TestLoadOrCreateCACreatesThenLoads(t *testing.T) {
18 path := filepath.Join(t.TempDir(), "user_ca")
19
20 signer, err := loadOrCreateCA(path)
21 require.NoError(t, err)
22 require.NotNil(t, signer)
23
24 info, err := os.Stat(path)
25 require.NoError(t, err)
26 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "key material must be written 0600")
27
28 again, err := loadOrCreateCA(path)
29 require.NoError(t, err)
30 assert.Equal(t, signer.PublicKey().Marshal(), again.PublicKey().Marshal(),
31 "an existing key must load back unchanged, not be regenerated")
32 }
33
34 // TestLoadOrCreateCARejectsGarbage rejects an unparseable key file without
35 // leaking its bytes in the error.
36 func TestLoadOrCreateCARejectsGarbage(t *testing.T) {
37 path := filepath.Join(t.TempDir(), "user_ca")
38 require.NoError(t, os.WriteFile(path, []byte("this is not a pem key"), 0o600))
39
40 _, err := loadOrCreateCA(path)
41 require.Error(t, err)
42 assert.NotContains(t, err.Error(), "not a pem key", "key bytes must never appear in errors")
43 }
44
45 // TestLoadOrCreateCAReadError treats a non-not-exist read failure (a directory
46 // at the path) as a fatal error, not a signal to generate a key.
47 func TestLoadOrCreateCAReadError(t *testing.T) {
48 dir := t.TempDir()
49 _, err := loadOrCreateCA(dir)
50 require.Error(t, err)
51 }
52
53 // TestRegisterAddsTool pins the single SDK-generics adapter: it must wire a
54 // typed handler onto the server without panicking.
55 func TestRegisterAddsTool(t *testing.T) {
56 s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
57 register(s, "noop", "does nothing", func(context.Context, struct{}) (struct{}, error) {
58 return struct{}{}, nil
59 })
60 }
internal/server/boot/boot.go
Old New
@@ -0,0 +1,268 @@
1 // Package boot implements the eitri-server command line behind a tested RunCLI
2 // so cmd/eitri-server stays thin wiring (arch R14). It opens the store, handles
3 // the server certificate, starts the housekeeping goroutines, binds the QUIC
4 // and HTTP listeners, constructs the API, and wires the SSH jump gate — the
5 // single-node control plane's whole boot sequence.
6 //
7 // No TLS termination happens here — front eitri-server with a reverse proxy for
8 // TLS.
9 package boot
10
11 import (
12 "context"
13 "errors"
14 "flag"
15 "fmt"
16 "log/slog"
17 "net/http"
18 "os/signal"
19 "syscall"
20 "time"
21
22 "github.com/a73x/eitri/internal/covsnap"
23 "github.com/a73x/eitri/internal/joinblob"
24 "github.com/a73x/eitri/internal/server/api"
25 serverconfig "github.com/a73x/eitri/internal/server/config"
26 "github.com/a73x/eitri/internal/server/health"
27 "github.com/a73x/eitri/internal/server/hub"
28 "github.com/a73x/eitri/internal/server/registry"
29 "github.com/a73x/eitri/internal/server/release"
30 "github.com/a73x/eitri/internal/server/store"
31 "github.com/a73x/eitri/internal/server/syncsvc"
32 "github.com/a73x/eitri/internal/server/web"
33 "github.com/a73x/eitri/internal/transport"
34 "github.com/quic-go/quic-go"
35 )
36
37 // RunCLI dispatches the eitri-server command line (everything after the binary
38 // name, --version excluded — that stays in cmd/eitri-server). It parses the
39 // -config flag and runs the control plane until SIGINT/SIGTERM.
40 func RunCLI(args []string) error {
41 fs := flag.NewFlagSet("eitri-server", flag.ContinueOnError)
42 cfgPath := fs.String("config", "/etc/eitri/server.json", "config file")
43 if err := fs.Parse(args); err != nil {
44 return err
45 }
46 return run(*cfgPath)
47 }
48
49 // run wires and serves the control plane, blocking until SIGINT/SIGTERM (a nil
50 // return) or an always-on server fails (a non-nil return). Every synchronous
51 // startup invariant surfaces as a returned error so cmd/eitri-server can exit
52 // non-zero — the process-fatal outcome each site previously reached directly.
53 func run(cfgPath string) error {
54 // Load enforces every startup invariant (required keys, OIDC block, URL
55 // shapes) — see internal/server/config, where the rules are tested.
56 cfg, err := serverconfig.Load(cfgPath)
57 if err != nil {
58 return fmt.Errorf("config %s: %w", cfgPath, err)
59 }
60
61 st, err := store.Open(cfg.DBPath, cfg.CIDRPool)
62 if err != nil {
63 return fmt.Errorf("open store: %w", err)
64 }
65
66 certPEM, certFP, err := st.ServerCert()
67 if err != nil {
68 return fmt.Errorf("server cert: %w", err)
69 }
70 keyPEM, err := st.ServerKeyPEM()
71 if err != nil {
72 return fmt.Errorf("server key: %w", err)
73 }
74
75 // Log the identity agents pin — the operator verifies this out-of-band
76 // during the rotation ceremony (docs/cert-rotation.md step 3).
77 slog.Info("server cert", "fingerprint", certFP)
78
79 // Rotation nudge: the agent pin ignores expiry so nothing breaks at
80 // NotAfter, but a long-lived key is a widening forgery window. Warn while
81 // inside the renewal window — at startup AND daily, because servers here
82 // are long-lived daemons that can cross into the window (or past expiry)
83 // without ever restarting. See docs/cert-rotation.md for the ceremony.
84 warnIfRenewalDue := func() {
85 notAfter, due := transport.CertRenewalDue(certPEM, time.Now())
86 if !due {
87 return
88 }
89 if notAfter.IsZero() {
90 slog.Warn("server cert unparseable — inspect server.crt (docs/cert-rotation.md)")
91 return
92 }
93 slog.Warn("server cert renewal due — rotate and re-enroll agents (docs/cert-rotation.md)",
94 "not_after", notAfter.Format(time.RFC3339))
95 }
96 warnIfRenewalDue()
97
98 // Audit retention: bound the append-only log (default 90 days, 0 disables).
99 // A negative value is almost certainly a typo — refuse rather than silently
100 // keeping the audit log forever ("0" is the explicit disable spelling).
101 auditRetention, err := serverconfig.ParseDuration("audit_retention", cfg.AuditRetention, 90*24*time.Hour,
102 func(d time.Duration) bool { return d >= 0 }, ">= 0")
103 if err != nil {
104 return fmt.Errorf("config: %w", err)
105 }
106 pruneAudit := func() {
107 if auditRetention <= 0 {
108 return
109 }
110 if n, err := st.PruneAudit(auditRetention); err != nil {
111 slog.Warn("audit prune failed", "err", err)
112 } else if n > 0 {
113 slog.Info("audit pruned", "rows", n, "retention", auditRetention)
114 }
115 }
116 pruneAudit()
117
118 // Daily housekeeping: cert-renewal nudge + audit retention.
119 go func() {
120 for range time.Tick(24 * time.Hour) {
121 warnIfRenewalDue()
122 pruneAudit()
123 }
124 }()
125
126 // Fail fast if the advertised addresses are non-empty but malformed (e.g. a
127 // URL with no scheme): otherwise every enroll-token mint would 500 at runtime.
128 if _, err := joinblob.Encode(cfg.AdvertiseHTTP, cfg.AdvertiseQUIC, "startup-probe", certFP); err != nil {
129 return fmt.Errorf("advertise_http/advertise_quic invalid: %w", err)
130 }
131
132 // SSH jump gate (§B): nil when ssh_listen is unset (gate OFF); see
133 // setupSSHGate. The listener itself is started below, once syncsvc.Service
134 // (the tunnel dialer) exists.
135 sshGate, err := setupSSHGate(cfg)
136 if err != nil {
137 return err
138 }
139
140 reg := registry.New(time.Now)
141 h := hub.New()
142
143 a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret),
144 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA},
145 ServerCertSHA256: certFP,
146 AdvertiseHTTP: cfg.AdvertiseHTTP,
147 AdvertiseQUIC: cfg.AdvertiseQUIC,
148 OIDC: api.OIDCConfig{
149 Issuer: cfg.OIDC.Issuer,
150 ClientID: cfg.OIDC.ClientID,
151 ClientSecret: cfg.OIDC.ClientSecret,
152 PublicURL: cfg.OIDC.PublicURL,
153 AllowedDomains: cfg.OIDC.AllowedDomains,
154 AllowedIdentities: cfg.OIDC.AllowedIdentities,
155 }},
156 st, reg, h)
157
158 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
159 if err != nil {
160 return fmt.Errorf("server tls: %w", err)
161 }
162 lis, err := quic.ListenAddr(cfg.QUICListen, tlsConf,
163 // Shared with the agent dialer via transport so the two ends can't drift.
164 transport.SyncQUICConfig())
165 if err != nil {
166 return fmt.Errorf("quic listen: %w", err)
167 }
168 maxCredAge, err := serverconfig.ParseDuration("credential_max_age", cfg.CredentialMaxAge, 0, nil, "")
169 if err != nil {
170 return fmt.Errorf("config: %w", err)
171 }
172 // SSH cert minters: no-op when the gate is off, so the endpoint 404s.
173 sshGate.wireAPI(a)
174
175 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge)
176 // Console broker: the API bridges browser WebSockets to agent console
177 // streams over the live sync connections the service tracks.
178 a.SetConsoleDialer(svc)
179
180 // fatal collects the first failure from any always-on server goroutine (the
181 // SSH gate, QUIC, HTTP). Buffered so a failing server never blocks on send;
182 // run returns the first error and cmd/eitri-server exits non-zero — the same
183 // process-fatal outcome each site previously reached via os.Exit(1). A dead
184 // server must not run silently.
185 fatal := make(chan error, 3)
186
187 // SSH jump gate listener (no-op when off); a failed bind is fatal, like
188 // QUIC/HTTP below.
189 if err := sshGate.startListener(st, svc, fatal); err != nil {
190 return err
191 }
192
193 go func() {
194 slog.Info("quic listening", "addr", cfg.QUICListen)
195 if err := svc.Serve(context.Background(), lis); err != nil {
196 fatal <- fmt.Errorf("quic serve: %w", err)
197 }
198 }()
199
200 // Background: finalize drained decommissioning hosts.
201 go a.StartBackground(context.Background())
202
203 // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else.
204 root := http.NewServeMux()
205 root.Handle("/api/", a.Handler())
206 // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how
207 // a browser establishes a session in the first place (spec §2).
208 root.Handle("/auth/", a.AuthHandler())
209 // Unauthenticated probes (outside /api/, so a load balancer or the deploy
210 // script needs no token). /livez is process-up; /readyz gates on the
211 // dependencies the server needs to actually serve — the DB. The QUIC
212 // listener bind is a startup invariant: quic.ListenAddr above returns an
213 // error that stops the process before this HTTP server serves, so a response
214 // here already implies QUIC bound.
215 root.HandleFunc("/livez", health.Live)
216 root.Handle("/readyz", health.Ready(3*time.Second,
217 health.Check{Name: "db", Probe: st.Ping},
218 ))
219 root.Handle("/", web.Handler())
220
221 // Graceful shutdown: SIGINT/SIGTERM stops accepting, drains in-flight
222 // requests, then closes the API — stopping the SSE snapshot-hub goroutine and
223 // releasing its notifier subscription. The QUIC listener and background worker
224 // die with the process.
225 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
226 defer stop()
227
228 // Release discovery: absent field ⇒ eitri.sh default; explicit "" disables.
229 manifestURL := "https://eitri.sh/dl/latest/manifest.json"
230 if cfg.ReleaseManifestURL != nil {
231 manifestURL = *cfg.ReleaseManifestURL
232 }
233 if manifestURL != "" {
234 rel := release.New(manifestURL)
235 go rel.Poll(ctx, 24*time.Hour, func(err error) {
236 slog.Warn("release manifest refresh failed", "err", err)
237 })
238 a.SetReleaseSource(rel)
239 }
240 a.SetAgentUpgrader(svc)
241
242 // Flush integration-coverage counters on SIGUSR1 (no-op unless built with
243 // -cover and GOCOVERDIR is set). Lets the deploy boot-gate snapshot coverage
244 // from the live server without bouncing the process.
245 covsnap.Install(ctx)
246
247 srv := &http.Server{Addr: cfg.HTTPListen, Handler: root}
248 go func() {
249 slog.Info("http listening", "addr", cfg.HTTPListen)
250 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
251 fatal <- fmt.Errorf("http serve: %w", err)
252 }
253 }()
254
255 select {
256 case err := <-fatal:
257 return err
258 case <-ctx.Done():
259 }
260 slog.Info("shutting down")
261 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
262 defer cancel()
263 if err := srv.Shutdown(shutdownCtx); err != nil {
264 slog.Warn("http graceful shutdown", "err", err)
265 }
266 a.Close()
267 return nil
268 }
internal/server/boot/cli_test.go
Old New
@@ -0,0 +1,25 @@
1 package boot
2
3 import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"
8 )
9
10 // TestRunCLIBadFlag surfaces an unknown flag as an error rather than exiting the
11 // process (ContinueOnError).
12 func TestRunCLIBadFlag(t *testing.T) {
13 err := RunCLI([]string{"--nonesuch"})
14 require.Error(t, err)
15 }
16
17 // TestRunCLIMissingConfig threads the -config flag through to the loader and
18 // surfaces a missing config as an error naming the path (the process would then
19 // exit non-zero), without starting any listener.
20 func TestRunCLIMissingConfig(t *testing.T) {
21 missing := t.TempDir() + "/no-such-server.json"
22 err := RunCLI([]string{"-config", missing})
23 require.Error(t, err)
24 assert.Contains(t, err.Error(), missing)
25 }
internal/server/boot/sshgate.go
Old New
@@ -0,0 +1,172 @@
1 package boot
2
3 import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "net"
8 "time"
9
10 "github.com/a73x/eitri/internal/server/api"
11 serverconfig "github.com/a73x/eitri/internal/server/config"
12 "github.com/a73x/eitri/internal/server/sshca"
13 "github.com/a73x/eitri/internal/server/sshgate"
14 "github.com/a73x/eitri/internal/server/store"
15 "github.com/a73x/eitri/internal/server/syncsvc"
16 "golang.org/x/crypto/ssh"
17 )
18
19 // sshGateSetup carries the jump-gate state from config-time setup to the later
20 // wiring points in startup (API cert minters, sync snapshot, gate listener).
21 // A nil *sshGateSetup means the gate is OFF: every method is a no-op on a nil
22 // receiver, so run holds one value instead of repeating `!= nil` guards.
23 type sshGateSetup struct {
24 ca *sshca.CA
25 listen string // cfg.SSHListen
26 domain string // cfg.SSHGateDomain
27 }
28
29 // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set
30 // (returns nil). When enabled, load or create the persistent user CA + gate
31 // host key (0600, never logged).
32 func setupSSHGate(cfg serverconfig.Config) (*sshGateSetup, error) {
33 if cfg.SSHListen == "" {
34 return nil, nil
35 }
36 if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" {
37 return nil, errors.New("ssh_ca_key and ssh_host_key are required when ssh_listen is set")
38 }
39 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey)
40 if err != nil {
41 return nil, fmt.Errorf("ssh ca: %w", err)
42 }
43 // Log the HOST CA identity operators pin via @cert-authority for gate + VM
44 // host verification. Only the *public* key is ever logged (private material
45 // never is). eitri holds no user CA — those are BYO per-tenant.
46 slog.Info("ssh jump gate configured", "listen", cfg.SSHListen,
47 "host_ca", string(sshGate.HostCAAuthorizedKey()))
48 // The gate listener itself is started later (startListener), once
49 // syncsvc.Service (the tunnel dialer) exists.
50 return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil
51 }
52
53 // wireAPI installs the per-VM host-cert minter and publishes the HOST CA: when
54 // the jump gate is enabled, eitri signs a persistent host key + cert at each VM
55 // create and serves the host CA pubkey via GET /api/v1/ssh-ca. eitri never
56 // mints user certs — user CAs are BYO per-tenant (uploaded, never held here).
57 // Left unwired when the gate is off, so the ssh-ca endpoint 404s.
58 func (g *sshGateSetup) wireAPI(a *api.API) {
59 if g == nil {
60 return
61 }
62 // Per-VM host certs: sign a persistent host key + cert at each VM create,
63 // so VMs present verifiable host keys (clients accept via @cert-authority).
64 a.SetHostCertMinter(api.NewHostMinter(g.ca.HostCA()))
65 // Publish the HOST CA public key so clients can pin `@cert-authority` for
66 // host verification of both the gate and every VM.
67 a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey()))
68 }
69
70 // startListener starts the SSH jump gate listener: when enabled, front
71 // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names
72 // against the store, tunnels port 22 through the sync connection (svc.OpenTCP),
73 // and trusts only certs signed by a registered tenant user CA. A failed bind or
74 // cert-sign is fatal (returned to run, like QUIC/HTTP); a serve failure after
75 // bind lands on fatal so a dead gate does not run silently.
76 func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service, fatal chan<- error) error {
77 if g == nil {
78 return nil
79 }
80 // The gate host cert's principal is the name clients dial. Prefer the
81 // configured domain; else the host part of ssh_listen; else "localhost".
82 gateDomain := g.domain
83 if gateDomain == "" {
84 if h, _, err := net.SplitHostPort(g.listen); err == nil {
85 gateDomain = h
86 }
87 }
88 if gateDomain == "" {
89 gateDomain = "localhost"
90 }
91 slog.Info("ssh gate host cert", "principal", gateDomain)
92 // Sign a long-lived HOST cert for the gate's own host key and present THAT
93 // (via a cert signer) instead of the bare key, so a client verifying with
94 // `@cert-authority` accepts the gate on first connect — no TOFU window.
95 gateCert, err := sshca.SignHostCert(g.ca.HostCA(), g.ca.HostKey().PublicKey(),
96 []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL)
97 if err != nil {
98 return fmt.Errorf("sign gate host cert: %w", err)
99 }
100 gateHostSigner, err := ssh.NewCertSigner(gateCert, g.ca.HostKey())
101 if err != nil {
102 return fmt.Errorf("gate host cert signer: %w", err)
103 }
104 gate := sshgate.New(gateHostSigner, userCALookup(st), resolveVM(st), authorizeVM(st), svc.OpenTCP, revokedCert(st))
105 ln, err := net.Listen("tcp", g.listen)
106 if err != nil {
107 return fmt.Errorf("ssh gate listen: %w", err)
108 }
109 go func() {
110 slog.Info("ssh jump gate listening", "addr", g.listen)
111 if err := gate.Serve(ln); err != nil {
112 fatal <- fmt.Errorf("ssh gate serve: %w", err)
113 }
114 }()
115 return nil
116 }
117
118 // resolveVM is tenant-scoped: the bare name is looked up WITHIN the connection's
119 // tenant only (VMByTenantName), so a name never resolves across tenants. A
120 // lookup error (unknown or tombstoned VM) reports ok=false rather than tunneling
121 // to a dead or foreign VM.
122 func resolveVM(st *store.Store) sshgate.Resolver {
123 return func(tenant, name string) (hostID, vmID string, ok bool) {
124 vm, err := st.VMByTenantName(tenant, name)
125 if err != nil {
126 return "", "", false
127 }
128 return vm.HostID, vm.ID, true
129 }
130 }
131
132 // authorizeVM re-reads the VM row and requires tenant equality: the tenant that
133 // the connection's user cert resolved to (via its per-tenant CA) must own the
134 // VM, or the connection is refused. A tombstoned VM (DeletedAt set) or a store
135 // error is refused too.
136 func authorizeVM(st *store.Store) sshgate.Authorizer {
137 return func(tenant, vmID string) bool {
138 vm, err := st.GetVM(vmID)
139 return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant
140 }
141 }
142
143 // revokedCert gates every cert auth against the revocation list. Fail-CLOSED
144 // for the single connection on a DB error: a store hiccup rejects THAT login
145 // (returns revoked=true) rather than fail-open (which would let a possibly-
146 // revoked cert through) or fail-the-whole-gate (which a global close would
147 // amount to, DoSing every login on any transient error).
148 func revokedCert(st *store.Store) sshgate.Revoker {
149 return func(serial uint64) bool {
150 revoked, err := st.IsSSHCertRevoked(serial)
151 if err != nil {
152 slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err)
153 return true
154 }
155 return revoked
156 }
157 }
158
159 // userCALookup trusts the DB-registered set of tenant user CAs and stamps each
160 // connection with the tenant that registered the signing CA. It looks up by the
161 // SAME canonical authorized_keys line the store persists (ca_pubkey), so the
162 // bytes agree. A lookup error fails closed (rejects the cert).
163 func userCALookup(st *store.Store) sshgate.UserCALookup {
164 return func(pub ssh.PublicKey) (string, bool) {
165 tenant, ok, err := st.TenantForUserCA(sshca.AuthorizedKeyLine(pub))
166 if err != nil {
167 slog.Error("tenant user-ca lookup failed; rejecting", "err", err)
168 return "", false
169 }
170 return tenant, ok
171 }
172 }
internal/server/boot/sshgate_test.go
Old New
@@ -0,0 +1,162 @@
1 package boot
2
3 import (
4 "crypto/ed25519"
5 "testing"
6
7 "github.com/a73x/eitri/internal/server/sshca"
8 "github.com/a73x/eitri/internal/server/store"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 "golang.org/x/crypto/ssh"
12 )
13
14 // newStore opens a fresh store on a temp DB. The gate closures resolve VMs and
15 // tenants against a REAL store — the same one the live gate uses — so these
16 // tests exercise the actual SQL, not a mock.
17 func newStore(t *testing.T) *store.Store {
18 t.Helper()
19 s, err := store.Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
20 require.NoError(t, err)
21 t.Cleanup(func() { s.Close() })
22 return s
23 }
24
25 // makeTenantHost provisions a tenant through the real JIT path and enrolls one
26 // host into it, returning the tenant ID and host. Enrollment tokens require an
27 // existing tenant (FK enforced), so the tenant must be created first.
28 func makeTenantHost(t *testing.T, s *store.Store, subject, email string) (string, store.Host) {
29 t.Helper()
30 tn, err := s.CreateTenantForIdentity("https://idp", subject, email)
31 require.NoError(t, err)
32 tok, err := s.CreateEnrollmentToken(tn.ID)
33 require.NoError(t, err)
34 h, err := s.RedeemEnrollmentToken(tok, "host-"+tn.ID, "linux", "amd64", "cloudhv", "")
35 require.NoError(t, err)
36 return tn.ID, h
37 }
38
39 func makeVM(t *testing.T, s *store.Store, host store.Host, name string) store.VM {
40 t.Helper()
41 vm := store.VM{
42 ID: "vm-" + name, HostID: host.ID, Name: name,
43 ImageURL: "http://img", ImageSHA256: "abc",
44 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
45 }
46 require.NoError(t, s.CreateVM(vm))
47 got, err := s.GetVM(vm.ID)
48 require.NoError(t, err)
49 return got
50 }
51
52 // TestResolveVMIsTenantScoped pins that the gate resolver looks a bare VM name
53 // up WITHIN the connection's tenant only: the same name in another tenant does
54 // not resolve, so one tenant can never tunnel to another's VM by guessing names.
55 func TestResolveVMIsTenantScoped(t *testing.T) {
56 s := newStore(t)
57 tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
58 tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
59 vm := makeVM(t, s, hostA, "web")
60
61 resolve := resolveVM(s)
62
63 hostID, vmID, ok := resolve(tenantA, "web")
64 require.True(t, ok, "the owning tenant must resolve its own VM")
65 assert.Equal(t, hostA.ID, hostID)
66 assert.Equal(t, vm.ID, vmID)
67
68 _, _, ok = resolve(tenantB, "web")
69 assert.False(t, ok, "resolution must not cross tenants")
70
71 _, _, ok = resolve(tenantA, "nope")
72 assert.False(t, ok, "an unknown name does not resolve")
73 }
74
75 // TestResolveVMTombstonedNotResolvable pins that a tombstoned VM stops
76 // resolving: the gate must not tunnel to a dead VM.
77 func TestResolveVMTombstonedNotResolvable(t *testing.T) {
78 s := newStore(t)
79 tenant, host := makeTenantHost(t, s, "sub-a", "alpha@x.com")
80 vm := makeVM(t, s, host, "web")
81
82 resolve := resolveVM(s)
83 _, _, ok := resolve(tenant, "web")
84 require.True(t, ok)
85
86 require.NoError(t, s.TombstoneVM(vm.ID))
87 _, _, ok = resolve(tenant, "web")
88 assert.False(t, ok, "a tombstoned VM must not resolve")
89 }
90
91 // TestAuthorizeVM pins the second gate check: the connection's tenant must OWN
92 // the VM (tenant equality), the VM must be live, and any store error refuses.
93 func TestAuthorizeVM(t *testing.T) {
94 s := newStore(t)
95 tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
96 tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
97 vm := makeVM(t, s, hostA, "web")
98
99 authorize := authorizeVM(s)
100
101 assert.True(t, authorize(tenantA, vm.ID), "the owning tenant is authorized")
102 assert.False(t, authorize(tenantB, vm.ID), "a different tenant must be rejected")
103 assert.False(t, authorize(tenantA, "no-such-vm"), "an unknown VM must be rejected")
104
105 // A tombstoned VM (DeletedAt set) is refused even for its owner.
106 require.NoError(t, s.TombstoneVM(vm.ID))
107 assert.False(t, authorize(tenantA, vm.ID), "a tombstoned VM must be rejected")
108 }
109
110 // TestRevokedCertFailsClosed pins the revocation gate: a known-revoked serial
111 // reads revoked, an unknown serial does not, and a store error fails CLOSED
112 // (reports revoked=true) so a DB hiccup rejects THAT login rather than letting a
113 // possibly-revoked cert through.
114 func TestRevokedCertFailsClosed(t *testing.T) {
115 s := newStore(t)
116 tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com")
117
118 revoked := revokedCert(s)
119 assert.False(t, revoked(42), "an unknown serial is not revoked")
120
121 require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop"))
122 assert.True(t, revoked(42), "a revoked serial reads revoked")
123
124 // Force a store error: after Close the DB handle is dead and the lookup
125 // errors. The gate must fail closed — reject the connection.
126 require.NoError(t, s.Close())
127 assert.True(t, revoked(43), "a store error must fail closed (reject)")
128 }
129
130 // TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a
131 // registered tenant CA resolves to that tenant, an unknown CA does not, and a
132 // store error fails CLOSED (rejects the cert).
133 func TestUserCALookupFailsClosed(t *testing.T) {
134 s := newStore(t)
135 tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com")
136
137 _, priv, err := ed25519.GenerateKey(nil)
138 require.NoError(t, err)
139 pub, err := ssh.NewPublicKey(priv.Public())
140 require.NoError(t, err)
141 caLine := sshca.AuthorizedKeyLine(pub)
142 require.NoError(t, s.AddTenantUserCA(tenant, caLine, "tenant", "laptop", "admin"))
143
144 lookup := userCALookup(s)
145
146 gotTenant, ok := lookup(pub)
147 require.True(t, ok, "a registered CA must resolve")
148 assert.Equal(t, tenant, gotTenant, "the CA maps to the tenant that registered it")
149
150 // An unregistered CA does not resolve (no error, just not found).
151 _, otherPriv, err := ed25519.GenerateKey(nil)
152 require.NoError(t, err)
153 otherPub, err := ssh.NewPublicKey(otherPriv.Public())
154 require.NoError(t, err)
155 _, ok = lookup(otherPub)
156 assert.False(t, ok, "an unregistered CA must not resolve")
157
158 // Force a store error: after Close the lookup errors and must fail closed.
159 require.NoError(t, s.Close())
160 _, ok = lookup(pub)
161 assert.False(t, ok, "a store error must fail closed (reject)")
162 }
internal/server/config/load.go
Old New
@@ -0,0 +1,99 @@
1 // load.go reads and validates the server config. It lives here rather than in
2 // cmd/eitri-server so the rules are testable and coverage-gated (arch R14:
3 // main packages are wiring only).
4
5 package config
6
7 import (
8 "encoding/json"
9 "fmt"
10 "log/slog"
11 "net/url"
12 "os"
13 "regexp"
14 "strings"
15 "time"
16 )
17
18 // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept local
19 // to the control plane rather than shared with the agent's imagecache: R1
20 // forbids the control plane importing the data plane, even transitively.
21 var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`)
22
23 // Load reads path, decodes the JSON, and enforces every startup invariant the
24 // server refuses to boot without. It warns (but does not fail) on the retired
25 // admin_token key so an old config gets pruned rather than silently carried.
26 func Load(path string) (Config, error) {
27 raw, err := os.ReadFile(path)
28 if err != nil {
29 return Config{}, fmt.Errorf("read config: %w", err)
30 }
31 var cfg Config
32 if err := json.Unmarshal(raw, &cfg); err != nil {
33 return Config{}, fmt.Errorf("parse config: %w", err)
34 }
35 if err := validate(cfg); err != nil {
36 return Config{}, err
37 }
38 // admin_token is retired: sign-in is OIDC and console credentials are
39 // sessions/PATs. Warn once so the operator prunes the stale key.
40 if cfg.AdminToken != "" {
41 slog.Warn("server.json: admin_token is no longer used and is ignored; remove it")
42 }
43 return cfg, nil
44 }
45
46 func validate(cfg Config) error {
47 if cfg.HostSecret == "" {
48 return fmt.Errorf("host_secret is required")
49 }
50 // The server is a pure OIDC relying party (spec §2): issuer, client_id and
51 // public_url are required. Collect every missing key so the operator fixes
52 // server.json in one pass rather than one restart per key.
53 var missingOIDC []string
54 if cfg.OIDC.Issuer == "" {
55 missingOIDC = append(missingOIDC, "oidc.issuer")
56 }
57 if cfg.OIDC.ClientID == "" {
58 missingOIDC = append(missingOIDC, "oidc.client_id")
59 }
60 if cfg.OIDC.PublicURL == "" {
61 missingOIDC = append(missingOIDC, "oidc.public_url")
62 }
63 if len(missingOIDC) > 0 {
64 return fmt.Errorf("missing required keys (point them at eitri-oidc or your IdP): %s", strings.Join(missingOIDC, ", "))
65 }
66 // public_url builds the OIDC callback URL, so it must be an absolute
67 // http(s) URL with a host — catch a bare host, missing scheme, or
68 // scheme-only URL at boot, not at the first redirect.
69 if u, err := url.Parse(cfg.OIDC.PublicURL); err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
70 return fmt.Errorf("oidc.public_url must be an absolute http(s) URL, got %q", cfg.OIDC.PublicURL)
71 }
72 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
73 return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)")
74 }
75 // Fail fast on a malformed default image digest rather than letting every
76 // mint silently propagate a bad hash.
77 if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) {
78 return fmt.Errorf("default_image_sha256 malformed (want 64 lowercase hex chars), got %q", cfg.DefaultImageSHA)
79 }
80 return nil
81 }
82
83 // ParseDuration parses raw (a config duration string) for the knob named name
84 // (the JSON field label echoed in errors). Empty raw keeps def. valid, when
85 // non-nil, is the knob's range rule; rule (e.g. ">= 0") spells it in the
86 // error. A nil valid skips the range check.
87 func ParseDuration(name, raw string, def time.Duration, valid func(time.Duration) bool, rule string) (time.Duration, error) {
88 if raw == "" {
89 return def, nil
90 }
91 d, err := time.ParseDuration(raw)
92 if err != nil {
93 return 0, fmt.Errorf("%s invalid: %w", name, err)
94 }
95 if valid != nil && !valid(d) {
96 return 0, fmt.Errorf("%s must be %s, got %q", name, rule, raw)
97 }
98 return d, nil
99 }
internal/server/config/load_test.go
Old New
@@ -0,0 +1,136 @@
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9 )
10
11 // minimal is a config that passes every validation rule.
12 const minimal = `{
13 "host_secret": "s3cret",
14 "advertise_http": "http://192.0.2.1:8080",
15 "advertise_quic": "192.0.2.1:8443",
16 "oidc": {
17 "issuer": "http://127.0.0.1:9111",
18 "client_id": "eitri-console",
19 "public_url": "http://192.0.2.1:8080"
20 }
21 }`
22
23 func write(t *testing.T, content string) string {
24 t.Helper()
25 p := filepath.Join(t.TempDir(), "server.json")
26 if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
27 t.Fatal(err)
28 }
29 return p
30 }
31
32 func TestLoadValid(t *testing.T) {
33 cfg, err := Load(write(t, minimal))
34 if err != nil {
35 t.Fatalf("Load: %v", err)
36 }
37 if cfg.HostSecret != "s3cret" || cfg.OIDC.ClientID != "eitri-console" {
38 t.Errorf("fields not decoded: %+v", cfg)
39 }
40 }
41
42 func TestLoadFileErrors(t *testing.T) {
43 if _, err := Load(filepath.Join(t.TempDir(), "absent.json")); err == nil {
44 t.Error("missing file must error")
45 }
46 if _, err := Load(write(t, "{nope")); err == nil || !strings.Contains(err.Error(), "parse config") {
47 t.Error("malformed json must error with parse context")
48 }
49 }
50
51 func TestLoadRequiresHostSecret(t *testing.T) {
52 _, err := Load(write(t, strings.Replace(minimal, `"host_secret": "s3cret",`, "", 1)))
53 if err == nil || !strings.Contains(err.Error(), "host_secret") {
54 t.Errorf("got %v", err)
55 }
56 }
57
58 // TestLoadNamesEveryMissingOIDCKey pins that the operator sees all missing
59 // oidc keys in ONE error, not one restart per key.
60 func TestLoadNamesEveryMissingOIDCKey(t *testing.T) {
61 _, err := Load(write(t, `{"host_secret": "s", "advertise_http": "h", "advertise_quic": "q"}`))
62 if err == nil {
63 t.Fatal("empty oidc block accepted")
64 }
65 for _, want := range []string{"oidc.issuer", "oidc.client_id", "oidc.public_url"} {
66 if !strings.Contains(err.Error(), want) {
67 t.Errorf("error %q must name %q", err, want)
68 }
69 }
70 }
71
72 // TestLoadRejectsMalformedPublicURL pins the boot-time URL check: public_url
73 // builds the OIDC callback, so a bare host, missing scheme, or non-http(s)
74 // scheme fails at startup rather than at the first redirect.
75 func TestLoadRejectsMalformedPublicURL(t *testing.T) {
76 for _, bad := range []string{"192.0.2.1:8080", "example.com", "http://", "ftp://x.example", "/just/a/path"} {
77 _, err := Load(write(t, strings.Replace(minimal, `"public_url": "http://192.0.2.1:8080"`, `"public_url": "`+bad+`"`, 1)))
78 if err == nil || !strings.Contains(err.Error(), "public_url") {
79 t.Errorf("public_url %q: got %v, want rejection", bad, err)
80 }
81 }
82 for _, good := range []string{"http://192.0.2.1:8080", "https://eitri.example.com"} {
83 if _, err := Load(write(t, strings.Replace(minimal, `"public_url": "http://192.0.2.1:8080"`, `"public_url": "`+good+`"`, 1))); err != nil {
84 t.Errorf("public_url %q: unexpected %v", good, err)
85 }
86 }
87 }
88
89 func TestLoadRequiresAdvertiseAddrs(t *testing.T) {
90 _, err := Load(write(t, strings.Replace(minimal, `"advertise_quic": "192.0.2.1:8443",`, "", 1)))
91 if err == nil || !strings.Contains(err.Error(), "advertise_http and advertise_quic") {
92 t.Errorf("got %v", err)
93 }
94 }
95
96 func TestLoadRejectsMalformedImageSHA(t *testing.T) {
97 withSHA := strings.Replace(minimal, `"host_secret": "s3cret",`,
98 `"host_secret": "s3cret", "default_image_sha256": "NOTHEX",`, 1)
99 if _, err := Load(write(t, withSHA)); err == nil || !strings.Contains(err.Error(), "default_image_sha256") {
100 t.Errorf("got %v", err)
101 }
102 okSHA := strings.Replace(minimal, `"host_secret": "s3cret",`,
103 `"host_secret": "s3cret", "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1)
104 if _, err := Load(write(t, okSHA)); err != nil {
105 t.Errorf("valid sha rejected: %v", err)
106 }
107 }
108
109 // TestLoadToleratesRetiredAdminToken pins that a stale admin_token key warns
110 // but does not fail — old configs keep booting.
111 func TestLoadToleratesRetiredAdminToken(t *testing.T) {
112 withTok := strings.Replace(minimal, `"host_secret": "s3cret",`,
113 `"host_secret": "s3cret", "admin_token": "stale",`, 1)
114 if _, err := Load(write(t, withTok)); err != nil {
115 t.Errorf("admin_token must warn, not fail: %v", err)
116 }
117 }
118
119 func TestParseDuration(t *testing.T) {
120 // Empty keeps the default.
121 d, err := ParseDuration("knob", "", 90*time.Hour, nil, "")
122 if err != nil || d != 90*time.Hour {
123 t.Errorf("empty: %v %v", d, err)
124 }
125 d, err = ParseDuration("knob", "2h", 0, nil, "")
126 if err != nil || d != 2*time.Hour {
127 t.Errorf("2h: %v %v", d, err)
128 }
129 if _, err := ParseDuration("knob", "banana", 0, nil, ""); err == nil || !strings.Contains(err.Error(), "knob invalid") {
130 t.Errorf("malformed: %v", err)
131 }
132 // The range rule is enforced and named in the error.
133 if _, err := ParseDuration("knob", "-1h", 0, func(d time.Duration) bool { return d >= 0 }, ">= 0"); err == nil || !strings.Contains(err.Error(), ">= 0") {
134 t.Errorf("range: %v", err)
135 }
136 }
internal/shape/classify.go
Old New
@@ -47,6 +47,7 @@ func classify(rel string) Plane {
47 strings.HasPrefix(rel, "internal/oidcprovider"), 47 strings.HasPrefix(rel, "internal/oidcprovider"),
48 strings.HasPrefix(rel, "internal/site"), 48 strings.HasPrefix(rel, "internal/site"),
49 strings.HasPrefix(rel, "internal/shape"), 49 strings.HasPrefix(rel, "internal/shape"),
50 strings.HasPrefix(rel, "internal/smoke"),
50 strings.HasPrefix(rel, "internal/cli"): 51 strings.HasPrefix(rel, "internal/cli"):
51 return PlaneTooling 52 return PlaneTooling
52 default: 53 default:
internal/shape/classify_test.go
Old New
@@ -35,6 +35,7 @@ func TestClassifyAssignsPlaneByPrefix(t *testing.T) {
35 "internal/covsnap": PlaneTooling, 35 "internal/covsnap": PlaneTooling,
36 "internal/gateclient": PlaneTooling, 36 "internal/gateclient": PlaneTooling,
37 "internal/shape": PlaneTooling, 37 "internal/shape": PlaneTooling,
38 "internal/smoke": PlaneTooling,
38 "internal/somethingnew": PlaneUnclassified, 39 "internal/somethingnew": PlaneUnclassified,
39 "pkg/whatever": PlaneUnclassified, 40 "pkg/whatever": PlaneUnclassified,
40 } 41 }
internal/site/cli.go
Old New
@@ -0,0 +1,65 @@
1 // cli.go is the eitri-site command line: `eitri-site` (no subcommand) renders
2 // the static site, `eitri-site manifest` writes the release manifest. It lives
3 // here rather than in cmd/eitri-site so it is testable and coverage-gated
4 // (arch R14: main packages are wiring only).
5
6 package site
7
8 import (
9 "encoding/json"
10 "flag"
11 "fmt"
12 "os"
13 )
14
15 // RunCLI dispatches the eitri-site command line (everything after the binary
16 // name, --version excluded — that stays in cmd/eitri-site). The default path
17 // renders the site; the "manifest" subcommand writes the release manifest.
18 func RunCLI(args []string) error {
19 if len(args) > 0 && args[0] == "manifest" {
20 if err := runManifest(args[1:]); err != nil {
21 return fmt.Errorf("manifest: %w", err)
22 }
23 return nil
24 }
25 return runBuild(args)
26 }
27
28 func runBuild(args []string) error {
29 fs := flag.NewFlagSet("eitri-site", flag.ExitOnError)
30 docs := fs.String("docs", "docs", "docs directory (markdown sources)")
31 siteDir := fs.String("site", "site", "site directory (index.md, template.html, style.css)")
32 dist := fs.String("dist", "", "optional dist/<version> dir with release artifacts")
33 out := fs.String("out", "site/dist", "output webroot")
34 if err := fs.Parse(args); err != nil {
35 return err
36 }
37 return Build(Config{DocsDir: *docs, SiteDir: *siteDir, DistDir: *dist, OutDir: *out})
38 }
39
40 func runManifest(args []string) error {
41 fs := flag.NewFlagSet("eitri-site manifest", flag.ExitOnError)
42 ver := fs.String("version", "", "release version (vX.Y.Z)")
43 dist := fs.String("dist", "", "dist/<version> dir holding bare agent binaries")
44 base := fs.String("base", "", "base URL artifacts are served from")
45 out := fs.String("out", "", "output path (default <dist>/manifest.json)")
46 if err := fs.Parse(args); err != nil {
47 return err
48 }
49 if *ver == "" || *dist == "" || *base == "" {
50 return fmt.Errorf("-version, -dist, and -base are required")
51 }
52 m, err := BuildManifest(*ver, *dist, *base)
53 if err != nil {
54 return err
55 }
56 raw, err := json.MarshalIndent(m, "", " ")
57 if err != nil {
58 return err
59 }
60 path := *out
61 if path == "" {
62 path = *dist + "/manifest.json"
63 }
64 return os.WriteFile(path, append(raw, '\n'), 0o644)
65 }
internal/site/cli_test.go
Old New
@@ -0,0 +1,78 @@
1 package site
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/a73x/eitri/internal/server/release"
11 )
12
13 // TestRunCLIBuild renders the site through the command-line entry point,
14 // exercising the default (no-subcommand) flag path.
15 func TestRunCLIBuild(t *testing.T) {
16 root, docs, siteDir := writeFixture(t)
17 out := filepath.Join(root, "out")
18 if err := RunCLI([]string{"-docs", docs, "-site", siteDir, "-out", out}); err != nil {
19 t.Fatalf("RunCLI build: %v", err)
20 }
21 if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil {
22 t.Errorf("index.html not emitted: %v", err)
23 }
24 }
25
26 // TestRunCLIBuildError surfaces a build failure (an absent tree) as an error,
27 // not a panic.
28 func TestRunCLIBuildError(t *testing.T) {
29 absent := filepath.Join(t.TempDir(), "absent")
30 if err := RunCLI([]string{"-docs", absent, "-site", absent, "-out", absent}); err == nil {
31 t.Error("build over an absent tree must error")
32 }
33 }
34
35 // TestRunCLIManifest writes a release manifest through the "manifest"
36 // subcommand, to both an explicit -out and the default <dist>/manifest.json.
37 func TestRunCLIManifest(t *testing.T) {
38 dist := t.TempDir()
39 if err := os.WriteFile(filepath.Join(dist, "eitri-agent_linux_amd64"), []byte("x"), 0o755); err != nil {
40 t.Fatal(err)
41 }
42
43 outPath := filepath.Join(dist, "m.json")
44 if err := RunCLI([]string{"manifest", "-version", "v0.0.1", "-dist", dist, "-base", "https://eitri.sh/dl/v0.0.1", "-out", outPath}); err != nil {
45 t.Fatalf("RunCLI manifest: %v", err)
46 }
47 raw, err := os.ReadFile(outPath)
48 if err != nil {
49 t.Fatal(err)
50 }
51 var m release.Manifest
52 if err := json.Unmarshal(raw, &m); err != nil {
53 t.Fatalf("manifest not valid json: %v", err)
54 }
55 if m.Version != "v0.0.1" {
56 t.Errorf("manifest version = %q", m.Version)
57 }
58
59 // With -out omitted the manifest lands at <dist>/manifest.json.
60 if err := RunCLI([]string{"manifest", "-version", "v0.0.1", "-dist", dist, "-base", "https://eitri.sh/dl/v0.0.1"}); err != nil {
61 t.Fatalf("RunCLI manifest default out: %v", err)
62 }
63 if _, err := os.Stat(filepath.Join(dist, "manifest.json")); err != nil {
64 t.Errorf("default manifest.json not written: %v", err)
65 }
66 }
67
68 // TestRunCLIManifestRequiresFlags names the missing required flags, prefixed so
69 // the caller can tell a manifest failure from a build failure.
70 func TestRunCLIManifestRequiresFlags(t *testing.T) {
71 err := RunCLI([]string{"manifest", "-version", "v0.0.1"})
72 if err == nil || !strings.Contains(err.Error(), "required") {
73 t.Errorf("manifest without -dist/-base: got %v", err)
74 }
75 if err != nil && !strings.Contains(err.Error(), "manifest:") {
76 t.Errorf("manifest error must be prefixed: got %v", err)
77 }
78 }
internal/smoke/config.go
Old New
@@ -0,0 +1,103 @@
1 package smoke
2
3 import (
4 "fmt"
5 "strconv"
6 "strings"
7 )
8
9 // Config holds the environment-sourced settings for one smoke run. It mirrors
10 // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV.
11 type Config struct {
12 ServerURL string
13 CIUser string
14 CIPasswordFile string
15 CIPATFile string
16 AgentUserHost string
17 AgentPort int
18 AgentStateDir string
19
20 // Carried for a later coverage-collection task; optional here.
21 ServerGocoverdir string
22 AgentGocoverdir string
23 CoverOut string
24
25 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the
26 // scenario proves guest access through the gate (a hard gate). Optional.
27 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>"
28 SmokeVMUser string // SMOKE_VM_USER, default "ubuntu"
29 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key
30 }
31
32 // loadConfig reads the required smoke settings via getenv (never the real
33 // process environment directly, so callers can inject a fake for tests). It
34 // returns an error naming the first missing required variable.
35 func loadConfig(getenv func(string) string) (Config, error) {
36 serverURL := getenv("SERVER_URL")
37 ciUser := getenv("CI_USER")
38 ciPasswordFile := getenv("CI_PASSWORD_FILE")
39 ciPATFile := getenv("CI_PAT_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 ciUser == "" {
48 missing = append(missing, "CI_USER")
49 }
50 if ciPasswordFile == "" {
51 missing = append(missing, "CI_PASSWORD_FILE")
52 }
53 if ciPATFile == "" {
54 missing = append(missing, "CI_PAT_FILE")
55 }
56 if agentHosts == "" {
57 missing = append(missing, "AGENT_HOSTS")
58 }
59 if agentStateDir == "" {
60 missing = append(missing, "AGENT_STATE_DIR")
61 }
62 if len(missing) > 0 {
63 return Config{}, fmt.Errorf("missing required env var(s): %s", strings.Join(missing, ", "))
64 }
65
66 userHost, port := parseAgentHost(agentHosts)
67
68 smokeVMUser := getenv("SMOKE_VM_USER")
69 if smokeVMUser == "" {
70 smokeVMUser = "ubuntu"
71 }
72
73 return Config{
74 ServerURL: serverURL,
75 CIUser: ciUser,
76 CIPasswordFile: ciPasswordFile,
77 CIPATFile: ciPATFile,
78 AgentUserHost: userHost,
79 AgentPort: port,
80 AgentStateDir: agentStateDir,
81 ServerGocoverdir: getenv("SERVER_GOCOVERDIR"),
82 AgentGocoverdir: getenv("AGENT_GOCOVERDIR"),
83 CoverOut: getenv("COVER_OUT"),
84 SmokeGate: getenv("SMOKE_GATE"),
85 SmokeVMUser: smokeVMUser,
86 SmokeUserCAFile: getenv("SMOKE_USER_CA_FILE"),
87 }, nil
88 }
89
90 // parseAgentHost takes the AGENT_HOSTS value (space-separated "user@host[:port]"
91 // entries) and returns the first entry's user@host plus its port. The port is
92 // the substring after the LAST colon when that substring is entirely digits;
93 // otherwise there is no port and it defaults to 22.
94 func parseAgentHost(agentHosts string) (userHost string, port int) {
95 entry := strings.Fields(agentHosts)[0]
96
97 if idx := strings.LastIndex(entry, ":"); idx != -1 {
98 if p, err := strconv.Atoi(entry[idx+1:]); err == nil {
99 return entry[:idx], p
100 }
101 }
102 return entry, 22
103 }
internal/smoke/config_test.go
Old New
@@ -0,0 +1,153 @@
1 package smoke
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 "CI_USER": "ci@eitri.local",
18 "CI_PASSWORD_FILE": "/etc/eitri/ci-password",
19 "CI_PAT_FILE": "/etc/eitri/ci-pat",
20 "AGENT_HOSTS": "ubuntu@10.0.0.5:2222",
21 "AGENT_STATE_DIR": "/var/lib/eitri-agent",
22 }
23 }
24
25 func TestLoadConfigParsesAgentHostWithPort(t *testing.T) {
26 cfg, err := loadConfig(fakeGetenv(requiredVals()))
27 if err != nil {
28 t.Fatalf("loadConfig: %v", err)
29 }
30 if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
31 t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
32 }
33 if cfg.AgentPort != 2222 {
34 t.Errorf("AgentPort = %d, want 2222", cfg.AgentPort)
35 }
36 }
37
38 func TestLoadConfigDefaultsPortWithoutColon(t *testing.T) {
39 vals := requiredVals()
40 vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5"
41 cfg, err := loadConfig(fakeGetenv(vals))
42 if err != nil {
43 t.Fatalf("loadConfig: %v", err)
44 }
45 if cfg.AgentUserHost != "ubuntu@10.0.0.5" {
46 t.Errorf("AgentUserHost = %q, want ubuntu@10.0.0.5", cfg.AgentUserHost)
47 }
48 if cfg.AgentPort != 22 {
49 t.Errorf("AgentPort = %d, want 22", cfg.AgentPort)
50 }
51 }
52
53 func TestLoadConfigMultiEntryTakesFirst(t *testing.T) {
54 vals := requiredVals()
55 vals["AGENT_HOSTS"] = "ubuntu@10.0.0.5:2200 ubuntu@10.0.0.6:2201"
56 cfg, err := loadConfig(fakeGetenv(vals))
57 if err != nil {
58 t.Fatalf("loadConfig: %v", err)
59 }
60 if cfg.AgentUserHost != "ubuntu@10.0.0.5" || cfg.AgentPort != 2200 {
61 t.Errorf("got %q:%d, want ubuntu@10.0.0.5:2200", cfg.AgentUserHost, cfg.AgentPort)
62 }
63 }
64
65 func TestLoadConfigMissingRequiredVars(t *testing.T) {
66 cases := []struct {
67 name string
68 unset string
69 wantErr string
70 }{
71 {"missing server url", "SERVER_URL", "SERVER_URL"},
72 {"missing ci user", "CI_USER", "CI_USER"},
73 {"missing ci password file", "CI_PASSWORD_FILE", "CI_PASSWORD_FILE"},
74 {"missing ci pat file", "CI_PAT_FILE", "CI_PAT_FILE"},
75 {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"},
76 {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"},
77 }
78 for _, tc := range cases {
79 t.Run(tc.name, func(t *testing.T) {
80 vals := requiredVals()
81 delete(vals, tc.unset)
82 _, err := loadConfig(fakeGetenv(vals))
83 if err == nil {
84 t.Fatal("loadConfig: want error, got nil")
85 }
86 if !strings.Contains(err.Error(), tc.wantErr) {
87 t.Errorf("error = %q, want to mention %q", err.Error(), tc.wantErr)
88 }
89 })
90 }
91 }
92
93 func TestLoadConfigMissingAllRequiredVars(t *testing.T) {
94 _, err := loadConfig(fakeGetenv(nil))
95 if err == nil {
96 t.Fatal("loadConfig: want error, got nil")
97 }
98 for _, want := range []string{"SERVER_URL", "CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} {
99 if !strings.Contains(err.Error(), want) {
100 t.Errorf("error = %q, missing %q", err.Error(), want)
101 }
102 }
103 }
104
105 func TestLoadConfigSmokeGateDefaults(t *testing.T) {
106 cfg, err := loadConfig(fakeGetenv(requiredVals()))
107 if err != nil {
108 t.Fatalf("loadConfig: %v", err)
109 }
110 if cfg.SmokeGate != "" {
111 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate)
112 }
113 if cfg.SmokeVMUser != "ubuntu" {
114 t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser)
115 }
116 if cfg.SmokeUserCAFile != "" {
117 t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile)
118 }
119 }
120
121 func TestLoadConfigSmokeGatePassThrough(t *testing.T) {
122 vals := requiredVals()
123 vals["SMOKE_GATE"] = "gate.example:2222"
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.SmokeVMUser != "debian" {
134 t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser)
135 }
136 if cfg.SmokeUserCAFile != "/etc/eitri-smoke/user_ca" {
137 t.Errorf("SmokeUserCAFile = %q, want /etc/eitri-smoke/user_ca", cfg.SmokeUserCAFile)
138 }
139 }
140
141 func TestLoadConfigOptionalCoverageVarsPassThrough(t *testing.T) {
142 vals := requiredVals()
143 vals["SERVER_GOCOVERDIR"] = "/tmp/server-cover"
144 vals["AGENT_GOCOVERDIR"] = "/tmp/agent-cover"
145 vals["COVER_OUT"] = "/tmp/out"
146 cfg, err := loadConfig(fakeGetenv(vals))
147 if err != nil {
148 t.Fatalf("loadConfig: %v", err)
149 }
150 if cfg.ServerGocoverdir != "/tmp/server-cover" || cfg.AgentGocoverdir != "/tmp/agent-cover" || cfg.CoverOut != "/tmp/out" {
151 t.Errorf("optional coverage vars not passed through: %+v", cfg)
152 }
153 }
internal/smoke/coverage.go
Old New
@@ -0,0 +1,139 @@
1 package smoke
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 }
internal/smoke/coverage_test.go
Old New
@@ -0,0 +1,30 @@
1 package smoke
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 }
internal/smoke/gatecheck.go
Old New
@@ -0,0 +1,78 @@
1 package smoke
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. The
16 // tenant is the operator PAT's own tenant (derived via Me() by the caller), so
17 // the CA registration and connect names match the fleet's real partition.
18 func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks {
19 auth := gateclient.NewGateAuth(ca, userCA, tenant, now)
20 return &gateHooks{
21 register: func(ctx context.Context) error { return auth.Register(ctx) },
22 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) },
23 }
24 }
25
26 // gateExec proves the guest is reachable through the SSH-CA gate: it retries
27 // dial+login over the guest's pre-sshd boot window (a dial or session error
28 // mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser and
29 // confirms `id -un` echoes that same user, or the 120s deadline expires.
30 func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error {
31 var lastErr error
32 err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) {
33 client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{
34 Gate: cfg.SmokeGate,
35 VMUser: cfg.SmokeVMUser,
36 Auth: auth,
37 }, vmName)
38 if dialErr != nil {
39 lastErr = dialErr
40 return false, nil
41 }
42 defer client.Close()
43
44 out, runErr := runGuestCommand(client, "id -un")
45 if runErr != nil {
46 lastErr = runErr
47 return false, nil
48 }
49
50 got := strings.TrimSpace(out)
51 if got != cfg.SmokeVMUser {
52 return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser)
53 }
54 return true, nil
55 })
56 if err != nil {
57 if errors.Is(err, errPollTimeout) {
58 return fmt.Errorf("FAIL: could not reach guest %q through the gate within 120s: %w", vmName, lastErr)
59 }
60 return err
61 }
62 return nil
63 }
64
65 // runGuestCommand runs cmd in a new session on client and returns its stdout.
66 func runGuestCommand(client *ssh.Client, cmd string) (string, error) {
67 session, err := client.NewSession()
68 if err != nil {
69 return "", fmt.Errorf("open ssh session: %w", err)
70 }
71 defer session.Close()
72
73 out, err := session.Output(cmd)
74 if err != nil {
75 return "", fmt.Errorf("run %q: %w", cmd, err)
76 }
77 return string(out), nil
78 }
internal/smoke/login.go
Old New
@@ -0,0 +1,108 @@
1 package smoke
2
3 import (
4 "fmt"
5 "net/http"
6 "net/http/cookiejar"
7 "net/url"
8 "time"
9
10 "github.com/a73x/eitri/internal/server/api/client"
11 )
12
13 // sessionCookie is the name of the console session cookie the callback sets.
14 // Its presence in the jar after the credential POST is the one honest signal
15 // that sign-in succeeded — a failed password re-renders the login form as a
16 // plain 200 without ever setting it.
17 const sessionCookie = "eitri_session"
18
19 // loginPAT signs in through the real OIDC code flow — the same doors a browser
20 // walks — and mints a short-lived PAT for this run. There is no special grant
21 // for machines (spec §3): CI registers a flat-file user and then drives the
22 // standard authorization-code flow headlessly against the plain-HTML login
23 // form, lands an ordinary session, and mints a PAT through the normal route.
24 func loginPAT(serverURL, email, password string) (string, error) {
25 base, err := url.Parse(serverURL)
26 if err != nil {
27 return "", fmt.Errorf("parse server url %q: %w", serverURL, err)
28 }
29 jar, err := cookiejar.New(nil)
30 if err != nil {
31 return "", fmt.Errorf("cookie jar: %w", err)
32 }
33 // A default (redirect-following) client: GET /auth/login bounces through the
34 // issuer's authorize endpoint to the login form, and the credential POST
35 // runs issuer -> /auth/callback -> / — we want every hop followed so the
36 // session cookie lands in the jar and resp.Request.URL is the form's URL.
37 hc := &http.Client{Jar: jar, Timeout: 30 * time.Second}
38
39 // GET /auth/login follows the redirect chain to the issuer's login form.
40 // We POST credentials straight back to resp.Request.URL — the authorize
41 // URL we landed on, query intact — so no HTML parsing is needed and the
42 // form's action attribute is never consulted. (eitri-oidc accepts the
43 // credential POST on that same URL by contract, spec §2.1.)
44 resp, err := hc.Get(serverURL + "/auth/login")
45 if err != nil {
46 return "", fmt.Errorf("GET /auth/login: %w", err)
47 }
48 resp.Body.Close()
49 if resp.StatusCode != http.StatusOK {
50 return "", fmt.Errorf("sign-in: login form GET returned %d (want 200) at %s", resp.StatusCode, resp.Request.URL)
51 }
52 formURL := resp.Request.URL.String()
53
54 // POST credentials to the form URL; the redirects run issuer -> /auth/callback
55 // -> / and the callback plants the session cookie in the jar on success.
56 resp2, err := hc.PostForm(formURL, url.Values{
57 "email": {email},
58 "password": {password},
59 })
60 if err != nil {
61 return "", fmt.Errorf("POST credentials: %w", err)
62 }
63 resp2.Body.Close()
64
65 // A failed sign-in re-renders the login form as a 200 without a session
66 // cookie. Detect success by the cookie's presence in the jar, never by the
67 // status. The password is never included in the error.
68 if cookieByName(jar.Cookies(base), sessionCookie) == nil {
69 return "", fmt.Errorf("sign-in failed for %q: no %s cookie after credential POST "+
70 "(final status %d at %s) — check the CI user exists in eitri-oidc and the password matches",
71 email, sessionCookie, resp2.StatusCode, resp2.Request.URL)
72 }
73
74 // Mint the PAT through the shared client, riding the session cookie in the
75 // jar (Token left empty so no Bearer header is sent).
76 c := &client.Client{BaseURL: serverURL, HTTP: hc}
77 tok, err := c.CreateAPIToken("boot-gate", time.Hour)
78 if err != nil {
79 return "", fmt.Errorf("mint boot-gate PAT: %w", err)
80 }
81 return tok.Token, nil
82 }
83
84 // proveCredentialChain is the boot-gate's phase-1 proof: it confirms the minted
85 // PAT resolves to a non-empty tenant via GET /api/v1/me, exercising the whole
86 // issuer -> login -> session -> PAT-mint chain without touching VMs. It returns
87 // the tenant handle so the caller can log which tenant the ci user landed in.
88 func proveCredentialChain(serverURL, token string) (string, error) {
89 c := &client.Client{BaseURL: serverURL, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}
90 me, err := c.Me()
91 if err != nil {
92 return "", fmt.Errorf("credential-chain proof: Me() with minted PAT: %w", err)
93 }
94 if me.Tenant == "" {
95 return "", fmt.Errorf("credential-chain proof: minted PAT resolved to an empty tenant")
96 }
97 return me.Tenant, nil
98 }
99
100 // cookieByName returns the named cookie from cookies, or nil if absent.
101 func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
102 for _, c := range cookies {
103 if c.Name == name {
104 return c
105 }
106 }
107 return nil
108 }
internal/smoke/login_test.go
Old New
@@ -0,0 +1,171 @@
1 package smoke
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/oidcprovider"
13 "github.com/a73x/eitri/internal/server/api"
14 "github.com/a73x/eitri/internal/server/api/client"
15 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry"
17 "github.com/a73x/eitri/internal/server/store"
18 )
19
20 // smokeLoginEnv is a whole eitri-server /auth + /api stack wired to a real
21 // internal/oidcprovider issuer — the exact credential chain a deploy walks. It
22 // exists to prove loginPAT end-to-end: sign in through the login form, land a
23 // session, mint a PAT, and use it against the API.
24 type smokeLoginEnv struct {
25 serverURL string
26 st *store.Store
27 }
28
29 // newSmokeLoginEnv stands up the issuer and server with one seeded user and
30 // returns the server's base URL. It mirrors internal/server/api/auth_test.go's
31 // fixture: the httptest listener binds on construction so we know the server's
32 // address (hence the OIDC redirect URL) before wiring the two ends.
33 func newSmokeLoginEnv(t *testing.T, email, password string) smokeLoginEnv {
34 t.Helper()
35
36 apiSrv := httptest.NewUnstartedServer(nil)
37 publicURL := "http://" + apiSrv.Listener.Addr().String()
38
39 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
40 if err != nil {
41 t.Fatalf("store.Open: %v", err)
42 }
43 t.Cleanup(func() { st.Close() })
44
45 usersPath := filepath.Join(t.TempDir(), "users.json")
46 if err := oidcprovider.AddUser(usersPath, email, password); err != nil {
47 t.Fatalf("AddUser: %v", err)
48 }
49 prov, err := oidcprovider.New(oidcprovider.Config{
50 UsersFile: usersPath,
51 SigningKey: filepath.Join(t.TempDir(), "signing.key"),
52 Clients: []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
53 })
54 if err != nil {
55 t.Fatalf("oidcprovider.New: %v", err)
56 }
57 oidcSrv := httptest.NewServer(prov.Handler())
58 t.Cleanup(oidcSrv.Close)
59 prov.SetIssuer(oidcSrv.URL)
60
61 a := api.New(api.Config{
62 HostSecret: []byte("hostsecret"),
63 OIDC: api.OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL},
64 }, st, registry.New(time.Now), hub.New())
65 t.Cleanup(a.Close)
66
67 root := http.NewServeMux()
68 root.Handle("/api/", a.Handler())
69 root.Handle("/auth/", a.AuthHandler())
70 apiSrv.Config.Handler = root
71 apiSrv.Start()
72 t.Cleanup(apiSrv.Close)
73
74 return smokeLoginEnv{serverURL: publicURL, st: st}
75 }
76
77 func TestLoginPATMintsUsableToken(t *testing.T) {
78 const (
79 email = "ci@eitri.local"
80 password = "hunter2hunter2"
81 )
82 env := newSmokeLoginEnv(t, email, password)
83
84 token, err := loginPAT(env.serverURL, email, password)
85 if err != nil {
86 t.Fatalf("loginPAT: %v", err)
87 }
88 if token == "" {
89 t.Fatal("loginPAT returned an empty token")
90 }
91
92 // The PAT must actually authenticate an ordinary API call.
93 c := &client.Client{BaseURL: env.serverURL, Token: token, HTTP: &http.Client{Timeout: 10 * time.Second}}
94 me, err := c.Me()
95 if err != nil {
96 t.Fatalf("Me() with minted PAT: %v", err)
97 }
98 if me.Email != email {
99 t.Errorf("Me().Email = %q, want %q", me.Email, email)
100 }
101 if _, err := c.ListHosts(context.Background()); err != nil {
102 t.Errorf("ListHosts() with minted PAT: %v", err)
103 }
104 }
105
106 // TestProveCredentialChain is the phase-1 proof: signing in and minting a PAT
107 // must resolve to a non-empty tenant via Me() (the ci user's JIT tenant),
108 // without any VM involvement.
109 func TestProveCredentialChain(t *testing.T) {
110 const (
111 email = "ci@eitri.local"
112 password = "hunter2hunter2"
113 )
114 env := newSmokeLoginEnv(t, email, password)
115
116 token, err := loginPAT(env.serverURL, email, password)
117 if err != nil {
118 t.Fatalf("loginPAT: %v", err)
119 }
120 tenant, err := proveCredentialChain(env.serverURL, token)
121 if err != nil {
122 t.Fatalf("proveCredentialChain: %v", err)
123 }
124 if tenant == "" {
125 t.Fatal("proveCredentialChain returned an empty tenant")
126 }
127 }
128
129 // TestScenarioPATDerivesTenant is the phase-2 contract: a scenario client built
130 // from an operator-minted PAT derives its tenant via Me() — not from any env or
131 // hardcoded default. The store mints the PAT directly, standing in for the
132 // console-minted "deploy" token a real operator saves to CI_PAT_FILE.
133 func TestScenarioPATDerivesTenant(t *testing.T) {
134 env := newSmokeLoginEnv(t, "ci@eitri.local", "hunter2hunter2")
135
136 // A distinct operator tenant with a row (handleMe 401s on a rowless tenant).
137 tn, err := env.st.CreateTenantForIdentity("https://op.example", "op-subject", "op@example.com")
138 if err != nil {
139 t.Fatalf("CreateTenantForIdentity: %v", err)
140 }
141 secret, _, err := env.st.CreateAPIToken(tn.ID, "deploy", 0)
142 if err != nil {
143 t.Fatalf("CreateAPIToken: %v", err)
144 }
145
146 c := &client.Client{BaseURL: env.serverURL, Token: secret, HTTP: &http.Client{Timeout: 10 * time.Second}}
147 me, err := c.Me()
148 if err != nil {
149 t.Fatalf("Me() with store-minted PAT: %v", err)
150 }
151 if me.Tenant != tn.ID {
152 t.Errorf("Me().Tenant = %q, want %q (derived, not assumed)", me.Tenant, tn.ID)
153 }
154 }
155
156 func TestLoginPATWrongPasswordFails(t *testing.T) {
157 const email = "ci@eitri.local"
158 env := newSmokeLoginEnv(t, email, "the-right-password")
159
160 _, err := loginPAT(env.serverURL, email, "the-wrong-password")
161 if err == nil {
162 t.Fatal("loginPAT: want error on wrong password, got nil")
163 }
164 // The failure must name the sign-in problem and must not echo the password.
165 if !strings.Contains(err.Error(), "sign-in failed") || !strings.Contains(err.Error(), email) {
166 t.Errorf("error = %q, want it to mention the sign-in failure and the user", err)
167 }
168 if strings.Contains(err.Error(), "the-wrong-password") {
169 t.Errorf("error must not echo the password: %q", err)
170 }
171 }
internal/smoke/run.go
Old New
@@ -0,0 +1,157 @@
1 // Package smoke is the deploy boot-gate harness. It drives the live eitri
2 // fleet through a credential-chain proof and a create -> boot-proof -> gate-SSH
3 // -> reap of one throwaway VM, returning an error on any failure. It is a
4 // client of the eitri API plus SSH; it embeds no control-plane or agent code.
5 // The eitri-smoke command is thin wiring over Run (arch R14).
6 package smoke
7
8 import (
9 "context"
10 "crypto/rand"
11 "encoding/hex"
12 "fmt"
13 "net/http"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/a73x/eitri/internal/server/api/client"
22 )
23
24 // Run executes one boot-gate pass, reading its settings from the process
25 // environment. It returns an error describing the first failed step; a nil
26 // return means the credential chain, VM boot-proof, and reap all passed.
27 func Run() error {
28 cfg, err := loadConfig(os.Getenv)
29 if err != nil {
30 return err
31 }
32
33 // Phase 1 — credential-chain proof. The admin token is gone: the boot-gate
34 // authenticates like a human. Read the machine identity's password (CI_USER,
35 // the deploy identity), sign in through the real OIDC code flow, mint a
36 // short-lived PAT, and confirm it resolves to a tenant (spec §3). This proves
37 // issuer, login form, session, and PAT mint end to end; it deliberately never
38 // touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's
39 // `default` tenant is human-owned), so the lifecycle half runs as the operator
40 // below.
41 pwBytes, err := os.ReadFile(cfg.CIPasswordFile)
42 if err != nil {
43 return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err)
44 }
45 password := strings.TrimRight(string(pwBytes), "\r\n")
46
47 token, err := loginPAT(cfg.ServerURL, cfg.CIUser, password)
48 if err != nil {
49 return err
50 }
51 ciTenant, err := proveCredentialChain(cfg.ServerURL, token)
52 if err != nil {
53 return err
54 }
55 fmt.Printf("credential chain OK (tenant %s)\n", ciTenant)
56
57 // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from
58 // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation
59 // story). The scenario's tenant is DERIVED via Me(), never assumed, and
60 // threaded into the user-CA registration and gate connect name below.
61 patBytes, err := os.ReadFile(cfg.CIPATFile)
62 if err != nil {
63 return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err)
64 }
65 pat := strings.TrimRight(string(patBytes), "\r\n")
66
67 api := &client.Client{
68 BaseURL: cfg.ServerURL,
69 Token: pat,
70 UserCALabel: "eitri-smoke",
71 HTTP: &http.Client{Timeout: 30 * time.Second},
72 }
73 me, err := api.Me()
74 if err != nil {
75 return fmt.Errorf("resolve operator PAT tenant: %w", err)
76 }
77 if me.Tenant == "" {
78 return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", cfg.CIPATFile)
79 }
80 tenant := me.Tenant
81 fmt.Printf("operator PAT tenant: %s\n", tenant)
82
83 vmName, err := randVMName()
84 if err != nil {
85 return fmt.Errorf("generate vm name: %w", err)
86 }
87
88 var gate *gateHooks
89 if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" {
90 userCA, err := loadOrCreateUserCA(cfg.SmokeUserCAFile)
91 if err != nil {
92 return fmt.Errorf("load smoke user CA: %w", err)
93 }
94 gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep)
95 } else {
96 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)")
97 }
98
99 ctx := context.Background()
100 msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey)
101 if err != nil {
102 return err
103 }
104 fmt.Println(msg)
105
106 // The boot gate has passed. Collect integration coverage as a by-product
107 // when COVER_OUT is set; a failure here must NOT fail the deploy, so warn
108 // and carry on — the gate above is the authoritative check.
109 if cfg.CoverOut != "" {
110 if err := collectCoverage(ctx, cfg); err != nil {
111 fmt.Fprintln(os.Stderr, "eitri-smoke: coverage collection failed (boot gate still passed):", err)
112 }
113 }
114 return nil
115 }
116
117 // randVMName generates a throwaway VM name of the form "smoke-<8 hex digits>",
118 // unique enough that concurrent smoke runs don't collide.
119 func randVMName() (string, error) {
120 var b [4]byte
121 if _, err := rand.Read(b[:]); err != nil {
122 return "", err
123 }
124 return "smoke-" + hex.EncodeToString(b[:]), nil
125 }
126
127 // realRunSSH returns the real sshFunc: it shells out to the ssh binary
128 // against userHost:port with the deploy boot-gate's ssh flags, running
129 // remoteCmd non-interactively and returning its stdout.
130 func realRunSSH(userHost string, port int) sshFunc {
131 return func(ctx context.Context, remoteCmd string) (string, error) {
132 cmd := exec.CommandContext(ctx, "ssh",
133 "-p", strconv.Itoa(port),
134 "-o", "BatchMode=yes",
135 "-o", "ConnectTimeout=10",
136 userHost, remoteCmd)
137 out, err := cmd.Output()
138 return string(out), err
139 }
140 }
141
142 // realReadPubKey reads the local operator's SSH public key, trying
143 // ~/.ssh/id_ed25519.pub then ~/.ssh/id_rsa.pub. It returns "" (not an error)
144 // if neither is present — the scenario tolerates a keyless VM.
145 func realReadPubKey() string {
146 home, err := os.UserHomeDir()
147 if err != nil {
148 return ""
149 }
150 for _, name := range []string{"id_ed25519.pub", "id_rsa.pub"} {
151 data, err := os.ReadFile(filepath.Join(home, ".ssh", name))
152 if err == nil {
153 return strings.TrimSpace(string(data))
154 }
155 }
156 return ""
157 }
internal/smoke/scenario.go
Old New
@@ -0,0 +1,196 @@
1 package smoke
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 }
internal/smoke/scenario_test.go
Old New
@@ -0,0 +1,455 @@
1 package smoke
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 }
internal/smoke/userca.go
Old New
@@ -0,0 +1,56 @@
1 package smoke
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 }
internal/smoke/userca_test.go
Old New
@@ -0,0 +1,37 @@
1 package smoke
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 }
scripts/coverage.sh
Old New
@@ -25,6 +25,7 @@ declare -A FLOOR=(
25 [internal/agent/cloudhv]=40 25 [internal/agent/cloudhv]=40
26 [internal/agent/syncclient]=74 26 [internal/agent/syncclient]=74
27 [internal/server/api]=76 27 [internal/server/api]=76
28 [internal/server/boot]=17
28 [internal/server/api/client]=89 29 [internal/server/api/client]=89
29 [internal/server/api/spec]=91 30 [internal/server/api/spec]=91
30 [internal/server/store]=76 31 [internal/server/store]=76
@@ -32,15 +33,19 @@ declare -A FLOOR=(
32 [internal/server/release]=90 33 [internal/server/release]=90
33 [internal/agent/selfupdate]=65 34 [internal/agent/selfupdate]=65
34 [internal/agent/bootstrap]=70 35 [internal/agent/bootstrap]=70
36 [internal/agent/run]=30
35 [internal/server/hosttoken]=95 37 [internal/server/hosttoken]=95
36 [internal/server/hub]=90 38 [internal/server/hub]=90
37 [internal/server/syncsvc]=75 39 [internal/server/syncsvc]=75
38 [internal/server/web]=90 40 [internal/server/web]=90
39 [internal/transport]=77 41 [internal/transport]=77
40 [internal/shape]=88 42 [internal/shape]=88
41 [internal/site]=80 43 [internal/site]=84
42 [internal/cli]=60 44 [internal/smoke]=46
43 [internal/oidcprovider]=78 45 [internal/cli]=64
46 [internal/mcpserver]=57
47 [internal/oidcprovider]=79
48 [internal/server/config]=95
44 ) 49 )
45 50
46 profile="$(mktemp)" 51 profile="$(mktemp)"