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

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:
jochen
2026-10-08 01:44:43 +02:00
parent 5ddc59cd32
commit 4499e85476
18 changed files with 1073 additions and 75 deletions
+22 -11
View File
@@ -168,11 +168,14 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
nil)},
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
"or, with files, the files it would be given.",
"or, with files, the files it would be given; or, with diff, what a push would change there against what " +
"it was last sent: resources added, removed and changed, a container's changed fields (it is recreated), " +
"and any mount whose directory on the machine would move (novox/hq ADR 0217).",
Input: schema(map[string]string{
"node": "the machine's name",
"files": "\"true\": the files this machine would be given, instead of the declaration as JSON",
}, []string{"node"}, "files")},
"diff": "\"true\": what a push would change on this machine, against what it was last sent; not with files",
}, []string{"node"}, "files", "diff")},
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
Input: schema(map[string]string{"node": "the machine's name",
@@ -194,9 +197,12 @@ var ControllerVerbs = []Verb{
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
"Answers at once that it is running, with a call id: `calls` with that id says what it sent " +
"(a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7).",
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
"`plan` with diff, and name the machine with move to send it.",
Input: schema(map[string]string{
"node": "the machine's name; without it, every machine that is behind",
"move": "with node: the modules whose data may move with this push, comma-separated (optional)",
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
"why": "why this is pushed by hand: recorded in the hand-act log",
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
@@ -222,15 +228,20 @@ var ControllerVerbs = []Verb{
"node": "the machine that runs the module",
"module": "the module's name",
}, []string{"node", "module"})},
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it; " +
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
"what its definition says; a cleared or replaced layer is kept in the history.",
Input: schema(map[string]string{
"module": "the module's name",
"values": "the settings as a JSON object, for set",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it; not with values",
}, []string{"module"}, "clear")},
"module": "the module's name",
"values": "the settings as a JSON object, for set",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it; not with values",
"replace": "\"true\": with values, the set is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
}, []string{"module"}, "clear", "replace", "history")},
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
+34 -12
View File
@@ -793,11 +793,23 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
return fmt.Errorf("%s: %s is given per node — a port is a fact about one machine; "+
"set it with --node", module, catalogue.PortsSetting)
}
_, err = i.store.Pool().Exec(ctx,
// The layer it replaces is kept (novox/hq ADR 0217), in the same transaction as the write: a
// previous value is one command away, not in a backup, and a write that fails leaves no history.
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, keepReplacedSQL, module, nil, "set"); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`insert into settings (node, module, values) values (null, $1, $2)
on conflict (module) where node is null
do update set values = excluded.values, set_at = now()`, module, raw)
return wrapModule(err, module)
do update set values = excluded.values, set_at = now()`, module, raw); err != nil {
return wrapModule(err, module)
}
return tx.Commit(ctx)
}
node, err := i.NodeByName(ctx, nodeName)
if err != nil {
@@ -817,6 +829,10 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
return err
}
}
// The layer it replaces is kept (novox/hq ADR 0217), in the same transaction as the write.
if _, err := tx.Exec(ctx, keepReplacedSQL, module, node.ID, "set"); err != nil {
return err
}
_, err = tx.Exec(ctx,
`insert into settings (node, module, values) values ($1, $2, $3)
on conflict (node, module) where node is not null
@@ -1009,18 +1025,24 @@ func wrapModule(err error, module string) error {
// ClearSettings removes a layer.
func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error {
if nodeName == "" {
_, err := i.store.Pool().Exec(ctx,
`delete from settings where module = $1 and node is null`, module)
return err
}
node, err := i.NodeByName(ctx, nodeName)
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`delete from settings where module = $1 and node = $2`, module, node.ID)
return err
// The layer it removes is kept (novox/hq ADR 0217), in the same transaction as the removal.
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, keepReplacedSQL, module, nodeID, "clear"); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`delete from settings where module = $1 and node is not distinct from $2::uuid`, module, nodeID); err != nil {
return err
}
return tx.Commit(ctx)
}
// SettingsFor is the layers that apply to one module on one node, in the order they are applied.
@@ -0,0 +1,26 @@
-- What a change replaces (novox/hq ADR 0217, to-be 44).
--
-- Two records the mesh did not keep, and each time a change took effect unseen it was the one
-- missing. A settings layer is replaced whole, and the layer it replaced was nowhere: on 2026-10-05
-- one placement set for one module on one machine dropped that machine's whole layer for it, and
-- the old one was read back from a database backup (novox/hq issue 304). And of what a machine was sent the
-- mesh kept only a digest — enough to say *whether* it changed, never *what*.
-- Every layer that was replaced or cleared, with when it had been set and when it went. Not a
-- foreign key to settings: the row it was is the row being replaced.
create table settings_history (
node uuid references node(id) on delete cascade,
module text not null,
values jsonb not null,
set_at timestamptz,
replaced_at timestamptz not null default now(),
-- set · clear
replaced_by text not null
);
create index settings_history_by_layer on settings_history (module, node, replaced_at desc);
-- What a machine was last sent, summarised: per resource its id, type, a digest of it and of each
-- field, and a container's mounts. Not the declaration: a file's content may carry a secret, and
-- this lands in the store's backups. Read only to compare a plan with what was sent; what a machine
-- *should* be is composed from the mesh's records every time, as before (migration 0014).
alter table node add column sent_summary jsonb;
+114
View File
@@ -0,0 +1,114 @@
package inventory
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// What a change replaces (novox/hq ADR 0217, to-be 44): the settings layer a set or a clear replaced,
// and a summary of what a machine was last sent. Both were missing when a change took effect unseen.
// Layer is one settings layer as it stands — the whole mesh's when nodeName is empty — and whether
// there is one. A layer is replaced whole when set; reading it first is how one key is changed
// without losing the others.
func (i *Inventory) Layer(ctx context.Context, nodeName, module string) (map[string]any, bool, error) {
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return nil, false, err
}
var raw []byte
err = i.store.Pool().QueryRow(ctx,
`select values from settings where module = $1 and node is not distinct from $2::uuid`,
module, nodeID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
values := map[string]any{}
if err := json.Unmarshal(raw, &values); err != nil {
return nil, false, err
}
return values, true, nil
}
// PastLayer is a layer that was replaced or cleared.
type PastLayer struct {
Values map[string]any
SetAt *time.Time
ReplacedAt time.Time
// ReplacedBy is "set" or "clear".
ReplacedBy string
}
// SettingsHistory is every layer of one module on one machine — the whole mesh's when nodeName is
// empty — that was replaced or cleared, the latest first.
func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string) ([]PastLayer, error) {
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select values, set_at, replaced_at, replaced_by from settings_history
where module = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
module, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PastLayer
for rows.Next() {
var raw []byte
var p PastLayer
if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy); err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &p.Values); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// keepReplacedSQL copies a layer into the history before it is replaced or cleared; run in the same
// transaction as the write where there is one, so a write that fails leaves no history of it.
const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by)
select node, module, values, set_at, $3 from settings
where module = $1 and node is not distinct from $2::uuid`
// layerNode is the node id of a layer, nil for the whole mesh's.
func (i *Inventory) layerNode(ctx context.Context, nodeName string) (*string, error) {
if nodeName == "" {
return nil, nil
}
n, err := i.NodeByName(ctx, nodeName)
if err != nil {
return nil, err
}
return &n.ID, nil
}
// RecordSentSummary keeps what a machine was last sent, summarised (migration 0078): read only to
// compare what would be sent with it, never as what the machine should be.
func (i *Inventory) RecordSentSummary(ctx context.Context, node string, summary []byte) error {
_, err := i.store.Pool().Exec(ctx, `update node set sent_summary = $2 where id = $1`, node, summary)
return err
}
// SentSummary is what a machine was last sent, summarised, by its name; empty for a machine sent
// nothing since the summary was first kept.
func (i *Inventory) SentSummary(ctx context.Context, name string) ([]byte, error) {
var raw []byte
err := i.store.Pool().QueryRow(ctx,
`select sent_summary from node where name = $1`, name).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return raw, err
}
+104
View File
@@ -0,0 +1,104 @@
package inventory
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// novox/hq ADR 0217: a settings layer can be read, and the one a set or a clear replaced is kept, so
// a previous value is one command away rather than in a database backup (novox/hq issue 304).
func TestTheLayerASetReplacesIsKeptAndReadable(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
web := catalogue.Manifest{Module: "web", Version: "1",
Resources: []map[string]any{
{"id": "server", "type": "container", "name": "web", "image": "x"},
{"id": "conf", "type": "file", "path": "/etc/web.json", "content": "{}", "merge": "json"},
}}
if err := inv.RegisterModule(ctx, web, Source{}); err != nil {
t.Fatal(err)
}
if _, has, err := inv.Layer(ctx, "anchor", "web"); err != nil || has {
t.Fatalf("a layer nobody set: %v %v", has, err)
}
first := map[string]any{"colour": "blue", "size": "large"}
if err := inv.SetSettings(ctx, "anchor", "web", first); err != nil {
t.Fatal(err)
}
got, has, err := inv.Layer(ctx, "anchor", "web")
if err != nil || !has || got["colour"] != "blue" || got["size"] != "large" {
t.Fatalf("the layer read back: %v %v %v", got, has, err)
}
if past, err := inv.SettingsHistory(ctx, "anchor", "web"); err != nil || len(past) != 0 {
t.Fatalf("a first set replaced something: %v %v", past, err)
}
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{"colour": "red"}); err != nil {
t.Fatal(err)
}
if err := inv.ClearSettings(ctx, "anchor", "web"); err != nil {
t.Fatal(err)
}
past, err := inv.SettingsHistory(ctx, "anchor", "web")
if err != nil || len(past) != 2 {
t.Fatalf("history %v %v", past, err)
}
// The latest first: the clear took the red layer, the set took the first.
if past[0].ReplacedBy != "clear" || past[0].Values["colour"] != "red" {
t.Errorf("the cleared layer: %+v", past[0])
}
if past[1].ReplacedBy != "set" || past[1].Values["size"] != "large" {
t.Errorf("the replaced layer still has what the set dropped: %+v", past[1])
}
// The mesh-wide layer keeps its own history, apart from the node's.
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil {
t.Fatal(err)
}
mesh, err := inv.SettingsHistory(ctx, "", "web")
if err != nil || len(mesh) != 1 || mesh[0].Values["colour"] != "green" {
t.Fatalf("the mesh-wide history %v %v", mesh, err)
}
// And a mesh-wide clear keeps the layer it removes, beside the node's untouched.
if err := inv.ClearSettings(ctx, "", "web"); err != nil {
t.Fatal(err)
}
if _, has, err := inv.Layer(ctx, "", "web"); err != nil || has {
t.Fatalf("a cleared mesh-wide layer is still there: %v %v", has, err)
}
mesh, err = inv.SettingsHistory(ctx, "", "web")
if err != nil || len(mesh) != 2 || mesh[0].ReplacedBy != "clear" || mesh[0].Values["colour"] != "grey" {
t.Fatalf("the mesh-wide clear was not kept: %+v %v", mesh, err)
}
if past, err := inv.SettingsHistory(ctx, "anchor", "web"); err != nil || len(past) != 2 {
t.Fatalf("the node's history changed with the mesh's: %v %v", past, err)
}
}
// What a machine was last sent is kept, summarised, and read back by its name; a machine sent
// nothing since has nothing to compare with.
func TestWhatAMachineWasSentIsKeptForComparison(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
n, err := inv.AddNode(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if got, err := inv.SentSummary(ctx, "anchor"); err != nil || len(got) != 0 {
t.Fatalf("a machine never sent anything: %s %v", got, err)
}
if err := inv.RecordSentSummary(ctx, n.ID, []byte(`[{"id":"web.server","type":"container"}]`)); err != nil {
t.Fatal(err)
}
got, err := inv.SentSummary(ctx, "anchor")
if err != nil || len(got) == 0 {
t.Fatalf("read back: %s %v", got, err)
}
}