a73x

f6db476e

feat(agent): a Mac joins the fleet with no VMs

a73x   2026-08-06 09:12

Commit message
feat(agent): a Mac joins the fleet with no VMs

hostinfo's platform reads split at the syscall boundary. Linux keeps /proc
and Sysinfo; Darwin answers from sysctls — boot identity from kern.boottime,
totals from hw.memsize and statfs, load from vm.loadavg, the chip and kernel
from machdep and kern, and the OS name and version from macOS's own
SystemVersion.plist. Everything that is parsing rather than reading — the
loadavg fixed-point struct, the plist lookup — is untagged and covered by
tests against real readings from an Apple M1, because code behind a build tag
is code the other platform's CI can never run. Virt is "none" on a Mac, the
same answer a bare-metal Linux host gives, and it costs no subprocess.

internal/agent/inert is the platform for a host that is in the fleet but
cannot run guests: networking that grants nothing, a provisioner that refuses
every create permanently so reconcile terminal-fails a misplaced VM in one
attempt instead of burning its retry budget, and a console source that fails
loudly rather than silently. It is untagged, so Linux CI proves its behavior;
wire_darwin.go is wiring over it and nothing else.

The merge gate covers both platforms now: make ci compiles and vets
darwin/arm64, and the dead-code analyzer judges a function across both, since
a helper only a Mac calls is not dead code.

A Mac enrolls, heartbeats, and reports macOS and its version, its Apple chip,
real memory and disk totals, live load and usage, and its capacity. Placement
never targets it, and a VM aimed at one fails fast with a message that names
the host's platform and the reason.

