No change to a machine takes effect unseen (hq ADR 0217, to-be 44)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/no-change-takes-effect-unseen delivered: every member is delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/no-change-takes-effect-unseen delivered: every member is delivered
Two incidents had one shape: a change took effect that nobody saw first (hq issues 241, 304). Three guards, each silent when nothing is at stake: - settings show [--history], and a set that answers each key it adds, changes and removes, and refuses a removal unless --replace; the replaced or cleared layer is kept in settings_history, in the same transaction as the write (migration 0078). - every send keeps a summary of what it sent (no file content), plan <node> --diff compares with it, and push with no machine is refused unless --all. - a push that would give a running container's mount another host directory holds that machine, naming the module, mount and both directories, until push <node> --move <module>; a named push's cascade is held the same way. Rebased onto main and fitted to it: the hold runs before the push says what it recreates, a whole push still says so first and leaves machines waiting for a gate, the verb's push with no machine is still --behind and a push still needs why. The verbs take plan diff, settings replace and history, and push move beside a machine, each refused where it cannot take effect.
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 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 `<module>.<id>`.
|
||||
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
|
||||
// <module>`); 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
|
||||
}
|
||||
Reference in New Issue
Block a user