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
156 lines
6.1 KiB
Go
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
|
|
}
|