diff --git a/cmd/mesh-controller/machine_lines.go b/cmd/mesh-controller/machine_lines.go new file mode 100644 index 00000000..03f6e4b3 --- /dev/null +++ b/cmd/mesh-controller/machine_lines.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// machineLines is what a machine said it is made of (novox/hq issue 160), as `node` shows it: every fact in +// one block with its age, the architecture and kernel the profile always carried among them, and each fact +// the machine could not determine said as unknown with its why — never as a zero, and never left out. +func machineLines(a inventory.MachineAccount, had bool, now time.Time) []string { + if !had { + return []string{" what it is made of: never said — this machine has not reported a profile"} + } + m := a.Machine + if m == nil { + return []string{ + " what it is made of: not said — its node-engine is older than the one that says it", + " " + factLine("architecture", orUnsaid(a.Architecture)), + " " + factLine("kernel", orUnsaid(a.Kernel)), + } + } + age := roughly(now.Sub(m.ObservedAt)) + " ago" + if m.ObservedAt.IsZero() { + // Said with no time: its age is not known, and is never read as two thousand years. + age = "at a time it did not say (age unknown)" + } + out := []string{fmt.Sprintf(" what it is made of, as it said %s:", age)} + add := func(name, value string) { out = append(out, " "+factLine(name, value)) } + unknown := map[string]string{} + for k, v := range m.Unknown { + unknown[k] = v + } + // said is the value, or the fact's own reason it is unknown; a fact neither said nor unknown is a + // node-engine that broke its own rule, and is said so rather than shown blank. + said := func(fact, value string) string { + if value != "" { + delete(unknown, fact) + return value + } + if why, ok := unknown[fact]; ok { + delete(unknown, fact) + return "unknown: " + why + } + return "unknown: the machine said neither it nor why" + } + + add("architecture", orUnsaid(a.Architecture)) + release := said("kernel release", m.KernelRelease) + if m.KernelRelease == "" { + release = "(release " + release + ")" + } + add("kernel", orUnsaid(a.Kernel)+" "+release) + dist := said("distribution", m.Distribution) + if m.Distribution != "" { + dist += " " + said("version", m.Version) + } + add("distribution", dist) + add("processor", said("processor", m.Processor)) + add("cores", said("cores", countWords(m.Cores))) + add("threads", said("threads", countWords(m.Threads))) + add("memory", said("memory", bytesWords(m.MemoryBytes))) + add("virtual", said("virtual or physical", m.Virtual)) + up := "" + if m.BootedAt != nil { + up = fmt.Sprintf("%s, since %s", roughly(now.Sub(*m.BootedAt)), m.BootedAt.Local().Format("2006-01-02 15:04")) + } + add("up", said("uptime", up)) + add("timezone", said("timezone", m.Timezone)) + if len(m.Filesystems) == 0 { + add("filesystems", said("filesystems", "")) + } else { + for i, f := range m.Filesystems { + name := "" + if i == 0 { + name = "filesystems" + } + add(name, fmt.Sprintf("%s %s, %s free (%s %s)", f.Mount, bytesWords(f.SizeBytes), + bytesWords(f.FreeBytes), f.Type, f.Device)) + } + if why, ok := unknown["filesystems"]; ok { + add("", "and some unknown: "+why) + delete(unknown, "filesystems") + } + } + add("public address", said("public address", "")) + // Any other fact a newer node-engine named unknown, so nothing it said is dropped. + rest := make([]string, 0, len(unknown)) + for k := range unknown { + rest = append(rest, k) + } + sort.Strings(rest) + for _, k := range rest { + add(k, "unknown: "+unknown[k]) + } + return out +} + +func factLine(name, value string) string { return fmt.Sprintf("%-15s %s", name, value) } + +func orUnsaid(s string) string { + if strings.TrimSpace(s) == "" { + return "not said" + } + return s +} + +// countWords is a number a machine said, or nothing when it said none (its unknown says why). +func countWords(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprint(n) +} + +// bytesWords is an amount of bytes a person reads, in binary units; nothing when none was said. +func bytesWords(n int64) string { + if n <= 0 { + return "" + } + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + v, u := float64(n), 0 + for v >= 1024 && u < len(units)-1 { + v /= 1024 + u++ + } + if u == 0 { + return fmt.Sprintf("%d B", n) + } + return fmt.Sprintf("%.1f %s", v, units[u]) +} diff --git a/cmd/mesh-controller/machine_lines_test.go b/cmd/mesh-controller/machine_lines_test.go new file mode 100644 index 00000000..9f7e2e55 --- /dev/null +++ b/cmd/mesh-controller/machine_lines_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// Defends novox/hq issue 160: `node` shows every fact a machine said it is made of, in one block with its +// age, the architecture and kernel among them; and a fact it could not determine as unknown with why. +func TestNodeShowsWhatAMachineIsMadeOfWithItsAge(t *testing.T) { + now := time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) + booted := now.Add(-75 * time.Hour) + a := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{ + ObservedAt: now.Add(-3 * time.Hour), KernelRelease: "7.2.8-arch1-2", Distribution: "Arch Linux", + Version: "rolling", Processor: "AMD Ryzen 9 8945HS", Cores: 8, Threads: 16, MemoryBytes: 32891494400, + Virtual: "physical", BootedAt: &booted, Timezone: "Europe/Brussels", + Filesystems: []inventory.MountedFilesystem{{Mount: "/", Device: "/dev/nvme0n1p3", Type: "ext4", + SizeBytes: 987978522624, FreeBytes: 31830110208}}, + Unknown: map[string]string{"public address": "how it learns it is not decided yet"}, + }} + got := strings.Join(machineLines(a, true, now), "\n") + t.Log("\n" + got) + for _, want := range []string{ + "what it is made of, as it said 3h ago:", + "architecture amd64", + "kernel linux 7.2.8-arch1-2", + "distribution Arch Linux rolling", + "processor AMD Ryzen 9 8945HS", + "cores 8", + "threads 16", + "memory 30.6 GiB", + "virtual physical", + "up 3d, since", + "timezone Europe/Brussels", + "filesystems / 920.1 GiB, 29.6 GiB free (ext4 /dev/nvme0n1p3)", + "public address unknown: how it learns it is not decided yet", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q", want) + } + } +} + +func TestAFactAMachineCouldNotReadIsShownUnknownNeverZero(t *testing.T) { + now := time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) + a := inventory.MachineAccount{Architecture: "arm64", Kernel: "linux", Machine: &inventory.MadeOf{ + ObservedAt: now, Unknown: map[string]string{"memory": "MemTotal not found in /proc/meminfo", + "cores": "/proc/cpuinfo says no core id", "kernel release": "permission denied"}}} + got := strings.Join(machineLines(a, true, now), "\n") + t.Log("\n" + got) + for _, want := range []string{ + "memory unknown: MemTotal not found in /proc/meminfo", + "cores unknown: /proc/cpuinfo says no core id", + "kernel linux (release unknown: permission denied)", + // Neither said nor named unknown: the node-engine broke its rule, and that is shown. + "timezone unknown: the machine said neither it nor why", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q", want) + } + } + if strings.Contains(got, " 0 ") || strings.Contains(got, "0 B") { + t.Error("a fact the machine could not read was shown as a zero") + } +} + +func TestAnOlderNodeEngineStillShowsArchitectureAndKernel(t *testing.T) { + got := strings.Join(machineLines(inventory.MachineAccount{Architecture: "amd64", Kernel: "linux"}, true, + time.Now()), "\n") + if !strings.Contains(got, "older than the one that says it") || !strings.Contains(got, "architecture amd64") || + !strings.Contains(got, "kernel linux") { + t.Errorf("%s", got) + } + if got := strings.Join(machineLines(inventory.MachineAccount{}, false, time.Now()), "\n"); !strings.Contains(got, "never said") { + t.Errorf("%s", got) + } +} + +func TestAnAccountWithoutItsTimeSaysAgeUnknown(t *testing.T) { + got := machineLines(inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", + Machine: &inventory.MadeOf{KernelRelease: "7.2"}}, true, time.Now())[0] + if !strings.Contains(got, "age unknown") || strings.Contains(got, "d ago") { + t.Errorf("%s", got) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 8a1e4ac4..7a269c7f 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -875,6 +875,17 @@ func showNode(ctx context.Context, stored *stores, name string) error { } } + // What it is made of, in one block with its age (novox/hq issue 160): collected and shown in the same + // breath, so no fact the machine says is kept and read by nothing. + if account, had, err := inv.MachineOf(ctx, name); err != nil { + fmt.Printf("\n what it is made of could NOT be read: %v\n", err) + } else { + fmt.Println() + for _, line := range machineLines(account, had, time.Now()) { + fmt.Println(line) + } + } + held, err := inv.Profile(ctx, name) if err != nil { return err diff --git a/internal/inventory/machine.go b/internal/inventory/machine.go new file mode 100644 index 00000000..9934eddc --- /dev/null +++ b/internal/inventory/machine.go @@ -0,0 +1,74 @@ +package inventory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// MachineAccount is what a machine said it is made of (novox/hq issue 160), from the profile it last +// reported: the architecture and kernel family the profile always carried, and the facts a node-engine that +// knows how says beside them. Read by `node`, so what a machine says of itself is visible in one place — +// collected and shown in the same breath, not collected and read by nothing (issue 160's pattern). +type MachineAccount struct { + Architecture string `json:"architecture"` + Kernel string `json:"kernel"` + // Machine is nil when the node-engine that reported is older than the one that says it. + Machine *MadeOf `json:"machine"` +} + +// MadeOf is the node-engine's account of what it is made of, as it put it on the wire. A fact it could +// not determine is absent here and named in Unknown with why — never a zero. +type MadeOf struct { + ObservedAt time.Time `json:"observed_at"` + KernelRelease string `json:"kernel_release"` + Distribution string `json:"distribution"` + Version string `json:"version"` + Processor string `json:"processor"` + Cores int `json:"cores"` + Threads int `json:"threads"` + MemoryBytes int64 `json:"memory_bytes"` + Virtual string `json:"virtual"` + BootedAt *time.Time `json:"booted_at"` + Timezone string `json:"timezone"` + Filesystems []MountedFilesystem `json:"filesystems"` + Unknown map[string]string `json:"unknown"` +} + +// MountedFilesystem is one local filesystem a machine has mounted. +type MountedFilesystem struct { + Mount string `json:"mount"` + Device string `json:"device"` + Type string `json:"type"` + SizeBytes int64 `json:"size_bytes"` + FreeBytes int64 `json:"free_bytes"` +} + +// MachineOf is what a node last said it is made of. had is false for a node that has never reported a +// profile at all, which is a different answer from one whose node-engine does not say the facts yet. +func (i *Inventory) MachineOf(ctx context.Context, nodeName string) (account MachineAccount, had bool, err error) { + var raw []byte + err = i.store.Pool().QueryRow(ctx, `select profile from node where name = $1`, nodeName).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return MachineAccount{}, false, fmt.Errorf("%w: %s", ErrNoSuchNode, nodeName) + } + if err != nil { + return MachineAccount{}, false, err + } + return machineFrom(raw) +} + +func machineFrom(raw []byte) (MachineAccount, bool, error) { + if len(raw) == 0 { + return MachineAccount{}, false, nil + } + var account MachineAccount + if err := json.Unmarshal(raw, &account); err != nil { + return MachineAccount{}, false, err + } + return account, true, nil +} diff --git a/internal/inventory/machine_test.go b/internal/inventory/machine_test.go new file mode 100644 index 00000000..a30475c2 --- /dev/null +++ b/internal/inventory/machine_test.go @@ -0,0 +1,42 @@ +package inventory + +import ( + "testing" +) + +// The node-engine's words for a machine's facts (novox/hq issue 160), as mesh-host's profile.Machine puts +// them on the wire. A rename on either side fails here rather than as a blank on a real machine. +func TestAMachinesAccountIsReadInTheNodeEnginesWords(t *testing.T) { + raw := []byte(`{"architecture":"amd64","kernel":"linux","capabilities":[], + "machine":{"observed_at":"2026-10-12T15:00:00Z","kernel_release":"7.2.8-arch1-2", + "distribution":"Arch Linux","version":"rolling","processor":"AMD Ryzen 9","cores":8,"threads":16, + "memory_bytes":32891494400,"virtual":"physical","booted_at":"2026-10-09T08:50:34Z", + "timezone":"Europe/Brussels", + "filesystems":[{"mount":"/","device":"/dev/nvme0n1p3","type":"ext4","size_bytes":100,"free_bytes":40}], + "unknown":{"public address":"not decided"}}}`) + a, had, err := machineFrom(raw) + if err != nil || !had { + t.Fatalf("had %v, err %v", had, err) + } + m := a.Machine + if a.Architecture != "amd64" || a.Kernel != "linux" || m == nil { + t.Fatalf("%+v", a) + } + if m.KernelRelease != "7.2.8-arch1-2" || m.Distribution != "Arch Linux" || m.Version != "rolling" || + m.Processor != "AMD Ryzen 9" || m.Cores != 8 || m.Threads != 16 || m.MemoryBytes != 32891494400 || + m.Virtual != "physical" || m.BootedAt == nil || m.Timezone != "Europe/Brussels" || + len(m.Filesystems) != 1 || m.Filesystems[0].FreeBytes != 40 || m.Unknown["public address"] == "" || + m.ObservedAt.IsZero() { + t.Errorf("%+v", m) + } +} + +func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) { + a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux","capabilities":[]}`)) + if err != nil || !had || a.Machine != nil || a.Architecture != "amd64" { + t.Errorf("%+v had %v err %v", a, had, err) + } + if _, had, _ := machineFrom(nil); had { + t.Error("a node that never reported read as having reported") + } +}