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
This commit is contained in:
2026-09-02 22:34:44 +02:00
parent 73c010e7ef
commit 4a80cc1002
6 changed files with 1056 additions and 14 deletions
+75 -14
View File
@@ -17,6 +17,7 @@ import (
"text/tabwriter"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/profile"
)
@@ -25,16 +26,25 @@ import (
// 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
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)
--json machine-readable output
--timeout how long any single probe may take (default 10s)
--store where the applied-state store lives (apply; default ` + defaultStore + `)
Stage 1: reports only. It applies nothing, connects to nothing, listens on nothing.
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() {
@@ -45,7 +55,7 @@ func main() {
command, opts, err := parseArgs(os.Args[1:])
if err == nil {
err = run(ctx, command, opts.json, opts.timeout)
err = run(ctx, command, opts)
}
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
@@ -56,6 +66,8 @@ func main() {
type options struct {
json bool
timeout time.Duration
store string
file string
}
// parseArgs takes the subcommand first, then its flags.
@@ -78,36 +90,47 @@ func parseArgs(args []string) (string, options, error) {
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. 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 {
// 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, jsonOut bool, timeout time.Duration) error {
func run(ctx context.Context, command string, opts options) error {
switch command {
case "profile":
p := profile.Detect(ctx, profile.Default(nil), timeout)
if jsonOut {
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), timeout)
if jsonOut {
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
@@ -121,6 +144,44 @@ func run(ctx context.Context, command string, jsonOut bool, timeout time.Duratio
}
}
// 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("", " ")