diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index fb1db70..b2c2db9 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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("", " ") diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..04e6a66 --- /dev/null +++ b/internal/apply/apply.go @@ -0,0 +1,155 @@ +package apply + +import "fmt" + +// Result is what one apply did: which resources it brought to the declared state, and which it +// removed because they were applied before and are no longer declared. +type Result struct { + Applied []Record + Removed []Record +} + +// Apply makes this machine match decl, records what it did, and removes what it once applied and +// decl no longer names. +// +// The three properties that govern it are each a recorded decision, not a preference: +// +// - A failed step fails the apply (ADR 0008). This function stops at the first resource it +// cannot bring to state and returns the error. It does not log and continue: a partial apply +// that reports success is the mesh's most expensive shape. +// - What was applied is recorded after it works, never before (ADR 0035). Each success is +// appended to what the store will hold; a failure leaves the machine in whatever state it +// reached, and the store is saved reflecting exactly that — never more. +// - The host is authoritative over its own footprint and inert everywhere else (ADR 0043). +// Removal touches only resources the store recorded as created by the host. +// +// identity is this node's own name. A declaration addressed to another node is refused; a host +// with no identity yet — the first node — applies whatever it is handed, because it has nothing +// to check against (ADR 0043). +func Apply(decl Declaration, identity string, store *Store, appliers map[string]Applier) (Result, error) { + if decl.For != "" && identity != "" && decl.For != identity { + return Result{}, fmt.Errorf( + "declaration is for %q and this node is %q — refused, a node applies only what is "+ + "addressed to it", decl.For, identity) + } + + prior := store.Records() + declared := make(map[string]bool, len(decl.Resources)) + for _, r := range decl.Resources { + declared[r.ID] = true + } + + // Who created what, carried across applies. "created" must be sticky: once the host brought a + // resource into being, it stays the creator through every re-apply, or a second apply would + // see the resource already present, record created=false, and then decline to remove + // something it in fact created. That flip would leak a host-created resource on the next + // declaration that drops it — reported handled, actually orphaned. + priorCreated := make(map[string]bool, len(prior)) + for _, rec := range prior { + priorCreated[rec.ID] = rec.Created + } + + // Apply in the stated order, recording each success as it lands. + applied := make([]Record, 0, len(decl.Resources)) + for _, r := range decl.Resources { + a, ok := appliers[r.Type] + if !ok { + // Parse already refused unknown types, so this is a host wired inconsistently with + // its own shape table — a bug, surfaced rather than skipped. + err := fmt.Errorf("resource %q is a %q with no applier — refusing", r.ID, r.Type) + saveMerged(store, prior, applied) + return Result{}, err + } + created, err := a.Apply(r) + if err != nil { + // The resource is not at the declared state. Record what did land (this one did not, + // so it is not appended), persist that, and fail. + saveMerged(store, prior, applied) + return Result{}, fmt.Errorf("applying %s %q: %w", r.Type, r.ID, err) + } + applied = append(applied, Record{ + ID: r.ID, Type: r.Type, Path: r.Path(), + Created: created || priorCreated[r.ID], + }) + } + + // Remove what was applied before and is no longer declared, in reverse application order so + // a file goes before the directory that held it. Only host-created resources are touched. + removedIDs := map[string]bool{} + var removed []Record + for i := len(prior) - 1; i >= 0; i-- { + rec := prior[i] + if declared[rec.ID] { + continue + } + if rec.Created { + if a, ok := appliers[rec.Type]; ok { + if err := a.Remove(rec); err != nil { + // A removal that failed leaves the resource present. That is a failed step, + // so the apply fails — and the store must reflect reality: the declared set + // that landed, plus every undeclared prior record not yet removed (this one + // included), in application order. + store.replace(survivorsAfterFailedRemoval(applied, prior, declared, removedIDs)) + _ = store.Save() + return Result{}, fmt.Errorf("removing %s %q: %w", rec.Type, rec.ID, err) + } + } + } + removedIDs[rec.ID] = true + removed = append(removed, rec) + } + + // Success: the store now holds exactly the declared set, freshly recorded. + store.replace(applied) + if err := store.Save(); err != nil { + return Result{}, fmt.Errorf("apply succeeded but its record could not be saved: %w", err) + } + return Result{Applied: applied, Removed: removed}, nil +} + +// survivorsAfterFailedRemoval is what the machine still holds when a removal fails: the declared +// set that was just applied, plus every prior undeclared record not successfully removed — +// including the one whose removal failed — kept in application order. +func survivorsAfterFailedRemoval(applied, prior []Record, declared, removedIDs map[string]bool) []Record { + survivors := append([]Record{}, applied...) + for _, rec := range prior { + if declared[rec.ID] || removedIDs[rec.ID] { + continue + } + survivors = append(survivors, rec) + } + return survivors +} + +// saveMerged persists prior records overlaid with what was just applied, for the failure path: +// the machine holds both the untouched prior resources and the ones that landed before the +// failure, so the store must record both. +func saveMerged(store *Store, prior, applied []Record) { + store.replace(mergeByID(prior, applied)) + _ = store.Save() +} + +// mergeByID returns base with overlay applied on top, overlay winning on a shared id, preserving +// base order and appending overlay-only records. +func mergeByID(base, overlay []Record) []Record { + byID := make(map[string]Record, len(overlay)) + for _, r := range overlay { + byID[r.ID] = r + } + out := make([]Record, 0, len(base)+len(overlay)) + seen := map[string]bool{} + for _, r := range base { + if o, ok := byID[r.ID]; ok { + out = append(out, o) + seen[r.ID] = true + continue + } + out = append(out, r) + } + for _, r := range overlay { + if !seen[r.ID] { + out = append(out, r) + } + } + return out +} diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go new file mode 100644 index 0000000..10347b9 --- /dev/null +++ b/internal/apply/apply_test.go @@ -0,0 +1,313 @@ +package apply + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// A declaration this host does not fully understand is refused whole — the property the whole +// project is built around, tested at the boundary it matters most. +func TestParseRefusesWhatItCannotFullyUnderstand(t *testing.T) { + cases := map[string]string{ + "unknown version": `{"version":2,"resources":[]}`, + "unknown type": `{"version":1,"resources":[ + {"id":"a","type":"container","path":"/x"}]}`, + "unknown field on a known type": `{"version":1,"resources":[ + {"id":"a","type":"directory","path":"/x","colour":"blue"}]}`, + "unknown top-level field": `{"version":1,"nodes":[],"resources":[]}`, + "resource without an id": `{"version":1,"resources":[{"type":"directory","path":"/x"}]}`, + "resource without a type": `{"version":1,"resources":[{"id":"a","path":"/x"}]}`, + "resource without a path": `{"version":1,"resources":[{"id":"a","type":"directory"}]}`, + "two resources sharing id": `{"version":1,"resources":[{"id":"a","type":"directory","path":"/x"},{"id":"a","type":"directory","path":"/y"}]}`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if _, err := Parse([]byte(raw)); err == nil { + t.Fatalf("accepted a declaration it should have refused whole") + } + }) + } +} + +func TestParseAcceptsAWellFormedDeclaration(t *testing.T) { + raw := `{"version":1,"for":"anchor","resources":[ + {"id":"state","type":"directory","path":"/var/lib/x","mode":"0700"}, + {"id":"conf","type":"file","path":"/var/lib/x/conf","mode":"0600","content":"k=v\n"}]}` + d, err := Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if d.For != "anchor" || len(d.Resources) != 2 { + t.Fatalf("parsed wrong: %+v", d) + } + if d.Resources[0].Path() != "/var/lib/x" { + t.Fatalf("path accessor wrong: %q", d.Resources[0].Path()) + } +} + +// Applying a declaration lands the resources, and applying it again changes nothing — the +// idempotency the node host promises. +func TestApplyIsIdempotent(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + dir := filepath.Join(root, "svc") + file := filepath.Join(dir, "conf") + + decl := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`","mode":"0755"}, + {"id":"f","type":"file","path":"`+file+`","mode":"0644","content":"hello\n"}]}`) + + for i := 0; i < 2; i++ { + store, err := LoadStore(storePath) + if err != nil { + t.Fatal(err) + } + res, err := Apply(decl, "", store, Appliers()) + if err != nil { + t.Fatalf("apply %d: %v", i, err) + } + if len(res.Applied) != 2 { + t.Fatalf("apply %d: expected 2 applied, got %d", i, len(res.Applied)) + } + } + if got, _ := os.ReadFile(file); string(got) != "hello\n" { + t.Fatalf("file content wrong: %q", got) + } +} + +// A resource dropped from a declaration is removed on the next apply — but only because the host +// created it. This is desired-state convergence with the data-loss guard intact. +func TestUndeclaredResourceIsRemovedWhenHostCreatedIt(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + dir := filepath.Join(root, "svc") + gone := filepath.Join(dir, "gone") + kept := filepath.Join(dir, "kept") + + first := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}, + {"id":"gone","type":"file","path":"`+gone+`","content":"x"}, + {"id":"kept","type":"file","path":"`+kept+`","content":"y"}]}`) + store, _ := LoadStore(storePath) + if _, err := Apply(first, "", store, Appliers()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(gone); err != nil { + t.Fatalf("first apply did not create the file: %v", err) + } + + second := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}, + {"id":"kept","type":"file","path":"`+kept+`","content":"y"}]}`) + store, _ = LoadStore(storePath) + res, err := Apply(second, "", store, Appliers()) + if err != nil { + t.Fatal(err) + } + if len(res.Removed) != 1 || res.Removed[0].ID != "gone" { + t.Fatalf("expected 'gone' removed, got %+v", res.Removed) + } + if _, err := os.Stat(gone); !os.IsNotExist(err) { + t.Fatalf("the undeclared file was not removed") + } + if _, err := os.Stat(kept); err != nil { + t.Fatalf("the still-declared file was wrongly removed: %v", err) + } +} + +// "created" is sticky across re-applies. A resource created once, then re-applied (so it already +// exists the second time), must still be removed when later dropped — the host does not forget it +// was the creator just because the resource was present on a subsequent apply. +func TestCreatedIsStickyAcrossReapplies(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + dir := filepath.Join(root, "svc") + file := filepath.Join(dir, "conf") + + full := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}, + {"id":"f","type":"file","path":"`+file+`","content":"x"}]}`) + // Apply it twice. On the second apply everything already exists, so a naive "created" would + // flip to false and the file would later be treated as adopted. + for i := 0; i < 2; i++ { + store, _ := LoadStore(storePath) + if _, err := Apply(full, "", store, Appliers()); err != nil { + t.Fatalf("apply %d: %v", i, err) + } + } + + dropped := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}]}`) + store, _ := LoadStore(storePath) + res, err := Apply(dropped, "", store, Appliers()) + if err != nil { + t.Fatal(err) + } + if len(res.Removed) != 1 || res.Removed[0].ID != "f" { + t.Fatalf("re-applied-then-dropped file was not removed: %+v", res.Removed) + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatal("host reported the file removed but it is still on disk (created flag was not sticky)") + } +} + +// The host never removes what it did not create. A directory it merely adopted — one that +// already existed, holding data — survives being dropped from the declaration. +func TestAdoptedResourceIsNeverRemoved(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + existing := filepath.Join(root, "data") // pre-exists: the host will adopt, not create it + if err := os.Mkdir(existing, 0o755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(existing, "precious") + if err := os.WriteFile(sentinel, []byte("workload data"), 0o644); err != nil { + t.Fatal(err) + } + + first := mustParse(t, `{"version":1,"resources":[ + {"id":"data","type":"directory","path":"`+existing+`","mode":"0755"}]}`) + store, _ := LoadStore(storePath) + res, err := Apply(first, "", store, Appliers()) + if err != nil { + t.Fatal(err) + } + if res.Applied[0].Created { + t.Fatalf("host claimed to have created a directory that already existed") + } + + // Drop it from the declaration entirely. An adopted directory is not the host's to remove. + empty := mustParse(t, `{"version":1,"resources":[]}`) + store, _ = LoadStore(storePath) + if _, err := Apply(empty, "", store, Appliers()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("adopted directory (and its data) was removed — the guard failed: %v", err) + } +} + +// A failed step fails the apply, and the store records what actually landed — never more. +func TestFailedStepFailsTheApplyAndRecordsOnlyWhatLanded(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + good := filepath.Join(root, "good") + // The second file's parent does not exist, so writing it fails — a mid-declaration failure. + bad := filepath.Join(root, "nonexistent-dir", "bad") + + decl := mustParse(t, `{"version":1,"resources":[ + {"id":"good","type":"file","path":"`+good+`","content":"ok"}, + {"id":"bad","type":"file","path":"`+bad+`","content":"no"}]}`) + store, _ := LoadStore(storePath) + if _, err := Apply(decl, "", store, Appliers()); err == nil { + t.Fatal("apply reported success despite a step that could not be done") + } + + // The store must record 'good' (it landed) and not 'bad' (it did not). + reloaded, _ := LoadStore(storePath) + ids := map[string]bool{} + for _, r := range reloaded.Records() { + ids[r.ID] = true + } + if !ids["good"] { + t.Fatalf("the store forgot a resource that actually landed") + } + if ids["bad"] { + t.Fatalf("the store recorded a resource that never landed — a report from intent") + } +} + +// A declaration addressed to another node is refused; one addressed here, or unaddressed, is +// applied. +func TestAddressing(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "d") + decl := mustParse(t, `{"version":1,"for":"anchor","resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}]}`) + + store, _ := LoadStore(filepath.Join(root, "s1.json")) + if _, err := Apply(decl, "workstation", store, Appliers()); err == nil { + t.Fatal("a node applied a declaration addressed to a different node") + } + + store, _ = LoadStore(filepath.Join(root, "s2.json")) + if _, err := Apply(decl, "anchor", store, Appliers()); err != nil { + t.Fatalf("a node refused a declaration addressed to it: %v", err) + } + + // The first node — no identity yet — applies whatever it carries. + store, _ = LoadStore(filepath.Join(root, "s3.json")) + if _, err := Apply(decl, "", store, Appliers()); err != nil { + t.Fatalf("a node with no identity refused its own bundle: %v", err) + } +} + +// Read-back catches a value that did not take. Mode and owner are asserted against the machine +// after applying, so a file written with the wrong permissions is a failure, not a success. +func TestApplyReadsModeBack(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "f") + decl := mustParse(t, `{"version":1,"resources":[ + {"id":"f","type":"file","path":"`+file+`","mode":"0600","content":"x"}]}`) + store, _ := LoadStore(filepath.Join(root, "s.json")) + if _, err := Apply(decl, "", store, Appliers()); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(file) + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode not applied: %04o", info.Mode().Perm()) + } +} + +// A directory the host created but that now holds something is not removed — os.Remove refuses a +// non-empty directory, and that refusal is the guard, surfaced as a failed apply. +func TestCreatedDirectoryHoldingDataIsNotSilentlyDeleted(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "store.json") + dir := filepath.Join(root, "svc") + + first := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`"}]}`) + store, _ := LoadStore(storePath) + if _, err := Apply(first, "", store, Appliers()); err != nil { + t.Fatal(err) + } + // Something drops data into the host-created directory after the fact. + if err := os.WriteFile(filepath.Join(dir, "appeared"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + + empty := mustParse(t, `{"version":1,"resources":[]}`) + store, _ = LoadStore(storePath) + if _, err := Apply(empty, "", store, Appliers()); err == nil { + t.Fatal("host deleted, or claimed to delete, a non-empty directory it once created") + } + if _, err := os.Stat(filepath.Join(dir, "appeared")); err != nil { + t.Fatalf("data in the directory was lost: %v", err) + } +} + +func mustParse(t *testing.T, raw string) Declaration { + t.Helper() + d, err := Parse([]byte(raw)) + if err != nil { + t.Fatalf("test declaration did not parse: %v", err) + } + return d +} + +// Sanity: the current uid is what owner read-back compares against, so an owner naming this user +// verifies rather than needing root. +func TestOwnerReadBackAgainstCurrentUser(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "d") + owner := strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()) + decl := mustParse(t, `{"version":1,"resources":[ + {"id":"d","type":"directory","path":"`+dir+`","mode":"0755","owner":"`+owner+`"}]}`) + store, _ := LoadStore(filepath.Join(root, "s.json")) + if _, err := Apply(decl, "", store, Appliers()); err != nil { + t.Fatalf("applying an owner matching the current user failed: %v", err) + } +} diff --git a/internal/apply/declaration.go b/internal/apply/declaration.go new file mode 100644 index 0000000..215c311 --- /dev/null +++ b/internal/apply/declaration.go @@ -0,0 +1,195 @@ +// Package apply is tier 0's one job: take a declaration and make this machine match it. +// +// A declaration is data, not instructions — an ordered list of typed resources the host owns, +// settled by novox/hq ADR 0043. This package is the *consumer* side of that record: what the +// host accepts, and what it does with it. What produces a declaration (the control plane, or a +// hand-authored substrate.lock) is deliberately not here. +// +// The cardinal rule of the whole project appears twice in this file, because a declaration is +// exactly where it bites: an unknown version, an unknown resource type, or an unknown field is +// a refusal of the WHOLE declaration — never a skip, never best-effort. A host that applied the +// parts it understood would leave a machine that looks configured and is not, which is +// novox/hq 04-ISSUES/003 with the declaration on the other side of the wire. +package apply + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +// Version is the one declaration vocabulary this host understands. A declaration naming any +// other version is refused whole — an older host cannot be handed a newer vocabulary and +// quietly do half of it (ADR 0043). +const Version = 1 + +// Declaration is what crosses the link, or what substrate.lock carries: an ordered list of +// resources, addressed to one node. +type Declaration struct { + // Version of the vocabulary. Refused whole if it is not exactly Version. + Version int `json:"version"` + // For names the node this is meant for. A host with an identity refuses a declaration + // addressed elsewhere; a host with no identity yet — the first node — has nothing to check + // against and applies it (ADR 0043). Empty means unaddressed, which any host applies. + For string `json:"for"` + // Resources, in the order they are to be applied. The host does not sort them and does not + // resolve dependencies: ordering is the control plane's decision, stated rather than derived + // (ADR 0037). + Resources []Resource `json:"resources"` +} + +// Resource is one thing the host owns on this machine. Its identity is a name the control plane +// keeps stable across declarations — not a position, not a hash of its content — because that +// stable name is what lets the store say "this is the same resource I applied last time", which +// is what makes convergence and removal possible at all (ADR 0043). +// +// Beyond id and type, a resource's fields are type-specific and validated against the shape the +// type declares. They are kept as raw JSON so an unknown field can be refused rather than +// silently dropped by struct decoding. +type Resource struct { + ID string + Type string + Fields map[string]json.RawMessage +} + +// Path is the host-owned filesystem path this resource lives at. Every type this host applies +// so far is addressed by a path, and the store needs it to remove the resource later. +func (r Resource) Path() string { + return r.stringField("path") +} + +func (r Resource) stringField(key string) string { + raw, ok := r.Fields[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s +} + +// shape is the set of field keys a resource type may carry, beyond the common id and type. +// This is the host's half of a wire contract whose other half is the control plane's catalogue +// (novox/mesh-control examples/modules/modules_test.go). Duplicated deliberately, because the +// host shares no code with any other tier (ADR 0041) — and checked on both sides, because a +// contract with two copies and no check is a contract only until someone edits one. +// +// A type absent from this table is unknown TO THIS HOST, and unknown is refused. That is not a +// gap to apologise for: the network-free types come first (ADR 0043), and a host refusing a +// container resource it cannot yet apply is the same protection as refusing an unknown one — +// it never does half a declaration. +var shapes = map[string][]string{ + "directory": {"path", "mode", "owner"}, + "file": {"path", "content", "mode", "owner"}, +} + +// Parse reads a declaration and refuses anything it does not fully understand. +// +// The refusal is whole and it is specific: the error names what it could not accept, because a +// boundary that refuses without saying why is worse than the thing it guards (ADR 0039). +func Parse(raw []byte) (Declaration, error) { + // The envelope is decoded strictly: an unknown top-level field is refused like any other. + var envelope struct { + Version int `json:"version"` + For string `json:"for"` + Resources []json.RawMessage `json:"resources"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&envelope); err != nil { + return Declaration{}, fmt.Errorf("not a declaration this host accepts: %w", err) + } + + if envelope.Version != Version { + return Declaration{}, fmt.Errorf( + "declaration version %d, and this host speaks version %d — refused whole rather than "+ + "applying a vocabulary it does not know", envelope.Version, Version) + } + + d := Declaration{Version: envelope.Version, For: envelope.For} + seen := map[string]bool{} + for i, rawRes := range envelope.Resources { + r, err := parseResource(rawRes) + if err != nil { + return Declaration{}, fmt.Errorf("resource %d: %w", i, err) + } + if seen[r.ID] { + return Declaration{}, fmt.Errorf( + "resource %d: id %q appears twice — an id is how the store tells one resource "+ + "from another, so two cannot share one", i, r.ID) + } + seen[r.ID] = true + d.Resources = append(d.Resources, r) + } + return d, nil +} + +func parseResource(raw json.RawMessage) (Resource, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Resource{}, fmt.Errorf("not an object: %w", err) + } + + id := decodeString(fields["id"]) + typ := decodeString(fields["type"]) + if id == "" { + return Resource{}, fmt.Errorf("has no id, and every resource must have one") + } + if typ == "" { + return Resource{}, fmt.Errorf("%q has no type", id) + } + + allowed, known := shapes[typ] + if !known { + return Resource{}, fmt.Errorf( + "%q is a %q, which this host cannot apply — refused whole, because applying the rest "+ + "would leave a machine that looks configured and is not", id, typ) + } + + // Every field beyond the common two must belong to the type's shape. An unknown one is + // refused: a firewall-scope key read by nothing is exactly the fault this prevents + // (04-ISSUES/003). + ok := map[string]bool{"id": true, "type": true} + for _, k := range allowed { + ok[k] = true + } + extra := make(map[string]json.RawMessage, len(fields)) + for k, v := range fields { + if !ok[k] { + return Resource{}, fmt.Errorf( + "%q is a %s and carries %q, which that shape does not have", id, typ, k) + } + if k != "id" && k != "type" { + extra[k] = v + } + } + + if _, hasPath := extra["path"]; !hasPath { + return Resource{}, fmt.Errorf("%q is a %s and names no path", id, typ) + } + + return Resource{ID: id, Type: typ, Fields: extra}, nil +} + +func decodeString(raw json.RawMessage) string { + if raw == nil { + return "" + } + var s string + _ = json.Unmarshal(raw, &s) + return s +} + +// KnownTypes lists the resource types this host can apply, in stable order. Exists so the CLI +// and tests can state the host's reach rather than restating the shape table. +func KnownTypes() []string { + out := make([]string, 0, len(shapes)) + for t := range shapes { + out = append(out, t) + } + sort.Strings(out) + return out +} diff --git a/internal/apply/resources.go b/internal/apply/resources.go new file mode 100644 index 0000000..c4d9b09 --- /dev/null +++ b/internal/apply/resources.go @@ -0,0 +1,233 @@ +package apply + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" +) + +// Applier makes this machine match one kind of resource, and reads back to prove it took. +// +// Read-back is not optional and it is not this package's habit alone: setting a value is not +// evidence the value took (novox/hq how-we-build §5, and 05-the-node-host.md as a component +// requirement). So Apply writes AND confirms, and returns an error if the machine does not then +// match — a firewall is asked whether the rule loaded, and a file is read back byte for byte. +type Applier interface { + // Type is the resource type this handles — the key in the shape table. + Type() string + // Apply makes the machine match r and reads back to confirm. created reports whether the + // host brought the resource into being (as opposed to adopting one already present), which + // is what the store needs so removal never deletes what the host did not create. + Apply(r Resource) (created bool, err error) + // Remove undoes a resource the host created. Only ever called for a store Record whose + // Created is true, and written to never destroy data it did not put there. + Remove(rec Record) error +} + +// Appliers is the set of types this host can apply, keyed by type name. +func Appliers() map[string]Applier { + return map[string]Applier{ + "directory": directoryApplier{}, + "file": fileApplier{}, + } +} + +// --- directory --- + +type directoryApplier struct{} + +func (directoryApplier) Type() string { return "directory" } + +func (directoryApplier) Apply(r Resource) (bool, error) { + path := r.Path() + mode, err := parseMode(r.stringField("mode"), 0o755) + if err != nil { + return false, err + } + + created := false + info, statErr := os.Lstat(path) + switch { + case statErr == nil: + if !info.IsDir() { + return false, fmt.Errorf("%s exists and is not a directory", path) + } + case os.IsNotExist(statErr): + if err := os.Mkdir(path, mode); err != nil { + return false, fmt.Errorf("creating %s: %w", path, err) + } + created = true + default: + return false, fmt.Errorf("inspecting %s: %w", path, statErr) + } + + if err := os.Chmod(path, mode); err != nil { + return created, fmt.Errorf("setting mode on %s: %w", path, err) + } + if err := applyOwner(path, r.stringField("owner")); err != nil { + return created, err + } + + // Read back: the directory must now exist, be a directory, and hold the mode and owner + // asked for. Anything else is a value that did not take. + if err := verifyPathState(path, true, mode, r.stringField("owner")); err != nil { + return created, fmt.Errorf("%s did not take: %w", path, err) + } + return created, nil +} + +func (directoryApplier) Remove(rec Record) error { + // os.Remove, never RemoveAll: it fails on a non-empty directory, and that failure is the + // point. A directory the host created but that now holds something is not the host's to + // delete — data outlives the mesh that declared it (ADR 0030). + err := os.Remove(rec.Path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("removing directory %s (left in place): %w", rec.Path, err) + } + return nil +} + +// --- file --- + +type fileApplier struct{} + +func (fileApplier) Type() string { return "file" } + +func (fileApplier) Apply(r Resource) (bool, error) { + path := r.Path() + mode, err := parseMode(r.stringField("mode"), 0o644) + if err != nil { + return false, err + } + content := []byte(r.stringField("content")) + + _, statErr := os.Lstat(path) + created := os.IsNotExist(statErr) + if statErr != nil && !created { + return false, fmt.Errorf("inspecting %s: %w", path, statErr) + } + + // Idempotent: the file is rewritten only when the bytes differ, so applying the same + // declaration twice changes nothing the second time. Mode and owner are still reconciled + // below, because those can drift without the content doing so. + needsWrite := created + if !created { + existing, readErr := os.ReadFile(path) + needsWrite = readErr != nil || string(existing) != string(content) + } + if needsWrite { + if err := os.WriteFile(path, content, mode); err != nil { + return created, fmt.Errorf("writing %s: %w", path, err) + } + } + if err := os.Chmod(path, mode); err != nil { + return created, fmt.Errorf("setting mode on %s: %w", path, err) + } + if err := applyOwner(path, r.stringField("owner")); err != nil { + return created, err + } + + // Read back: the file must now hold exactly these bytes and this mode. A file whose content + // was composed on the machine, or whose write was short, is a value that did not take. + got, err := os.ReadFile(path) + if err != nil { + return created, fmt.Errorf("%s did not take: reading it back: %w", path, err) + } + if string(got) != string(content) { + return created, fmt.Errorf("%s did not take: content read back does not match", path) + } + if err := verifyPathState(path, false, mode, r.stringField("owner")); err != nil { + return created, fmt.Errorf("%s did not take: %w", path, err) + } + return created, nil +} + +func (fileApplier) Remove(rec Record) error { + err := os.Remove(rec.Path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("removing file %s: %w", rec.Path, err) + } + return nil +} + +// --- shared --- + +func parseMode(s string, fallback os.FileMode) (os.FileMode, error) { + if s == "" { + return fallback, nil + } + n, err := strconv.ParseUint(s, 8, 32) + if err != nil { + return 0, fmt.Errorf("mode %q is not an octal number like \"0700\": %w", s, err) + } + return os.FileMode(n), nil +} + +// applyOwner sets uid:gid when an owner is named. Owners are numeric because a container's user +// has no name on the machine (novox/mesh-control: "an owner may be numeric"). An empty owner is +// left untouched — not every resource asserts one. +func applyOwner(path, owner string) error { + if owner == "" { + return nil + } + uid, gid, err := parseOwner(owner) + if err != nil { + return err + } + if err := os.Chown(path, uid, gid); err != nil { + return fmt.Errorf("setting owner %s on %s: %w", owner, path, err) + } + return nil +} + +func parseOwner(owner string) (int, int, error) { + parts := strings.SplitN(owner, ":", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("owner %q is not \"uid:gid\"", owner) + } + uid, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("owner %q: uid is not a number", owner) + } + gid, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("owner %q: gid is not a number", owner) + } + return uid, gid, nil +} + +// verifyPathState reads back mode and (when asserted) owner, and reports the first mismatch. +func verifyPathState(path string, wantDir bool, mode os.FileMode, owner string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.IsDir() != wantDir { + return fmt.Errorf("expected directory=%v, found directory=%v", wantDir, info.IsDir()) + } + if info.Mode().Perm() != mode.Perm() { + return fmt.Errorf("expected mode %04o, found %04o", mode.Perm(), info.Mode().Perm()) + } + if owner != "" { + wantUID, wantGID, err := parseOwner(owner) + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot read owner back on this platform") + } + if int(st.Uid) != wantUID || int(st.Gid) != wantGID { + return fmt.Errorf("expected owner %d:%d, found %d:%d", wantUID, wantGID, st.Uid, st.Gid) + } + } + return nil +} diff --git a/internal/apply/store.go b/internal/apply/store.go new file mode 100644 index 0000000..f9d07b2 --- /dev/null +++ b/internal/apply/store.go @@ -0,0 +1,85 @@ +package apply + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Store is the host's record of what it has applied to this machine, and it is authoritative +// while disconnected (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md). It is not a cache of +// the control plane: it is what makes removal possible — the host removes what it previously +// applied and is no longer declared, and it knows what it applied because this recorded it +// (ADR 0043). +// +// The records are kept in application order, so removal can run in reverse — a file goes before +// the directory that holds it. +type Store struct { + path string + records []Record +} + +// Record is one applied resource, holding just enough to remove it. +// +// Created is the whole of the data-loss guard. The host is authoritative over its own footprint +// and inert everywhere else (ADR 0043), so it removes only what it created. A directory it +// merely adopted — one that already held workload data — is recorded Created:false and is never +// removed, which is the same rule that ADR 0018 exists to enforce: never act on a path you did +// not create. +type Record struct { + ID string `json:"id"` + Type string `json:"type"` + Path string `json:"path"` + Created bool `json:"created"` +} + +// LoadStore reads the store at path. A missing file is an empty store, not an error: a machine +// the host has never applied to has applied nothing, which is a fact with a true empty answer. +func LoadStore(path string) (*Store, error) { + s := &Store{path: path} + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("reading the applied-state store %s: %w", path, err) + } + var records []Record + if err := json.Unmarshal(raw, &records); err != nil { + return nil, fmt.Errorf( + "the applied-state store %s is not readable — refusing rather than treating a machine "+ + "as blank when it is not: %w", path, err) + } + s.records = records + return s, nil +} + +// Records returns the applied resources in application order. +func (s *Store) Records() []Record { return s.records } + +// Save writes the store atomically: a torn store is a machine that has forgotten what it holds, +// so the write goes to a sibling temp file and is renamed into place. +func (s *Store) Save() error { + if s.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("preparing the store directory: %w", err) + } + raw, err := json.MarshalIndent(s.records, "", " ") + if err != nil { + return fmt.Errorf("encoding the store: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil { + return fmt.Errorf("writing the store: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("committing the store: %w", err) + } + return nil +} + +// replace sets the records to exactly what was just applied, in order. +func (s *Store) replace(records []Record) { s.records = records }