Files
mesh-host/cmd/mesh-host/main.go
T
jschoubben 4a80cc1002 apply: tier 0 consumes a declaration and converges this machine
Stage 2 begins. The host stops only reporting and starts doing its one
job (ADR 0037): take an ordered list of typed resources and make the
machine match it, from a local file, with no mesh present.

What lands in this slice — the network-free vocabulary ADR 0043 names
first:
- Parse: JSON, refused WHOLE on an unknown version, type, field, a
  missing id/type/path, or a duplicate id. An older host cannot be
  handed a newer vocabulary and do half of it.
- directory and file appliers, each reading back after it writes —
  mode and owner asserted against the machine, content compared byte
  for byte. A value that did not take is a failed apply, not a success.
- store: the applied-state record, authoritative while disconnected,
  written atomically. It is what makes removal possible.
- Convergence: apply in the stated order (the host never reorders),
  record each success AFTER it works (ADR 0035), and remove what was
  applied before and is no longer declared — in reverse order, so a
  file goes before the directory that held it.
- The data-loss guard: the host removes ONLY what it created, never
  what it adopted, and a created directory that now holds data is
  refused (os.Remove, never RemoveAll) rather than deleted (ADR 0018,
  0030). created is sticky across re-applies — caught by running the
  real binary, not just the unit tests: recomputing it from disk made
  a re-applied resource look adopted and leak on the next drop.
- Addressing: a declaration for another node is refused; a host with
  no identity yet applies its bundle (the first-node path).

Not yet: sealed secrets, and the types that need the network or a
runtime (container, package, network, service, archive, user, action)
— they follow, and until then the host refuses them rather than doing
part of a declaration.

CLI: mesh-host apply [--store P] FILE.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-02 22:34:44 +02:00

242 lines
7.3 KiB
Go

// 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/apply"
"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"
// defaultStore is where the host records what it has applied — authoritative while disconnected
// (novox/hq 05-the-node-host.md). Under /var/lib because it outlives any single apply.
const defaultStore = "/var/lib/mesh-host/store.json"
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
apply [--store P] FILE
make this machine match the declaration in FILE
version
--json machine-readable output
--timeout how long any single probe may take (default 10s)
--store where the applied-state store lives (apply; default ` + defaultStore + `)
profile and inventory report; apply changes this machine, and only within its own footprint —
it removes what it once applied and no longer sees declared, and never touches what it did not
create. Put --store before FILE.
`
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)
}
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
os.Exit(1)
}
}
type options struct {
json bool
timeout time.Duration
store string
file string
}
// 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")
set.StringVar(&opts.store, "store", defaultStore, "where the applied-state store lives")
if err := set.Parse(args); err != nil {
return "", opts, err
}
// Anything left over was neither the command nor a flag. apply takes exactly one positional —
// the declaration file; every other command takes none. A mistyped argument that changes
// nothing and reports success is worse than an error, so leftovers are refused, not ignored.
rest := set.Args()
if command == "apply" {
if len(rest) != 1 {
return "", opts, fmt.Errorf("apply needs exactly one declaration file (put --store before it)")
}
opts.file = rest[0]
} else if 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, opts options) error {
switch command {
case "profile":
p := profile.Detect(ctx, profile.Default(nil), opts.timeout)
if opts.json {
return writeJSON(p)
}
writeProfile(p)
return nil
case "inventory":
inv := inventory.Collect(ctx, nil, profile.Default(nil), opts.timeout)
if opts.json {
return writeJSON(inv)
}
writeInventory(inv)
return nil
case "apply":
return runApply(opts)
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)
}
}
// runApply reads a declaration from a file and makes this machine match it.
//
// Identity is empty here: stage 1 has no link, so a node has no name yet and applies whatever it
// is handed — the first-node path (ADR 0043). When the link arrives, the node's identity is read
// from the store and passed through, and a declaration addressed elsewhere is refused.
func runApply(opts options) error {
if opts.file == "" {
return fmt.Errorf("apply needs a declaration file")
}
raw, err := os.ReadFile(opts.file)
if err != nil {
return fmt.Errorf("reading declaration: %w", err)
}
decl, err := apply.Parse(raw)
if err != nil {
return err
}
store, err := apply.LoadStore(opts.store)
if err != nil {
return err
}
res, err := apply.Apply(decl, "", store, apply.Appliers())
if err != nil {
return err
}
if opts.json {
return writeJSON(res)
}
for _, r := range res.Applied {
fmt.Printf(" applied %-10s %s\n", r.Type, r.Path)
}
for _, r := range res.Removed {
fmt.Printf(" removed %-10s %s\n", r.Type, r.Path)
}
fmt.Printf("\n%d applied, %d removed\n", len(res.Applied), len(res.Removed))
return nil
}
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)
}
}
}