package catalogue import ( "fmt" "strings" "testing" ) func mod(name string, provides, requires, capabilities []string, claims ...Claim) Manifest { return Manifest{Module: name, Provides: Offers(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(), World{}) 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(), World{}) 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(), World{}) 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(), World{}) 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(), World{}) 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(), World{}); err == nil { t.Errorf("%v were both assigned", pair) } } if _, err := Resolve(catalogue, []string{"mir"}, workstation(), World{}); err != nil { t.Errorf("the newcomer alone was refused: %v", err) } } func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *testing.T) { // A person put a display server on a seatless machine. The remedy differs from a missing module // and the message has to say which — nothing can be installed to give a server a seat. But it is // one module on the wrong machine, not a reason the whole node fails to resolve: it is kept out // of what the node runs and reported as un-applied, so the healthy modules beside it still // converge. server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} got, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})), []string{"xorg"}, server, World{}) if err != nil { t.Fatalf("one un-hostable assignment refused the whole node: %v", err) } if len(got.Modules) != 0 { t.Errorf("xorg was declared on a machine that cannot run it: %v", names(got)) } if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "xorg" { t.Fatalf("xorg was not reported as un-applied: %+v", got.Unhostable) } if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "seat" { t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) } if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") { t.Errorf("the report reads like a missing module") } } func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) { // The other side of the distinction. A module the machine cannot host that is *required* by // something running here makes the set incoherent: the requiring module cannot have its // requirement met on this machine, so it is refused rather than quietly declared without it. server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} _, err := Resolve(shelf( mod("desktop", nil, []string{"display-server"}, nil), mod("xorg", []string{"display-server"}, nil, []string{"seat"}), ), []string{"desktop"}, server, World{}) if err == nil { t.Fatal("a node requiring a module the machine cannot host was resolved") } if !strings.Contains(err.Error(), "wrong machine") { t.Errorf("the refusal reads like a missing module: %v", err) } } func TestOneUnhostableAssignmentDoesNotTakeDownTheHealthyModules(t *testing.T) { // The bug the whole-mesh dry-run found: fail2ban declares a capability the host lacks, and its // one un-hostable assignment refused the entire node's resolution — so a push refused to send // the healthy modules beside it too. The healthy modules must still resolve and converge; the // un-hostable one is reported as un-applied, neither silently dropped nor fatal to the rest. server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} got, err := Resolve(shelf( mod("web", nil, nil, nil), mod("cache", nil, nil, nil), mod("cron", nil, nil, nil), // The one on the wrong machine: it needs a firewall the machine's profile does not report. mod("fail2ban", nil, nil, []string{"firewall"}), ), []string{"web", "cache", "cron", "fail2ban"}, server, World{}) if err != nil { t.Fatalf("one un-hostable assignment refused the whole node: %v", err) } // The healthy three resolved. if got := names(got); len(got) != 3 { t.Fatalf("the healthy modules did not all resolve: %v", got) } for _, m := range got.Modules { if m.Module == "fail2ban" { t.Error("fail2ban was declared on a machine that cannot run it") } } // The un-hostable one is reported, not silently dropped. if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "fail2ban" { t.Fatalf("fail2ban was not reported as un-applied: %+v", got.Unhostable) } if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "firewall" { t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) } } 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(), World{Held: []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(), World{Held: 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(), World{Held: 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(), World{}) 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(), World{}) 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(), World{}) 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 three. Every reason that genuinely // refuses the set is collected, rather than only the first: a claim two modules both take, a // requirement nothing answers, and a requirement several answer without anybody choosing. _, err := Resolve(shelf( mod("xorg", nil, nil, nil, Claim{Name: "the-seat"}), mod("wayland", nil, nil, nil, Claim{Name: "the-seat"}), mod("i3", nil, []string{"compositor"}, nil), mod("editor", nil, []string{"shell"}, nil), mod("bash", []string{"shell"}, nil, nil), mod("zsh", []string{"shell"}, nil, nil), ), []string{"xorg", "wayland", "i3", "editor"}, workstation(), World{}) 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(), World{}) 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(), World{}) 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(), World{}) 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(), World{}) // 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 } // A requirement answered on the same machine is still a requirement (novox/hq 04-ISSUES/021). // // **The machine stops being a trust boundary once both ends are containers.** A consumer reaches // its provider over TCP from its own container, and the database asks for a password exactly as it // would from another machine. Before this, two such modules resolved cleanly with zero needs: no // credential was made, the consumer's secret file was never written, and whatever read it failed // somewhere else entirely. // // It went unnoticed because everything proven until then was cross-machine, which is the // interesting case for a mesh and the rare one in practice. func TestSomethingAnsweredOnThisMachineIsStillANeed(t *testing.T) { provider := Manifest{ Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"), Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, } consumer := Manifest{ Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/database.env"}, } got, err := Resolve(shelf(provider, consumer), []string{"postgres", "keycloak"}, Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) if err != nil { t.Fatal(err) } if len(got.Needs) != 1 { t.Fatalf("%d need(s); a consumer of a brokered provision needs a credential wherever "+ "the provider is", len(got.Needs)) } if got.Needs[0].From != "anchor" { t.Errorf("the need names %q as the provider, and it is this machine", got.Needs[0].From) } // And it carries what the provider says a consumer must know, which a local provider never // contributes through the world. if got.Needs[0].Serves["port"] != 5432 { t.Errorf("the need carries %v, and the provider serves port 5432", got.Needs[0].Serves) } } // A name nothing grants is unchanged: answered here means answered, and nothing more is owed. func TestSomethingOrdinaryAnsweredHereNeedsNothing(t *testing.T) { got, err := Resolve(shelf( mod("zsh", []string{"shell"}, nil, nil), mod("editor-user", nil, []string{"shell"}, nil), ), []string{"zsh", "editor-user"}, Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) if err != nil { t.Fatal(err) } if len(got.Needs) != 0 { t.Fatalf("a shell answered on this machine produced %d need(s)", len(got.Needs)) } }