No change to a machine takes effect unseen (hq ADR 0217)
Three guards, each silent when nothing is at stake: - settings: show reads a layer; set says what it adds, changes and removes, refuses a removal without --replace, and keeps the layer it replaced (settings_history, migration 0057). - push: plan --diff compares with what the machine was last sent, now kept as a summary that holds no file content; push with no machine needs --all. - a running container whose mount would point at another directory holds that machine's push until --move names the module; the other machines go ahead.
This commit is contained in:
@@ -119,8 +119,13 @@ var ControllerVerbs = []Verb{
|
||||
}, []string{"node", "provision", "from", "module"})},
|
||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
||||
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
|
||||
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
|
||||
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named. " +
|
||||
"A push that would move a running module's data to another directory is held for that machine and says so; " +
|
||||
"name the module in move to send it.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name; every machine behind when absent",
|
||||
"move": "modules whose data may move with this push, comma-separated (optional)",
|
||||
}, nil)},
|
||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
||||
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
|
||||
@@ -139,14 +144,18 @@ 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. " +
|
||||
"Setting replaces that layer whole and says what it adds, changes and removes; a set that would remove a key " +
|
||||
"is refused unless replace is \"true\". The replaced layer is kept (history). Takes effect at the next push. " +
|
||||
"With clear, removes the layer and the module is back to what its definition says.",
|
||||
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",
|
||||
"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",
|
||||
"replace": "\"true\" when the set is meant to remove keys the layer had",
|
||||
"history": "\"true\", without values: the layers this one replaced, latest first",
|
||||
}, []string{"module"})},
|
||||
{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 " +
|
||||
|
||||
@@ -691,6 +691,11 @@ 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)
|
||||
}
|
||||
// The layer it replaces is kept (novox/hq ADR 0217): a previous value is one command
|
||||
// away, not in a backup.
|
||||
if err := i.keepReplaced(ctx, nil, module, "set"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into settings (node, module, values) values (null, $1, $2)
|
||||
on conflict (module) where node is null
|
||||
@@ -715,6 +720,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
|
||||
@@ -908,6 +917,10 @@ func wrapModule(err error, module string) error {
|
||||
// ClearSettings removes a layer.
|
||||
func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error {
|
||||
if nodeName == "" {
|
||||
// The layer it removes is kept (novox/hq ADR 0217).
|
||||
if err := i.keepReplaced(ctx, nil, module, "clear"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`delete from settings where module = $1 and node is null`, module)
|
||||
return err
|
||||
@@ -916,6 +929,9 @@ func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.keepReplaced(ctx, &node.ID, module, "clear"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`delete from settings where module = $1 and node = $2`, module, node.ID)
|
||||
return err
|
||||
|
||||
@@ -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 (issue 246). 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;
|
||||
@@ -0,0 +1,120 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// keepReplaced writes down the layer about to be replaced or cleared, if there is one.
|
||||
func (i *Inventory) keepReplaced(ctx context.Context, nodeID *string, module, how string) error {
|
||||
_, err := i.store.Pool().Exec(ctx, keepReplacedSQL, module, nodeID, how)
|
||||
return 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 0057): 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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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 (issue 246).
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user