Stage 2 — the host applies a declaration

A declaration is JSON, versioned, and an ordered list of resources with stable
identities (novox/hq ADR 0043). The vocabulary is directory, file and service,
and anything outside it — an unknown version, type or field — refuses the WHOLE
declaration. A host that skipped what it did not understand would apply most of
what it was sent and report success.

It converges rather than executes: applying twice changes nothing the second
time, and applying to a drifted machine returns it. A mode is maintained rather
than set, because a permission applied at creation is not a permission held —
this repository has paid for that once already.

It owns a footprint and only that. What it applied and is no longer declared is
removed; what it did not create is never touched. Removal runs FIRST, because a
resource leaving a declaration while another arrives at the same path is an
ordinary rename, and removing afterwards would delete the file just written.

The store arrives here rather than at stage 3, as ADR 0043 predicted: nothing
can be removed without knowing what was applied. It is written atomically,
refuses to start empty when it exists and cannot be read — believing it owns
nothing would leave everything behind forever — and is saved even when an apply
fails, because what was applied before the failure is on the machine either way.

Three faults found by running inside a raised machine rather than by reasoning:

A unit that DOES NOT EXIST reads as `inactive` from `systemctl is-active`,
exactly as a stopped one does. So declaring a unit stopped reported success for
a unit the host cannot manage at all — absence read as satisfaction, which is
04-ISSUES/007 wearing a different hat. LoadState separates them.

Removing an orphaned service whose unit has since been uninstalled failed the
whole apply, and a host holding such a record could then apply NOTHING, ever,
with no way out but editing its state by hand. Removal is now idempotent for the
same reason os.RemoveAll is.

And the flag parser was wrong in the same way twice: fixing `mesh-host inventory
--json` by taking the subcommand off the front left `mesh-host apply decl.json
--dry-run` broken identically, because the standard library stops at the first
non-flag argument wherever that argument is. Parsed in a loop now.

