Files
mesh-host/internal/inventory/inventory.go
jschoubben ee2648188d Repoint ADR references after HQ consolidated 65 records to 23
96 comments across the two repos named records that no longer exist. Each now
points at the consolidated record that holds its reasoning -- ADR 0034 (a test
defends a decision) is 0017, the eight host records are 0005, the four lab
records are 0016.

Worth noting for next time: these are references from outside HQ, so renumbering
there is not free. It cost 38 files here.
2026-08-28 23:33:44 +02:00

141 lines
4.5 KiB
Go

// Package inventory answers: what is this machine, and what does it hold?
//
// Reported upward and never asked downward (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md).
// Everything here is read from the machine at the moment of asking — nothing is remembered,
// nothing is derived from a file that says what the machine ought to be
// (novox/hq ADR 0018).
package inventory
import (
"context"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/novox/mesh-host/internal/profile"
)
// Inventory is what a machine reports about itself.
//
// Deliberately small. Everything here is either needed to identify the machine or needed to
// decide what may be placed on it; anything else would be a fact the mesh stores and nothing
// reads, which is the shape 04-ISSUES/003 records.
type Inventory struct {
// Machine is what this machine calls itself. NOT its node name — a node's name is assigned
// by the mesh, and a host that named itself would be deciding something.
Machine string `json:"machine"`
OS string `json:"os"`
Architecture string `json:"architecture"`
Kernel string `json:"kernel,omitempty"`
Distribution string `json:"distribution,omitempty"`
CPUs int `json:"cpus"`
MemoryKB int64 `json:"memory_kb,omitempty"`
Profile profile.Profile `json:"profile"`
// ObservedAt is when this was read. An inventory with no timestamp cannot be told from a
// stale one, and a node that has been unreachable for a week is an ordinary situation
// (novox/hq ADR 0004) rather than an error — so the age of the observation is part of it.
ObservedAt time.Time `json:"observed_at"`
// Unreadable lists what could not be determined, and why. An absent field and a field that
// failed to read are different facts, and collapsing them loses the one worth acting on.
Unreadable []string `json:"unreadable,omitempty"`
}
// Reader supplies the machine's own files. Replaced in tests only for the parsing layer; the
// real reader is exercised against this machine as well.
type Reader func(path string) ([]byte, error)
// Collect reads the machine. It never fails: a fact that cannot be read is recorded as
// unreadable rather than aborting, because an inventory missing one field is useful and an
// inventory that refused to be taken is not.
func Collect(ctx context.Context, read Reader, detectors []profile.Detector, timeout time.Duration) Inventory {
if read == nil {
read = os.ReadFile
}
inv := Inventory{
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
CPUs: runtime.NumCPU(),
ObservedAt: time.Now().UTC(),
}
if name, err := os.Hostname(); err == nil {
inv.Machine = name
} else {
inv.Unreadable = append(inv.Unreadable, "machine name: "+err.Error())
}
if b, err := read("/proc/sys/kernel/osrelease"); err == nil {
inv.Kernel = strings.TrimSpace(string(b))
} else {
inv.Unreadable = append(inv.Unreadable, "kernel: "+err.Error())
}
if b, err := read("/etc/os-release"); err == nil {
inv.Distribution = distributionFrom(string(b))
} else {
inv.Unreadable = append(inv.Unreadable, "distribution: "+err.Error())
}
if b, err := read("/proc/meminfo"); err == nil {
if kb, ok := memoryFrom(string(b)); ok {
inv.MemoryKB = kb
} else {
inv.Unreadable = append(inv.Unreadable, "memory: MemTotal not found in /proc/meminfo")
}
} else {
inv.Unreadable = append(inv.Unreadable, "memory: "+err.Error())
}
inv.Profile = profile.Detect(ctx, detectors, timeout)
return inv
}
// distributionFrom pulls the human name out of an os-release file.
//
// Prefers PRETTY_NAME, falls back to ID. Values may be quoted or not, and a line may contain
// an `=` in the value, so the split is on the first only.
func distributionFrom(osRelease string) 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), `"'`)
}
if pretty := fields["PRETTY_NAME"]; pretty != "" {
return pretty
}
return fields["ID"]
}
// memoryFrom reads MemTotal, in kilobytes, from a meminfo file.
func memoryFrom(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 {
return 0, false
}
return kb, true
}
return 0, false
}