Files
mesh-controller/internal/catalogue/resolve_test.go
T
jschoubben 65ade756f2 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.
2026-08-29 23:01:09 +02:00

329 lines
12 KiB
Go

package catalogue
import (
"fmt"
"strings"
"testing"
)
func mod(name string, provides, requires, capabilities []string, claims ...Claim) Manifest {
return Manifest{Module: name, Provides: provides, Requires: requires,
Capabilities: capabilities, Claims: claims}
}
func shelf(ms ...Manifest) map[string]Manifest {
out := map[string]Manifest{}
for _, m := range ms {
out[m.Module] = m
}
return out
}
func workstation() Node {
return Node{Name: "workstation", Site: "house",
Capabilities: map[string]bool{"seat": true, "container-runtime": true}}
}
func names(r Resolution) []string {
var out []string
for _, m := range r.Modules {
out = append(out, m.Module)
}
return out
}
func TestARequirementWithOneAnswerIsTakenSilently(t *testing.T) {
// `install i3` should bring in xorg without asking anybody anything, because there was no
// choice to make. This is what keeps the refusing rule from being tiresome.
got, err := Resolve(shelf(
mod("i3", nil, []string{"xorg"}, []string{"seat"}),
mod("xorg", []string{"display-server"}, nil, []string{"seat"}, Claim{Name: "the-seat"}),
), []string{"i3"}, workstation(), nil)
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 2 {
t.Fatalf("resolved %v; i3 should have brought xorg with it", names(got))
}
if got.Because["xorg"] == "assigned" {
t.Error("xorg is recorded as assigned; it was required")
}
}
func TestARequirementWithSeveralAnswersIsRefusedAndNamed(t *testing.T) {
// The mesh does not pick. A default would be a choice made for somebody who finds out later,
// and naming the candidates is the whole remedy.
_, err := Resolve(shelf(
mod("editor", nil, []string{"shell"}, nil),
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"editor"}, workstation(), nil)
if err == nil {
t.Fatal("a requirement with three answers was resolved without asking")
}
for _, want := range []string{"bash", "fish", "zsh", "choose one"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not mention %q: %v", want, err)
}
}
}
func TestARequirementWithNoAnswerIsRefused(t *testing.T) {
_, err := Resolve(shelf(mod("i3", nil, []string{"xorg"}, nil)),
[]string{"i3"}, workstation(), nil)
if err == nil || !strings.Contains(err.Error(), "nothing provides") {
t.Fatalf("a requirement nothing satisfies gave %v", err)
}
}
func TestSeveralModulesMayProvideTheSameThingAndCoexist(t *testing.T) {
// Shells. Nothing is claimed, so any number may be assigned — which is the case that made
// "flavor" look necessary and turns out to need nothing at all.
got, err := Resolve(shelf(
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"bash", "zsh", "fish"}, workstation(), nil)
if err != nil {
t.Fatalf("three shells could not coexist: %v", err)
}
if len(got.Modules) != 3 {
t.Errorf("resolved %v", names(got))
}
}
func TestTwoModulesClaimingOneThingAreRefused(t *testing.T) {
// xorg and wayland. Neither knows the other exists — the refusal comes from both claiming
// the seat, which is what lets a third display server be added without editing either.
_, err := Resolve(shelf(
mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}),
mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}),
), []string{"xorg", "wayland"}, workstation(), nil)
if err == nil {
t.Fatal("two modules claiming the seat were both assigned")
}
if !strings.Contains(err.Error(), "the-seat") || !strings.Contains(err.Error(), "per node") {
t.Errorf("the refusal does not say what was claimed or how widely: %v", err)
}
}
func TestAThirdModuleNeedsNoChangeToTheOthers(t *testing.T) {
// The property claims exist for. A third display server says what it claims and nothing else
// in the catalogue is touched — where pairwise exclusion would need xorg and wayland edited
// to know about it, and the edits would grow as the square of the count.
catalogue := shelf(
mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}),
mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}),
mod("mir", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}),
)
for _, pair := range [][]string{{"xorg", "mir"}, {"wayland", "mir"}} {
if _, err := Resolve(catalogue, pair, workstation(), nil); err == nil {
t.Errorf("%v were both assigned", pair)
}
}
if _, err := Resolve(catalogue, []string{"mir"}, workstation(), nil); err != nil {
t.Errorf("the newcomer alone was refused: %v", err)
}
}
func TestAMissingCapabilityIsSaidToBeTheWrongMachine(t *testing.T) {
// The remedy differs from a missing module and the message has to say which. Nothing can be
// installed to give a server a seat.
server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}}
_, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})),
[]string{"xorg"}, server, nil)
if err == nil {
t.Fatal("a display server was assigned to a machine with no seat")
}
if !strings.Contains(err.Error(), "wrong machine") {
t.Errorf("the refusal reads like a missing module: %v", err)
}
}
func TestAMeshWideClaimIsHeldByOneNode(t *testing.T) {
// The hub, said as a claim rather than hard-coded. Another node already holds it, so this one
// cannot.
_, err := Resolve(shelf(mod("hub", nil, nil, nil, Claim{Name: "the-hub", Scope: ScopeMesh})),
[]string{"hub"}, workstation(),
[]Held{{Claim: "the-hub", Scope: ScopeMesh, Node: "anchor", Module: "hub"}})
if err == nil {
t.Fatal("two nodes both hold a mesh-wide claim")
}
if !strings.Contains(err.Error(), "anchor") || !strings.Contains(err.Error(), "one per mesh") {
t.Errorf("the refusal does not say who holds it: %v", err)
}
}
func TestASiteClaimOnlyCollidesWithinThatSite(t *testing.T) {
// A DHCP server per segment. Two of them is a fault at one site and perfectly ordinary
// across two, and treating site as mesh would forbid the ordinary case.
catalogue := shelf(mod("dhcp", nil, nil, nil, Claim{Name: "dhcp", Scope: ScopeSite}))
elsewhere := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "house"}}
if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), elsewhere); err == nil {
t.Error("two DHCP servers at one site were allowed")
}
faraway := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "office"}}
if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), faraway); err != nil {
t.Errorf("a DHCP server at another site was treated as a collision: %v", err)
}
}
func TestTwoModulesWritingOneFileAreRefusedWithoutAnyClaim(t *testing.T) {
// This conflict costs no manifest field: the mesh already holds every resource of every
// module, so two declaring one path are visible without either knowing the other exists.
a := mod("a", nil, nil, nil)
a.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}}
b := mod("b", nil, nil, nil)
b.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}}
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), nil)
if err == nil {
t.Fatal("two modules writing the same file were both assigned")
}
if !strings.Contains(err.Error(), "/etc/thing.conf") {
t.Errorf("the refusal does not name the file: %v", err)
}
}
func TestResourceIdentitiesCarryTheirModule(t *testing.T) {
// Two modules may reasonably both call something "config". Without the prefix the second
// would silently replace the first, and the node would apply one of them and report success.
a := mod("a", nil, nil, nil)
a.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/a"}}
b := mod("b", nil, nil, nil)
b.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/b"}}
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), nil)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, r := range mustDeclare(t, got) {
id := r["id"].(string)
if seen[id] {
t.Errorf("two resources share the identity %q", id)
}
seen[id] = true
}
if !seen["a.config"] || !seen["b.config"] {
t.Errorf("identities are not qualified by module: %v", seen)
}
}
func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) {
// Otherwise it names a resource that no longer exists under that identity, and the service
// quietly stops being restarted when its own configuration changes.
m := mod("thing", nil, nil, nil)
m.Resources = []map[string]any{
{"id": "conf", "type": "file", "path": "/etc/thing.conf"},
{"id": "svc", "type": "service", "unit": "thing.service", "state": "running",
"restart-on": []any{"conf"}},
}
got, err := Resolve(shelf(m), []string{"thing"}, workstation(), nil)
if err != nil {
t.Fatal(err)
}
for _, r := range mustDeclare(t, got) {
if r["id"] == "thing.svc" {
if got := fmt.Sprint(r["restart-on"]); got != "[thing.conf]" {
t.Errorf("a service reflects %s, which is not a resource in the declaration", got)
}
return
}
}
t.Error("the service is missing from the declaration")
}
func TestEveryReasonIsGivenAtOnce(t *testing.T) {
// Somebody resolving these fixes them in one pass or in four.
server := Node{Name: "server", Capabilities: map[string]bool{}}
_, err := Resolve(shelf(
mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"}),
mod("wayland", nil, nil, []string{"seat"}, Claim{Name: "the-seat"}),
), []string{"xorg", "wayland"}, server, nil)
if err == nil {
t.Fatal("expected refusals")
}
if strings.Count(err.Error(), "\n - ") < 3 {
t.Errorf("only some problems were reported:\n%v", err)
}
}
func TestACycleStopsRatherThanRunsAway(t *testing.T) {
// Two modules requiring each other is a mistake somebody makes, and it must produce an answer
// rather than a stack overflow.
got, err := Resolve(shelf(
mod("a", nil, []string{"b"}, nil),
mod("b", nil, []string{"a"}, nil),
), []string{"a"}, workstation(), nil)
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 2 {
t.Errorf("a cycle resolved to %v", names(got))
}
}
func TestChoosingOneSatisfiesTheRequirement(t *testing.T) {
// The other half of refusing. "Choose one and assign it" has to actually work, or the remedy
// names three modules and then ignores the one you pick — which is how this read the first
// time it was used on a real mesh.
got, err := Resolve(shelf(
mod("editor", nil, []string{"shell"}, nil),
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"editor", "zsh"}, workstation(), nil)
if err != nil {
t.Fatalf("choosing a shell did not satisfy the requirement for one: %v", err)
}
if len(got.Modules) != 2 {
t.Errorf("resolved %v; only the chosen shell should have come in", names(got))
}
}
func TestChoosingSeveralIsStillFine(t *testing.T) {
// And the choice is not exclusive. Nothing is claimed, so a person may have all three and
// the requirement is answered by whichever they picked.
got, err := Resolve(shelf(
mod("editor", nil, []string{"shell"}, nil),
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"editor", "zsh", "bash", "fish"}, workstation(), nil)
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 4 {
t.Errorf("resolved %v", names(got))
}
}
func TestARequirementNamingAModuleMeansThatModule(t *testing.T) {
// i3 requires xorg and means xorg, not "anything calling itself a display server". Otherwise
// assigning wayland would silently satisfy i3 and the machine would come up with a window
// manager talking to nothing.
_, err := Resolve(shelf(
mod("i3", nil, []string{"xorg"}, nil),
mod("xorg", []string{"display-server"}, nil, nil),
mod("wayland", []string{"display-server", "xorg"}, nil, nil),
), []string{"i3", "wayland"}, workstation(), nil)
// wayland claiming to provide "xorg" is a manifest saying something untrue; what matters is
// that a real xorg module still wins when it exists, and that the answer is not silent.
if err != nil && !strings.Contains(err.Error(), "xorg") {
t.Errorf("unexpected refusal: %v", err)
}
}
func mustDeclare(t *testing.T, r Resolution) []map[string]any {
t.Helper()
out, err := r.Declaration(nil)
if err != nil {
t.Fatal(err)
}
return out
}