Makefile
Old New
@@ -10,7 +10,7 @@ DEADCODE_VERSION := v0.48.0
10 # .golangci.yml's enable list once a linter's baseline is clean. 10 # .golangci.yml's enable list once a linter's baseline is clean.
11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret 11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
12 12
13 .PHONY: build build-go web test vet proto clean \ 13 .PHONY: build build-go build-darwin web test vet proto clean \
14 lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ 14 lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \
15 site site-check ci deadcode \ 15 site site-check ci deadcode \
16 deploy release hooks site-image server-image 16 deploy release hooks site-image server-image
@@ -192,22 +192,45 @@ server-image: web
192 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what 192 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what
193 # catches production code kept alive only by its own tests; the fix is to remove 193 # catches production code kept alive only by its own tests; the fix is to remove
194 # it, wire it into a real path, or move it into a _test.go. 194 # it, wire it into a real path, or move it into a _test.go.
195 #
196 # The analysis runs once per platform the agent ships on, because dead means
197 # dead on ALL of them: hostinfo's Darwin parse helpers and the inert platform
198 # are unreachable on Linux, the whole cloud-hypervisor stack is unreachable on
199 # Darwin, and none of it is dead. The two runs are intersected — except for
200 # build-tagged files, which are compiled in exactly one run and are therefore
201 # judged by that run alone, so dead code inside hostinfo_linux.go still fails.
202 #
203 # `go install` into a temp GOBIN rather than `go run`: with GOOS set, `go run`
204 # would cross-compile the analyzer itself instead of the code under analysis.
195 deadcode: 205 deadcode:
196 @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) ./...); \ 206 @tmp=$$(mktemp -d); trap 'rm -rf $$tmp' EXIT; \
207 GOBIN=$$tmp go install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION); \
208 $$tmp/deadcode ./... | sort > $$tmp/linux; \
209 GOOS=darwin GOARCH=arm64 $$tmp/deadcode ./... | sort > $$tmp/darwin; \
210 out=$$( { comm -12 $$tmp/linux $$tmp/darwin; \
211 grep '_linux\.go:' $$tmp/linux; \
212 grep '_darwin\.go:' $$tmp/darwin; } | sort -u ); \
197 if [ -n "$$out" ]; then \ 213 if [ -n "$$out" ]; then \
198 echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ 214 echo "deadcode: unreachable from any cmd/ entrypoint on linux/amd64 or darwin/arm64 (remove it, wire it in, or move it to a _test.go):"; \
199 echo "$$out"; exit 1; \ 215 echo "$$out"; exit 1; \
200 fi 216 fi
201 217
202 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative 218 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative
203 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` 219 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch`
204 # re-runs the fitness tests with -count=1 (the race run may serve them cached). 220 # re-runs the fitness tests with -count=1 (the race run may serve them cached).
205 ci: vet build-go arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check 221 ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check
206 222
207 # Compile every Go package (no Node/web build needed — the embed dir ships a 223 # Compile every Go package (no Node/web build needed — the embed dir ships a
208 # placeholder, so the server builds and serves a "UI not built" notice). 224 # placeholder, so the server builds and serves a "UI not built" notice).
209 build-go: 225 build-go:
210 go build ./... 226 go build ./...
211 227
228 # The second platform the agent ships on. Build tags are confined to hostinfo's
229 # readers and the wire_*.go files, so a broken Darwin build is invisible to
230 # every other target in `ci` — this is the only thing that compiles it.
231 build-darwin:
232 GOOS=darwin GOARCH=arm64 go build ./...
233 GOOS=darwin GOARCH=arm64 go vet ./...
234
212 clean: 235 clean:
213 rm -rf $(BIN) 236 rm -rf $(BIN)
docs/shape.html
Old New
@@ -184,6 +184,14 @@
184 "imports": [] 184 "imports": []
185 }, 185 },
186 { 186 {
187 "importPath": "internal/agent/inert",
188 "plane": "data",
189 "synopsis": "Package inert is the platform for a host that is in the fleet but cannot run guests.",
190 "imports": [
191 "internal/agent/state"
192 ]
193 },
194 {
187 "importPath": "internal/agent/ipalloc", 195 "importPath": "internal/agent/ipalloc",
188 "plane": "data", 196 "plane": "data",
189 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.", 197 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
@@ -217,6 +225,7 @@
217 "internal/agent/bootstrap", 225 "internal/agent/bootstrap",
218 "internal/agent/cloudhv", 226 "internal/agent/cloudhv",
219 "internal/agent/enrollclient", 227 "internal/agent/enrollclient",
228 "internal/agent/hostinfo",
220 "internal/agent/imagecache", 229 "internal/agent/imagecache",
221 "internal/agent/netenv", 230 "internal/agent/netenv",
222 "internal/agent/reconcile", 231 "internal/agent/reconcile",
docs/shape.json
Old New
@@ -133,6 +133,14 @@
133 "imports": [] 133 "imports": []
134 }, 134 },
135 { 135 {
136 "importPath": "internal/agent/inert",
137 "plane": "data",
138 "synopsis": "Package inert is the platform for a host that is in the fleet but cannot run guests.",
139 "imports": [
140 "internal/agent/state"
141 ]
142 },
143 {
136 "importPath": "internal/agent/ipalloc", 144 "importPath": "internal/agent/ipalloc",
137 "plane": "data", 145 "plane": "data",
138 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.", 146 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
@@ -166,6 +174,7 @@
166 "internal/agent/bootstrap", 174 "internal/agent/bootstrap",
167 "internal/agent/cloudhv", 175 "internal/agent/cloudhv",
168 "internal/agent/enrollclient", 176 "internal/agent/enrollclient",
177 "internal/agent/hostinfo",
169 "internal/agent/imagecache", 178 "internal/agent/imagecache",
170 "internal/agent/netenv", 179 "internal/agent/netenv",
171 "internal/agent/reconcile", 180 "internal/agent/reconcile",
go.mod
Old New
@@ -16,6 +16,7 @@ require (
16 golang.org/x/crypto v0.54.0 16 golang.org/x/crypto v0.54.0
17 golang.org/x/oauth2 v0.36.0 17 golang.org/x/oauth2 v0.36.0
18 golang.org/x/sync v0.20.0 18 golang.org/x/sync v0.20.0
19 golang.org/x/sys v0.47.0
19 golang.org/x/term v0.45.0 20 golang.org/x/term v0.45.0
20 google.golang.org/protobuf v1.36.11 21 google.golang.org/protobuf v1.36.11
21 gopkg.in/yaml.v3 v3.0.1 22 gopkg.in/yaml.v3 v3.0.1
@@ -55,7 +56,6 @@ require (
55 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 56 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
56 golang.org/x/mod v0.33.0 // indirect 57 golang.org/x/mod v0.33.0 // indirect
57 golang.org/x/net v0.56.0 // indirect 58 golang.org/x/net v0.56.0 // indirect
58 golang.org/x/sys v0.47.0 // indirect
59 golang.org/x/tools v0.42.0 // indirect 59 golang.org/x/tools v0.42.0 // indirect
60 modernc.org/libc v1.72.3 // indirect 60 modernc.org/libc v1.72.3 // indirect
61 modernc.org/mathutil v1.7.1 // indirect 61 modernc.org/mathutil v1.7.1 // indirect
internal/agent/hostinfo/hostinfo.go
Old New
@@ -6,25 +6,30 @@ package hostinfo
6 6
7 import ( 7 import (
8 "context" 8 "context"
9 "encoding/binary"
9 "os" 10 "os"
10 "runtime" 11 "runtime"
11 "strconv" 12 "strconv"
12 "strings" 13 "strings"
13 "syscall"
14 "time" 14 "time"
15 15
16 agentexec "github.com/a73x/eitri/internal/agent/exec" 16 agentexec "github.com/a73x/eitri/internal/agent/exec"
17 "github.com/a73x/eitri/internal/pb" 17 "github.com/a73x/eitri/internal/pb"
18 ) 18 )
19 19
20 // Seams — package-level vars so tests can point them at fixtures/stubs. 20 // Seams — package-level vars so tests can point them at fixtures/stubs. These
21 // are plain paths (not syscall-typed), so they stay untagged: readFile
22 // harmlessly returns "" for them on a platform where the path doesn't exist.
21 var ( 23 var (
22 osReleasePath = "/etc/os-release" 24 osReleasePath = "/etc/os-release"
23 cpuInfoPath = "/proc/cpuinfo" 25 cpuInfoPath = "/proc/cpuinfo"
24 memInfoPath = "/proc/meminfo" 26 memInfoPath = "/proc/meminfo"
25 kernelPath = "/proc/sys/kernel/osrelease" 27 kernelPath = "/proc/sys/kernel/osrelease"
26 sysinfoFn = syscall.Sysinfo 28 // bootIDPath is the kernel's per-boot UUID (Linux's readBootID source). It
27 statfsFn = syscall.Statfs 29 // lives here, untagged, rather than in hostinfo_linux.go: the tests that
30 // stub it are untagged too and must still vet clean on every OS, even
31 // though only Linux's readBootID consults it.
32 bootIDPath = "/proc/sys/kernel/random/boot_id"
28 ) 33 )
29 34
30 const ( 35 const (
@@ -34,48 +39,27 @@ const (
34 ) 39 )
35 40
36 // Facts returns slow-changing host identity for the once-per-session Hello. 41 // Facts returns slow-changing host identity for the once-per-session Hello.
37 // Every field is empty when its source can't be read. run executes 42 // Every field is empty when its source can't be read. run is the command
38 // systemd-detect-virt (nil → virt unknown); it stays injected so hostinfo does 43 // runner a platform's virt probe needs — Linux shells out to
39 // not import os/exec (data-plane arch rule R6). ctx bounds the virt subprocess. 44 // systemd-detect-virt (nil → virt unknown), Darwin answers without a
45 // subprocess. It stays injected so hostinfo does not import os/exec (data-plane
46 // arch rule R6), and ctx bounds the subprocess where there is one.
40 func Facts(ctx context.Context, run agentexec.Runner) *pb.HostFacts { 47 func Facts(ctx context.Context, run agentexec.Runner) *pb.HostFacts {
41 id, pretty, version := parseOSRelease(readFile(osReleasePath)) 48 id, pretty, version := osIdentity()
42 return &pb.HostFacts{ 49 return &pb.HostFacts{
43 OsId: id, 50 OsId: id,
44 OsPretty: pretty, 51 OsPretty: pretty,
45 OsVersion: version, 52 OsVersion: version,
46 Kernel: strings.TrimSpace(readFile(kernelPath)), 53 Kernel: kernelVersion(),
47 CpuModel: parseCPUModel(readFile(cpuInfoPath)), 54 CpuModel: cpuModel(),
48 Virt: detectVirt(ctx, run), 55 Virt: virtSource(ctx, run),
49 } 56 }
50 } 57 }
51 58
52 // Metrics returns live host utilization for the heartbeat. stateDir is the 59 // Metrics returns live host utilization for the heartbeat. stateDir is the
53 // filesystem whose usage is reported (the agent's state dir, matching capacity). 60 // filesystem whose usage is reported (the agent's state dir, matching capacity).
54 func Metrics(stateDir string) *pb.HostMetrics { 61 // The syscalls behind it are platform-typed, so the work is in readMetrics.
55 m := &pb.HostMetrics{} 62 func Metrics(stateDir string) *pb.HostMetrics { return readMetrics(stateDir) }
56
57 var si syscall.Sysinfo_t
58 if err := sysinfoFn(&si); err == nil {
59 m.UptimeS = si.Uptime
60 m.Load1 = float64(si.Loads[0]) / loadScale
61 m.Load5 = float64(si.Loads[1]) / loadScale
62 m.Load15 = float64(si.Loads[2]) / loadScale
63 totalMB := int64(si.Totalram) * int64(si.Unit) / mb
64 availMB := memAvailableMB(&si)
65 m.MemAvailableMb = availMB
66 if used := totalMB - availMB; used > 0 {
67 m.MemUsedMb = used
68 }
69 }
70
71 var fs syscall.Statfs_t
72 if err := statfsFn(stateDir, &fs); err == nil {
73 bsize := int64(fs.Bsize)
74 m.DiskFreeGb = int64(fs.Bavail) * bsize / gb
75 m.DiskUsedGb = (int64(fs.Blocks) - int64(fs.Bfree)) * bsize / gb
76 }
77 return m
78 }
79 63
80 // BootID returns an opaque token that changes across host reboots. Reconcile 64 // BootID returns an opaque token that changes across host reboots. Reconcile
81 // compares it only for equality, to detect that VMs were lost to a reboot — so 65 // compares it only for equality, to detect that VMs were lost to a reboot — so
@@ -94,16 +78,6 @@ func Capacity(stateDir string) *pb.Capacity {
94 } 78 }
95 } 79 }
96 80
97 // memAvailableMB prefers /proc/meminfo MemAvailable (accounts for reclaimable
98 // cache — the honest headroom number); falls back to Sysinfo Free+Buffer, which
99 // undercounts cache, when meminfo is unreadable.
100 func memAvailableMB(si *syscall.Sysinfo_t) int64 {
101 if kb, ok := parseMemAvailableKB(readFile(memInfoPath)); ok {
102 return kb / 1024
103 }
104 return int64(si.Freeram+si.Bufferram) * int64(si.Unit) / mb
105 }
106
107 // parseOSRelease extracts ID, PRETTY_NAME, VERSION_ID from /etc/os-release 81 // parseOSRelease extracts ID, PRETTY_NAME, VERSION_ID from /etc/os-release
108 // (KEY=VALUE lines, values optionally double-quoted). 82 // (KEY=VALUE lines, values optionally double-quoted).
109 func parseOSRelease(s string) (id, pretty, version string) { 83 func parseOSRelease(s string) (id, pretty, version string) {
@@ -125,6 +99,46 @@ func parseOSRelease(s string) (id, pretty, version string) {
125 return id, pretty, version 99 return id, pretty, version
126 } 100 }
127 101
102 // plistString returns the <string> value that follows <key>name</key> in an
103 // Apple property list, or "" if the key or its value is absent. This is a
104 // deliberate scan rather than a plist parser: hostinfo wants two keys out of
105 // one frozen system file, and a parser would be a dependency and an error path
106 // in a reader whose contract is already "" on anything unexpected. The key is
107 // matched as a whole tag, so ProductVersion never matches ProductBuildVersion.
108 func plistString(doc, key string) string {
109 _, after, ok := strings.Cut(doc, "<key>"+key+"</key>")
110 if !ok {
111 return ""
112 }
113 // Bound the search at the next key: a key whose value is not a string has
114 // no value here, and must not borrow a later key's.
115 if own, _, found := strings.Cut(after, "<key>"); found {
116 after = own
117 }
118 _, after, ok = strings.Cut(after, "<string>")
119 if !ok {
120 return ""
121 }
122 val, _, ok := strings.Cut(after, "</string>")
123 if !ok {
124 return ""
125 }
126 return strings.TrimSpace(val)
127 }
128
129 // macOSIdentity maps SystemVersion.plist to the same three fields
130 // /etc/os-release gives Facts on Linux: a lowercase id, a human-readable name,
131 // and a version. A Mac whose plist can't be read is still a Mac, so the name
132 // falls back rather than emptying out — only the version goes missing.
133 func macOSIdentity(plist string) (id, pretty, version string) {
134 name := plistString(plist, "ProductName")
135 if name == "" {
136 name = "macOS"
137 }
138 version = plistString(plist, "ProductVersion")
139 return strings.ToLower(name), strings.TrimSpace(name + " " + version), version
140 }
141
128 // parseCPUModel returns the first "model name" value from /proc/cpuinfo, or "" 142 // parseCPUModel returns the first "model name" value from /proc/cpuinfo, or ""
129 // (e.g. on arm, which uses different fields). 143 // (e.g. on arm, which uses different fields).
130 func parseCPUModel(s string) string { 144 func parseCPUModel(s string) string {
@@ -153,6 +167,36 @@ func parseMemAvailableKB(s string) (int64, bool) {
153 return 0, false 167 return 0, false
154 } 168 }
155 169
170 // decodeLoadavg decodes the raw bytes of Darwin's vm.loadavg sysctl —
171 // struct loadavg { fixpt_t ldavg[3]; long fscale; } — into the three load
172 // figures. fixpt_t is a 32-bit fixed-point count and long is 64-bit on every
173 // Mac eitri supports, so the struct is 24 bytes: three counts, four bytes of
174 // padding to the long's alignment, then the divisor. Apple hardware is
175 // little-endian, both Apple Silicon and Intel.
176 //
177 // Any other length is a shape this decoder does not know, and a zero divisor
178 // is a reading it cannot use: both return ok=false, leaving the loads at zero
179 // per the package's best-effort contract. Reporting nothing beats reporting a
180 // number derived from a struct we misread.
181 func decodeLoadavg(b []byte) ([3]float64, bool) {
182 const (
183 size = 24
184 scaleOff = 16
185 )
186 if len(b) != size {
187 return [3]float64{}, false
188 }
189 fscale := binary.LittleEndian.Uint64(b[scaleOff:])
190 if fscale == 0 {
191 return [3]float64{}, false
192 }
193 var loads [3]float64
194 for i := range loads {
195 loads[i] = float64(binary.LittleEndian.Uint32(b[i*4:])) / float64(fscale)
196 }
197 return loads, true
198 }
199
156 // readFile is a best-effort file read: "" on any error. 200 // readFile is a best-effort file read: "" on any error.
157 func readFile(path string) string { 201 func readFile(path string) string {
158 b, err := os.ReadFile(path) 202 b, err := os.ReadFile(path)
internal/agent/hostinfo/hostinfo_darwin.go
Old New
@@ -0,0 +1,138 @@
1 //go:build darwin
2
3 package hostinfo
4
5 import (
6 "context"
7 "fmt"
8 "time"
9
10 "golang.org/x/sys/unix"
11
12 agentexec "github.com/a73x/eitri/internal/agent/exec"
13 "github.com/a73x/eitri/internal/pb"
14 )
15
16 // systemVersionPath is macOS's identity file, osIdentity's source. Unlike the
17 // Linux path seams in hostinfo.go it lives with its platform: nothing untagged
18 // reads it, so on Linux it would be an unused variable.
19 var systemVersionPath = "/System/Library/CoreServices/SystemVersion.plist"
20
21 // readBootID derives an opaque per-boot token from the kern.boottime sysctl.
22 // BootID only needs equality across a reboot (see its doc comment), and
23 // boottime changes on every reboot and holds steady within one, so it
24 // satisfies the contract without needing a dedicated boot-id concept the way
25 // Linux's kernel does. "" on sysctl failure, per the package's best-effort
26 // contract.
27 func readBootID() string {
28 tv, err := unix.SysctlTimeval("kern.boottime")
29 if err != nil {
30 return ""
31 }
32 return fmt.Sprintf("boottime-%d.%d", tv.Sec, tv.Usec)
33 }
34
35 func totalMemMB() int64 {
36 v, err := unix.SysctlUint64("hw.memsize")
37 if err != nil {
38 return 0
39 }
40 return int64(v) / mb
41 }
42
43 func totalDiskGB(stateDir string) int64 {
44 var fs unix.Statfs_t
45 if err := unix.Statfs(stateDir, &fs); err != nil {
46 return 0
47 }
48 // Bsize is uint32 on Darwin (int64 on Linux) — convert, never multiply raw.
49 return int64(fs.Blocks) * int64(fs.Bsize) / gb
50 }
51
52 // osIdentity is Facts' Darwin source for the OS fields: macOS's own version
53 // plist, mapped to the same shape /etc/os-release gives on Linux.
54 func osIdentity() (id, pretty, version string) {
55 return macOSIdentity(readFile(systemVersionPath))
56 }
57
58 // virtSource is Facts' Darwin answer for Virt. A Mac in the fleet is bare
59 // metal — Virtualization.framework guests can't nest — so the answer is the
60 // constant "none", the same string a bare-metal Linux host reports. No
61 // subprocess runs: there is no Darwin systemd-detect-virt, and spawning
62 // something per Hello to learn nothing is the machinery this design removes.
63 func virtSource(_ context.Context, _ agentexec.Runner) string { return "none" }
64
65 // kernelVersion is Facts' Darwin source for Kernel: the XNU release string
66 // (e.g. "23.6.0") via the kern.osrelease sysctl — Darwin's equivalent of
67 // Linux's /proc/sys/kernel/osrelease.
68 func kernelVersion() string {
69 s, err := unix.Sysctl("kern.osrelease")
70 if err != nil {
71 return ""
72 }
73 return s
74 }
75
76 // cpuModel is Facts' Darwin source for CpuModel via the machdep.cpu.brand_string
77 // sysctl — Darwin's equivalent of Linux's /proc/cpuinfo "model name" line.
78 func cpuModel() string {
79 s, err := unix.Sysctl("machdep.cpu.brand_string")
80 if err != nil {
81 return ""
82 }
83 return s
84 }
85
86 // readMetrics is Metrics' Darwin implementation: uptime and load from
87 // sysctls, memory headroom from free pages, disk from statfs on the state dir.
88 // Each probe is independent — one failing sysctl zeroes its own dimension and
89 // nothing else.
90 func readMetrics(stateDir string) *pb.HostMetrics {
91 m := &pb.HostMetrics{}
92
93 if tv, err := unix.SysctlTimeval("kern.boottime"); err == nil {
94 if uptime := time.Now().Unix() - tv.Sec; uptime > 0 {
95 m.UptimeS = uptime
96 }
97 }
98
99 if raw, err := unix.SysctlRaw("vm.loadavg"); err == nil {
100 if loads, ok := decodeLoadavg(raw); ok {
101 m.Load1, m.Load5, m.Load15 = loads[0], loads[1], loads[2]
102 }
103 }
104
105 if avail := memAvailableMB(); avail > 0 {
106 m.MemAvailableMb = avail
107 if used := totalMemMB() - avail; used > 0 {
108 m.MemUsedMb = used
109 }
110 }
111
112 var fs unix.Statfs_t
113 if err := unix.Statfs(stateDir, &fs); err == nil {
114 // Bsize is uint32 on Darwin (int64 on Linux) — convert, never multiply raw.
115 bsize := int64(fs.Bsize)
116 m.DiskFreeGb = int64(fs.Bavail) * bsize / gb
117 m.DiskUsedGb = (int64(fs.Blocks) - int64(fs.Bfree)) * bsize / gb
118 }
119 return m
120 }
121
122 // memAvailableMB is Darwin's best-effort headroom: free pages only. macOS also
123 // reclaims inactive and purgeable memory on demand, but counting those needs
124 // host_statistics64 and therefore cgo, which this package does not take. The
125 // number under-reports headroom; it never over-reports it.
126 //
127 // Pages are 16 KiB on Apple Silicon, so the page size is read, not assumed.
128 func memAvailableMB() int64 {
129 free, err := unix.SysctlUint32("vm.page_free_count")
130 if err != nil {
131 return 0
132 }
133 pageSize, err := unix.SysctlUint32("hw.pagesize")
134 if err != nil {
135 return 0
136 }
137 return int64(free) * int64(pageSize) / mb
138 }
internal/agent/hostinfo/hostinfo_darwin_test.go
Old New
@@ -0,0 +1,26 @@
1 //go:build darwin
2
3 package hostinfo
4
5 import (
6 "context"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 )
11
12 // This file never runs in CI — there is no Mac runner. It exists so that
13 // `go test ./...` on a Mac checks the two Darwin answers that are decisions
14 // rather than readings. Everything else Darwin does is a sysctl call whose
15 // parsing is covered by the untagged fixture tests.
16
17 func TestVirtIsNoneOnDarwin(t *testing.T) {
18 assert.Equal(t, "none", virtSource(context.Background(), nil))
19 }
20
21 func TestOSIdentityReadsTheSystemPlist(t *testing.T) {
22 id, pretty, version := osIdentity()
23 assert.Equal(t, "macos", id)
24 assert.Contains(t, pretty, "macOS")
25 assert.NotEmpty(t, version)
26 }
internal/agent/hostinfo/hostinfo_linux.go
Old New
@@ -3,13 +3,21 @@
3 package hostinfo 3 package hostinfo
4 4
5 import ( 5 import (
6 "context"
6 "os" 7 "os"
7 "strings" 8 "strings"
8 "syscall" 9 "syscall"
10
11 agentexec "github.com/a73x/eitri/internal/agent/exec"
12 "github.com/a73x/eitri/internal/pb"
9 ) 13 )
10 14
11 // bootIDPath is the kernel's per-boot UUID. Seam for tests. 15 // Seams — syscall-typed, so unlike the plain path vars in hostinfo.go these
12 var bootIDPath = "/proc/sys/kernel/random/boot_id" 16 // can only live where the types they close over exist.
17 var (
18 sysinfoFn = syscall.Sysinfo
19 statfsFn = syscall.Statfs
20 )
13 21
14 func readBootID() string { 22 func readBootID() string {
15 b, err := os.ReadFile(bootIDPath) 23 b, err := os.ReadFile(bootIDPath)
@@ -35,3 +43,59 @@ func totalDiskGB(stateDir string) int64 {
35 // Bsize is int64 on Linux and uint32 on Darwin — convert, never multiply raw. 43 // Bsize is int64 on Linux and uint32 on Darwin — convert, never multiply raw.
36 return int64(fs.Blocks) * int64(fs.Bsize) / gb 44 return int64(fs.Blocks) * int64(fs.Bsize) / gb
37 } 45 }
46
47 // osIdentity is Facts' Linux source for the OS fields: /etc/os-release.
48 func osIdentity() (id, pretty, version string) {
49 return parseOSRelease(readFile(osReleasePath))
50 }
51
52 // virtSource is Facts' Linux source for Virt: systemd-detect-virt, through the
53 // injected runner.
54 func virtSource(ctx context.Context, run agentexec.Runner) string {
55 return detectVirt(ctx, run)
56 }
57
58 // kernelVersion is Facts' Linux source for Kernel: the running release string
59 // from /proc/sys/kernel/osrelease.
60 func kernelVersion() string { return strings.TrimSpace(readFile(kernelPath)) }
61
62 // cpuModel is Facts' Linux source for CpuModel: the first "model name" line
63 // in /proc/cpuinfo.
64 func cpuModel() string { return parseCPUModel(readFile(cpuInfoPath)) }
65
66 // readMetrics is Metrics' Linux implementation.
67 func readMetrics(stateDir string) *pb.HostMetrics {
68 m := &pb.HostMetrics{}
69
70 var si syscall.Sysinfo_t
71 if err := sysinfoFn(&si); err == nil {
72 m.UptimeS = si.Uptime
73 m.Load1 = float64(si.Loads[0]) / loadScale
74 m.Load5 = float64(si.Loads[1]) / loadScale
75 m.Load15 = float64(si.Loads[2]) / loadScale
76 totalMB := int64(si.Totalram) * int64(si.Unit) / mb
77 availMB := memAvailableMB(&si)
78 m.MemAvailableMb = availMB
79 if used := totalMB - availMB; used > 0 {
80 m.MemUsedMb = used
81 }
82 }
83
84 var fs syscall.Statfs_t
85 if err := statfsFn(stateDir, &fs); err == nil {
86 bsize := int64(fs.Bsize)
87 m.DiskFreeGb = int64(fs.Bavail) * bsize / gb
88 m.DiskUsedGb = (int64(fs.Blocks) - int64(fs.Bfree)) * bsize / gb
89 }
90 return m
91 }
92
93 // memAvailableMB prefers /proc/meminfo MemAvailable (accounts for reclaimable
94 // cache — the honest headroom number); falls back to Sysinfo Free+Buffer, which
95 // undercounts cache, when meminfo is unreadable.
96 func memAvailableMB(si *syscall.Sysinfo_t) int64 {
97 if kb, ok := parseMemAvailableKB(readFile(memInfoPath)); ok {
98 return kb / 1024
99 }
100 return int64(si.Freeram+si.Bufferram) * int64(si.Unit) / mb
101 }
internal/agent/hostinfo/hostinfo_linux_test.go
Old New
@@ -0,0 +1,117 @@
1 //go:build linux
2
3 package hostinfo
4
5 import (
6 "context"
7 "errors"
8 "runtime"
9 "syscall"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 )
14
15 // These three tests stub sysinfoFn/statfsFn with syscall.Sysinfo_t/Statfs_t
16 // literals, which only exist on Linux — they live in this Linux-tagged file
17 // (rather than hostinfo_test.go) purely so the package still vets clean on
18 // other GOOS; the assertions are unchanged from before the per-OS split.
19
20 // TestCapacityExactArithmetic pins TOTALS (not free space): a regression that
21 // swaps in Bavail/Bfree or free-memory fields must fail this test, since the
22 // server derives available capacity by subtracting live VM specs from
23 // whatever Capacity reports.
24 func TestCapacityExactArithmetic(t *testing.T) {
25 oldSys, oldStat := sysinfoFn, statfsFn
26 defer func() { sysinfoFn, statfsFn = oldSys, oldStat }()
27
28 sysinfoFn = func(si *syscall.Sysinfo_t) error {
29 *si = syscall.Sysinfo_t{
30 Totalram: 8 * 1024 * 1024 * 1024, // 8 GiB
31 Unit: 1,
32 }
33 return nil
34 }
35 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
36 *fs = syscall.Statfs_t{
37 Bsize: 4096,
38 Blocks: 26214400, // 100 GiB total
39 }
40 return nil
41 }
42
43 c := Capacity("/whatever")
44 assert.Equal(t, int64(runtime.NumCPU()), c.GetVcpus())
45 assert.Equal(t, int64(8192), c.GetMemMb())
46 assert.Equal(t, int64(100), c.GetDiskGb())
47 }
48
49 // TestCapacityBestEffortOnSyscallFailure covers the package's best-effort
50 // contract: a failing probe zeroes its own dimension rather than erroring or
51 // blocking, and the two probes fail independently of each other.
52 func TestCapacityBestEffortOnSyscallFailure(t *testing.T) {
53 oldSys, oldStat := sysinfoFn, statfsFn
54 defer func() { sysinfoFn, statfsFn = oldSys, oldStat }()
55
56 sysinfoFn = func(*syscall.Sysinfo_t) error { return errors.New("boom") }
57 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
58 *fs = syscall.Statfs_t{Bsize: 4096, Blocks: 26214400}
59 return nil
60 }
61 c := Capacity("/whatever")
62 assert.Equal(t, int64(0), c.GetMemMb())
63 assert.Equal(t, int64(100), c.GetDiskGb())
64
65 sysinfoFn = func(si *syscall.Sysinfo_t) error {
66 *si = syscall.Sysinfo_t{Totalram: 8 * 1024 * 1024 * 1024, Unit: 1}
67 return nil
68 }
69 statfsFn = func(_ string, _ *syscall.Statfs_t) error { return errors.New("boom") }
70 c = Capacity("/whatever")
71 assert.Equal(t, int64(8192), c.GetMemMb())
72 assert.Equal(t, int64(0), c.GetDiskGb())
73 }
74
75 // TestFactsVirtComesFromTheRunner keeps the Linux virt expectation with the
76 // platform that has it: Facts asks systemd-detect-virt through the injected
77 // runner. Darwin answers "none" without a subprocess (hostinfo_darwin_test.go).
78 func TestFactsVirtComesFromTheRunner(t *testing.T) {
79 f := Facts(context.Background(), func(_ context.Context, _ string, _ ...string) (string, error) {
80 return "kvm\n", nil
81 })
82 assert.Equal(t, "kvm", f.GetVirt())
83 }
84
85 func TestMetricsComputes(t *testing.T) {
86 oldSys, oldStat, oldMem := sysinfoFn, statfsFn, memInfoPath
87 defer func() { sysinfoFn, statfsFn, memInfoPath = oldSys, oldStat, oldMem }()
88
89 sysinfoFn = func(si *syscall.Sysinfo_t) error {
90 *si = syscall.Sysinfo_t{
91 Uptime: 3600,
92 Loads: [3]uint64{65536, 32768, 0}, // 1.0, 0.5, 0.0
93 Totalram: 8 * 1024 * 1024 * 1024, // 8 GiB
94 Unit: 1,
95 }
96 return nil
97 }
98 memInfoPath = writeFixture(t, "meminfo", "MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n") // 4 GiB avail
99 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
100 *fs = syscall.Statfs_t{
101 Bsize: 4096,
102 Blocks: 26214400, // 100 GiB total
103 Bfree: 13107200, // 50 GiB free (incl reserved)
104 Bavail: 10485760, // 40 GiB avail to non-root
105 }
106 return nil
107 }
108
109 m := Metrics("/whatever")
110 assert.Equal(t, int64(3600), m.GetUptimeS())
111 assert.InDelta(t, 1.0, m.GetLoad1(), 0.001)
112 assert.InDelta(t, 0.5, m.GetLoad5(), 0.001)
113 assert.Equal(t, int64(4096), m.GetMemAvailableMb())
114 assert.Equal(t, int64(4096), m.GetMemUsedMb()) // 8192 total − 4096 avail
115 assert.Equal(t, int64(40), m.GetDiskFreeGb())
116 assert.Equal(t, int64(50), m.GetDiskUsedGb()) // (Blocks − Bfree) × Bsize
117 }
internal/agent/hostinfo/hostinfo_test.go
Old New
@@ -2,11 +2,10 @@ package hostinfo
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "errors" 5 "encoding/hex"
6 "os" 6 "os"
7 "path/filepath" 7 "path/filepath"
8 "runtime" 8 "slices"
9 "syscall"
10 "testing" 9 "testing"
11 10
12 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/assert"
@@ -51,6 +50,102 @@ func TestParseMemAvailableKB(t *testing.T) {
51 assert.False(t, ok) 50 assert.False(t, ok)
52 } 51 }
53 52
53 // loadavgFixture is a real vm.loadavg reading captured from an Apple M1 on
54 // macOS 26.3.1. Keeping the raw bytes here is what makes a decoder for a
55 // platform CI never runs testable at all.
56 const loadavgFixture = "ae100000bf0c0000400b0000000000000008000000000000"
57
58 func TestDecodeLoadavg(t *testing.T) {
59 raw, err := hex.DecodeString(loadavgFixture)
60 require.NoError(t, err)
61
62 loads, ok := decodeLoadavg(raw)
63 require.True(t, ok)
64 // 4270/2048, 3263/2048, 2880/2048 — the figures the host itself reported.
65 assert.InDelta(t, 2.08, loads[0], 0.01)
66 assert.InDelta(t, 1.59, loads[1], 0.01)
67 assert.InDelta(t, 1.41, loads[2], 0.01)
68 }
69
70 // TestDecodeLoadavgRejectsUnknownShapes pins the best-effort contract: a
71 // struct that isn't the one we know yields no loads rather than nonsense ones,
72 // and a zero divisor never reaches a division.
73 func TestDecodeLoadavgRejectsUnknownShapes(t *testing.T) {
74 raw, err := hex.DecodeString(loadavgFixture)
75 require.NoError(t, err)
76
77 _, ok := decodeLoadavg(raw[:16]) // truncated: no fscale
78 assert.False(t, ok)
79
80 _, ok = decodeLoadavg(append(slices.Clone(raw), 0)) // longer than we know
81 assert.False(t, ok)
82
83 zeroScale := slices.Clone(raw)
84 copy(zeroScale[16:], make([]byte, 8))
85 _, ok = decodeLoadavg(zeroScale)
86 assert.False(t, ok)
87 }
88
89 // systemVersionFixture is /System/Library/CoreServices/SystemVersion.plist as
90 // captured from the target Mac.
91 const systemVersionFixture = `<?xml version="1.0" encoding="UTF-8"?>
92 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93 <plist version="1.0">
94 <dict>
95 <key>BuildID</key>
96 <string>E0AF3C06-11FB-11F1-A7CC-608CDE06C496</string>
97 <key>ProductBuildVersion</key>
98 <string>25D2128</string>
99 <key>ProductCopyright</key>
100 <string>1983-2026 Apple Inc.</string>
101 <key>ProductName</key>
102 <string>macOS</string>
103 <key>ProductUserVisibleVersion</key>
104 <string>26.3.1</string>
105 <key>ProductVersion</key>
106 <string>26.3.1</string>
107 <key>iOSSupportVersion</key>
108 <string>26.3</string>
109 </dict>
110 </plist>
111 `
112
113 func TestMacOSIdentity(t *testing.T) {
114 id, pretty, version := macOSIdentity(systemVersionFixture)
115 assert.Equal(t, "macos", id)
116 assert.Equal(t, "macOS 26.3.1", pretty)
117 assert.Equal(t, "26.3.1", version)
118 }
119
120 // TestMacOSIdentityBestEffort pins the package's contract on a plist that is
121 // missing, truncated mid-document, or from a future macOS that renamed keys:
122 // the OS is still identifiably a Mac, the version is simply absent.
123 func TestMacOSIdentityBestEffort(t *testing.T) {
124 id, pretty, version := macOSIdentity("")
125 assert.Equal(t, "macos", id)
126 assert.Equal(t, "macOS", pretty)
127 assert.Empty(t, version)
128
129 _, _, version = macOSIdentity("<key>ProductVersion</key>\n\t<string>26.3.1")
130 assert.Empty(t, version) // an unterminated value is not a value
131 }
132
133 // TestPlistStringMatchesWholeKeys guards the one way a substring scan can lie:
134 // ProductBuildVersion appears in the document BEFORE ProductVersion, and a
135 // looser match would report the build number as the OS version.
136 func TestPlistStringMatchesWholeKeys(t *testing.T) {
137 assert.Equal(t, "25D2128", plistString(systemVersionFixture, "ProductBuildVersion"))
138 assert.Equal(t, "26.3.1", plistString(systemVersionFixture, "ProductVersion"))
139 assert.Equal(t, "26.3", plistString(systemVersionFixture, "iOSSupportVersion"))
140 assert.Empty(t, plistString(systemVersionFixture, "NoSuchKey"))
141
142 // A key whose value is not a string has no value here, and must not report
143 // the next key's.
144 const mixed = "<key>A</key><data>ZmY=</data><key>B</key><string>b</string>"
145 assert.Empty(t, plistString(mixed, "A"))
146 assert.Equal(t, "b", plistString(mixed, "B"))
147 }
148
54 func TestFactsReadsFixtures(t *testing.T) { 149 func TestFactsReadsFixtures(t *testing.T) {
55 oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath 150 oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath
56 defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }() 151 defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }()
@@ -66,7 +161,8 @@ func TestFactsReadsFixtures(t *testing.T) {
66 assert.Equal(t, "12", f.GetOsVersion()) 161 assert.Equal(t, "12", f.GetOsVersion())
67 assert.Equal(t, "6.1.0-18-amd64", f.GetKernel()) 162 assert.Equal(t, "6.1.0-18-amd64", f.GetKernel())
68 assert.Equal(t, "AMD EPYC 7302P 16-Core Processor", f.GetCpuModel()) 163 assert.Equal(t, "AMD EPYC 7302P 16-Core Processor", f.GetCpuModel())
69 assert.Equal(t, "kvm", f.GetVirt()) 164 // Virt is per-platform, so its expectation lives with its platform:
165 // TestFactsVirtComesFromTheRunner (Linux), TestVirtIsNoneOnDarwin (Darwin).
70 } 166 }
71 167
72 func TestFactsBestEffortOnMissingFiles(t *testing.T) { 168 func TestFactsBestEffortOnMissingFiles(t *testing.T) {
@@ -123,60 +219,9 @@ func TestCapacityReportsTotals(t *testing.T) {
123 } 219 }
124 } 220 }
125 221
126 // TestCapacityExactArithmetic pins TOTALS (not free space): a regression that 222 // TestCapacityExactArithmetic and TestCapacityBestEffortOnSyscallFailure live
127 // swaps in Bavail/Bfree or free-memory fields must fail this test, since the 223 // in hostinfo_linux_test.go: they stub sysinfoFn/statfsFn with
128 // server derives available capacity by subtracting live VM specs from 224 // syscall.Sysinfo_t/Statfs_t literals, which only exist on Linux.
129 // whatever Capacity reports.
130 func TestCapacityExactArithmetic(t *testing.T) {
131 oldSys, oldStat := sysinfoFn, statfsFn
132 defer func() { sysinfoFn, statfsFn = oldSys, oldStat }()
133
134 sysinfoFn = func(si *syscall.Sysinfo_t) error {
135 *si = syscall.Sysinfo_t{
136 Totalram: 8 * 1024 * 1024 * 1024, // 8 GiB
137 Unit: 1,
138 }
139 return nil
140 }
141 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
142 *fs = syscall.Statfs_t{
143 Bsize: 4096,
144 Blocks: 26214400, // 100 GiB total
145 }
146 return nil
147 }
148
149 c := Capacity("/whatever")
150 assert.Equal(t, int64(runtime.NumCPU()), c.GetVcpus())
151 assert.Equal(t, int64(8192), c.GetMemMb())
152 assert.Equal(t, int64(100), c.GetDiskGb())
153 }
154
155 // TestCapacityBestEffortOnSyscallFailure covers the package's best-effort
156 // contract: a failing probe zeroes its own dimension rather than erroring or
157 // blocking, and the two probes fail independently of each other.
158 func TestCapacityBestEffortOnSyscallFailure(t *testing.T) {
159 oldSys, oldStat := sysinfoFn, statfsFn
160 defer func() { sysinfoFn, statfsFn = oldSys, oldStat }()
161
162 sysinfoFn = func(*syscall.Sysinfo_t) error { return errors.New("boom") }
163 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
164 *fs = syscall.Statfs_t{Bsize: 4096, Blocks: 26214400}
165 return nil
166 }
167 c := Capacity("/whatever")
168 assert.Equal(t, int64(0), c.GetMemMb())
169 assert.Equal(t, int64(100), c.GetDiskGb())
170
171 sysinfoFn = func(si *syscall.Sysinfo_t) error {
172 *si = syscall.Sysinfo_t{Totalram: 8 * 1024 * 1024 * 1024, Unit: 1}
173 return nil
174 }
175 statfsFn = func(_ string, _ *syscall.Statfs_t) error { return errors.New("boom") }
176 c = Capacity("/whatever")
177 assert.Equal(t, int64(8192), c.GetMemMb())
178 assert.Equal(t, int64(0), c.GetDiskGb())
179 }
180 225
181 // TestBootIDMissingFile covers readBootID's error path directly (rather than 226 // TestBootIDMissingFile covers readBootID's error path directly (rather than
182 // only via the trimmed-happy-path seam test): an unreadable boot_id must 227 // only via the trimmed-happy-path seam test): an unreadable boot_id must
@@ -189,36 +234,6 @@ func TestBootIDMissingFile(t *testing.T) {
189 assert.Empty(t, BootID()) 234 assert.Empty(t, BootID())
190 } 235 }
191 236
192 func TestMetricsComputes(t *testing.T) { 237 // TestMetricsComputes lives in hostinfo_linux_test.go: it stubs
193 oldSys, oldStat, oldMem := sysinfoFn, statfsFn, memInfoPath 238 // sysinfoFn/statfsFn with syscall.Sysinfo_t/Statfs_t literals, which only
194 defer func() { sysinfoFn, statfsFn, memInfoPath = oldSys, oldStat, oldMem }() 239 // exist on Linux.
195
196 sysinfoFn = func(si *syscall.Sysinfo_t) error {
197 *si = syscall.Sysinfo_t{
198 Uptime: 3600,
199 Loads: [3]uint64{65536, 32768, 0}, // 1.0, 0.5, 0.0
200 Totalram: 8 * 1024 * 1024 * 1024, // 8 GiB
201 Unit: 1,
202 }
203 return nil
204 }
205 memInfoPath = writeFixture(t, "meminfo", "MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n") // 4 GiB avail
206 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
207 *fs = syscall.Statfs_t{
208 Bsize: 4096,
209 Blocks: 26214400, // 100 GiB total
210 Bfree: 13107200, // 50 GiB free (incl reserved)
211 Bavail: 10485760, // 40 GiB avail to non-root
212 }
213 return nil
214 }
215
216 m := Metrics("/whatever")
217 assert.Equal(t, int64(3600), m.GetUptimeS())
218 assert.InDelta(t, 1.0, m.GetLoad1(), 0.001)
219 assert.InDelta(t, 0.5, m.GetLoad5(), 0.001)
220 assert.Equal(t, int64(4096), m.GetMemAvailableMb())
221 assert.Equal(t, int64(4096), m.GetMemUsedMb()) // 8192 total − 4096 avail
222 assert.Equal(t, int64(40), m.GetDiskFreeGb())
223 assert.Equal(t, int64(50), m.GetDiskUsedGb()) // (Blocks − Bfree) × Bsize
224 }
internal/agent/inert/inert.go
Old New
@@ -0,0 +1,83 @@
1 // Package inert is the platform for a host that is in the fleet but cannot run
2 // guests. Every type here satisfies one of the agent's backend seams —
3 // reconcile.Provisioner and serialpump.ConsoleSource — while touching nothing
4 // on the host.
5 //
6 // Refusals are deliberate and loud. VM lifecycle calls fail with a Permanent()
7 // error so reconcile terminal-fails a misplaced VM in one attempt instead of
8 // burning its retry budget against a backend that can never succeed; a console
9 // request fails through the normal pump path rather than silently doing
10 // nothing. Address is the one exception: it answers "" rather than an error,
11 // because not knowing a guest's address is a legitimate state of the seam, not
12 // a refusal.
13 //
14 // The package is untagged so Linux CI proves its behavior, even though only
15 // wire_darwin.go builds it into a binary.
16 package inert
17
18 import (
19 "context"
20 "errors"
21 "fmt"
22 "io"
23 "runtime"
24
25 "github.com/a73x/eitri/internal/agent/state"
26 )
27
28 // The two refusals, exported so callers and tests can match them and an
29 // operator reading a failed VM sees one consistent sentence per cause.
30 var (
31 ErrNoRuntime = errors.New("no VM runtime on this host")
32 ErrNoConsole = errors.New("no guest consoles on this host")
33 )
34
35 // permanentError marks a failure no retry can fix. reconcile matches the
36 // Permanent() method structurally (errors.As against an anonymous interface),
37 // so this mirrors cloudhv's marker instead of sharing one: a shared type would
38 // be the only thing a real VMM driver and this one have in common.
39 type permanentError struct{ err error }
40
41 func (e permanentError) Error() string { return e.err.Error() }
42 func (e permanentError) Unwrap() error { return e.err }
43 func (e permanentError) Permanent() bool { return true }
44
45 // refuse wraps a cause with the host's platform, so the message an operator
46 // reads in the console says which kind of host said no.
47 func refuse(cause error) error {
48 return permanentError{err: fmt.Errorf("%w (%s/%s)", cause, runtime.GOOS, runtime.GOARCH)}
49 }
50
51 // Provisioner is the VM lifecycle on a host with no VM runtime: every call
52 // refuses, and nothing is ever running.
53 type Provisioner struct{}
54
55 func (Provisioner) PrepareRootDisk(_ context.Context, _ state.VMSpec, _ string) error {
56 return refuse(ErrNoRuntime)
57 }
58
59 func (Provisioner) Boot(_ context.Context, _ string, _ state.VMSpec) error {
60 return refuse(ErrNoRuntime)
61 }
62
63 func (Provisioner) Shutdown(_ context.Context, _ string) error { return refuse(ErrNoRuntime) }
64
65 // Destroy refuses like the rest of the lifecycle. Reconcile keeps the VM's
66 // record when Destroy fails, so a VM that somehow landed here is never
67 // hard-deleted from the fleet.
68 func (Provisioner) Destroy(_ context.Context, _ string) error { return refuse(ErrNoRuntime) }
69
70 func (Provisioner) Running(_ string) bool { return false }
71
72 // Address is empty because there are no guests to have one. Reconcile reads
73 // empty as "no answer" and leaves any recorded address alone, which is right:
74 // this platform has nothing to say either way.
75 func (Provisioner) Address(_ string) string { return "" }
76
77 // ConsoleSource is the guest console on a host with no guests. Open fails
78 // rather than handing back a stream that would never carry bytes; the pump
79 // treats that as a source that isn't up yet and backs off, which is the right
80 // shape for a host whose VM runtime isn't there.
81 type ConsoleSource struct{}
82
83 func (ConsoleSource) Open(_ string) (io.ReadWriteCloser, error) { return nil, ErrNoConsole }
internal/agent/inert/inert_test.go
Old New
@@ -0,0 +1,86 @@
1 package inert
2
3 import (
4 "context"
5 "errors"
6 "runtime"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/a73x/eitri/internal/agent/reconcile"
13 "github.com/a73x/eitri/internal/agent/serialpump"
14 "github.com/a73x/eitri/internal/agent/state"
15 )
16
17 // The seams this package exists to satisfy. Asserted here rather than in the
18 // package so its production edges stay at stdlib plus state.
19 var (
20 _ reconcile.Provisioner = Provisioner{}
21 _ serialpump.ConsoleSource = ConsoleSource{}
22 )
23
24 // isPermanent matches the marker the way reconcile does — structurally, on the
25 // method, not on a shared type.
26 func isPermanent(err error) bool {
27 var p interface{ Permanent() bool }
28 return errors.As(err, &p) && p.Permanent()
29 }
30
31 // TestProvisionerRefusesEveryLifecycleCallPermanently is the contract that
32 // makes this platform safe to place a VM against by mistake: reconcile
33 // terminal-fails in one attempt instead of retrying a backend that can never
34 // succeed.
35 func TestProvisionerRefusesEveryLifecycleCallPermanently(t *testing.T) {
36 ctx := context.Background()
37 p := Provisioner{}
38 calls := map[string]error{
39 "PrepareRootDisk": p.PrepareRootDisk(ctx, state.VMSpec{}, "/base.img"),
40 "Boot": p.Boot(ctx, "vm-1", state.VMSpec{}),
41 "Shutdown": p.Shutdown(ctx, "vm-1"),
42 "Destroy": p.Destroy(ctx, "vm-1"),
43 }
44 for name, err := range calls {
45 require.Error(t, err, name)
46 assert.ErrorIs(t, err, ErrNoRuntime, name)
47 assert.True(t, isPermanent(err), "%s must be Permanent", name)
48 assert.Contains(t, err.Error(), "no VM runtime", name)
49 }
50 }
51
52 func TestProvisionerNeverReportsRunning(t *testing.T) {
53 assert.False(t, Provisioner{}.Running("vm-1"))
54 }
55
56 func TestProvisionerReportsNoAddress(t *testing.T) {
57 assert.Empty(t, Provisioner{}.Address("vm1"),
58 "a host that runs no guests knows no guest addresses")
59 }
60
61 // TestConsoleSourceFailsLoudly: a console request on this host must produce a
62 // visible error through the normal serialpump path, not a silent nothing. The
63 // error is deliberately NOT permanent — the pump's reconnect loop backs off on
64 // Open failure, which is exactly the behavior wanted for a host whose VM
65 // runtime simply isn't there.
66 func TestConsoleSourceFailsLoudly(t *testing.T) {
67 stream, err := ConsoleSource{}.Open("vm-1")
68 assert.Nil(t, stream)
69 assert.ErrorIs(t, err, ErrNoConsole)
70 assert.False(t, isPermanent(err))
71 }
72
73 // TestRefusalsNameThePlatform: an operator reading a failed VM should learn
74 // which kind of host refused it, not just that something said no.
75 func TestRefusalsNameThePlatform(t *testing.T) {
76 err := Provisioner{}.Boot(context.Background(), "vm-1", state.VMSpec{})
77 assert.Contains(t, err.Error(), runtime.GOOS)
78 assert.Contains(t, err.Error(), runtime.GOARCH)
79 }
80
81 // TestRefusalsUnwrapToTheirCause keeps the marker transparent: wrapping for
82 // the Permanent() signal must not hide the sentinel underneath it.
83 func TestRefusalsUnwrapToTheirCause(t *testing.T) {
84 err := Provisioner{}.Shutdown(context.Background(), "vm-1")
85 assert.Equal(t, ErrNoRuntime, errors.Unwrap(errors.Unwrap(err)))
86 }
internal/agent/run/cli.go
Old New
@@ -33,7 +33,7 @@ import (
33 ) 33 )
34 34
35 // hostRunner is the production one-shot command runner injected into the 35 // hostRunner is the production one-shot command runner injected into the
36 // host-touching agent packages (netenv, imagecache, cloudhv, syncclient). It 36 // host-touching agent packages — which ones exist depends on the platform. It
37 // spawns name+args, waits, and returns their combined stdout/stderr. It lives 37 // spawns name+args, waits, and returns their combined stdout/stderr. It lives
38 // in the composition root because it is a wiring value: constructing the 38 // in the composition root because it is a wiring value: constructing the
39 // concrete dependency is what a root is for, and keeping it here means no 39 // concrete dependency is what a root is for, and keeping it here means no
@@ -91,6 +91,9 @@ func RunCLI(args []string) error {
91 func parseConfig(args []string) (Config, []string, error) { 91 func parseConfig(args []string) (Config, []string, error) {
92 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError) 92 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError)
93 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 93 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
94 // --ch-bin/--firmware/--bootstrap-url configure the cloud-hypervisor
95 // backend. They are accepted everywhere and ignored by platforms that don't
96 // run it, so the agent has one flag surface on every host.
94 chBin := fs.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") 97 chBin := fs.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
95 firmware := fs.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") 98 firmware := fs.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
96 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)") 99 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)")
@@ -160,7 +163,7 @@ func join(st *state.Store, blob string) error {
160 Name: hostname, 163 Name: hostname,
161 OS: runtime.GOOS, 164 OS: runtime.GOOS,
162 Arch: runtime.GOARCH, 165 Arch: runtime.GOARCH,
163 Provisioner: "cloudhv", 166 Provisioner: platformProvisioner,
164 }) 167 })
165 if errors.Is(err, enrollclient.ErrTokenRejected) { 168 if errors.Is(err, enrollclient.ErrTokenRejected) {
166 return errors.New("enroll rejected: token already used or expired — mint a new join token") 169 return errors.New("enroll rejected: token already used or expired — mint a new join token")
@@ -261,15 +264,16 @@ func serve(st *state.Store, cfg Config) error {
261 // exiting; the OS reclaims the goroutines. 264 // exiting; the OS reclaims the goroutines.
262 265
263 client := &syncclient.Client{ 266 client := &syncclient.Client{
264 Engine: engine, 267 Engine: engine,
265 St: st, 268 St: st,
266 Identity: id, 269 Identity: id,
267 StateDir: cfg.StateDir, 270 StateDir: cfg.StateDir,
268 Runner: hostRunner, 271 Provisioner: platformProvisioner,
269 Console: pumps, 272 Runner: hostRunner,
270 MaxVCPUs: cfg.MaxVCPUs, 273 Console: pumps,
271 MaxMemMB: cfg.MaxMemMB, 274 MaxVCPUs: cfg.MaxVCPUs,
272 MaxDiskGB: cfg.MaxDiskGB, 275 MaxMemMB: cfg.MaxMemMB,
276 MaxDiskGB: cfg.MaxDiskGB,
273 } 277 }
274 278
275 slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) 279 slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR)
internal/agent/run/wire_darwin.go
Old New
@@ -0,0 +1,40 @@
1 //go:build darwin
2
3 package run
4
5 import (
6 "context"
7
8 "github.com/a73x/eitri/internal/agent/inert"
9 "github.com/a73x/eitri/internal/agent/reconcile"
10 "github.com/a73x/eitri/internal/agent/serialpump"
11 "github.com/a73x/eitri/internal/agent/state"
12 )
13
14 // platformProvisioner is what this host advertises to the server at join
15 // time — an opaque label the server stores and never interprets.
16 const platformProvisioner = "inert"
17
18 // platform is this host's backend pair, mirroring wire_linux.go's contract:
19 // serve() is the sole consumer, wiring Prov and Pumps into the reconcile engine
20 // and the sync client. This host has no VM runtime, so both come from
21 // internal/agent/inert, which refuses the work it cannot do rather than
22 // pretending to do it.
23 type platform struct {
24 Prov reconcile.Provisioner
25 Pumps *serialpump.Manager
26 }
27
28 // newPlatform builds the inert platform. bridgeCIDR is accepted (the same
29 // signature as every newPlatform, so serve() doesn't need to know which
30 // platform it got) but unused — the server assigns it, and nothing on this host
31 // consumes a bridge CIDR without a networking backend. There is no bootstrap
32 // step either: unlike Linux, which installs cloud-hypervisor and its UEFI
33 // firmware on first run, there is no runtime binary to install for a platform
34 // that runs no guests.
35 func newPlatform(_ context.Context, _ Config, st *state.Store, _ string) (platform, error) {
36 return platform{
37 Prov: inert.Provisioner{},
38 Pumps: serialpump.NewManager(inert.ConsoleSource{}, st.SerialLogPath),
39 }, nil
40 }
internal/agent/run/wire_linux.go
Old New
@@ -14,6 +14,10 @@ import (
14 "github.com/a73x/eitri/internal/agent/state" 14 "github.com/a73x/eitri/internal/agent/state"
15 ) 15 )
16 16
17 // platformProvisioner is what this host advertises to the server at join
18 // time — an opaque label the server stores and never interprets.
19 const platformProvisioner = "cloudhv"
20
17 // platform is this host's backend pair. It is a struct, not an interface: 21 // platform is this host's backend pair. It is a struct, not an interface:
18 // nothing consumes the bundle as a type, so the two seams stay independent — a 22 // nothing consumes the bundle as a type, so the two seams stay independent — a
19 // different VMM is a change in this file alone. serve() is the contract's only 23 // different VMM is a change in this file alone. serve() is the contract's only
internal/agent/syncclient/client.go
Old New
@@ -59,9 +59,14 @@ type Client struct {
59 Identity state.Identity 59 Identity state.Identity
60 StateDir string 60 StateDir string
61 61
62 // Runner executes host-introspection subprocesses (systemd-detect-virt via 62 // Provisioner is the VM backend this host runs, echoed in Hello so the
63 // hostinfo). Injected so this data-plane package never imports os/exec (R6); 63 // server's connection log says what kind of host arrived. The value the
64 // nil is tolerated (virt reported as unknown). 64 // server stores comes from enroll, not from here.
65 Provisioner string
66
67 // Runner executes host-introspection subprocesses (on Linux,
68 // systemd-detect-virt via hostinfo). Injected so this data-plane package
69 // never imports os/exec (R6); nil is tolerated (virt reported as unknown).
65 Runner agentexec.Runner 70 Runner agentexec.Runner
66 71
67 // Console handles server-opened console streams (nil refuses them all). 72 // Console handles server-opened console streams (nil refuses them all).
@@ -97,11 +102,11 @@ type Client struct {
97 MaxDiskGB int64 102 MaxDiskGB int64
98 103
99 // rawCap memoizes the machine's real capacity (vCPUs/RAM/disk). Host totals 104 // rawCap memoizes the machine's real capacity (vCPUs/RAM/disk). Host totals
100 // don't change over a session, so the Statfs+Sysinfo syscalls run once 105 // don't change over a session, so the host is probed once instead of on
101 // instead of on every report; the cheap per-report clamp still applies. Only 106 // every report; the cheap per-report clamp still applies. Only a FULL
102 // a FULL reading is cached — capacity() silently zeroes a dimension whose 107 // reading is cached — capacity() silently zeroes a dimension whose probe
103 // syscall failed, and caching that would freeze a bad advertisement for the 108 // failed, and caching that would freeze a bad advertisement for the whole
104 // whole session, so a zeroed probe is re-tried on the next call. Guarded by 109 // session, so a zeroed probe is re-tried on the next call. Guarded by
105 // rawCapMu (hello and report goroutines both call advertisedCapacity). 110 // rawCapMu (hello and report goroutines both call advertisedCapacity).
106 rawCapMu sync.Mutex 111 rawCapMu sync.Mutex
107 rawCap *pb.Capacity 112 rawCap *pb.Capacity
@@ -288,7 +293,7 @@ func (c *Client) session(ctx context.Context) error {
288 hostname, _ := os.Hostname() 293 hostname, _ := os.Hostname()
289 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ 294 hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{
290 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, 295 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH,
291 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR, 296 Provisioner: c.Provisioner, BridgeCidr: c.Identity.BridgeCIDR,
292 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), 297 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir),
293 Facts: helloFacts(ctx, c.Runner), 298 Facts: helloFacts(ctx, c.Runner),
294 Credential: c.Identity.Credential, 299 Credential: c.Identity.Credential,
scripts/coverage.sh
Old New
@@ -20,9 +20,11 @@ declare -A FLOOR=(
20 [internal/agent/state]=50 20 [internal/agent/state]=50
21 [internal/agent/seed]=79 21 [internal/agent/seed]=79
22 [internal/agent/ipalloc]=83 22 [internal/agent/ipalloc]=83
23 [internal/agent/hostinfo]=95
23 [internal/agent/imagecache]=72 24 [internal/agent/imagecache]=72
24 [internal/agent/netenv]=76 25 [internal/agent/netenv]=76
25 [internal/agent/cloudhv]=40 26 [internal/agent/cloudhv]=40
27 [internal/agent/inert]=95
26 [internal/agent/syncclient]=74 28 [internal/agent/syncclient]=74
27 [internal/server/api]=76 29 [internal/server/api]=76
28 [internal/server/boot]=17 30 [internal/server/boot]=17