Files
mesh-host/internal/apply/apply.go
T
jschoubben 4a80cc1002 apply: tier 0 consumes a declaration and converges this machine
Stage 2 begins. The host stops only reporting and starts doing its one
job (ADR 0037): take an ordered list of typed resources and make the
machine match it, from a local file, with no mesh present.

What lands in this slice — the network-free vocabulary ADR 0043 names
first:
- Parse: JSON, refused WHOLE on an unknown version, type, field, a
  missing id/type/path, or a duplicate id. An older host cannot be
  handed a newer vocabulary and do half of it.
- directory and file appliers, each reading back after it writes —
  mode and owner asserted against the machine, content compared byte
  for byte. A value that did not take is a failed apply, not a success.
- store: the applied-state record, authoritative while disconnected,
  written atomically. It is what makes removal possible.
- Convergence: apply in the stated order (the host never reorders),
  record each success AFTER it works (ADR 0035), and remove what was
  applied before and is no longer declared — in reverse order, so a
  file goes before the directory that held it.
- The data-loss guard: the host removes ONLY what it created, never
  what it adopted, and a created directory that now holds data is
  refused (os.Remove, never RemoveAll) rather than deleted (ADR 0018,
  0030). created is sticky across re-applies — caught by running the
  real binary, not just the unit tests: recomputing it from disk made
  a re-applied resource look adopted and leak on the next drop.
- Addressing: a declaration for another node is refused; a host with
  no identity yet applies its bundle (the first-node path).

Not yet: sealed secrets, and the types that need the network or a
runtime (container, package, network, service, archive, user, action)
— they follow, and until then the host refuses them rather than doing
part of a declaration.

CLI: mesh-host apply [--store P] FILE.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-02 22:34:44 +02:00

156 lines
6.1 KiB
Go

package apply
import "fmt"
// Result is what one apply did: which resources it brought to the declared state, and which it
// removed because they were applied before and are no longer declared.
type Result struct {
Applied []Record
Removed []Record
}
// Apply makes this machine match decl, records what it did, and removes what it once applied and
// decl no longer names.
//
// The three properties that govern it are each a recorded decision, not a preference:
//
// - A failed step fails the apply (ADR 0008). This function stops at the first resource it
// cannot bring to state and returns the error. It does not log and continue: a partial apply
// that reports success is the mesh's most expensive shape.
// - What was applied is recorded after it works, never before (ADR 0035). Each success is
// appended to what the store will hold; a failure leaves the machine in whatever state it
// reached, and the store is saved reflecting exactly that — never more.
// - The host is authoritative over its own footprint and inert everywhere else (ADR 0043).
// Removal touches only resources the store recorded as created by the host.
//
// identity is this node's own name. A declaration addressed to another node is refused; a host
// with no identity yet — the first node — applies whatever it is handed, because it has nothing
// to check against (ADR 0043).
func Apply(decl Declaration, identity string, store *Store, appliers map[string]Applier) (Result, error) {
if decl.For != "" && identity != "" && decl.For != identity {
return Result{}, fmt.Errorf(
"declaration is for %q and this node is %q — refused, a node applies only what is "+
"addressed to it", decl.For, identity)
}
prior := store.Records()
declared := make(map[string]bool, len(decl.Resources))
for _, r := range decl.Resources {
declared[r.ID] = true
}
// Who created what, carried across applies. "created" must be sticky: once the host brought a
// resource into being, it stays the creator through every re-apply, or a second apply would
// see the resource already present, record created=false, and then decline to remove
// something it in fact created. That flip would leak a host-created resource on the next
// declaration that drops it — reported handled, actually orphaned.
priorCreated := make(map[string]bool, len(prior))
for _, rec := range prior {
priorCreated[rec.ID] = rec.Created
}
// Apply in the stated order, recording each success as it lands.
applied := make([]Record, 0, len(decl.Resources))
for _, r := range decl.Resources {
a, ok := appliers[r.Type]
if !ok {
// Parse already refused unknown types, so this is a host wired inconsistently with
// its own shape table — a bug, surfaced rather than skipped.
err := fmt.Errorf("resource %q is a %q with no applier — refusing", r.ID, r.Type)
saveMerged(store, prior, applied)
return Result{}, err
}
created, err := a.Apply(r)
if err != nil {
// The resource is not at the declared state. Record what did land (this one did not,
// so it is not appended), persist that, and fail.
saveMerged(store, prior, applied)
return Result{}, fmt.Errorf("applying %s %q: %w", r.Type, r.ID, err)
}
applied = append(applied, Record{
ID: r.ID, Type: r.Type, Path: r.Path(),
Created: created || priorCreated[r.ID],
})
}
// Remove what was applied before and is no longer declared, in reverse application order so
// a file goes before the directory that held it. Only host-created resources are touched.
removedIDs := map[string]bool{}
var removed []Record
for i := len(prior) - 1; i >= 0; i-- {
rec := prior[i]
if declared[rec.ID] {
continue
}
if rec.Created {
if a, ok := appliers[rec.Type]; ok {
if err := a.Remove(rec); err != nil {
// A removal that failed leaves the resource present. That is a failed step,
// so the apply fails — and the store must reflect reality: the declared set
// that landed, plus every undeclared prior record not yet removed (this one
// included), in application order.
store.replace(survivorsAfterFailedRemoval(applied, prior, declared, removedIDs))
_ = store.Save()
return Result{}, fmt.Errorf("removing %s %q: %w", rec.Type, rec.ID, err)
}
}
}
removedIDs[rec.ID] = true
removed = append(removed, rec)
}
// Success: the store now holds exactly the declared set, freshly recorded.
store.replace(applied)
if err := store.Save(); err != nil {
return Result{}, fmt.Errorf("apply succeeded but its record could not be saved: %w", err)
}
return Result{Applied: applied, Removed: removed}, nil
}
// survivorsAfterFailedRemoval is what the machine still holds when a removal fails: the declared
// set that was just applied, plus every prior undeclared record not successfully removed —
// including the one whose removal failed — kept in application order.
func survivorsAfterFailedRemoval(applied, prior []Record, declared, removedIDs map[string]bool) []Record {
survivors := append([]Record{}, applied...)
for _, rec := range prior {
if declared[rec.ID] || removedIDs[rec.ID] {
continue
}
survivors = append(survivors, rec)
}
return survivors
}
// saveMerged persists prior records overlaid with what was just applied, for the failure path:
// the machine holds both the untouched prior resources and the ones that landed before the
// failure, so the store must record both.
func saveMerged(store *Store, prior, applied []Record) {
store.replace(mergeByID(prior, applied))
_ = store.Save()
}
// mergeByID returns base with overlay applied on top, overlay winning on a shared id, preserving
// base order and appending overlay-only records.
func mergeByID(base, overlay []Record) []Record {
byID := make(map[string]Record, len(overlay))
for _, r := range overlay {
byID[r.ID] = r
}
out := make([]Record, 0, len(base)+len(overlay))
seen := map[string]bool{}
for _, r := range base {
if o, ok := byID[r.ID]; ok {
out = append(out, o)
seen[r.ID] = true
continue
}
out = append(out, r)
}
for _, r := range overlay {
if !seen[r.ID] {
out = append(out, r)
}
}
return out
}