Files
mesh-host/internal/profile/profile.go
T
jschoubben 73c010e7ef Stage 1 — the host reports what a machine is and can do
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.
2026-08-26 00:25:08 +02:00

119 lines
4.1 KiB
Go

// 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
}