Settings: changing a module's config without editing its file

Managed files are generated and never edited, so somebody's intention about one
has to live where the generator can see it. It does now: the module ships
defaults, settings go over the top by key, and the file is produced from both.
Upstream can rewrite its half freely and the keys somebody chose survive.

Two layers, both from the start. The mesh's settings for a module, then one
machine's over those. A node that differs is expressed by differing, rather
than by restating everything the rest already say -- which would pin all of it
against future changes for no reason.

An override beats a default and there is nothing to resolve. A setting is a
statement about that key made deliberately; the default was only ever what to
do in the absence of one. So when upstream changes a key somebody has set,
there is no conflict, no merge markers, and nothing to ask.

Nested blocks merge and lists are replaced whole. Setting one field of a block
must not delete its siblings, or every setting would restate the whole block
and pin all of it. A list that merged element-wise could neither be shortened
nor reordered, and there is no correct guess about which element is "the same
one".

A module can keep specific keys for itself -- a socket path its own code
depends on -- and setting one is REFUSED rather than ignored. A setting quietly
dropped is somebody believing they changed something.

Settings that reach nothing are named at the moment they would be used, not
discovered later by the machine not behaving differently.

`plan --files` prints what a machine would be given before it is sent, because
"1 resource" does not tell you whether the merge landed.

One test kept with a note that it does not defend this code: output stability
comes from Go's encoder sorting map keys, so it passes with the merging
removed. Worth having as the thing that would catch a change of encoder, but it
is not evidence about anything written here, and it was checked.
This commit is contained in:
2026-08-29 23:01:09 +02:00
parent 653e232f1c
commit 65ade756f2
7 changed files with 624 additions and 19 deletions
+89
View File
@@ -303,3 +303,92 @@ func (i *Inventory) ProfileOf(ctx context.Context, nodeName string) (map[string]
}
return out, nil
}
// SetSettings records what somebody wants a module's configuration to say.
//
// An empty node name means the whole mesh. Replacing rather than merging what is already there:
// this is a statement of the whole layer, so removing a key is done by leaving it out, which is
// the only way removing one could work at all.
func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
if nodeName == "" {
_, err = i.store.Pool().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)
}
node, err := i.NodeByName(ctx, nodeName)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into settings (node, module, values) values ($1, $2, $3)
on conflict (node, module) where node is not null
do update set values = excluded.values, set_at = now()`, node.ID, module, raw)
return wrapModule(err, module)
}
func wrapModule(err error, module string) error {
if err != nil && strings.Contains(err.Error(), "settings_module_fkey") {
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
return err
}
// 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)
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
}
// SettingsFor is the layers that apply to one module on one node, in the order they are applied.
//
// The mesh's first, then the node's, so a node that differs is expressed by differing rather
// than by restating everything the rest of the mesh already says.
func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([]catalogue.Layer, error) {
node, err := i.NodeByName(ctx, nodeName)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select node is null, values from settings
where module = $1 and (node is null or node = $2)
order by node is null desc`, module, node.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var layers []catalogue.Layer
for rows.Next() {
var meshWide bool
var raw []byte
if err := rows.Scan(&meshWide, &raw); err != nil {
return nil, err
}
values := map[string]any{}
if err := json.Unmarshal(raw, &values); err != nil {
return nil, err
}
from := nodeName
if meshWide {
from = "the mesh"
}
layers = append(layers, catalogue.Layer{From: from, Values: values})
}
return layers, rows.Err()
}
@@ -0,0 +1,25 @@
-- What somebody wants a module's configuration to say on a node.
--
-- Managed files are generated and never edited (novox/hq ADR 0011), so an intention about one has
-- to live somewhere the generator can see it. This is that place: the file is produced from the
-- module's defaults and these together, and the module can rewrite its half freely.
create table settings (
-- Null means the whole mesh. Two layers, and both wanted from the start: "every machine
-- running this gets that" and "this one differs" are different statements, and expressing the
-- second by repeating the first would pin everything it restated against future changes.
node uuid references node(id) on delete cascade,
module text not null references module(name) on delete cascade,
values jsonb not null,
set_at timestamptz not null default now()
);
-- One row per layer per module. A partial index for each half, because null is not equal to null
-- and a plain unique constraint would let the mesh-wide layer be written twice.
create unique index settings_for_the_mesh on settings (module) where node is null;
create unique index settings_for_a_node on settings (node, module) where node is not null;
-- Settings go when their module does, unlike assignments, which hold a module in place. A setting
-- for a module nobody has is not something a machine is running -- it is a note about a thing that
-- no longer exists, and keeping it would mean the mesh reporting settings that can never apply.