f6db476e
feat(agent): a Mac joins the fleet with no VMs
a73x 2026-08-06 09:12
Commit message
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 |