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
+66 -2
View File
@@ -1,6 +1,7 @@
package inventory
import (
"context"
"errors"
"testing"
@@ -8,7 +9,7 @@ import (
)
func manifest(name string, provides, requires []string) catalogue.Manifest {
return catalogue.Manifest{Module: name, Provides: provides, Requires: requires}
return catalogue.Manifest{Module: name, Provides: catalogue.Offers(provides...), Requires: requires}
}
func TestAModuleRoundTripsWholeAndUnshredded(t *testing.T) {
@@ -17,7 +18,7 @@ func TestAModuleRoundTripsWholeAndUnshredded(t *testing.T) {
// stored — the module system is the thing most likely to grow.
inv := fresh(t)
m := catalogue.Manifest{
Module: "xorg", Provides: []string{"display-server"},
Module: "xorg", Provides: catalogue.Offers("display-server"),
Capabilities: []string{"seat"},
Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}},
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/X11/x.conf"}},
@@ -364,3 +365,66 @@ func TestASourceNobodyHasCheckedIsNotBehind(t *testing.T) {
t.Error("a module with no known head reports as behind")
}
}
func TestAPinSurvivesAndCanBeChanged(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
for _, n := range []string{"user", "first", "second"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
if err := inv.PinProvision(ctx, "user", "database", "first"); err != nil {
t.Fatal(err)
}
// Changing the answer replaces it rather than adding a second, or a machine would be told to
// use two databases and nothing would say which.
if err := inv.PinProvision(ctx, "user", "database", "second"); err != nil {
t.Fatal(err)
}
pins, err := inv.PinsFor(ctx, "user")
if err != nil {
t.Fatal(err)
}
if len(pins) != 1 || pins["database"] != "second" {
t.Fatalf("got %v", pins)
}
if err := inv.UnpinProvision(ctx, "user", "database"); err != nil {
t.Fatal(err)
}
if pins, _ := inv.PinsFor(ctx, "user"); len(pins) != 0 {
t.Fatalf("the choice outlived being removed: %v", pins)
}
// Removing something that was never said is a mistake worth reporting, not a silent success.
if err := inv.UnpinProvision(ctx, "user", "database"); err == nil {
t.Fatal("unpinning something nobody pinned reported success")
}
}
func TestAPinGoesWhenTheProviderLeavesTheMesh(t *testing.T) {
// Otherwise a machine is pointed at something that no longer exists and reported as
// configured, which is the failure mode this whole project keeps refusing.
inv := fresh(t)
ctx := context.Background()
for _, n := range []string{"consumer", "provider"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
if err := inv.PinProvision(ctx, "consumer", "database", "provider"); err != nil {
t.Fatal(err)
}
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'provider'`); err != nil {
t.Fatal(err)
}
// Counted in the table, not read through PinsFor. PinsFor joins on the provider, so a pin
// left behind by a departed node is invisible through it whether or not it was cleaned up —
// which made the first version of this test pass with the cascade removed.
rows, err := inv.pinRows(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatalf("a choice outlived the machine it named: %d row(s) left", rows)
}
}