apply: tier 0 consumes a declaration and converges this machine #1
@@ -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
@@ -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("", " ")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user