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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/mesh-host
|
||||
/dist/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user