Have a machine say what it is made of, and say it once a day unasked (issue 160)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The mesh knew a machine's architecture and kernel family and nothing it is made of, and heard even
that only when it sent the machine something. The profile now carries the machine's memory, each local
filesystem's size and free space, processor model, cores and threads, distribution and version, kernel
release, virtual or physical, boot time and timezone; a fact it cannot read is named unknown with why,
never zeroed. The reconcile says the profile unasked once the mesh last heard it 23 hours ago, so on a
machine nobody pushed to nothing the mesh holds of it is older than a day.
This commit is contained in:
jochen
2026-10-12 17:21:36 +02:00
parent dd9ab7927d
commit ec983bfc3d
5 changed files with 697 additions and 1 deletions
+64
View File
@@ -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")
}
}
+32 -1
View File
@@ -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.
+433
View File
@@ -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()
}
+161
View File
@@ -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)
}
+7
View File
@@ -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
}