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(Rendering{}) if err != nil { t.Fatal(err) } return out }