Show what a machine said it is made of in node, with its age (issue 160)
The architecture and kernel a machine reported were kept and read by nothing, and the facts the node-engine now says (memory, filesystems, processor, distribution, virtual or physical, uptime, timezone) would have joined them. node shows every one in one block with the age of the account, and a fact the machine could not determine as unknown with its why, never as a zero.
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []string{fmt.Sprintf(" what it is made of, as it said %s ago:", roughly(now.Sub(m.ObservedAt)))}
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
held, err := inv.Profile(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user