Files
mesh-host/internal/inventory/inventory.go
T
jschoubben 73c010e7ef Stage 1 — the host reports what a machine is and can do
Tier 0's first slice, per novox/hq 03-DESIGN/01-to-be/05-the-node-host.md. It
applies nothing, connects to nothing, listens on nothing. 2.9 MB, static, no
dynamic dependencies: copy it onto a machine and run it is the whole install,
which is the property ADR 0041 rests on.

A capability is detected, never assumed. Every detector runs something that only
succeeds if the thing FUNCTIONS — the daemon is asked for its version, the
package database is queried, the firewall is asked to list a ruleset, which
needs the privilege as well as the tool. 04-ISSUES/007 is the fault this
prevents: a client on disk with its daemon down looks exactly like a working
runtime, and a node assigned work on that basis fails when the work arrives.

Every verdict carries the reason and the method. A capability reported absent
with no reason is the same fault in a new place: something nobody can act on.

Two bugs found by running rather than reasoning, both silent:

systemctl is-system-running exits non-zero for every state except `running` —
including `degraded`, which means units failed and the init is emphatically
there. Reading the exit code reported NO service manager on a machine whose init
it was. That is 007 in the mirror, and both directions place work wrongly. A
verdict now reads what a tool says about itself, not only how it exited.

And `mesh-host inventory --json` printed text: the standard library stops
parsing at the first non-flag argument, so the flag sat unread and the command
exited 0 having ignored what was asked. The parser now takes the subcommand off
the front, and a stray or mistyped argument is refused rather than dropped.

Detection deliberately does NOT follow ADR 0008. That rule governs applying
state, where a failed step means the machine is not what was asked for. A failed
probe is a finding — "absent, because the probe failed" — and aborting would
replace one legible absence with total ignorance of the rest.

25 tests: structure and logic with a fake runner, and the same detectors against
this machine, because a test that fakes the system under detection asserts only
that the fake behaves as expected.
2026-08-26 00:25:08 +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 0035).
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 0036) 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
}