From 73c010e7efc270c260086c5d7d0a46c07a39de8b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 26 Aug 2026 00:25:08 +0200 Subject: [PATCH] =?UTF-8?q?Stage=201=20=E2=80=94=20the=20host=20reports=20?= =?UTF-8?q?what=20a=20machine=20is=20and=20can=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 0's first slice, per novox/hq 03-DESIGN/01-to-be/05-the-node-host.md. It applies nothing, connects to nothing, listens on nothing. 2.9 MB, static, no dynamic dependencies: copy it onto a machine and run it is the whole install, which is the property ADR 0041 rests on. A capability is detected, never assumed. Every detector runs something that only succeeds if the thing FUNCTIONS — the daemon is asked for its version, the package database is queried, the firewall is asked to list a ruleset, which needs the privilege as well as the tool. 04-ISSUES/007 is the fault this prevents: a client on disk with its daemon down looks exactly like a working runtime, and a node assigned work on that basis fails when the work arrives. Every verdict carries the reason and the method. A capability reported absent with no reason is the same fault in a new place: something nobody can act on. Two bugs found by running rather than reasoning, both silent: systemctl is-system-running exits non-zero for every state except `running` — including `degraded`, which means units failed and the init is emphatically there. Reading the exit code reported NO service manager on a machine whose init it was. That is 007 in the mirror, and both directions place work wrongly. A verdict now reads what a tool says about itself, not only how it exited. And `mesh-host inventory --json` printed text: the standard library stops parsing at the first non-flag argument, so the flag sat unread and the command exited 0 having ignored what was asked. The parser now takes the subcommand off the front, and a stray or mistyped argument is refused rather than dropped. Detection deliberately does NOT follow ADR 0008. That rule governs applying state, where a failed step means the machine is not what was asked for. A failed probe is a finding — "absent, because the probe failed" — and aborting would replace one legible absence with total ignorance of the rest. 25 tests: structure and logic with a fake runner, and the same detectors against this machine, because a test that fakes the system under detection asserts only that the fake behaves as expected. --- .gitignore | 2 + Makefile | 24 +++ README.md | 98 +++++++++++ cmd/mesh-host/main.go | 180 +++++++++++++++++++++ cmd/mesh-host/main_test.go | 83 ++++++++++ go.mod | 3 + internal/inventory/inventory.go | 140 ++++++++++++++++ internal/inventory/inventory_test.go | 118 ++++++++++++++ internal/profile/detectors.go | 206 ++++++++++++++++++++++++ internal/profile/profile.go | 118 ++++++++++++++ internal/profile/profile_system_test.go | 90 +++++++++++ internal/profile/profile_test.go | 195 ++++++++++++++++++++++ 12 files changed, 1257 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/mesh-host/main.go create mode 100644 cmd/mesh-host/main_test.go create mode 100644 go.mod create mode 100644 internal/inventory/inventory.go create mode 100644 internal/inventory/inventory_test.go create mode 100644 internal/profile/detectors.go create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_system_test.go create mode 100644 internal/profile/profile_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..235d26c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/mesh-host +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9921e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +# The gate. Green is the definition of done (novox/hq how-we-build §5). +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development) +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: check test vet fmt build clean + +check: fmt vet test build + +fmt: + @test -z "$$(gofmt -l . )" || { echo "unformatted:"; gofmt -l . ; exit 1; } + +vet: + go vet ./... + +# Structure and logic, and the same checks against this machine. The boundary is never mocked. +test: + go test ./... -count=1 + +# Static on purpose: copy it onto a machine and run it is the whole installation. +build: + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host + +clean: + rm -f mesh-host diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2ac393 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# mesh-host + +Tier 0 of the Novox Mesh. The one thing ever installed by hand, and the only thing that changes +a machine. + +``` +scp mesh-host root@machine:/usr/local/bin/ +mesh-host profile +``` + +That is the whole installation. One statically linked binary, nothing else present, no runtime +to install first ([`novox/hq` ADR 0041](https://git.novox.be/novox/hq)). + +## What it is for + +**Apply declared state on this machine.** Overlay membership, packet filtering, packages, +services, containers and filesystems are not six concerns it carries; they are six instances of +the one. + +**It does not decide.** Anything needing knowledge of another node is the control plane's, and +the host never queries the mesh database. It receives declarations and applies them. + +## What exists today + +**Stage 1 only: it reports.** It applies nothing, connects to nothing, and listens on nothing. + +``` +mesh-host profile what this machine can be asked to do +mesh-host inventory what this machine is, and what it holds + --json machine-readable + --timeout how long any single probe may take (default 10s) +``` + +``` +$ mesh-host profile +linux/amd64 + + yes container-runtime 29.7.2 + no firewall nft exited 1: Operation not permitted (you must be root) + yes graphical-session x11: :1 + yes overlay wg0 + yes package-manager pacman 7.1.0 + no privileged effective uid 1000, not 0 + yes service-manager degraded + +cannot be asked to: [firewall privileged] +``` + +Stages 2 to 4 — applying from a pinned bundle, the link and the local store, and enrolment — +are designed and not built. + +## A capability is detected, never assumed + +The reason this is the first thing built rather than a detail of it. + +**An installed package is not a capability.** A container client on disk with its daemon down +looks exactly like a working runtime, and a node assigned work on that basis fails at the +moment the work arrives. So every detector runs something that only succeeds if the thing is +**functioning** — the daemon is asked for its version, the package database is queried, the +firewall is asked to list a ruleset, which needs the privilege as well as the tool. + +**Every verdict says how it knows.** A capability reported absent with no reason is a fault +nobody can act on. The reason is what a person reads when a node will not take work they +expected it to take. + +**Exit codes are not the whole answer.** Found by running against a real machine rather than by +reasoning: `systemctl is-system-running` exits non-zero for every state except `running` — +including `degraded`, which means some units failed and the init is emphatically there. Reading +the exit code reported no service manager on a machine whose init it was. That is the same +fault in the mirror — installed-but-broken reported present, working-but-imperfect reported +absent — and both place work wrongly. + +## Building + +``` +go test ./... structure and logic, and the same checks against this machine +CGO_ENABLED=0 go build -ldflags="-s -w" -o mesh-host ./cmd/mesh-host +``` + +Roughly 3 MB, static, no dynamic dependencies. Cross-compiles with `GOOS`/`GOARCH`; a host is +built once per architecture and copied, never built on the machine it runs on. + +**Mocking the boundary is forbidden** ([`novox/hq` ADR 0034](https://git.novox.be/novox/hq)). +Every detector is exercised against a fake runner for its logic *and* against this machine for +its behaviour. The tests do not assert which capabilities a machine has — that varies, and is +the point of detecting — they assert that detection tells the truth about whatever is there. + +## Where the reasoning lives + +Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq), not here. This +repository carries implementation and does not carry decisions. + +- `03-DESIGN/01-to-be/05-the-node-host.md` — what this is and the order it is built in +- `02-DECISIONS/0037-the-host-applies-it-does-not-decide.md` — the one concern +- `02-DECISIONS/0038-a-node-joins-by-linking-first.md` — one behaviour, two sources +- `02-DECISIONS/0039-the-link-is-the-security-boundary.md` — a node owns no password +- `02-DECISIONS/0041-the-host-depends-on-nothing.md` — why this is a static binary, and Go +- `04-ISSUES/007-an-installed-package-is-not-a-capability` — why detection works this way diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go new file mode 100644 index 0000000..fb1db70 --- /dev/null +++ b/cmd/mesh-host/main.go @@ -0,0 +1,180 @@ +// Command mesh-host is tier 0 of the Novox Mesh: the one thing installed by hand, and the +// only thing that changes a machine. +// +// Stage 1 (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md) is profile and inventory only — +// the host reads what this machine can do and what it is, and reports it. It applies nothing, +// connects to nothing, and listens on nothing. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "text/tabwriter" + "time" + + "github.com/novox/mesh-host/internal/inventory" + "github.com/novox/mesh-host/internal/profile" +) + +// version is stamped at build time. Unset in a development build, and said so rather than +// defaulted to something that looks like a release. +var version = "development build" + +const usage = `mesh-host — the node host + + profile what this machine can be asked to do + inventory what this machine is, and what it holds + version + + --json machine-readable output + --timeout how long any single probe may take (default 10s) + +Stage 1: reports only. It applies nothing, connects to nothing, listens on nothing. +` + +func main() { + // A probe runs a command on a real machine. Ctrl-C must stop the host, not be swallowed by + // whatever it is waiting for. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + command, opts, err := parseArgs(os.Args[1:]) + if err == nil { + err = run(ctx, command, opts.json, opts.timeout) + } + if err != nil { + fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err) + os.Exit(1) + } +} + +type options struct { + json bool + timeout time.Duration +} + +// parseArgs takes the subcommand first, then its flags. +// +// The standard library stops parsing at the first non-flag argument, so `mesh-host inventory +// --json` left `--json` sitting in the positional arguments and printed text — a flag the user +// passed, silently ignored, with a successful exit. That is the fault this whole project keeps +// naming, so the parser takes the subcommand off the front and parses what follows. +func parseArgs(args []string) (string, options, error) { + opts := options{timeout: 10 * time.Second} + + command := "" + if len(args) > 0 { + command = args[0] + args = args[1:] + } + + set := flag.NewFlagSet("mesh-host", flag.ContinueOnError) + set.SetOutput(os.Stderr) + set.Usage = func() { fmt.Fprint(os.Stderr, usage) } + set.BoolVar(&opts.json, "json", false, "machine-readable output") + set.DurationVar(&opts.timeout, "timeout", opts.timeout, "how long any single probe may take") + + if err := set.Parse(args); err != nil { + return "", opts, err + } + // Anything left over was neither the command nor a flag. Refused rather than ignored: a + // mistyped argument that changes nothing and reports success is worse than an error. + if rest := set.Args(); len(rest) > 0 { + return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", rest[0]) + } + return command, opts, nil +} + +func run(ctx context.Context, command string, jsonOut bool, timeout time.Duration) error { + switch command { + case "profile": + p := profile.Detect(ctx, profile.Default(nil), timeout) + if jsonOut { + return writeJSON(p) + } + writeProfile(p) + return nil + + case "inventory": + inv := inventory.Collect(ctx, nil, profile.Default(nil), timeout) + if jsonOut { + return writeJSON(inv) + } + writeInventory(inv) + return nil + + case "version": + fmt.Println(version) + return nil + + case "", "help", "-h", "--help": + fmt.Fprint(os.Stderr, usage) + return nil + + default: + return fmt.Errorf("unknown command %q — try `mesh-host help`", command) + } +} + +func writeJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// writeProfile prints every verdict WITH its reason. +// +// The reason is not decoration: a capability reported absent with no reason is something +// nobody can act on, and this is the surface where a person meets that. +func writeProfile(p profile.Profile) { + fmt.Printf("%s/%s\n\n", p.Kernel, p.Architecture) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + for _, v := range p.Capabilities { + mark := "no " + if v.Present { + mark = "yes" + } + fmt.Fprintf(w, " %s\t%s\t%s\n", mark, v.Name, v.Detail) + } + w.Flush() + + if missing := p.Missing(); len(missing) > 0 { + fmt.Printf("\ncannot be asked to: %v\n", missing) + } +} + +func writeInventory(inv inventory.Inventory) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "machine\t%s\n", inv.Machine) + if inv.Distribution != "" { + fmt.Fprintf(w, "distribution\t%s\n", inv.Distribution) + } + if inv.Kernel != "" { + fmt.Fprintf(w, "kernel\t%s\n", inv.Kernel) + } + fmt.Fprintf(w, "architecture\t%s/%s\n", inv.OS, inv.Architecture) + fmt.Fprintf(w, "cpus\t%d\n", inv.CPUs) + if inv.MemoryKB > 0 { + fmt.Fprintf(w, "memory\t%d MB\n", inv.MemoryKB/1024) + } + fmt.Fprintf(w, "observed\t%s\n", inv.ObservedAt.Format(time.RFC3339)) + w.Flush() + + fmt.Println() + writeProfile(inv.Profile) + + // Printed last and never hidden. An inventory that quietly omits what it could not read + // is the same fault as a report assembled from intent (novox/hq ADR 0035). + if len(inv.Unreadable) > 0 { + fmt.Println("\ncould not read:") + for _, u := range inv.Unreadable { + fmt.Printf(" %s\n", u) + } + } +} diff --git a/cmd/mesh-host/main_test.go b/cmd/mesh-host/main_test.go new file mode 100644 index 0000000..bdec6e3 --- /dev/null +++ b/cmd/mesh-host/main_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "testing" + "time" +) + +// Argument handling gets tests because it already failed silently once: `mesh-host inventory +// --json` printed text. The standard library stops parsing at the first non-flag argument, so +// the flag sat unread in the positional arguments and the command exited 0 having ignored what +// the user asked for. +// +// Silently doing something other than what was asked, and reporting success, is the fault this +// project exists to name — so it gets defended here rather than remembered. + +func TestAFlagAfterTheCommandIsRead(t *testing.T) { + command, opts, err := parseArgs([]string{"inventory", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if command != "inventory" { + t.Errorf("command = %q, want inventory", command) + } + if !opts.json { + t.Error("--json after the subcommand was ignored") + } +} + +func TestFlagsAreReadInEitherPosition(t *testing.T) { + for _, args := range [][]string{ + {"profile", "--json", "--timeout", "3s"}, + {"profile", "--timeout=3s", "--json"}, + } { + _, opts, err := parseArgs(args) + if err != nil { + t.Fatalf("%v: unexpected error: %v", args, err) + } + if !opts.json || opts.timeout != 3*time.Second { + t.Errorf("%v parsed as json=%v timeout=%s", args, opts.json, opts.timeout) + } + } +} + +func TestAMistypedFlagIsRefusedNotIgnored(t *testing.T) { + // The cost of getting this wrong is asymmetric: an error is a moment's annoyance, and a + // silently dropped flag is a report that answers a question nobody asked. + if _, _, err := parseArgs([]string{"profile", "--jsom"}); err == nil { + t.Fatal("a mistyped flag was accepted") + } +} + +func TestAnUnexpectedArgumentIsRefused(t *testing.T) { + if _, _, err := parseArgs([]string{"profile", "extra"}); err == nil { + t.Fatal("a stray argument was ignored rather than refused") + } +} + +func TestTheDefaultsAreTheDocumentedOnes(t *testing.T) { + // The usage text promises 10s. A default that drifts from what is printed is a small lie + // that costs someone an afternoon. + _, opts, err := parseArgs([]string{"profile"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.timeout != 10*time.Second { + t.Errorf("default timeout is %s; the usage text says 10s", opts.timeout) + } + if opts.json { + t.Error("json output is on by default; the usage text says it is a flag") + } +} + +func TestNoCommandIsNotAnError(t *testing.T) { + // Running the binary with no arguments prints usage and exits 0. A host that returns + // failure for "tell me what you do" is noise in every script that probes it. + command, _, err := parseArgs(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if command != "" { + t.Errorf("command = %q, want empty", command) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e7610d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/novox/mesh-host + +go 1.24 diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go new file mode 100644 index 0000000..6437eb1 --- /dev/null +++ b/internal/inventory/inventory.go @@ -0,0 +1,140 @@ +// 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 0035). +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 0036) 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 +} diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go new file mode 100644 index 0000000..b0dfdb0 --- /dev/null +++ b/internal/inventory/inventory_test.go @@ -0,0 +1,118 @@ +package inventory + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/novox/mesh-host/internal/profile" +) + +func TestAnUnreadableFactIsRecordedNotFatal(t *testing.T) { + // An inventory missing one field is useful; an inventory that refused to be taken is not. + // And an absent fact and a fact that failed to read are different things — collapsing them + // loses the one worth acting on. + failing := func(string) ([]byte, error) { return nil, errors.New("permission denied") } + inv := Collect(context.Background(), failing, nil, time.Second) + + if inv.Machine == "" && len(inv.Unreadable) == 0 { + t.Fatal("nothing was read and nothing was reported unreadable") + } + if len(inv.Unreadable) == 0 { + t.Fatal("every file failed and nothing was recorded as unreadable") + } + for _, u := range inv.Unreadable { + if !strings.Contains(u, ":") { + t.Errorf("unreadable entry does not say which fact failed: %q", u) + } + } + if inv.Architecture == "" || inv.CPUs == 0 { + t.Error("facts that need no file were lost along with the ones that did") + } +} + +func TestDistributionIsReadFromTheMachineNotAssumed(t *testing.T) { + cases := map[string]string{ + "PRETTY_NAME=\"Arch Linux\"\nID=arch\n": "Arch Linux", + "ID=arch\n": "arch", + "# a comment\n\nID=debian\nPRETTY_NAME='Debian 13'\n": "Debian 13", + "NAME=Weird\nPRETTY_NAME=\"Has=Equals\"\n": "Has=Equals", + "": "", + } + for input, want := range cases { + if got := distributionFrom(input); got != want { + t.Errorf("distributionFrom(%q) = %q, want %q", input, got, want) + } + } +} + +func TestMemoryIsReadOrReportedMissing(t *testing.T) { + if kb, ok := memoryFrom("MemTotal: 32762868 kB\nMemFree: 1 kB\n"); !ok || kb != 32762868 { + t.Errorf("memoryFrom returned %d, %v", kb, ok) + } + // A meminfo without MemTotal must not read as zero memory — a machine reporting no memory + // would be excluded from placement for a parsing failure. + if _, ok := memoryFrom("MemFree: 1 kB\n"); ok { + t.Error("a meminfo with no MemTotal was read as a successful measurement") + } + if _, ok := memoryFrom("MemTotal: not-a-number kB\n"); ok { + t.Error("an unparseable MemTotal was read as a successful measurement") + } +} + +func TestTheInventorySaysWhenItWasTaken(t *testing.T) { + // A node unreachable for a week is an ordinary situation (novox/hq ADR 0036), so an + // inventory that cannot be told from a stale one is missing the fact that matters. + before := time.Now().UTC() + inv := Collect(context.Background(), nil, nil, time.Second) + + if inv.ObservedAt.IsZero() { + t.Fatal("the inventory does not say when it was observed") + } + if inv.ObservedAt.Before(before.Add(-time.Second)) || inv.ObservedAt.After(time.Now().UTC().Add(time.Second)) { + t.Errorf("ObservedAt is not the moment of observation: %s", inv.ObservedAt) + } +} + +func TestTheHostDoesNotNameTheNode(t *testing.T) { + // A node's name is assigned by the mesh. A host that named itself would be deciding + // something, which is precisely what novox/hq ADR 0037 forbids it to do. + inv := Collect(context.Background(), nil, nil, time.Second) + hostname, _ := os.Hostname() + + if inv.Machine != hostname { + t.Errorf("Machine is %q, not the machine's own hostname %q", inv.Machine, hostname) + } +} + +// --- against this machine --------------------------------------------------------------- + +func TestAgainstThisMachine_inventoryIsTrue(t *testing.T) { + // novox/hq ADR 0034: behaviour against a real system is tested alongside, not mocked. + inv := Collect(context.Background(), nil, profile.Default(nil), 10*time.Second) + + if inv.Machine == "" { + t.Error("this machine did not report a name") + } + if inv.CPUs < 1 { + t.Errorf("this machine reported %d cpus", inv.CPUs) + } + if inv.OS == "linux" { + if inv.Kernel == "" { + t.Error("a linux machine reported no kernel version") + } + if inv.MemoryKB <= 0 { + t.Error("a linux machine reported no memory") + } + } + if len(inv.Profile.Capabilities) == 0 { + t.Error("the inventory carries no profile") + } + + t.Logf("machine=%s dist=%q kernel=%s arch=%s cpus=%d mem=%dMB unreadable=%v", + inv.Machine, inv.Distribution, inv.Kernel, inv.Architecture, + inv.CPUs, inv.MemoryKB/1024, inv.Unreadable) +} diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go new file mode 100644 index 0000000..e9ca9cf --- /dev/null +++ b/internal/profile/detectors.go @@ -0,0 +1,206 @@ +package profile + +import ( + "context" + "fmt" + "os" + "strings" +) + +// Named capabilities. Constants rather than strings at the call site, because a capability +// nothing declares is a capability nothing can require, and a typo would produce exactly that. +const ( + CapContainerRuntime = "container-runtime" + CapPackageManager = "package-manager" + CapServiceManager = "service-manager" + CapFirewall = "firewall" + CapOverlay = "overlay" + CapGraphicalSession = "graphical-session" + CapPrivileged = "privileged" +) + +// commandCapability is the shape most detectors take: run something, and treat a working +// invocation as evidence. +// +// It runs a command that only succeeds if the thing is FUNCTIONING, never `--version` alone. +// A version string proves a binary is on disk, which is the assumption 04-ISSUES/007 records +// as false: the package was installed and the daemon was not running. +type commandCapability struct { + name string + command string + args []string + // why describes what a success actually proves, and is reported as the detector's `How`. + why string + // interpret decides the verdict from what the command said and how it exited. + // + // Exists because "exit zero" is not a universal answer. A degraded service manager reports + // its state on stdout and exits non-zero — it is running, and reading only the exit code + // declared no service manager on a machine whose init it was. That is 04-ISSUES/007 in the + // mirror: 007 is installed-but-broken reported present; this is working-but-imperfect + // reported absent. Both place work wrongly, and this one was only visible by running + // against a real machine. + // + // nil means the ordinary rule: success is exit zero. + interpret func(stdout string, err error) (present bool, detail string) + runner Runner +} + +func (c commandCapability) Name() string { return c.name } + +func (c commandCapability) Detect(ctx context.Context) Verdict { + out, err := c.runner(ctx, c.command, c.args...) + + interpret := c.interpret + if interpret == nil { + interpret = exitZero + } + present, detail := interpret(out, err) + + if strings.TrimSpace(detail) == "" { + // A verdict with no reason is the fault in a new place: something nobody can act on. + // Reached when a command fails silently, which systemctl does. + if present { + detail = "responded" + } else { + detail = fmt.Sprintf("%s gave no reason", c.command) + } + } + return Verdict{Name: c.name, Present: present, Detail: firstLine(detail), How: c.why} +} + +// exitZero is the ordinary rule: the command worked, so the capability is there. +func exitZero(stdout string, err error) (bool, string) { + if err != nil { + return false, err.Error() + } + return true, stdout +} + +// systemRunning reads what an init system says about itself rather than how it exited. +// +// `is-system-running` exits non-zero for every state except `running` — including `degraded`, +// which means units failed and the init is emphatically present. Treating that as absent made +// a machine running systemd report no service manager. +func systemRunning(stdout string, err error) (bool, string) { + state := strings.TrimSpace(firstLine(stdout)) + switch state { + case "running", "degraded", "starting", "maintenance", "stopping": + return true, state + case "": + if err != nil { + return false, err.Error() + } + return false, "said nothing" + default: + // `offline` and `unknown` mean it is not managing this machine. + return false, state + } +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 200 { + s = s[:200] + "…" + } + return s +} + +// privileged reports whether the host can change this machine at all. +// +// Reported as a capability rather than checked at startup on purpose: a host that cannot act +// is still a host that can report, and novox/hq ADR 0036 says what varies between nodes lives +// here rather than in the definition of a node. +type privileged struct{} + +func (privileged) Name() string { return CapPrivileged } + +func (privileged) Detect(context.Context) Verdict { + uid := os.Geteuid() + if uid == 0 { + return Verdict{ + Name: CapPrivileged, Present: true, + Detail: "effective uid 0", + How: "effective uid — the host changes a machine, which needs root", + } + } + return Verdict{ + Name: CapPrivileged, Present: false, + Detail: fmt.Sprintf("effective uid %d, not 0", uid), + How: "effective uid — the host changes a machine, which needs root", + } +} + +// graphicalSession reports whether anything could display a window here. +// +// Environment rather than a probe, because a display server is reachable through a socket a +// detector would have to guess at, and the variables are what an application would use anyway. +// Stated so the limit is visible: this detects that a session is ADVERTISED, which is weaker +// than the other detectors here. +type graphicalSession struct{} + +func (graphicalSession) Name() string { return CapGraphicalSession } + +func (graphicalSession) Detect(context.Context) Verdict { + const how = "DISPLAY / WAYLAND_DISPLAY — weaker than the other checks: advertised, not probed" + if d := os.Getenv("WAYLAND_DISPLAY"); d != "" { + return Verdict{Name: CapGraphicalSession, Present: true, Detail: "wayland: " + d, How: how} + } + if d := os.Getenv("DISPLAY"); d != "" { + return Verdict{Name: CapGraphicalSession, Present: true, Detail: "x11: " + d, How: how} + } + return Verdict{ + Name: CapGraphicalSession, Present: false, + Detail: "neither DISPLAY nor WAYLAND_DISPLAY is set", + How: how, + } +} + +// Default returns the detectors the host runs when nobody says otherwise. +// +// Each command is chosen to prove the thing WORKS rather than exists: +// - the container runtime is asked for server-side information, which fails when the daemon +// is down even though the client is installed — the exact shape of 04-ISSUES/007; +// - the service manager is asked whether it is the running init, not whether it is present; +// - the firewall is asked to list a ruleset, which needs both the tool and the permission. +func Default(runner Runner) []Detector { + if runner == nil { + runner = ExecRunner + } + return []Detector{ + privileged{}, + graphicalSession{}, + commandCapability{ + name: CapContainerRuntime, command: "docker", args: []string{"info", "--format", "{{.ServerVersion}}"}, + why: "asks the daemon for its version — a running daemon, not an installed client", + runner: runner, + }, + commandCapability{ + name: CapPackageManager, command: "pacman", args: []string{"-Q", "pacman"}, + why: "queries the package database — a working database, not a binary on disk", + runner: runner, + }, + commandCapability{ + name: CapServiceManager, command: "systemctl", args: []string{"is-system-running"}, + why: "reads the init's own account of its state — degraded is still running", + interpret: systemRunning, + runner: runner, + }, + commandCapability{ + name: CapFirewall, command: "nft", args: []string{"list", "ruleset"}, + why: "lists the ruleset — needs the tool AND the privilege to use it", + runner: runner, + }, + commandCapability{ + name: CapOverlay, command: "wg", args: []string{"show", "interfaces"}, + why: "asks the kernel for interfaces — needs the module, not just the tool", + runner: runner, + }, + } +} + +// isRoot is the same question `privileged` answers, exposed for tests that must check the +// detector against something other than itself. +func isRoot() bool { return os.Geteuid() == 0 } diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..5ce7d70 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,118 @@ +// Package profile answers one question: what can this machine be asked to do? +// +// A capability is DETECTED, never assumed. That distinction is the whole point of this +// package and it is not pedantry — novox/hq 04-ISSUES/007 records the fault it exists to +// prevent: an installed package was treated as a capability, and a node was assigned work it +// could not perform because the package was present and the thing was not running. +// +// So a capability here is not "is it installed". It is "does it work", and every detector +// says how it knows. +package profile + +import ( + "context" + "errors" + "fmt" + "os/exec" + "runtime" + "sort" + "strings" + "time" +) + +// Verdict is what a detector concluded, and why. +// +// Why is not decoration. A capability reported absent with no reason is the same problem in a +// new place: something that cannot be acted on. The reason is what a person reads when a node +// will not take work they expected it to take. +type Verdict struct { + Name string `json:"name"` + // Present is true only when the capability is usable, not merely installed. + Present bool `json:"present"` + // Detail says what was observed — a version, a path, or why it is absent. + Detail string `json:"detail"` + // How names the check that produced this, so a wrong answer can be found. + How string `json:"how"` +} + +// Detector decides one capability. It is given a context so a hung probe cannot hang the host. +type Detector interface { + Name() string + Detect(ctx context.Context) Verdict +} + +// Runner executes a command. Replaceable in tests for the pure-logic layer ONLY — every +// detector in this package is exercised against the real machine as well, because a test that +// fakes the system under detection asserts that the fake behaves as expected +// (novox/hq ADR 0034). +type Runner func(ctx context.Context, name string, args ...string) (stdout string, err error) + +// ExecRunner runs a real command, with output captured and stdin closed. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = nil + out, err := cmd.Output() + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return string(out), fmt.Errorf("%s exited %d: %s", + name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))) + } + return string(out), fmt.Errorf("%s: %w", name, err) + } + return string(out), nil +} + +// Profile is every verdict, in a stable order. +type Profile struct { + Architecture string `json:"architecture"` + Kernel string `json:"kernel"` + Capabilities []Verdict `json:"capabilities"` +} + +// Has reports whether a named capability is present. Unknown names are absent, not an error: +// asking about a capability nothing detects is a question with a true answer. +func (p Profile) Has(name string) bool { + for _, v := range p.Capabilities { + if v.Name == name { + return v.Present + } + } + return false +} + +// Missing lists the names that are not present, in order. What a node cannot do is the half +// that decides whether work may be placed on it. +func (p Profile) Missing() []string { + var out []string + for _, v := range p.Capabilities { + if !v.Present { + out = append(out, v.Name) + } + } + sort.Strings(out) + return out +} + +// Detect runs every detector and collects the verdicts. +// +// A detector that fails does not fail the profile. This is deliberately NOT the rule in +// novox/hq ADR 0008: that rule governs applying state, where a failed step means the machine +// is not what was asked for. Detection is the opposite — a failed probe is a finding, and the +// finding is "absent, because the probe failed", which is exactly what a caller needs to know. +// Aborting would replace one legible absence with total ignorance. +func Detect(ctx context.Context, detectors []Detector, timeout time.Duration) Profile { + p := Profile{ + Architecture: runtime.GOARCH, + Kernel: runtime.GOOS, + } + for _, d := range detectors { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + p.Capabilities = append(p.Capabilities, d.Detect(probeCtx)) + cancel() + } + sort.Slice(p.Capabilities, func(i, j int) bool { + return p.Capabilities[i].Name < p.Capabilities[j].Name + }) + return p +} diff --git a/internal/profile/profile_system_test.go b/internal/profile/profile_system_test.go new file mode 100644 index 0000000..a6b58f2 --- /dev/null +++ b/internal/profile/profile_system_test.go @@ -0,0 +1,90 @@ +package profile + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +// Against the real machine. novox/hq ADR 0034: structure and logic are tested first, behaviour +// against a real system alongside, and mocking the boundary is forbidden — a test that fakes +// the system under detection asserts that the fake behaves as expected. +// +// These do not assert WHICH capabilities this machine has; that varies per machine and is the +// point of detecting. They assert that detection tells the truth about whatever is here. + +func TestAgainstThisMachine_detectionAgreesWithReality(t *testing.T) { + got := Detect(context.Background(), Default(nil), 10*time.Second) + + if got.Architecture == "" || got.Kernel == "" { + t.Fatal("the machine did not report its own architecture or kernel") + } + if len(got.Capabilities) == 0 { + t.Fatal("no capability was reported at all") + } + + // The claim is checkable independently: a capability reported present must have a command + // that is actually on this machine. The reverse is deliberately NOT asserted — a command + // being present while the capability is absent is exactly the fault 04-ISSUES/007 records, + // and this suite exists partly to let that state be observed rather than assumed away. + commands := map[string]string{ + CapContainerRuntime: "docker", + CapPackageManager: "pacman", + CapServiceManager: "systemctl", + CapFirewall: "nft", + CapOverlay: "wg", + } + for name, command := range commands { + if !got.Has(name) { + continue + } + if _, err := exec.LookPath(command); err != nil { + t.Errorf("%s reported present, but %q is not on this machine: %v", name, command, err) + } + } + + for _, v := range got.Capabilities { + t.Logf(" %-20s present=%-5v %s", v.Name, v.Present, v.Detail) + } +} + +func TestAgainstThisMachine_privilegeIsReportedHonestly(t *testing.T) { + // The host changes machines, so whether it can is the capability that decides what the + // rest of it may attempt. Reporting it wrongly in either direction is worse than not + // reporting it: claimed-and-absent means work is accepted and fails, and absent-when-held + // means a capable node refuses work. + var verdict Verdict + for _, v := range Detect(context.Background(), Default(nil), 5*time.Second).Capabilities { + if v.Name == CapPrivileged { + verdict = v + } + } + if verdict.Name == "" { + t.Fatal("privilege was not reported at all") + } + + // Checked against the process's own view rather than against the detector's. + root := isRoot() + if verdict.Present != root { + t.Errorf("privilege reported %v; this process is root=%v", verdict.Present, root) + } + if !strings.Contains(verdict.Detail, "uid") { + t.Errorf("privilege detail does not say what it observed: %q", verdict.Detail) + } +} + +func TestAgainstThisMachine_detectionIsBounded(t *testing.T) { + // Every probe runs a command on a real machine. If any of them can block, the host has a + // startup that sometimes never finishes — the least debuggable failure there is. + start := time.Now() + Detect(context.Background(), Default(nil), 2*time.Second) + elapsed := time.Since(start) + + budget := 2 * time.Second * time.Duration(len(Default(nil))) + if elapsed > budget { + t.Fatalf("detection took %s, past its own %s budget", elapsed, budget) + } + t.Logf("detected %d capabilities in %s", len(Default(nil)), elapsed) +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..926238d --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,195 @@ +package profile + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// The decision each test defends is named in the test, per novox/hq ADR 0034. These cover +// structure and logic; profile_system_test.go covers the same detectors against the real +// machine, because a test that fakes the system under detection asserts only that the fake +// behaves as expected. + +func TestIssue007_installedIsNotUsable(t *testing.T) { + // 04-ISSUES/007 — an installed package is not a capability. The client was present and the + // daemon was down, and the node took work it could not do. A detector whose command fails + // must report ABSENT, however installed the thing looks. + failing := func(context.Context, string, ...string) (string, error) { + return "", errors.New("docker exited 1: Cannot connect to the Docker daemon") + } + got := Detect(context.Background(), Default(failing), time.Second) + + if got.Has(CapContainerRuntime) { + t.Fatal("a runtime whose daemon refuses the connection was reported present") + } + for _, v := range got.Capabilities { + if v.Name != CapContainerRuntime { + continue + } + if !strings.Contains(v.Detail, "Cannot connect") { + t.Fatalf("the reason was lost; detail was %q", v.Detail) + } + if v.How == "" { + t.Fatal("a verdict with no stated method cannot be checked when it is wrong") + } + } +} + +func TestEveryVerdictSaysHowItKnows(t *testing.T) { + // A capability reported absent with no reason is the fault in a new place: something + // nobody can act on. Both outcomes must carry a method. + for _, runner := range []Runner{ + func(context.Context, string, ...string) (string, error) { return "ok", nil }, + func(context.Context, string, ...string) (string, error) { return "", errors.New("nope") }, + } { + for _, v := range Detect(context.Background(), Default(runner), time.Second).Capabilities { + if strings.TrimSpace(v.How) == "" { + t.Errorf("%s reports present=%v with no stated method", v.Name, v.Present) + } + if strings.TrimSpace(v.Detail) == "" { + t.Errorf("%s reports present=%v with no detail", v.Name, v.Present) + } + } + } +} + +func TestDetectionSurvivesAFailingProbe(t *testing.T) { + // Deliberately NOT ADR 0008. That rule governs APPLYING state, where a failed step means + // the machine is not what was asked for. A failed probe is a finding, and aborting would + // replace one legible absence with total ignorance of the rest. + only := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "pacman" { + return "pacman 7.0.0", nil + } + return "", errors.New("not here") + } + got := Detect(context.Background(), Default(only), time.Second) + + if len(got.Capabilities) != len(Default(nil)) { + t.Fatalf("expected every detector to report, got %d of %d", + len(got.Capabilities), len(Default(nil))) + } + if !got.Has(CapPackageManager) { + t.Error("the one working capability was lost among the failures") + } +} + +func TestAProbeCannotHangTheHost(t *testing.T) { + // A host that blocks forever on a wedged command reports nothing at all, which is worse + // than reporting the capability absent. + blocking := func(ctx context.Context, name string, args ...string) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + done := make(chan Profile, 1) + go func() { done <- Detect(context.Background(), Default(blocking), 50*time.Millisecond) }() + + select { + case got := <-done: + if got.Has(CapFirewall) { + t.Error("a probe that never answered was reported present") + } + case <-time.After(5 * time.Second): + t.Fatal("detection did not return — a wedged probe hung the host") + } +} + +func TestUnknownCapabilityIsAbsentNotAnError(t *testing.T) { + // Asking about a capability nothing detects is a question with a true answer. + p := Detect(context.Background(), nil, time.Second) + if p.Has("something-nothing-detects") { + t.Error("an undetected capability reported present") + } +} + +func TestMissingListsWhatCannotBeAskedOf(t *testing.T) { + // What a node CANNOT do is the half that decides whether work may be placed on it. + none := func(context.Context, string, ...string) (string, error) { return "", errors.New("no") } + missing := Detect(context.Background(), Default(none), time.Second).Missing() + + if len(missing) == 0 { + t.Fatal("everything failed and nothing was reported missing") + } + for i := 1; i < len(missing); i++ { + if missing[i-1] > missing[i] { + t.Fatalf("Missing() is not ordered: %v", missing) + } + } +} + +func TestTheProfileIsOrdered(t *testing.T) { + // Two runs on an unchanged machine produce the same profile. Without this, comparing what + // a node was against what it is would report differences that are only ordering. + ok := func(context.Context, string, ...string) (string, error) { return "fine", nil } + first := Detect(context.Background(), Default(ok), time.Second) + second := Detect(context.Background(), Default(ok), time.Second) + + if len(first.Capabilities) != len(second.Capabilities) { + t.Fatal("two runs disagreed on how many capabilities exist") + } + for i := range first.Capabilities { + if first.Capabilities[i].Name != second.Capabilities[i].Name { + t.Fatalf("ordering is not stable at %d: %q then %q", + i, first.Capabilities[i].Name, second.Capabilities[i].Name) + } + } +} + +func TestADegradedInitIsStillAnInit(t *testing.T) { + // Found by running against a real machine, not by reasoning. `systemctl is-system-running` + // exits non-zero for every state except `running` — including `degraded`, which means some + // units failed and the init is emphatically present. Reading the exit code declared no + // service manager on a machine whose init it was. + // + // This is 04-ISSUES/007 in the mirror: 007 is installed-but-broken reported present; this + // is working-but-imperfect reported absent. Both make the mesh place work wrongly. + degraded := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "systemctl" { + return "degraded\n", errors.New("systemctl exited 1: ") + } + return "", errors.New("not here") + } + got := Detect(context.Background(), Default(degraded), time.Second) + + if !got.Has(CapServiceManager) { + t.Fatal("a degraded init was reported absent — the machine would refuse work it can do") + } + for _, v := range got.Capabilities { + if v.Name == CapServiceManager && v.Detail != "degraded" { + t.Errorf("the state was lost; detail was %q", v.Detail) + } + } +} + +func TestAnInitThatIsNotManagingThisMachineIsAbsent(t *testing.T) { + // The other side of the same rule. `offline` means it is installed and not in charge — + // which must not be read as present just because a state came back. + for _, state := range []string{"offline", "unknown"} { + runner := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "systemctl" { + return state + "\n", errors.New("systemctl exited 1: ") + } + return "", errors.New("not here") + } + if Detect(context.Background(), Default(runner), time.Second).Has(CapServiceManager) { + t.Errorf("state %q was reported as a working service manager", state) + } + } +} + +func TestAFailureWithNoMessageStillCarriesAReason(t *testing.T) { + // systemctl fails with empty stderr, which produced a verdict reading "exited 1:" — absent, + // with nothing anyone could act on. + silent := func(context.Context, string, ...string) (string, error) { return "", errors.New("") } + for _, v := range Detect(context.Background(), Default(silent), time.Second).Capabilities { + if v.Present { + continue + } + if strings.TrimSpace(v.Detail) == "" || strings.HasSuffix(strings.TrimSpace(v.Detail), ":") { + t.Errorf("%s is absent for no stated reason: %q", v.Name, v.Detail) + } + } +}