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.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
)
|
||||
|
||||
func TestAnUnreadableFactIsRecordedNotFatal(t *testing.T) {
|
||||
// An inventory missing one field is useful; an inventory that refused to be taken is not.
|
||||
// And an absent fact and a fact that failed to read are different things — collapsing them
|
||||
// loses the one worth acting on.
|
||||
failing := func(string) ([]byte, error) { return nil, errors.New("permission denied") }
|
||||
inv := Collect(context.Background(), failing, nil, time.Second)
|
||||
|
||||
if inv.Machine == "" && len(inv.Unreadable) == 0 {
|
||||
t.Fatal("nothing was read and nothing was reported unreadable")
|
||||
}
|
||||
if len(inv.Unreadable) == 0 {
|
||||
t.Fatal("every file failed and nothing was recorded as unreadable")
|
||||
}
|
||||
for _, u := range inv.Unreadable {
|
||||
if !strings.Contains(u, ":") {
|
||||
t.Errorf("unreadable entry does not say which fact failed: %q", u)
|
||||
}
|
||||
}
|
||||
if inv.Architecture == "" || inv.CPUs == 0 {
|
||||
t.Error("facts that need no file were lost along with the ones that did")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionIsReadFromTheMachineNotAssumed(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"PRETTY_NAME=\"Arch Linux\"\nID=arch\n": "Arch Linux",
|
||||
"ID=arch\n": "arch",
|
||||
"# a comment\n\nID=debian\nPRETTY_NAME='Debian 13'\n": "Debian 13",
|
||||
"NAME=Weird\nPRETTY_NAME=\"Has=Equals\"\n": "Has=Equals",
|
||||
"": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := distributionFrom(input); got != want {
|
||||
t.Errorf("distributionFrom(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryIsReadOrReportedMissing(t *testing.T) {
|
||||
if kb, ok := memoryFrom("MemTotal: 32762868 kB\nMemFree: 1 kB\n"); !ok || kb != 32762868 {
|
||||
t.Errorf("memoryFrom returned %d, %v", kb, ok)
|
||||
}
|
||||
// A meminfo without MemTotal must not read as zero memory — a machine reporting no memory
|
||||
// would be excluded from placement for a parsing failure.
|
||||
if _, ok := memoryFrom("MemFree: 1 kB\n"); ok {
|
||||
t.Error("a meminfo with no MemTotal was read as a successful measurement")
|
||||
}
|
||||
if _, ok := memoryFrom("MemTotal: not-a-number kB\n"); ok {
|
||||
t.Error("an unparseable MemTotal was read as a successful measurement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheInventorySaysWhenItWasTaken(t *testing.T) {
|
||||
// A node unreachable for a week is an ordinary situation (novox/hq ADR 0036), so an
|
||||
// inventory that cannot be told from a stale one is missing the fact that matters.
|
||||
before := time.Now().UTC()
|
||||
inv := Collect(context.Background(), nil, nil, time.Second)
|
||||
|
||||
if inv.ObservedAt.IsZero() {
|
||||
t.Fatal("the inventory does not say when it was observed")
|
||||
}
|
||||
if inv.ObservedAt.Before(before.Add(-time.Second)) || inv.ObservedAt.After(time.Now().UTC().Add(time.Second)) {
|
||||
t.Errorf("ObservedAt is not the moment of observation: %s", inv.ObservedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHostDoesNotNameTheNode(t *testing.T) {
|
||||
// A node's name is assigned by the mesh. A host that named itself would be deciding
|
||||
// something, which is precisely what novox/hq ADR 0037 forbids it to do.
|
||||
inv := Collect(context.Background(), nil, nil, time.Second)
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
if inv.Machine != hostname {
|
||||
t.Errorf("Machine is %q, not the machine's own hostname %q", inv.Machine, hostname)
|
||||
}
|
||||
}
|
||||
|
||||
// --- against this machine ---------------------------------------------------------------
|
||||
|
||||
func TestAgainstThisMachine_inventoryIsTrue(t *testing.T) {
|
||||
// novox/hq ADR 0034: behaviour against a real system is tested alongside, not mocked.
|
||||
inv := Collect(context.Background(), nil, profile.Default(nil), 10*time.Second)
|
||||
|
||||
if inv.Machine == "" {
|
||||
t.Error("this machine did not report a name")
|
||||
}
|
||||
if inv.CPUs < 1 {
|
||||
t.Errorf("this machine reported %d cpus", inv.CPUs)
|
||||
}
|
||||
if inv.OS == "linux" {
|
||||
if inv.Kernel == "" {
|
||||
t.Error("a linux machine reported no kernel version")
|
||||
}
|
||||
if inv.MemoryKB <= 0 {
|
||||
t.Error("a linux machine reported no memory")
|
||||
}
|
||||
}
|
||||
if len(inv.Profile.Capabilities) == 0 {
|
||||
t.Error("the inventory carries no profile")
|
||||
}
|
||||
|
||||
t.Logf("machine=%s dist=%q kernel=%s arch=%s cpus=%d mem=%dMB unreadable=%v",
|
||||
inv.Machine, inv.Distribution, inv.Kernel, inv.Architecture,
|
||||
inv.CPUs, inv.MemoryKB/1024, inv.Unreadable)
|
||||
}
|
||||
Reference in New Issue
Block a user