Files
mesh-host/internal/profile/machine.go
T
jochen c7a4bf5c11
mesh/delivery ready: it delivers once merged
mesh/merge-gate out of date: judged against a trunk it does not name, the trunk is now ef26dba2753a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against a trunk it does not name, the trunk is now ef26dba2753a; judged again on a recheck or when it is merged
Judge the daily report by wall time, and keep one filesystem check at a time (issue 160)
Go's monotonic clock stops while a laptop is suspended, so a day judged by it could pass a day and a half
of wall time before the machine said what it is. And a hung statfs left a goroutine and thread blocked per
apply; now one check runs at a time and reports after it say the sizes unknown until it ends.
2026-10-12 17:42:18 +02:00

446 lines
14 KiB
Go

package profile
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"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}
// sizing is held while a check of the filesystems' sizes runs.
var sizing sync.Mutex
// 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"
}
// One check at a time, across every report: a filesystem whose statfs hangs keeps the one check that
// asked it, and every report after it says the sizes unknown until it finishes, rather than leaving
// another goroutine and thread blocked behind it on every apply.
if !sizing.TryLock() {
return nil, "the last check of the filesystems' sizes has not finished yet"
}
type answer struct {
fs []Filesystem
why []string
}
done := make(chan answer, 1)
go func() {
defer sizing.Unlock()
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()
}