diff --git a/cmd/mesh-host/machine_refresh_test.go b/cmd/mesh-host/machine_refresh_test.go new file mode 100644 index 0000000..3bcab8e --- /dev/null +++ b/cmd/mesh-host/machine_refresh_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "testing" + "time" + + "github.com/novox/mesh-host/internal/link" +) + +// Defends novox/hq issue 160: a machine nobody pushed to still says what it is made of once a day, so the +// newest of it the mesh holds is no older than a day. Before, the reconcile said it only with something +// else (a hold, a firewall, an outward link changed), and a converged machine's picture was as old as the +// last delivery to it. +func TestAMachineNobodyPushedToSaysWhatItIsOnceADay(t *testing.T) { + at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC) + w := &adoptionWatch{now: func() time.Time { return at }} + quiet := link.Report{Outward: []string{"wlan0"}, + Profile: map[string]any{"machine": map[string]any{"observed_at": at}}} + + if !w.news(quiet) { + t.Fatal("the first reconcile after the engine started did not say what the machine is") + } + w.said(quiet) + + for _, after := range []time.Duration{5 * time.Minute, 12 * time.Hour, 22*time.Hour + 55*time.Minute} { + at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(after) + if w.news(quiet) { + t.Errorf("%s after the mesh heard it, an unchanged reconcile was said again", after) + } + } + // The reconcile that falls past 23 hours says it, so it reaches the mesh inside the day. + at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(23*time.Hour + 5*time.Minute) + if !w.news(quiet) { + t.Fatal("a day on, the reconcile did not say what the machine is") + } + w.said(quiet) + at = at.Add(5 * time.Minute) + if w.news(quiet) { + t.Error("once said, it was said again at the next reconcile") + } +} + +// A delivery's account carries the profile too: heard, it starts the day again. +func TestADeliveryHeardStartsTheDayAgain(t *testing.T) { + at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC) + w := &adoptionWatch{now: func() time.Time { return at }} + report := link.Report{Outward: []string{"eth0"}, Profile: map[string]any{"capabilities": []any{}}} + w.said(report) + at = at.Add(20 * time.Hour) + w.said(report) // a delivery's apply + at = at.Add(20 * time.Hour) + if w.news(report) { + t.Error("said again unasked 20 hours after a delivery told the mesh") + } +} + +// A refusal carries nothing about the machine, so it is never said for being a day old. +func TestARefusalIsNotSaidForTheMachinesSake(t *testing.T) { + at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC) + w := &adoptionWatch{now: func() time.Time { return at }} + if w.machineDue(link.Report{Refused: "no"}) { + t.Error("a refusal was due for what the machine is made of") + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index feb1da7..9638706 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1352,6 +1352,29 @@ type adoptionWatch struct { last string // faulty is whether the last report the mesh heard said an apply refused or failed (novox/hq issue 233). faulty bool + // machineSaid is when the mesh last heard what this machine is made of (novox/hq issue 160), and now the + // clock it is judged by (time.Now when nil). + machineSaid time.Time + now func() time.Time +} + +// machineEvery is how old what a machine said of itself may grow before a reconcile says it again unasked +// (novox/hq issue 160): a machine nobody pushed to still says it once a day, so no fact the mesh holds of it +// is older than a day. Under a day by more than one reconcile interval, so the reconcile that says it falls +// inside the day rather than just past it. +const machineEvery = 23 * time.Hour + +func (w *adoptionWatch) clock() time.Time { + if w.now != nil { + return w.now() + } + return time.Now() +} + +// machineDue is whether a report carries what the machine is made of and the mesh last heard it a day ago +// or more. A refusal carries none, so it never is. +func (w *adoptionWatch) machineDue(r link.Report) bool { + return r.Refused == "" && len(r.Profile) > 0 && w.clock().Sub(w.machineSaid) >= machineEvery } // fingerprint is what a report says about adoption: each hold and whether it changed, the @@ -1416,7 +1439,8 @@ func applyState(r link.Report) string { // faulty is whether a report says an apply refused or failed. func faulty(r link.Report) bool { return r.Refused != "" || len(r.Failed) > 0 } -// news is whether a reconcile's report is worth saying unasked: it says something the last one the mesh heard +// news is whether a reconcile's report is worth saying unasked: what the machine is made of last reached the +// mesh a day ago or more (novox/hq issue 160); or it says something the last one the mesh heard // did not, and it is about the machine (worthSaying) — or about how the apply went, refused or failed now, or // clean after the last one the mesh heard was not (novox/hq issue 233). A refusal says nothing about the // machine, but that the machine refuses is what the mesh raises a condition from, and a clean apply after it is @@ -1424,6 +1448,10 @@ func faulty(r link.Report) bool { return r.Refused != "" || len(r.Failed) > 0 } func (w *adoptionWatch) news(r link.Report) bool { w.mu.Lock() defer w.mu.Unlock() + // What the machine is made of, once a day whatever else changed (novox/hq issue 160). + if w.machineDue(r) { + return true + } if adoptionFingerprint(r) == w.last { return false } @@ -1444,6 +1472,9 @@ func (w *adoptionWatch) said(r link.Report) { defer w.mu.Unlock() w.last = adoptionFingerprint(r) w.faulty = faulty(r) + if len(r.Profile) > 0 { + w.machineSaid = w.clock() + } } // changed is differs and said together, for a report published as it is made. diff --git a/internal/profile/machine.go b/internal/profile/machine.go new file mode 100644 index 0000000..db09a3e --- /dev/null +++ b/internal/profile/machine.go @@ -0,0 +1,433 @@ +package profile + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody +// deciding where a module should go wants to know, beside what it can do. +// +// **A fact it could not determine is said, never zeroed.** Every fact below is either present or +// named in Unknown with why; a machine reporting 0 bytes of memory would be read as a machine with +// none. So a field is omitted only when Unknown says why. +type Machine struct { + // ObservedAt is when this was read: the age every reader shows beside it. + ObservedAt time.Time `json:"observed_at"` + + // KernelRelease is the running kernel's release, as /proc/sys/kernel/osrelease says it. (The + // profile's own `kernel` is the kernel's family, `linux`.) + KernelRelease string `json:"kernel_release,omitempty"` + // Distribution and its Version, from /etc/os-release: VERSION_ID, or BUILD_ID for a rolling + // distribution that has no version (Arch Linux says `rolling`). + Distribution string `json:"distribution,omitempty"` + Version string `json:"version,omitempty"` + + // Processor is its model name; Cores the physical cores and Threads the logical processors. + Processor string `json:"processor,omitempty"` + Cores int `json:"cores,omitempty"` + Threads int `json:"threads,omitempty"` + + MemoryBytes int64 `json:"memory_bytes,omitempty"` + + // Virtual is `physical`, or the kind of virtual machine or container it is (`kvm`, `lxc`), as + // systemd-detect-virt names it. + Virtual string `json:"virtual,omitempty"` + + // BootedAt is when it booted: its uptime is the time since, so the fact does not change between + // two reports of the same boot. + BootedAt *time.Time `json:"booted_at,omitempty"` + + Timezone string `json:"timezone,omitempty"` + + // Filesystems is each local filesystem mounted, by where, with its size and what is free. + Filesystems []Filesystem `json:"filesystems,omitempty"` + + // Unknown names each fact that could not be determined, and why. + Unknown map[string]string `json:"unknown,omitempty"` +} + +// Filesystem is one mounted local filesystem. +type Filesystem struct { + Mount string `json:"mount"` + Device string `json:"device"` + Type string `json:"type"` + // SizeBytes and FreeBytes are what statfs says: free is what an unprivileged writer may still + // use (f_bavail), the number a person deciding where data goes wants. + SizeBytes int64 `json:"size_bytes"` + FreeBytes int64 `json:"free_bytes"` +} + +// The facts, by the names Unknown says them under. +const ( + FactKernelRelease = "kernel release" + FactDistribution = "distribution" + FactVersion = "version" + FactProcessor = "processor" + FactCores = "cores" + FactThreads = "threads" + FactMemory = "memory" + FactVirtual = "virtual or physical" + FactUptime = "uptime" + FactTimezone = "timezone" + FactFilesystems = "filesystems" + FactPublicAddress = "public address" +) + +// MachineFacts is every fact Unknown may name, in the order a reader shows them. +var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores, + FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress} + +// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see +// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not +// take on unasked; how a machine learns it is not decided yet, so it is said as unknown rather than +// guessed from a private address. +const publicAddressUnknown = "a machine behind a NAT cannot see its public address from itself, and how it " + + "learns it is not decided yet" + +// Sources is where the facts are read from. Replaced in tests only for the parsing layer; the real +// sources are exercised against this machine as well (novox/hq ADR 0017). +type Sources struct { + Read func(path string) ([]byte, error) + Readlink func(path string) (string, error) + Run Runner + // Statfs is (size, free) in bytes of the filesystem mounted at a path. + Statfs func(path string) (size, free int64, err error) + Now func() time.Time +} + +// RealSources reads this machine. +func RealSources() Sources { + return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now} +} + +func statfs(path string) (int64, int64, error) { + var s syscall.Statfs_t + if err := syscall.Statfs(path, &s); err != nil { + return 0, 0, err + } + return int64(s.Blocks) * int64(s.Bsize), int64(s.Bavail) * int64(s.Bsize), nil +} + +// DescribeMachine reads what this machine is made of. It never fails: a fact that cannot be read is +// named in Unknown with why, and the rest are still said. +func DescribeMachine(ctx context.Context, src Sources) Machine { + if src.Now == nil { + src.Now = time.Now + } + m := Machine{ObservedAt: src.Now().UTC(), Unknown: map[string]string{}} + unknown := func(fact string, why string) { m.Unknown[fact] = why } + + if b, err := src.Read("/proc/sys/kernel/osrelease"); err != nil { + unknown(FactKernelRelease, err.Error()) + } else if r := strings.TrimSpace(string(b)); r == "" { + unknown(FactKernelRelease, "/proc/sys/kernel/osrelease is empty") + } else { + m.KernelRelease = r + } + + if b, err := src.Read("/etc/os-release"); err != nil { + unknown(FactDistribution, err.Error()) + unknown(FactVersion, err.Error()) + } else { + name, version, whyName, whyVersion := distributionOf(string(b)) + m.Distribution, m.Version = name, version + if whyName != "" { + unknown(FactDistribution, whyName) + } + if whyVersion != "" { + unknown(FactVersion, whyVersion) + } + } + + if b, err := src.Read("/proc/cpuinfo"); err != nil { + unknown(FactProcessor, err.Error()) + unknown(FactCores, err.Error()) + unknown(FactThreads, err.Error()) + } else { + c := processorOf(string(b)) + m.Processor, m.Cores, m.Threads = c.model, c.cores, c.threads + for fact, why := range c.unknown { + unknown(fact, why) + } + } + + if b, err := src.Read("/proc/meminfo"); err != nil { + unknown(FactMemory, err.Error()) + } else if kb, ok := memTotalKB(string(b)); !ok { + unknown(FactMemory, "MemTotal not found in /proc/meminfo") + } else { + m.MemoryBytes = kb * 1024 + } + + m.Virtual, _ = virtualOf(ctx, src.Run, unknown) + + if b, err := src.Read("/proc/stat"); err != nil { + unknown(FactUptime, err.Error()) + } else if booted, ok := bootedAt(string(b)); !ok { + unknown(FactUptime, "btime not found in /proc/stat") + } else { + m.BootedAt = &booted + } + + if tz, why := timezoneOf(src); why != "" { + unknown(FactTimezone, why) + } else { + m.Timezone = tz + } + + if b, err := src.Read("/proc/self/mounts"); err != nil { + unknown(FactFilesystems, err.Error()) + } else { + fs, why := filesystemsOf(ctx, string(b), src.Statfs) + m.Filesystems = fs + if why != "" { + unknown(FactFilesystems, why) + } + } + + unknown(FactPublicAddress, publicAddressUnknown) + return m +} + +// distributionOf is the distribution's name and version from an os-release file, with why either is +// not said. NAME first, then ID; VERSION_ID first, then BUILD_ID. +func distributionOf(osRelease string) (name, version, whyName, whyVersion 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), `"'`) + } + name = firstOf(fields["NAME"], fields["ID"]) + version = firstOf(fields["VERSION_ID"], fields["BUILD_ID"]) + if name == "" { + whyName = "/etc/os-release names neither NAME nor ID" + } + if version == "" { + whyVersion = "/etc/os-release names neither VERSION_ID nor BUILD_ID" + } + return name, version, whyName, whyVersion +} + +func firstOf(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +type processor struct { + model string + cores, threads int + unknown map[string]string +} + +// processorOf reads /proc/cpuinfo: the model from `model name` (or `Model`, `Hardware`, `cpu model`, +// which other architectures use), threads from its `processor` entries, and cores from the distinct +// (physical id, core id) pairs — which an architecture that does not say them leaves unknown rather than +// guessed equal to the threads. +func processorOf(cpuinfo string) processor { + p := processor{unknown: map[string]string{}} + pairs := map[string]bool{} + physical := "0" + var models = map[string]string{} + for _, line := range strings.Split(cpuinfo, "\n") { + key, value, found := strings.Cut(line, ":") + if !found { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + switch key { + case "processor": + p.threads++ + case "physical id": + physical = value + case "core id": + pairs[physical+"/"+value] = true + case "model name", "Model", "Hardware", "cpu model": + if _, had := models[key]; !had { + models[key] = value + } + } + } + p.model = firstOf(models["model name"], models["cpu model"], models["Model"], models["Hardware"]) + p.cores = len(pairs) + if p.model == "" { + p.unknown[FactProcessor] = "/proc/cpuinfo names no model" + } + if p.threads == 0 { + p.unknown[FactThreads] = "/proc/cpuinfo lists no processor" + } + if p.cores == 0 { + p.unknown[FactCores] = "/proc/cpuinfo says no core id" + } + return p +} + +func memTotalKB(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 || kb <= 0 { + return 0, false + } + return kb, true + } + return 0, false +} + +// virtualOf asks systemd-detect-virt. It says `none` on a physical machine, and exits 1 doing so. +func virtualOf(ctx context.Context, run Runner, unknown func(string, string)) (string, bool) { + if run == nil { + unknown(FactVirtual, "nothing here runs commands") + return "", false + } + out, err := run(ctx, "systemd-detect-virt") + switch said := strings.TrimSpace(out); { + case said == "none": + return "physical", true + case said != "" && err == nil: + return said, true + case err != nil: + unknown(FactVirtual, "systemd-detect-virt: "+err.Error()) + default: + unknown(FactVirtual, "systemd-detect-virt said nothing") + } + return "", false +} + +func bootedAt(stat string) (time.Time, bool) { + for _, line := range strings.Split(stat, "\n") { + parts := strings.Fields(line) + if len(parts) == 2 && parts[0] == "btime" { + secs, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || secs <= 0 { + return time.Time{}, false + } + return time.Unix(secs, 0).UTC(), true + } + } + return time.Time{}, false +} + +// timezoneOf is the zone /etc/localtime links into the zoneinfo tree, or /etc/timezone's. +func timezoneOf(src Sources) (string, string) { + var whys []string + if src.Readlink != nil { + target, err := src.Readlink("/etc/localtime") + if err == nil { + if _, zone, found := strings.Cut(target, "zoneinfo/"); found && zone != "" { + return zone, "" + } + whys = append(whys, "/etc/localtime links to "+target+", outside a zoneinfo tree") + } else { + whys = append(whys, "/etc/localtime: "+err.Error()) + } + } + if b, err := src.Read("/etc/timezone"); err == nil { + if zone := strings.TrimSpace(string(b)); zone != "" { + return zone, "" + } + whys = append(whys, "/etc/timezone is empty") + } else { + whys = append(whys, err.Error()) + } + return "", strings.Join(whys, "; ") +} + +// localTypes are the filesystem types said: those on a local device. A network filesystem is left out on +// purpose — statfs on a server that stopped answering blocks, and this runs on every apply. +var localTypes = map[string]bool{"ext2": true, "ext3": true, "ext4": true, "xfs": true, "btrfs": true, + "zfs": true, "f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "fuseblk": true, + "jfs": true, "reiserfs": true, "bcachefs": true} + +// filesystemsOf lists each local filesystem once — a device mounted at several places (a btrfs +// subvolume, a bind mount) under its shortest mount — with its size and what is free. Bounded by the +// context, so a filesystem whose statfs hangs cannot hang the apply that reports it. +func filesystemsOf(ctx context.Context, mounts string, stat func(string) (int64, int64, error)) ([]Filesystem, string) { + if stat == nil { + return nil, "nothing here can ask a filesystem its size" + } + byDevice := map[string]Filesystem{} + for _, line := range strings.Split(mounts, "\n") { + f := strings.Fields(line) + if len(f) < 3 || !localTypes[f[2]] { + continue + } + mount := unescapeMount(f[1]) + if had, ok := byDevice[f[0]]; ok && len(had.Mount) <= len(mount) { + continue + } + byDevice[f[0]] = Filesystem{Mount: mount, Device: f[0], Type: f[2]} + } + if len(byDevice) == 0 { + return nil, "/proc/self/mounts lists no local filesystem" + } + + type answer struct { + fs []Filesystem + why []string + } + done := make(chan answer, 1) + go func() { + var a answer + for _, fs := range byDevice { + size, free, err := stat(fs.Mount) + if err != nil { + a.why = append(a.why, fmt.Sprintf("%s: %v", fs.Mount, err)) + continue + } + fs.SizeBytes, fs.FreeBytes = size, free + a.fs = append(a.fs, fs) + } + done <- a + }() + select { + case a := <-done: + sort.Slice(a.fs, func(i, j int) bool { return a.fs[i].Mount < a.fs[j].Mount }) + sort.Strings(a.why) + return a.fs, strings.Join(a.why, "; ") + case <-ctx.Done(): + return nil, "asking the filesystems their size did not finish: " + ctx.Err().Error() + } +} + +// unescapeMount undoes the octal escapes /proc/self/mounts writes a space or tab in a path as. +func unescapeMount(path string) string { + if !strings.Contains(path, `\`) { + return path + } + var b strings.Builder + for i := 0; i < len(path); i++ { + if path[i] == '\\' && i+3 < len(path) { + if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(n)) + i += 3 + continue + } + } + b.WriteByte(path[i]) + } + return b.String() +} diff --git a/internal/profile/machine_test.go b/internal/profile/machine_test.go new file mode 100644 index 0000000..fe32e64 --- /dev/null +++ b/internal/profile/machine_test.go @@ -0,0 +1,161 @@ +package profile + +import ( + "context" + "encoding/json" + "errors" + "os" + "runtime" + "strings" + "testing" + "time" +) + +// The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here); +// the real machine is asked below. + +var files = map[string]string{ + "/proc/sys/kernel/osrelease": "7.2.8-arch1-2\n", + "/etc/os-release": "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n", + "/proc/cpuinfo": "processor\t: 0\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 0\n\n" + + "processor\t: 1\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 0\n\n" + + "processor\t: 2\nmodel name\t: AMD Ryzen 9 5900HS\nphysical id\t: 0\ncore id\t\t: 1\n", + "/proc/meminfo": "MemTotal: 32000000 kB\nMemFree: 1 kB\n", + "/proc/stat": "cpu 1 2 3\nbtime 1791800000\n", + "/proc/self/mounts": "/dev/nvme0n1p2 / btrfs rw 0 0\n/dev/nvme0n1p2 /home btrfs rw 0 0\n" + + "proc /proc proc rw 0 0\ntank/data /srv/my\\040data zfs rw 0 0\n" + + "server:/x /mnt/x nfs4 rw 0 0\n/dev/loop3 /snap/core/1 squashfs ro 0 0\n", +} + +func given(read map[string]string) Sources { + return Sources{ + Read: func(p string) ([]byte, error) { + if s, ok := read[p]; ok { + return []byte(s), nil + } + return nil, &os.PathError{Op: "open", Path: p, Err: os.ErrNotExist} + }, + Readlink: func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Brussels", nil }, + Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") }, + Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil }, + Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) }, + } +} + +func TestMachine_saysEveryFactItCanRead(t *testing.T) { + m := DescribeMachine(context.Background(), given(files)) + + if m.KernelRelease != "7.2.8-arch1-2" || m.Distribution != "Arch Linux" || m.Version != "rolling" { + t.Errorf("kernel %q, distribution %q %q", m.KernelRelease, m.Distribution, m.Version) + } + if m.Processor != "AMD Ryzen 9 5900HS" || m.Cores != 2 || m.Threads != 3 { + t.Errorf("processor %q, %d cores, %d threads", m.Processor, m.Cores, m.Threads) + } + if m.MemoryBytes != 32000000*1024 { + t.Errorf("memory %d", m.MemoryBytes) + } + if m.Virtual != "physical" { + t.Errorf("systemd-detect-virt said none and exited 1; read as %q", m.Virtual) + } + if m.BootedAt == nil || m.BootedAt.Unix() != 1791800000 { + t.Errorf("booted at %v", m.BootedAt) + } + if m.Timezone != "Europe/Brussels" { + t.Errorf("timezone %q", m.Timezone) + } + // One btrfs device under its shortest mount, the zfs dataset with its escaped space, and no network or + // read-only image filesystem. + var mounts []string + for _, f := range m.Filesystems { + mounts = append(mounts, f.Mount) + if f.SizeBytes != 1000 || f.FreeBytes != 400 { + t.Errorf("%s: size %d free %d", f.Mount, f.SizeBytes, f.FreeBytes) + } + } + if got := strings.Join(mounts, ","); got != "/,/srv/my data" { + t.Errorf("filesystems %q", got) + } + // Everything read is not unknown; the public address is, with why. + for fact := range m.Unknown { + if fact != FactPublicAddress { + t.Errorf("%s said unknown though it was readable: %s", fact, m.Unknown[fact]) + } + } + if m.Unknown[FactPublicAddress] == "" { + t.Error("the public address is not said, and not said unknown either") + } +} + +// The acceptance of issue 160: a machine that cannot determine a fact says so rather than reporting a zero. +func TestMachine_aFactItCannotReadIsSaidUnknownNeverZero(t *testing.T) { + src := given(map[string]string{}) + src.Readlink = func(string) (string, error) { return "", os.ErrNotExist } + src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") } + m := DescribeMachine(context.Background(), src) + + raw, _ := json.Marshal(m) + var said map[string]any + _ = json.Unmarshal(raw, &said) + for key, v := range said { + if key == "observed_at" || key == "unknown" { + continue + } + t.Errorf("%s is said as %v from a machine where nothing could be read", key, v) + } + for _, fact := range MachineFacts { + if strings.TrimSpace(m.Unknown[fact]) == "" { + t.Errorf("%s is neither said nor said unknown with why", fact) + } + } +} + +func TestMachine_aHungFilesystemCannotHangTheReport(t *testing.T) { + src := given(files) + block := make(chan struct{}) + defer close(block) + src.Statfs = func(string) (int64, int64, error) { <-block; return 0, 0, nil } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + m := DescribeMachine(ctx, src) + if len(m.Filesystems) != 0 || !strings.Contains(m.Unknown[FactFilesystems], "did not finish") { + t.Errorf("filesystems %v, unknown %q", m.Filesystems, m.Unknown[FactFilesystems]) + } +} + +func TestMachine_coresUnsaidAreUnknownNotGuessed(t *testing.T) { + p := processorOf("processor\t: 0\nModel\t: Raspberry Pi 4\nprocessor\t: 1\n") + if p.model != "Raspberry Pi 4" || p.threads != 2 || p.cores != 0 || p.unknown[FactCores] == "" { + t.Errorf("%+v", p) + } +} + +// Against the real machine (ADR 0017): what it says agrees with what this process sees. +func TestAgainstThisMachine_machineSaysWhatItIs(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the facts are read from Linux's own files") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + m := DescribeMachine(ctx, RealSources()) + if m.MemoryBytes <= 0 && m.Unknown[FactMemory] == "" { + t.Error("memory neither said nor unknown") + } + if m.Threads != 0 && m.Threads < runtime.NumCPU() { + t.Errorf("%d threads said, and this process alone may use %d", m.Threads, runtime.NumCPU()) + } + if m.BootedAt != nil && m.BootedAt.After(time.Now()) { + t.Errorf("booted in the future: %v", m.BootedAt) + } + for _, f := range m.Filesystems { + if f.SizeBytes <= 0 || f.FreeBytes > f.SizeBytes { + t.Errorf("%s: size %d free %d", f.Mount, f.SizeBytes, f.FreeBytes) + } + } + for _, fact := range MachineFacts { + if why, unknown := m.Unknown[fact]; unknown && strings.TrimSpace(why) == "" { + t.Errorf("%s unknown with no why", fact) + } + } + raw, _ := json.MarshalIndent(m, "", " ") + t.Logf("%s", raw) +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index ace9572..333d515 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -68,6 +68,9 @@ type Profile struct { Architecture string `json:"architecture"` Kernel string `json:"kernel"` Capabilities []Verdict `json:"capabilities"` + // Machine is what it is made of (novox/hq issue 160), said with every profile: at enrolment, after + // every apply, and by the reconcile once a day when nothing else made it speak. + Machine *Machine `json:"machine,omitempty"` } // Has reports whether a named capability is present. Unknown names are absent, not an error: @@ -114,5 +117,9 @@ func Detect(ctx context.Context, detectors []Detector, timeout time.Duration) Pr sort.Slice(p.Capabilities, func(i, j int) bool { return p.Capabilities[i].Name < p.Capabilities[j].Name }) + describeCtx, cancel := context.WithTimeout(ctx, timeout) + m := DescribeMachine(describeCtx, RealSources()) + cancel() + p.Machine = &m return p }