novox/hq 04-ISSUES/021. Two modules where one provided what the other required, on one node, resolved cleanly with zero needs: no credential was made, the consumer's secret file was never written, and whatever read it would fail somewhere else entirely. Nothing was refused and nothing was reported. The world a node resolves against is every OTHER node, so a provider on the same machine never became a Needed, and the credential loop walks Needs. Every step reasonable, the sum a silent gap. It survived because everything proven until now was cross-machine — the interesting case for a mesh and the rare one in practice. The first module to want a database on its own machine was the first real one. The assumption underneath was that a local consumer needs no credential, which holds for a process reaching a unix socket where the system can vouch for the caller. It does not hold for containers, which is how nearly everything here runs: the consumer reaches the provider over TCP from its own container and the database asks for a password exactly as it would from another machine. **The machine stops being a trust boundary once both ends are containers.** A brokered provision answered here is now a need naming this node, and carries what the provider serves — which a local provider never contributes through the world. A name nothing grants is unchanged: a shell answered here is answered, and nothing more is owed. Both directions tested, both injections bite.
394 lines
15 KiB
Go
394 lines
15 KiB
Go
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 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, World{})
|
|
|
|
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(),
|
|
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 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, 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))
|
|
}
|
|
}
|