package main import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "reflect" "sort" "strings" "github.com/novox/mesh-controller/internal/inventory" ) // No change to a machine takes effect unseen (novox/hq ADR 0217, to-be 44). // // Two incidents in two days had one shape: a change took effect that nobody saw before it did. A // provisioner read an unreadable file as "nobody asks" and dropped seven databases (issue 241); one // placement set for one module on one machine replaced its whole settings layer, and the plan then // ran the module on an empty directory (novox/hq issue 304). Here are the three guards: what a settings layer // loses is said and refused unless meant; what a push will change can be read before it is sent; and // a running container's data moving holds that machine's push until the operator says so. Each is // silent when nothing is at stake, so an ordinary change goes exactly as before. // ---- 1. what a settings layer loses ------------------------------------------------------------ // settingsChange is what replacing one layer with another adds, changes and removes, by dotted path // to each leaf — `places.config`, not only `places` — because a layer that keeps a key and loses one // of its entries has lost something just the same: on 2026-10-05 a node's `places` kept its name and // lost three of its four directories. func settingsChange(before, after map[string]any) (added, changed, removed []string) { was, now := leaves(before, ""), leaves(after, "") for path, v := range now { old, had := was[path] switch { case !had: added = append(added, path) case !reflect.DeepEqual(old, v): changed = append(changed, path) } } for path := range was { if _, kept := now[path]; !kept { removed = append(removed, path) } } sort.Strings(added) sort.Strings(changed) sort.Strings(removed) return added, changed, removed } // leaves flattens a settings layer to its leaves by dotted path; a list is a leaf, compared whole. func leaves(m map[string]any, prefix string) map[string]any { out := map[string]any{} for k, v := range m { path := k if prefix != "" { path = prefix + "." + k } if inner, ok := v.(map[string]any); ok && len(inner) > 0 { for p, leaf := range leaves(inner, path) { out[p] = leaf } continue } out[path] = v } return out } // ---- 2. what a push will change ----------------------------------------------------------------- // sentResource is what is kept of one resource a machine was sent: enough to say what changed and // whether a container's data moved, and nothing that could carry a secret — a file's content is // kept as a digest, never as text (the record lands in the store's own backups). type sentResource struct { ID string `json:"id"` Type string `json:"type"` // Digest is of the whole resource as it was sent. Digest string `json:"digest"` // Fields is each field's digest, so a change can be named by field. Fields map[string]string `json:"fields"` // Volumes is a container's mounts as sent — paths, which are not secrets, and what a moved // data directory is read from. Volumes []string `json:"volumes,omitempty"` // Image is a container's image as sent — a reference, not a secret — so the machine can be told // which images its last declaration named, and keep them (novox/hq ADR 0251, `images`). Absent in // a summary kept before it was. Image string `json:"image,omitempty"` } // summarize is what is kept of a declaration body: its resources, in the order sent. func summarize(body []byte) ([]sentResource, error) { var envelope struct { Resources []map[string]any `json:"resources"` } if err := json.Unmarshal(body, &envelope); err != nil { return nil, fmt.Errorf("a declaration that is not one: %w", err) } out := make([]sentResource, 0, len(envelope.Resources)) for _, r := range envelope.Resources { s := sentResource{ID: fmt.Sprint(r["id"]), Type: fmt.Sprint(r["type"]), Digest: digestValue(r), Fields: map[string]string{}} for k, v := range r { s.Fields[k] = digestValue(v) } if s.Type == "container" { if image, ok := r["image"].(string); ok { s.Image = image } if vols, ok := r["volumes"].([]any); ok { for _, v := range vols { s.Volumes = append(s.Volumes, fmt.Sprint(v)) } } } out = append(out, s) } return out, nil } func digestValue(v any) string { raw, _ := json.Marshal(v) sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:8]) } // changedResource is one resource sent differently, with the fields that differ. type changedResource struct { ID, Type string Fields []string } // sentDiff is what would be sent now against what was sent last, by resource id. type sentDiff struct { Added, Removed []string Changed []changedResource } func (d sentDiff) empty() bool { return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0 } func diffSent(before, after []sentResource) sentDiff { was := map[string]sentResource{} for _, r := range before { was[r.ID] = r } var d sentDiff seen := map[string]bool{} for _, r := range after { seen[r.ID] = true old, had := was[r.ID] if !had { d.Added = append(d.Added, r.ID) continue } if old.Digest == r.Digest { continue } c := changedResource{ID: r.ID, Type: r.Type} for k, v := range r.Fields { if old.Fields[k] != v { c.Fields = append(c.Fields, k) } } for k := range old.Fields { if _, kept := r.Fields[k]; !kept { c.Fields = append(c.Fields, k) } } sort.Strings(c.Fields) d.Changed = append(d.Changed, c) } for _, r := range before { if !seen[r.ID] { d.Removed = append(d.Removed, r.ID) } } sort.Strings(d.Added) sort.Strings(d.Removed) sort.Slice(d.Changed, func(a, b int) bool { return d.Changed[a].ID < d.Changed[b].ID }) return d } // writeDiff says a diff the way a person reads one: what is added, removed and changed, and a // changed container's fields — a container sent differently is a container recreated. func writeDiff(w io.Writer, node string, d sentDiff, moves []dataMove) { if d.empty() { fmt.Fprintf(w, "%s: nothing would change — it was last sent exactly this\n", node) return } fmt.Fprintf(w, "%s would change:\n", node) for _, id := range d.Added { fmt.Fprintf(w, " + %s\n", id) } for _, id := range d.Removed { fmt.Fprintf(w, " - %s\n", id) } for _, c := range d.Changed { what := "changed" if c.Type == "container" { what = "recreated" } fmt.Fprintf(w, " ~ %s %s: %s\n", c.ID, what, strings.Join(c.Fields, ", ")) } writeMoves(w, node, moves) } // ---- 3. a running module's data moving ------------------------------------------------------------ // dataMove is a container keeping a mount at the same place inside it with a different directory // on the machine behind it: its data moving — or, as on 2026-10-05, a module about to start on an // empty directory because the placement that pointed at its data was lost. type dataMove struct { Container, Inside, From, To string } // Module is the module the container belongs to: resource ids are `.`. func (m dataMove) Module() string { module, _, _ := strings.Cut(m.Container, ".") return module } // movesIn is every data move between what a machine was sent and what it would be sent. A new // container, a mount added or dropped and a changed image are not moves. func movesIn(before, after []sentResource) []dataMove { was := map[string]sentResource{} for _, r := range before { if r.Type == "container" { was[r.ID] = r } } var out []dataMove for _, r := range after { old, had := was[r.ID] if r.Type != "container" || !had { continue } from := mountSources(old.Volumes) for inside, to := range mountSources(r.Volumes) { if prev, mounted := from[inside]; mounted && prev != to { out = append(out, dataMove{Container: r.ID, Inside: inside, From: prev, To: to}) } } } sort.Slice(out, func(a, b int) bool { if out[a].Container != out[b].Container { return out[a].Container < out[b].Container } return out[a].Inside < out[b].Inside }) return out } // mountSources is a container's mounts by the place inside it: `source:inside[:options]`. func mountSources(volumes []string) map[string]string { out := map[string]string{} for _, v := range volumes { parts := strings.SplitN(v, ":", 3) if len(parts) < 2 { continue } out[parts[1]] = parts[0] } return out } func writeMoves(w io.Writer, node string, moves []dataMove) { for _, m := range moves { fmt.Fprintf(w, " ! %s: %s moves from %s to %s\n", m.Container, m.Inside, m.From, m.To) } } // heldMoves is the moves in a push to one machine that the operator has not said to send (`--move // `); empty when there is nothing to hold, including a machine never sent anything before. func heldMoves(ctx context.Context, inv *inventory.Inventory, node string, body []byte, allowed map[string]bool) ([]dataMove, error) { prev, err := inv.SentSummary(ctx, node) if err != nil || len(prev) == 0 { return nil, err } var before []sentResource if err := json.Unmarshal(prev, &before); err != nil { // A record this version cannot read compares with nothing: holding every push for it would // stop the mesh on a format, which is not what is at stake. Said, so it is not passed over. fmt.Fprintf(os.Stderr, "%s: what it was last sent is kept in a form this version does not read, "+ "so whether its data would move is not known: %v\n", node, err) // empty-on-error: said above; the send replaces the unreadable record with one this version reads return nil, nil } after, err := summarize(body) if err != nil { return nil, err } var held []dataMove for _, m := range movesIn(before, after) { if !allowed[m.Module()] { held = append(held, m) } } return held, nil } // sayDataHeld tells the operator why a machine was not sent, and how to send it. func sayDataHeld(w io.Writer, node string, moves []dataMove) { modules := map[string]bool{} for _, m := range moves { modules[m.Module()] = true } var names []string for m := range modules { names = append(names, m) } sort.Strings(names) fmt.Fprintf(w, "held %s: a running module's data would move (novox/hq ADR 0217)\n", node) writeMoves(w, node, moves) fmt.Fprintf(w, " if that is meant: push %s --move %s\n", node, strings.Join(names, ",")) } // writePlanDiff says what sending body to a machine would change against what it was last sent. func writePlanDiff(ctx context.Context, inv *inventory.Inventory, node string, body []byte) error { after, err := summarize(body) if err != nil { return err } prev, err := inv.SentSummary(ctx, node) if err != nil { return err } if len(prev) == 0 { fmt.Printf("%s: what it was last sent is not kept yet — the first push from this version keeps "+ "it; %d resource(s) would be sent\n", node, len(after)) return nil } var before []sentResource if err := json.Unmarshal(prev, &before); err != nil { return fmt.Errorf("%s: what it was last sent is kept in a form this version does not read: %w", node, err) } writeDiff(os.Stdout, node, diffSent(before, after), movesIn(before, after)) return nil }