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.
141 lines
4.5 KiB
Go
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
|
|
}
|