apply: tier 0 consumes a declaration and converges this machine #1

Closed
jschoubben wants to merge 3 commits from apply/tier0-declaration-consumer into main
9 changed files with 1572 additions and 20 deletions
+9 -3
View File
@@ -22,13 +22,15 @@ the host never queries the mesh database. It receives declarations and applies t
## What exists today
**Stage 1 only: it reports.** It applies nothing, connects to nothing, and listens on nothing.
**It reports, and it has begun to apply.** It connects to nothing and listens on nothing.
```
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match the declaration in FILE
--json machine-readable
--timeout how long any single probe may take (default 10s)
--store P where the applied-state store lives (apply)
```
```
@@ -46,8 +48,12 @@ linux/amd64
cannot be asked to: [firewall privileged]
```
Stages 2 to 4 — applying from a pinned bundle, the link and the local store, and enrolment —
are designed and not built.
Stage 2 has started: `apply` consumes a declaration (novox/hq ADR 0043) from a local file and
converges this machine, with no mesh present. The first vocabulary is the one that needs no
network — directories and files — applied in the stated order, read back, and recorded so what
was applied and no longer declared is removed, while what the host did not create never is. The
remaining resource types, sealed secrets, the link to the control plane, the pinned substrate
bundle and enrolment are designed and not yet built.
## A capability is detected, never assumed
+80 -17
View File
@@ -1,9 +1,11 @@
// 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.
// It reports (profile, inventory) and it applies (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md).
// `apply` takes a declaration and makes this machine match it, from a local file, with no mesh
// present — the network-free resource types first (directories, files, containers, networks).
// It still connects to nothing and listens on nothing: the link to the control plane, sealed
// secrets, and the remaining resource types are designed and not yet built.
package main
import (
@@ -17,6 +19,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 +28,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 +57,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 +68,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 +92,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 +146,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.Ref)
}
for _, r := range res.Removed {
fmt.Printf(" removed %-10s %s\n", r.Type, r.Ref)
}
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("", " ")
+155
View File
@@ -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, Ref: r.ref(),
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
}
+313
View File
@@ -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)
}
}
+219
View File
@@ -0,0 +1,219 @@
package apply
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// containerRuntime is the runtime the mesh uses. Named rather than assumed, so the one place that
// would change for podman is here — and profile already detects that a container-runtime is
// present before any container resource is placed on a node.
const containerRuntime = "docker"
// specLabel carries a hash of the desired container spec. It is what lets a re-apply tell an
// up-to-date container from one that must be recreated, without parsing the runtime's own view of
// env, ports and mounts and hoping the comparison matches field for field. The host wrote the
// hash; the host trusts the hash.
const specLabel = "mesh-host.spec"
// Runner executes a container-runtime command. Replaceable in tests, and exercised against the
// real runtime as well (novox/hq ADR 0034): a test that only fakes the runtime asserts the fake
// behaves as expected, so the real path is smoke-tested too.
type Runner func(name string, args ...string) (string, error)
func execRunner(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("%s %s: %w: %s",
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return string(out), nil
}
// --- network ---
type networkApplier struct{ run Runner }
func (networkApplier) Type() string { return "network" }
func (n networkApplier) Apply(r Resource) (bool, error) {
name := r.stringField("name")
if _, err := n.run(containerRuntime, "network", "inspect", name); err == nil {
return false, nil // already present — adopted, not created
}
if _, err := n.run(containerRuntime, "network", "create", name); err != nil {
return false, fmt.Errorf("creating network %s: %w", name, err)
}
// Read back: the network must now be inspectable, or the create did not take.
if _, err := n.run(containerRuntime, "network", "inspect", name); err != nil {
return true, fmt.Errorf("network %s did not take: %w", name, err)
}
return true, nil
}
func (n networkApplier) Remove(rec Record) error {
if _, err := n.run(containerRuntime, "network", "rm", rec.Ref); err != nil {
if alreadyGone(err) {
return nil
}
return fmt.Errorf("removing network %s: %w", rec.Ref, err)
}
return nil
}
// --- container ---
type containerApplier struct{ run Runner }
func (containerApplier) Type() string { return "container" }
func (c containerApplier) Apply(r Resource) (bool, error) {
name := r.stringField("name")
image := r.stringField("image")
if image == "" {
return false, fmt.Errorf("container %q names no image", name)
}
hash := specHash(r)
exists, running, curHash := c.inspect(name)
// A container is up to date only when it is ours (its spec label matches this exact spec) AND
// it is running. A foreign container by the same name — one the old control plane started, with
// no label — does not match, and is recreated into ours. That is safe: a container carries no
// state, its data lives in bind-mounted directories declared separately, and recreating it does
// not touch them.
upToDate := exists && running && curHash == hash
if upToDate {
return false, nil // present and correct; this apply created nothing
}
if exists {
if _, err := c.run(containerRuntime, "rm", "-f", name); err != nil {
return false, fmt.Errorf("replacing container %s: %w", name, err)
}
}
if _, err := c.run(containerRuntime, runArgs(r, hash)...); err != nil {
return true, fmt.Errorf("starting container %s: %w", name, err)
}
// Read back: the container must now be running, on this exact spec. "Service started" only
// means the runtime returned — the host asks whether it is actually up (novox/hq
// troubleshooting/service-started-is-not-ready).
exists, running, curHash = c.inspect(name)
if !exists || !running {
return true, fmt.Errorf("container %s did not come up", name)
}
if curHash != hash {
return true, fmt.Errorf("container %s came up on a spec that is not the one declared", name)
}
return true, nil
}
func (c containerApplier) Remove(rec Record) error {
if _, err := c.run(containerRuntime, "rm", "-f", rec.Ref); err != nil {
if alreadyGone(err) {
return nil
}
return fmt.Errorf("removing container %s: %w", rec.Ref, err)
}
return nil
}
// alreadyGone reports whether a removal failed only because the thing was not there — which is
// success, not failure. The runtime phrases it variously ("No such container", "no such object",
// "not found") across versions, so the match is lenient and case-insensitive.
func alreadyGone(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "no such") || strings.Contains(msg, "not found")
}
// inspect reports whether a container by this name exists, whether it is running, and the spec
// hash it was labelled with (empty for a container the host did not label).
func (c containerApplier) inspect(name string) (exists, running bool, hash string) {
out, err := c.run(containerRuntime, "inspect", "-f",
"{{.State.Running}}|{{index .Config.Labels \""+specLabel+"\"}}", name)
if err != nil {
return false, false, ""
}
parts := strings.SplitN(strings.TrimSpace(out), "|", 2)
running = parts[0] == "true"
if len(parts) == 2 && parts[1] != "<no value>" {
hash = parts[1]
}
return true, running, hash
}
// runArgs builds the `docker run` invocation for r, labelled with its spec hash.
func runArgs(r Resource, hash string) []string {
args := []string{"run", "-d", "--name", r.stringField("name"), "--label", specLabel + "=" + hash}
if net := r.stringField("network"); net != "" {
args = append(args, "--network", net)
}
for _, ef := range r.stringSlice("env-file") {
args = append(args, "--env-file", ef)
}
// Env is emitted in the JSON-sorted order specHash also uses, so the invocation is stable.
env := r.stringMap("env")
for _, k := range sortedKeys(env) {
args = append(args, "-e", k+"="+env[k])
}
for _, p := range r.stringSlice("ports") {
args = append(args, "-p", p)
}
for _, v := range r.stringSlice("volumes") {
args = append(args, "-v", v)
}
for _, h := range r.stringSlice("hosts") {
args = append(args, "--add-host", h)
}
args = append(args, r.stringField("image"))
args = append(args, r.stringSlice("args")...)
return args
}
// specHash is a stable fingerprint of everything that decides whether a running container matches
// what is declared. Marshalled through a struct so the field set is explicit, and json.Marshal
// sorts map keys, so the same declaration always hashes the same.
func specHash(r Resource) string {
type spec struct {
Name string `json:"name"`
Image string `json:"image"`
Network string `json:"network"`
Env map[string]string `json:"env"`
EnvFile []string `json:"env_file"`
Ports []string `json:"ports"`
Volumes []string `json:"volumes"`
Hosts []string `json:"hosts"`
Args []string `json:"args"`
}
b, _ := json.Marshal(spec{
Name: r.stringField("name"),
Image: r.stringField("image"),
Network: r.stringField("network"),
Env: r.stringMap("env"),
EnvFile: r.stringSlice("env-file"),
Ports: r.stringSlice("ports"),
Volumes: r.stringSlice("volumes"),
Hosts: r.stringSlice("hosts"),
Args: r.stringSlice("args"),
})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])[:16]
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// small n; insertion order does not matter, only that it is stable and sorted
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j-1] > out[j]; j-- {
out[j-1], out[j] = out[j], out[j-1]
}
}
return out
}
+235
View File
@@ -0,0 +1,235 @@
package apply
import (
"fmt"
"os/exec"
"strings"
"testing"
)
// fakeRuntime scripts the container runtime: it records every invocation and answers `inspect`
// from a small in-memory model of which containers exist, whether they run, and their spec label.
type fakeRuntime struct {
calls []string
exists map[string]bool
running map[string]bool
label map[string]string
failOnRun bool
}
func newFakeRuntime() *fakeRuntime {
return &fakeRuntime{
exists: map[string]bool{},
running: map[string]bool{},
label: map[string]string{},
}
}
func (f *fakeRuntime) run(name string, args ...string) (string, error) {
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
switch {
case len(args) >= 1 && args[0] == "inspect":
container := args[len(args)-1]
if !f.exists[container] {
return "", fmt.Errorf("Error: No such object: %s", container)
}
return fmt.Sprintf("%v|%s", f.running[container], f.label[container]), nil
case len(args) >= 1 && args[0] == "run":
if f.failOnRun {
return "", fmt.Errorf("simulated run failure")
}
name, hash := parseRunNameAndLabel(args)
f.exists[name] = true
f.running[name] = true
f.label[name] = hash
return name, nil
case len(args) >= 2 && args[0] == "rm":
container := args[len(args)-1]
delete(f.exists, container)
delete(f.running, container)
delete(f.label, container)
return "", nil
case len(args) >= 2 && args[0] == "network" && args[1] == "inspect":
return "", fmt.Errorf("Error: No such network")
case len(args) >= 2 && args[0] == "network" && args[1] == "create":
return "", nil
}
return "", nil
}
func parseRunNameAndLabel(args []string) (name, hash string) {
for i := 0; i < len(args)-1; i++ {
switch args[i] {
case "--name":
name = args[i+1]
case "--label":
if v, ok := strings.CutPrefix(args[i+1], specLabel+"="); ok {
hash = v
}
}
}
return name, hash
}
func containerResource(t *testing.T, name, image string) Resource {
t.Helper()
d := mustParse(t, `{"version":1,"resources":[
{"id":"`+name+`","type":"container","name":"`+name+`","image":"`+image+`",
"network":"n","env":{"A":"1","B":"2"},"ports":["3000"],
"volumes":["/services/x/data:/data"]}]}`)
return d.Resources[0]
}
// A container that does not exist is created; applying the same declaration again finds it up to
// date and starts nothing.
func TestContainerApplyCreatesThenIsIdempotent(t *testing.T) {
f := newFakeRuntime()
c := containerApplier{run: f.run}
r := containerResource(t, "gitea", "gitea/gitea@sha256:abc")
created, err := c.Apply(r)
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("first apply did not report creating the container")
}
if countCalls(f.calls, "run") != 1 {
t.Fatalf("expected exactly one run, got calls: %v", f.calls)
}
f.calls = nil
created, err = c.Apply(r)
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("second apply recreated an up-to-date container")
}
if countCalls(f.calls, "run") != 0 {
t.Fatalf("idempotent apply still ran the container: %v", f.calls)
}
}
// A container whose spec changed is recreated: the old one removed, a new one started.
func TestContainerRecreatedWhenSpecChanges(t *testing.T) {
f := newFakeRuntime()
c := containerApplier{run: f.run}
if _, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:old")); err != nil {
t.Fatal(err)
}
f.calls = nil
// A new image is a new spec hash.
if _, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:new")); err != nil {
t.Fatal(err)
}
if countCalls(f.calls, "rm") != 1 || countCalls(f.calls, "run") != 1 {
t.Fatalf("a changed spec should remove and recreate; calls: %v", f.calls)
}
}
// A foreign container by the same name — no spec label — is taken over, because a container holds
// no state; its data is in bind mounts declared separately.
func TestForeignContainerIsTakenOver(t *testing.T) {
f := newFakeRuntime()
f.exists["gitea"] = true
f.running["gitea"] = true
f.label["gitea"] = "" // started by something else, unlabelled
c := containerApplier{run: f.run}
created, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:abc"))
if err != nil {
t.Fatal(err)
}
if !created {
t.Fatal("taking over a foreign container should count as creating ours")
}
if countCalls(f.calls, "rm") != 1 {
t.Fatalf("the foreign container should have been replaced: %v", f.calls)
}
}
// runArgs carries every declared facet through to the invocation, env in sorted order.
func TestRunArgsAreComplete(t *testing.T) {
r := containerResource(t, "gitea", "img@sha256:abc")
args := strings.Join(runArgs(r, "hash123"), " ")
for _, want := range []string{
"run -d --name gitea --label mesh-host.spec=hash123",
"--network n", "--env-file", "-e A=1 -e B=2", "-p 3000",
"-v /services/x/data:/data", "img@sha256:abc",
} {
if want == "--env-file" {
continue // this resource declares none; the others must all be present
}
if !strings.Contains(args, want) {
t.Errorf("run args missing %q\n got: %s", want, args)
}
}
}
// A container resource that names no image is refused rather than started blank.
func TestContainerWithoutImageIsRefused(t *testing.T) {
// Built directly: the shape allows omitting image, but the applier must not.
d := mustParse(t, `{"version":1,"resources":[
{"id":"x","type":"container","name":"x"}]}`)
f := newFakeRuntime()
if _, err := (containerApplier{run: f.run}).Apply(d.Resources[0]); err == nil {
t.Fatal("a container with no image was started")
}
}
func countCalls(calls []string, verb string) int {
n := 0
for _, c := range calls {
// docker <verb> ... — verb is the first arg after the runtime name.
fields := strings.Fields(c)
if len(fields) >= 2 && fields[1] == verb {
n++
}
}
return n
}
// Smoke test against the real runtime: a container really comes up, is idempotent, and is
// removed — the read-back path this whole design rests on, exercised for real. Skipped where the
// runtime or its image is not available, never failed for the environment (novox/hq ADR 0034).
func TestContainerAgainstRealRuntime(t *testing.T) {
if _, err := exec.LookPath(containerRuntime); err != nil {
t.Skipf("%s not installed", containerRuntime)
}
if out, err := exec.Command(containerRuntime, "run", "--rm", "alpine", "true").CombinedOutput(); err != nil {
t.Skipf("cannot run a probe container (no image/daemon): %s", strings.TrimSpace(string(out)))
}
name := "mesh-host-apply-smoke"
_ = exec.Command(containerRuntime, "rm", "-f", name).Run()
t.Cleanup(func() { _ = exec.Command(containerRuntime, "rm", "-f", name).Run() })
r := mustParse(t, `{"version":1,"resources":[
{"id":"c","type":"container","name":"`+name+`","image":"alpine","args":["sleep","30"]}]}`).Resources[0]
c := containerApplier{run: execRunner}
created, err := c.Apply(r)
if err != nil {
t.Fatalf("real apply failed: %v", err)
}
if !created {
t.Fatal("first real apply did not create the container")
}
// Idempotent: the running container is left alone.
created, err = c.Apply(r)
if err != nil {
t.Fatalf("real re-apply failed: %v", err)
}
if created {
t.Fatal("real re-apply recreated an up-to-date container")
}
// Removal really removes it.
if err := c.Remove(Record{ID: "c", Type: "container", Ref: name, Created: true}); err != nil {
t.Fatalf("real remove failed: %v", err)
}
if out, _ := exec.Command(containerRuntime, "inspect", name).CombinedOutput(); !strings.Contains(strings.ToLower(string(out)), "no such") {
t.Fatalf("container still present after removal: %s", out)
}
}
+231
View File
@@ -0,0 +1,231 @@
// 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 a file or directory resource lives at.
func (r Resource) Path() string {
return r.stringField("path")
}
// ref is what the store records to find this resource again for removal: a filesystem path for
// files and directories, a name for containers and networks. Every resource is addressed by one
// or the other, and the parser refuses a resource that has neither.
func (r Resource) ref() string {
if p := r.stringField("path"); p != "" {
return p
}
return r.stringField("name")
}
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
}
// stringSlice reads a field that is a JSON array of strings — ports, volumes, args, hosts.
func (r Resource) stringSlice(key string) []string {
raw, ok := r.Fields[key]
if !ok {
return nil
}
var out []string
_ = json.Unmarshal(raw, &out)
return out
}
// stringMap reads a field that is a JSON object of string→string — a container's env. Keys are
// returned sorted by the caller when order matters, so a container's spec hash is stable.
func (r Resource) stringMap(key string) map[string]string {
raw, ok := r.Fields[key]
if !ok {
return nil
}
var out map[string]string
_ = json.Unmarshal(raw, &out)
return out
}
// 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"},
"network": {"name"},
"container": {"name", "image", "env", "env-file", "ports", "volumes", "args", "hosts", "network"},
}
// 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
}
}
res := Resource{ID: id, Type: typ, Fields: extra}
if res.ref() == "" {
return Resource{}, fmt.Errorf(
"%q is a %s and names neither a path nor a name — the store would have no way to find "+
"it again", id, typ)
}
return res, 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
}
+243
View File
@@ -0,0 +1,243 @@
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, using the real container
// runtime.
func Appliers() map[string]Applier {
return appliersWith(execRunner)
}
// appliersWith builds the applier set against a given runtime runner. The filesystem appliers
// ignore it; the container and network ones drive the runtime through it, which is where a test
// substitutes a fake.
func appliersWith(run Runner) map[string]Applier {
return map[string]Applier{
"directory": directoryApplier{},
"file": fileApplier{},
"network": networkApplier{run: run},
"container": containerApplier{run: run},
}
}
// --- 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.Ref)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing directory %s (left in place): %w", rec.Ref, 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.Ref)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing file %s: %w", rec.Ref, 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
}
+87
View File
@@ -0,0 +1,87 @@
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"`
// Ref is how the resource is found again for removal: a filesystem path for files and
// directories, a container or network name for those.
Ref string `json:"ref"`
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 }