Where the answer to a requirement is allowed to live

Two different things were both written `requires`. A shell, a display
server and a private network have to be on the machine that needs them.
A database does not — it runs somewhere and is reached over the network.
Both were answered the same way, so requiring a database installed
PostgreSQL on every machine that ran a web application.

What a module provides now carries a scope, the same idea claims already
use, written short in the ordinary case:

  "provides": ["shell"]
  "provides": [{"name": "database", "scope": "mesh"}]

A mesh-scoped requirement is answered by finding the node already running
it — never by installing it here. Choosing a machine to put a database on
is a decision with consequences, and nothing resolving a web application
should make it silently. With nothing anywhere it refuses and says which
module to assign; with two it refuses and says how to choose.

Choosing is `pin <node> <provision> <from>`, kept per node because that
is the granularity the choice has. A pin at a machine that does not
provide it refuses rather than falling back — a fallback would quietly
move somebody's data. One provider does not overrule a pin either.

Resolving a node now needs to know what the others offer, and working
that out needs them resolved, so it is two passes: the first answers only
what each node offers, the second answers everything. Nothing is ever
declared from the first.

A node's plan says what it takes from elsewhere. It is the only part of a
set that stops working when a different machine goes away, and nothing
else in that output would have said so. It is also where a credential
will hang once there is a mechanism for handing one back.

One test found passing for the wrong reason: it read pins through a join
on the provider, which hides a dangling row whether or not it was cleaned
up. It counts rows now, and bites when the cascade is removed.
This commit is contained in:
2026-08-29 23:51:50 +02:00
parent 5a3a87e8c3
commit d4064122d6
12 changed files with 820 additions and 99 deletions
+31 -22
View File
@@ -7,7 +7,7 @@ import (
)
func mod(name string, provides, requires, capabilities []string, claims ...Claim) Manifest {
return Manifest{Module: name, Provides: provides, Requires: requires,
return Manifest{Module: name, Provides: Offers(provides...), Requires: requires,
Capabilities: capabilities, Claims: claims}
}
@@ -38,7 +38,8 @@ func TestARequirementWithOneAnswerIsTakenSilently(t *testing.T) {
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)
), []string{"i3"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
@@ -58,7 +59,8 @@ func TestARequirementWithSeveralAnswersIsRefusedAndNamed(t *testing.T) {
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"editor"}, workstation(), nil)
), []string{"editor"}, workstation(), World{})
if err == nil {
t.Fatal("a requirement with three answers was resolved without asking")
}
@@ -70,8 +72,8 @@ func TestARequirementWithSeveralAnswersIsRefusedAndNamed(t *testing.T) {
}
func TestARequirementWithNoAnswerIsRefused(t *testing.T) {
_, err := Resolve(shelf(mod("i3", nil, []string{"xorg"}, nil)),
[]string{"i3"}, workstation(), nil)
_, 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)
}
@@ -84,7 +86,8 @@ func TestSeveralModulesMayProvideTheSameThingAndCoexist(t *testing.T) {
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"bash", "zsh", "fish"}, workstation(), nil)
), []string{"bash", "zsh", "fish"}, workstation(), World{})
if err != nil {
t.Fatalf("three shells could not coexist: %v", err)
}
@@ -99,7 +102,8 @@ func TestTwoModulesClaimingOneThingAreRefused(t *testing.T) {
_, 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)
), []string{"xorg", "wayland"}, workstation(), World{})
if err == nil {
t.Fatal("two modules claiming the seat were both assigned")
}
@@ -118,11 +122,11 @@ func TestAThirdModuleNeedsNoChangeToTheOthers(t *testing.T) {
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 {
if _, err := Resolve(catalogue, pair, workstation(), World{}); err == nil {
t.Errorf("%v were both assigned", pair)
}
}
if _, err := Resolve(catalogue, []string{"mir"}, workstation(), nil); err != nil {
if _, err := Resolve(catalogue, []string{"mir"}, workstation(), World{}); err != nil {
t.Errorf("the newcomer alone was refused: %v", err)
}
}
@@ -131,8 +135,8 @@ 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)
_, 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")
}
@@ -146,7 +150,7 @@ func TestAMeshWideClaimIsHeldByOneNode(t *testing.T) {
// 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"}})
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")
}
@@ -161,12 +165,12 @@ func TestASiteClaimOnlyCollidesWithinThatSite(t *testing.T) {
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 {
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(), faraway); err != nil {
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)
}
}
@@ -179,7 +183,7 @@ func TestTwoModulesWritingOneFileAreRefusedWithoutAnyClaim(t *testing.T) {
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)
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
if err == nil {
t.Fatal("two modules writing the same file were both assigned")
}
@@ -196,7 +200,7 @@ func TestResourceIdentitiesCarryTheirModule(t *testing.T) {
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)
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
@@ -222,7 +226,7 @@ func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) {
{"id": "svc", "type": "service", "unit": "thing.service", "state": "running",
"restart-on": []any{"conf"}},
}
got, err := Resolve(shelf(m), []string{"thing"}, workstation(), nil)
got, err := Resolve(shelf(m), []string{"thing"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
@@ -243,7 +247,8 @@ func TestEveryReasonIsGivenAtOnce(t *testing.T) {
_, 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)
), []string{"xorg", "wayland"}, server, World{})
if err == nil {
t.Fatal("expected refusals")
}
@@ -258,7 +263,8 @@ func TestACycleStopsRatherThanRunsAway(t *testing.T) {
got, err := Resolve(shelf(
mod("a", nil, []string{"b"}, nil),
mod("b", nil, []string{"a"}, nil),
), []string{"a"}, workstation(), nil)
), []string{"a"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
@@ -276,7 +282,8 @@ func TestChoosingOneSatisfiesTheRequirement(t *testing.T) {
mod("bash", []string{"shell"}, nil, nil),
mod("zsh", []string{"shell"}, nil, nil),
mod("fish", []string{"shell"}, nil, nil),
), []string{"editor", "zsh"}, workstation(), nil)
), []string{"editor", "zsh"}, workstation(), World{})
if err != nil {
t.Fatalf("choosing a shell did not satisfy the requirement for one: %v", err)
}
@@ -293,7 +300,8 @@ func TestChoosingSeveralIsStillFine(t *testing.T) {
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)
), []string{"editor", "zsh", "bash", "fish"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
@@ -310,7 +318,8 @@ func TestARequirementNamingAModuleMeansThatModule(t *testing.T) {
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)
), []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") {