// 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 }