a73x

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)
}