30 new tests, 55 in total.
This commit is contained in:
2026-08-26 02:14:25 +02:00
parent 73c010e7ef
commit 9d8239afe8
9 changed files with 1771 additions and 15 deletions
+122 -8
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
@@ -17,8 +18,11 @@ import (
"text/tabwriter"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/profile"
"github.com/novox/mesh-host/internal/store"
)
// version is stamped at build time. Unset in a development build, and said so rather than
@@ -29,12 +33,16 @@ 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 FILE make this machine match a declaration
owned what this host has applied and still owns
version
--json machine-readable output
--timeout how long any single probe may take (default 10s)
--state where this node keeps what it knows (default /var/lib/mesh-host/state.json)
--dry-run read the declaration and refuse it if wrong, but change nothing
Stage 1: reports only. It applies nothing, connects to nothing, listens on nothing.
It connects to nothing and listens on nothing. What it applies comes from a file.
`
func main() {
@@ -45,7 +53,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 +64,9 @@ func main() {
type options struct {
json bool
timeout time.Duration
state string
dryRun bool
file string
}
// parseArgs takes the subcommand first, then its flags.
@@ -65,7 +76,7 @@ type options struct {
// 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}
opts := options{timeout: 10 * time.Second, state: store.DefaultPath}
command := ""
if len(args) > 0 {
@@ -78,19 +89,45 @@ 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.state, "state", opts.state, "where this node keeps what it knows")
set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing")
if err := set.Parse(args); err != nil {
return "", opts, err
// Parsed in a loop, because the standard library stops at the FIRST non-flag argument.
// `mesh-host inventory --json` hit that once, and taking the subcommand off the front
// fixed only half of it: `mesh-host apply decl.json --dry-run` left --dry-run unread in
// exactly the same way. A flag may sit before, after or between positionals, and one that
// is silently dropped is the fault this whole project keeps naming.
var positionals []string
rest := args
for {
if err := set.Parse(rest); err != nil {
return "", opts, err
}
rest = set.Args()
if len(rest) == 0 {
break
}
positionals = append(positionals, rest[0])
rest = rest[1:]
}
if command == "apply" {
if len(positionals) != 1 {
return "", opts, errors.New("apply needs exactly one declaration file")
}
opts.file = positionals[0]
return command, opts, nil
}
// 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])
if len(positionals) > 0 {
return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", positionals[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 {
jsonOut, timeout := opts.json, opts.timeout
switch command {
case "profile":
p := profile.Detect(ctx, profile.Default(nil), timeout)
@@ -108,6 +145,27 @@ func run(ctx context.Context, command string, jsonOut bool, timeout time.Duratio
writeInventory(inv)
return nil
case "apply":
return runApply(ctx, opts)
case "owned":
known, err := store.Load(opts.state)
if err != nil {
return err
}
if jsonOut {
return writeJSON(known)
}
if len(known.Resources) == 0 {
fmt.Println("this host has applied nothing on this machine")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
for _, r := range known.Resources {
fmt.Fprintf(w, " %s\t%s\t%s\n", r.Type, r.ID, r.Target)
}
return w.Flush()
case "version":
fmt.Println(version)
return nil
@@ -178,3 +236,59 @@ func writeInventory(inv inventory.Inventory) {
}
}
}
// runApply reads a declaration and makes the machine match it.
//
// The state is loaded before anything is touched and saved after, including when the apply
// fails part-way: what was applied before the failure is on the machine, and a host that did
// not record it would believe it owns less than it does and leave that behind forever.
func runApply(ctx context.Context, opts options) error {
raw, err := os.ReadFile(opts.file)
if err != nil {
return fmt.Errorf("reading the declaration: %w", err)
}
d, err := declaration.Parse(raw)
if err != nil {
return err
}
known, err := store.Load(opts.state)
if err != nil {
return err
}
if opts.dryRun {
fmt.Printf("%s: %d resource(s), version %d — accepted, nothing applied\n",
opts.file, len(d.Resources), d.Version)
return nil
}
report, updated, applyErr := apply.Apply(ctx, d, known, apply.ExecRunner, func(line string) {
if !opts.json {
fmt.Println(line)
}
})
// Saved whichever way it went. Recording only on success would lose the footprint of a
// failed apply, and that footprint is on the machine either way.
if saveErr := store.Save(opts.state, updated); saveErr != nil {
if applyErr != nil {
return fmt.Errorf("%w\n\nand the node's state could not be saved: %v", applyErr, saveErr)
}
return saveErr
}
if applyErr != nil {
return applyErr
}
if opts.json {
return writeJSON(report)
}
if !report.Changed() {
fmt.Printf("%s: already matches — %d resource(s) checked\n", opts.file, len(report.Outcomes))
return nil
}
fmt.Printf("%s: applied — %d resource(s)\n", opts.file, len(report.Outcomes))
return nil
}
+54
View File
@@ -1,6 +1,7 @@
package main
import (
"github.com/novox/mesh-host/internal/store"
"testing"
"time"
)
@@ -81,3 +82,56 @@ func TestNoCommandIsNotAnError(t *testing.T) {
t.Errorf("command = %q, want empty", command)
}
}
func TestApplyNeedsExactlyOneDeclaration(t *testing.T) {
// `apply` takes a file where every other command takes nothing, so the leftover-argument
// rule has an exception — and an exception is where a parser stops refusing things it
// should. Both directions are checked.
if _, _, err := parseArgs([]string{"apply"}); err == nil {
t.Error("apply with no file was accepted")
}
if _, _, err := parseArgs([]string{"apply", "a.json", "b.json"}); err == nil {
t.Error("apply with two files was accepted")
}
command, opts, err := parseArgs([]string{"apply", "decl.json", "--dry-run"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if command != "apply" || opts.file != "decl.json" || !opts.dryRun {
t.Errorf("parsed as command=%q file=%q dry-run=%v", command, opts.file, opts.dryRun)
}
}
func TestTheStateHasADocumentedDefault(t *testing.T) {
// A host that wrote its state somewhere unexpected would forget what it owns on the next
// run, and then leave everything it had applied behind forever.
_, opts, err := parseArgs([]string{"owned"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.state != store.DefaultPath {
t.Errorf("default state path is %q, not the documented %q", opts.state, store.DefaultPath)
}
}
func TestAFlagAfterAPositionalIsRead(t *testing.T) {
// The same fault as TestAFlagAfterTheCommandIsRead, one level down. Taking the subcommand
// off the front fixed the flag after the COMMAND and not the flag after its ARGUMENT: the
// standard library stops at the first non-flag argument wherever that argument is.
for _, args := range [][]string{
{"apply", "decl.json", "--dry-run", "--json"},
{"apply", "--dry-run", "decl.json", "--json"},
{"apply", "--dry-run", "--json", "decl.json"},
} {
command, opts, err := parseArgs(args)
if err != nil {
t.Errorf("%v: unexpected error: %v", args, err)
continue
}
if command != "apply" || opts.file != "decl.json" || !opts.dryRun || !opts.json {
t.Errorf("%v parsed as file=%q dry-run=%v json=%v",
args, opts.file, opts.dryRun, opts.json)
}
}
}