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.
339 lines
11 KiB
Go
339 lines
11 KiB
Go
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
|
|
}
|