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
242 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|