Files
mesh-host/internal/profile/machine.go
T
jochen ec983bfc3d
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Have a machine say what it is made of, and say it once a day unasked (issue 160)
The mesh knew a machine's architecture and kernel family and nothing it is made of, and heard even
that only when it sent the machine something. The profile now carries the machine's memory, each local
filesystem's size and free space, processor model, cores and threads, distribution and version, kernel
release, virtual or physical, boot time and timezone; a fact it cannot read is named unknown with why,
never zeroed. The reconcile says the profile unasked once the mesh last heard it 23 hours ago, so on a
machine nobody pushed to nothing the mesh holds of it is older than a day.
2026-10-12 17:21:36 +02:00

434 lines
14 KiB
Go

package profile
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody
// deciding where a module should go wants to know, beside what it can do.
//
// **A fact it could not determine is said, never zeroed.** Every fact below is either present or
// named in Unknown with why; a machine reporting 0 bytes of memory would be read as a machine with
// none. So a field is omitted only when Unknown says why.
type Machine struct {
// ObservedAt is when this was read: the age every reader shows beside it.
ObservedAt time.Time `json:"observed_at"`
// KernelRelease is the running kernel's release, as /proc/sys/kernel/osrelease says it. (The
// profile's own `kernel` is the kernel's family, `linux`.)
KernelRelease string `json:"kernel_release,omitempty"`
// Distribution and its Version, from /etc/os-release: VERSION_ID, or BUILD_ID for a rolling
// distribution that has no version (Arch Linux says `rolling`).
Distribution string `json:"distribution,omitempty"`
Version string `json:"version,omitempty"`
// Processor is its model name; Cores the physical cores and Threads the logical processors.
Processor string `json:"processor,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
// Virtual is `physical`, or the kind of virtual machine or container it is (`kvm`, `lxc`), as
// systemd-detect-virt names it.
Virtual string `json:"virtual,omitempty"`
// BootedAt is when it booted: its uptime is the time since, so the fact does not change between
// two reports of the same boot.
BootedAt *time.Time `json:"booted_at,omitempty"`
Timezone string `json:"timezone,omitempty"`
// Filesystems is each local filesystem mounted, by where, with its size and what is free.
Filesystems []Filesystem `json:"filesystems,omitempty"`
// Unknown names each fact that could not be determined, and why.
Unknown map[string]string `json:"unknown,omitempty"`
}
// Filesystem is one mounted local filesystem.
type Filesystem struct {
Mount string `json:"mount"`
Device string `json:"device"`
Type string `json:"type"`
// SizeBytes and FreeBytes are what statfs says: free is what an unprivileged writer may still
// use (f_bavail), the number a person deciding where data goes wants.
SizeBytes int64 `json:"size_bytes"`
FreeBytes int64 `json:"free_bytes"`
}
// The facts, by the names Unknown says them under.
const (
FactKernelRelease = "kernel release"
FactDistribution = "distribution"
FactVersion = "version"
FactProcessor = "processor"
FactCores = "cores"
FactThreads = "threads"
FactMemory = "memory"
FactVirtual = "virtual or physical"
FactUptime = "uptime"
FactTimezone = "timezone"
FactFilesystems = "filesystems"
FactPublicAddress = "public address"
)
// MachineFacts is every fact Unknown may name, in the order a reader shows them.
var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores,
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress}
// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see
// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not
// take on unasked; how a machine learns it is not decided yet, so it is said as unknown rather than
// guessed from a private address.
const publicAddressUnknown = "a machine behind a NAT cannot see its public address from itself, and how it " +
"learns it is not decided yet"
// Sources is where the facts are read from. Replaced in tests only for the parsing layer; the real
// sources are exercised against this machine as well (novox/hq ADR 0017).
type Sources struct {
Read func(path string) ([]byte, error)
Readlink func(path string) (string, error)
Run Runner
// Statfs is (size, free) in bytes of the filesystem mounted at a path.
Statfs func(path string) (size, free int64, err error)
Now func() time.Time
}
// RealSources reads this machine.
func RealSources() Sources {
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now}
}
func statfs(path string) (int64, int64, error) {
var s syscall.Statfs_t
if err := syscall.Statfs(path, &s); err != nil {
return 0, 0, err
}
return int64(s.Blocks) * int64(s.Bsize), int64(s.Bavail) * int64(s.Bsize), nil
}
// DescribeMachine reads what this machine is made of. It never fails: a fact that cannot be read is
// named in Unknown with why, and the rest are still said.
func DescribeMachine(ctx context.Context, src Sources) Machine {
if src.Now == nil {
src.Now = time.Now
}
m := Machine{ObservedAt: src.Now().UTC(), Unknown: map[string]string{}}
unknown := func(fact string, why string) { m.Unknown[fact] = why }
if b, err := src.Read("/proc/sys/kernel/osrelease"); err != nil {
unknown(FactKernelRelease, err.Error())
} else if r := strings.TrimSpace(string(b)); r == "" {
unknown(FactKernelRelease, "/proc/sys/kernel/osrelease is empty")
} else {
m.KernelRelease = r
}
if b, err := src.Read("/etc/os-release"); err != nil {
unknown(FactDistribution, err.Error())
unknown(FactVersion, err.Error())
} else {
name, version, whyName, whyVersion := distributionOf(string(b))
m.Distribution, m.Version = name, version
if whyName != "" {
unknown(FactDistribution, whyName)
}
if whyVersion != "" {
unknown(FactVersion, whyVersion)
}
}
if b, err := src.Read("/proc/cpuinfo"); err != nil {
unknown(FactProcessor, err.Error())
unknown(FactCores, err.Error())
unknown(FactThreads, err.Error())
} else {
c := processorOf(string(b))
m.Processor, m.Cores, m.Threads = c.model, c.cores, c.threads
for fact, why := range c.unknown {
unknown(fact, why)
}
}
if b, err := src.Read("/proc/meminfo"); err != nil {
unknown(FactMemory, err.Error())
} else if kb, ok := memTotalKB(string(b)); !ok {
unknown(FactMemory, "MemTotal not found in /proc/meminfo")
} else {
m.MemoryBytes = kb * 1024
}
m.Virtual, _ = virtualOf(ctx, src.Run, unknown)
if b, err := src.Read("/proc/stat"); err != nil {
unknown(FactUptime, err.Error())
} else if booted, ok := bootedAt(string(b)); !ok {
unknown(FactUptime, "btime not found in /proc/stat")
} else {
m.BootedAt = &booted
}
if tz, why := timezoneOf(src); why != "" {
unknown(FactTimezone, why)
} else {
m.Timezone = tz
}
if b, err := src.Read("/proc/self/mounts"); err != nil {
unknown(FactFilesystems, err.Error())
} else {
fs, why := filesystemsOf(ctx, string(b), src.Statfs)
m.Filesystems = fs
if why != "" {
unknown(FactFilesystems, why)
}
}
unknown(FactPublicAddress, publicAddressUnknown)
return m
}
// distributionOf is the distribution's name and version from an os-release file, with why either is
// not said. NAME first, then ID; VERSION_ID first, then BUILD_ID.
func distributionOf(osRelease string) (name, version, whyName, whyVersion string) {
fields := map[string]string{}
for _, line := range strings.Split(osRelease, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
fields[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
}
name = firstOf(fields["NAME"], fields["ID"])
version = firstOf(fields["VERSION_ID"], fields["BUILD_ID"])
if name == "" {
whyName = "/etc/os-release names neither NAME nor ID"
}
if version == "" {
whyVersion = "/etc/os-release names neither VERSION_ID nor BUILD_ID"
}
return name, version, whyName, whyVersion
}
func firstOf(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
type processor struct {
model string
cores, threads int
unknown map[string]string
}
// processorOf reads /proc/cpuinfo: the model from `model name` (or `Model`, `Hardware`, `cpu model`,
// which other architectures use), threads from its `processor` entries, and cores from the distinct
// (physical id, core id) pairs — which an architecture that does not say them leaves unknown rather than
// guessed equal to the threads.
func processorOf(cpuinfo string) processor {
p := processor{unknown: map[string]string{}}
pairs := map[string]bool{}
physical := "0"
var models = map[string]string{}
for _, line := range strings.Split(cpuinfo, "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "processor":
p.threads++
case "physical id":
physical = value
case "core id":
pairs[physical+"/"+value] = true
case "model name", "Model", "Hardware", "cpu model":
if _, had := models[key]; !had {
models[key] = value
}
}
}
p.model = firstOf(models["model name"], models["cpu model"], models["Model"], models["Hardware"])
p.cores = len(pairs)
if p.model == "" {
p.unknown[FactProcessor] = "/proc/cpuinfo names no model"
}
if p.threads == 0 {
p.unknown[FactThreads] = "/proc/cpuinfo lists no processor"
}
if p.cores == 0 {
p.unknown[FactCores] = "/proc/cpuinfo says no core id"
}
return p
}
func memTotalKB(meminfo string) (int64, bool) {
for _, line := range strings.Split(meminfo, "\n") {
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
return 0, false
}
kb, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || kb <= 0 {
return 0, false
}
return kb, true
}
return 0, false
}
// virtualOf asks systemd-detect-virt. It says `none` on a physical machine, and exits 1 doing so.
func virtualOf(ctx context.Context, run Runner, unknown func(string, string)) (string, bool) {
if run == nil {
unknown(FactVirtual, "nothing here runs commands")
return "", false
}
out, err := run(ctx, "systemd-detect-virt")
switch said := strings.TrimSpace(out); {
case said == "none":
return "physical", true
case said != "" && err == nil:
return said, true
case err != nil:
unknown(FactVirtual, "systemd-detect-virt: "+err.Error())
default:
unknown(FactVirtual, "systemd-detect-virt said nothing")
}
return "", false
}
func bootedAt(stat string) (time.Time, bool) {
for _, line := range strings.Split(stat, "\n") {
parts := strings.Fields(line)
if len(parts) == 2 && parts[0] == "btime" {
secs, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || secs <= 0 {
return time.Time{}, false
}
return time.Unix(secs, 0).UTC(), true
}
}
return time.Time{}, false
}
// timezoneOf is the zone /etc/localtime links into the zoneinfo tree, or /etc/timezone's.
func timezoneOf(src Sources) (string, string) {
var whys []string
if src.Readlink != nil {
target, err := src.Readlink("/etc/localtime")
if err == nil {
if _, zone, found := strings.Cut(target, "zoneinfo/"); found && zone != "" {
return zone, ""
}
whys = append(whys, "/etc/localtime links to "+target+", outside a zoneinfo tree")
} else {
whys = append(whys, "/etc/localtime: "+err.Error())
}
}
if b, err := src.Read("/etc/timezone"); err == nil {
if zone := strings.TrimSpace(string(b)); zone != "" {
return zone, ""
}
whys = append(whys, "/etc/timezone is empty")
} else {
whys = append(whys, err.Error())
}
return "", strings.Join(whys, "; ")
}
// localTypes are the filesystem types said: those on a local device. A network filesystem is left out on
// purpose — statfs on a server that stopped answering blocks, and this runs on every apply.
var localTypes = map[string]bool{"ext2": true, "ext3": true, "ext4": true, "xfs": true, "btrfs": true,
"zfs": true, "f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "fuseblk": true,
"jfs": true, "reiserfs": true, "bcachefs": true}
// filesystemsOf lists each local filesystem once — a device mounted at several places (a btrfs
// subvolume, a bind mount) under its shortest mount — with its size and what is free. Bounded by the
// context, so a filesystem whose statfs hangs cannot hang the apply that reports it.
func filesystemsOf(ctx context.Context, mounts string, stat func(string) (int64, int64, error)) ([]Filesystem, string) {
if stat == nil {
return nil, "nothing here can ask a filesystem its size"
}
byDevice := map[string]Filesystem{}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) < 3 || !localTypes[f[2]] {
continue
}
mount := unescapeMount(f[1])
if had, ok := byDevice[f[0]]; ok && len(had.Mount) <= len(mount) {
continue
}
byDevice[f[0]] = Filesystem{Mount: mount, Device: f[0], Type: f[2]}
}
if len(byDevice) == 0 {
return nil, "/proc/self/mounts lists no local filesystem"
}
type answer struct {
fs []Filesystem
why []string
}
done := make(chan answer, 1)
go func() {
var a answer
for _, fs := range byDevice {
size, free, err := stat(fs.Mount)
if err != nil {
a.why = append(a.why, fmt.Sprintf("%s: %v", fs.Mount, err))
continue
}
fs.SizeBytes, fs.FreeBytes = size, free
a.fs = append(a.fs, fs)
}
done <- a
}()
select {
case a := <-done:
sort.Slice(a.fs, func(i, j int) bool { return a.fs[i].Mount < a.fs[j].Mount })
sort.Strings(a.why)
return a.fs, strings.Join(a.why, "; ")
case <-ctx.Done():
return nil, "asking the filesystems their size did not finish: " + ctx.Err().Error()
}
}
// unescapeMount undoes the octal escapes /proc/self/mounts writes a space or tab in a path as.
func unescapeMount(path string) string {
if !strings.Contains(path, `\`) {
return path
}
var b strings.Builder
for i := 0; i < len(path); i++ {
if path[i] == '\\' && i+3 < len(path) {
if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(n))
i += 3
continue
}
}
b.WriteByte(path[i])
}
return b.String()
}