internal/agent/hostinfo/hostinfo.go
Ref: Size: 9.0 KiB History
// Package hostinfo gathers best-effort facts and live metrics about the fleet
// host the agent runs on. Every reader is best-effort: a failing read yields an
// empty string or zero, never a blocking error, so host introspection can never
// stall the agent's connection or heartbeat.
package hostinfo
import (
"context"
"encoding/binary"
"net"
"net/netip"
"os"
"runtime"
"strconv"
"strings"
"time"
agentexec "github.com/a73x/eitri/internal/agent/exec"
"github.com/a73x/eitri/internal/pb"
)
// Seams — package-level vars so tests can point them at fixtures/stubs. These
// are plain paths (not syscall-typed), so they stay untagged: readFile
// harmlessly returns "" for them on a platform where the path doesn't exist.
var (
osReleasePath = "/etc/os-release"
cpuInfoPath = "/proc/cpuinfo"
memInfoPath = "/proc/meminfo"
kernelPath = "/proc/sys/kernel/osrelease"
// bootIDPath is the kernel's per-boot UUID (Linux's readBootID source). It
// lives here, untagged, rather than in hostinfo_linux.go: the tests that
// stub it are untagged too and must still vet clean on every OS, even
// though only Linux's readBootID consults it.
bootIDPath = "/proc/sys/kernel/random/boot_id"
)
const (
loadScale = 65536.0 // Sysinfo.Loads fixed-point: units of 1<<SI_LOAD_SHIFT
mb = 1024 * 1024
gb = 1024 * 1024 * 1024
)
// Facts returns slow-changing host identity for the once-per-session Hello.
// Every field is empty when its source can't be read. run is the command
// runner a platform's virt probe needs — Linux shells out to
// systemd-detect-virt (nil → virt unknown), Darwin answers without a
// subprocess. It stays injected so hostinfo does not import os/exec (data-plane
// arch rule R6), and ctx bounds the subprocess where there is one.
func Facts(ctx context.Context, run agentexec.Runner) *pb.HostFacts {
id, pretty, version := osIdentity()
return &pb.HostFacts{
OsId: id,
OsPretty: pretty,
OsVersion: version,
Kernel: kernelVersion(),
CpuModel: cpuModel(),
Virt: virtSource(ctx, run),
}
}
// Metrics returns live host utilization for the heartbeat. stateDir is the
// filesystem whose usage is reported (the agent's state dir, matching capacity).
// The syscalls behind it are platform-typed, so the work is in readMetrics.
func Metrics(stateDir string) *pb.HostMetrics { return readMetrics(stateDir) }
// BootID returns an opaque token that changes across host reboots. Reconcile
// compares it only for equality, to detect that VMs were lost to a reboot — so
// any per-boot-unique value satisfies the contract, and the source is
// per-platform.
func BootID() string { return readBootID() }
// UplinkAddr reports the address this host presents on the network it reaches
// the control plane over — the address to dial for a port published on this
// host. serverAddr is the control plane's own host:port.
//
// It asks the kernel's routing table rather than picking an interface off a
// list: a UDP "connection" sends no packet, but it binds the socket to the
// source address the route to serverAddr would actually use, which is the one
// answer that stays right on a host with several addresses.
//
// Empty means "no answer": the route could not be resolved, or it resolves to
// an address nothing off this host can dial. The fleet then keeps whatever it
// already knows rather than recording somewhere unreachable.
func UplinkAddr(serverAddr string) string {
conn, err := net.Dial("udp", serverAddr)
if err != nil {
return ""
}
defer conn.Close()
return usableSourceAddr(conn.LocalAddr())
}
// usableSourceAddr reduces a socket's local address to the address an operator
// could dial, or "" when it is one nothing off this host can reach.
func usableSourceAddr(a net.Addr) string {
ua, ok := a.(*net.UDPAddr)
if !ok {
return ""
}
addr, ok := netip.AddrFromSlice(ua.IP)
if !ok {
return ""
}
addr = addr.Unmap()
if addr.IsUnspecified() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
return ""
}
return addr.String()
}
// Capacity returns the host's TOTAL capacity: total disk at stateDir, total
// memory, and CPU count. The server computes allocated/available by subtracting
// the sum of live VM specs, so these must be totals, not free space.
func Capacity(stateDir string) *pb.Capacity {
return &pb.Capacity{
Vcpus: int64(runtime.NumCPU()),
MemMb: totalMemMB(),
DiskGb: totalDiskGB(stateDir),
}
}
// parseOSRelease extracts ID, PRETTY_NAME, VERSION_ID from /etc/os-release
// (KEY=VALUE lines, values optionally double-quoted).
func parseOSRelease(s string) (id, pretty, version string) {
for line := range strings.SplitSeq(s, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
v = strings.Trim(v, `"`)
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
case "VERSION_ID":
version = v
}
}
return id, pretty, version
}
// plistString returns the <string> value that follows <key>name</key> in an
// Apple property list, or "" if the key or its value is absent. This is a
// deliberate scan rather than a plist parser: hostinfo wants two keys out of
// one frozen system file, and a parser would be a dependency and an error path
// in a reader whose contract is already "" on anything unexpected. The key is
// matched as a whole tag, so ProductVersion never matches ProductBuildVersion.
func plistString(doc, key string) string {
_, after, ok := strings.Cut(doc, "<key>"+key+"</key>")
if !ok {
return ""
}
// Bound the search at the next key: a key whose value is not a string has
// no value here, and must not borrow a later key's.
if own, _, found := strings.Cut(after, "<key>"); found {
after = own
}
_, after, ok = strings.Cut(after, "<string>")
if !ok {
return ""
}
val, _, ok := strings.Cut(after, "</string>")
if !ok {
return ""
}
return strings.TrimSpace(val)
}
// macOSIdentity maps SystemVersion.plist to the same three fields
// /etc/os-release gives Facts on Linux: a lowercase id, a human-readable name,
// and a version. A Mac whose plist can't be read is still a Mac, so the name
// falls back rather than emptying out — only the version goes missing.
func macOSIdentity(plist string) (id, pretty, version string) {
name := plistString(plist, "ProductName")
if name == "" {
name = "macOS"
}
version = plistString(plist, "ProductVersion")
return strings.ToLower(name), strings.TrimSpace(name + " " + version), version
}
// parseCPUModel returns the first "model name" value from /proc/cpuinfo, or ""
// (e.g. on arm, which uses different fields).
func parseCPUModel(s string) string {
for line := range strings.SplitSeq(s, "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "model name" {
return strings.TrimSpace(v)
}
}
return ""
}
// parseMemAvailableKB returns the MemAvailable value in kB and whether it was
// present.
func parseMemAvailableKB(s string) (int64, bool) {
for line := range strings.SplitSeq(s, "\n") {
if !strings.HasPrefix(line, "MemAvailable:") {
continue
}
fields := strings.Fields(line) // ["MemAvailable:", "N", "kB"]
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n, true
}
}
}
return 0, false
}
// decodeLoadavg decodes the raw bytes of Darwin's vm.loadavg sysctl —
// struct loadavg { fixpt_t ldavg[3]; long fscale; } — into the three load
// figures. fixpt_t is a 32-bit fixed-point count and long is 64-bit on every
// Mac eitri supports, so the struct is 24 bytes: three counts, four bytes of
// padding to the long's alignment, then the divisor. Apple hardware is
// little-endian, both Apple Silicon and Intel.
//
// Any other length is a shape this decoder does not know, and a zero divisor
// is a reading it cannot use: both return ok=false, leaving the loads at zero
// per the package's best-effort contract. Reporting nothing beats reporting a
// number derived from a struct we misread.
func decodeLoadavg(b []byte) ([3]float64, bool) {
const (
size = 24
scaleOff = 16
)
if len(b) != size {
return [3]float64{}, false
}
fscale := binary.LittleEndian.Uint64(b[scaleOff:])
if fscale == 0 {
return [3]float64{}, false
}
var loads [3]float64
for i := range loads {
loads[i] = float64(binary.LittleEndian.Uint32(b[i*4:])) / float64(fscale)
}
return loads, true
}
// readFile is a best-effort file read: "" on any error.
func readFile(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(b)
}
// detectVirt runs systemd-detect-virt through the injected Runner (nil → ""),
// keeping hostinfo off os/exec per the data-plane arch rule. The tool prints
// "none" (and exits non-zero) on bare metal and a name like "kvm" under
// virtualization; the output is used regardless of exit code, and empty output
// (binary absent) means unknown.
func detectVirt(ctx context.Context, run agentexec.Runner) string {
if run == nil {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
out, _ := run(ctx, "systemd-detect-virt")
return strings.TrimSpace(out)
}