Files
mesh-controller/internal/catalogue/brokered_test.go
T
jschoubben d4064122d6 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.
2026-08-29 23:51:50 +02:00

180 lines
6.9 KiB
Go

package catalogue
import (
"strings"
"testing"
)
// Where the answer to a requirement is allowed to live.
//
// 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 written
// `requires`, so both were answered the same way, and the second answer was to install PostgreSQL
// on every machine that runs a web application.
func brokeredShelf() map[string]Manifest {
return shelf(
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("database")},
Manifest{Module: "meshboard", Version: "1", Requires: []string{"database"}},
)
}
func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
// The fault this whole distinction exists for.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{Offered: map[string][]string{"database": {"anchor"}}})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
t.Fatalf("using a database installed one here: %s", have)
}
}
func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
// It is the only part of a node's set that stops working when a *different* machine goes
// away, and it is where a credential will have to be handed back.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{Offered: map[string][]string{"database": {"anchor"}}})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 {
t.Fatalf("got %v", got.Needs)
}
if got.Needs[0].Name != "database" || got.Needs[0].From != "anchor" {
t.Fatalf("got %v", got.Needs[0])
}
if got.Needs[0].For != "meshboard" {
t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
}
}
func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
// Refused rather than installed here. Choosing a machine to put a database on is a decision
// with consequences, and nothing resolving a web application should make it silently.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), World{})
if err == nil {
t.Fatal("a database was found in a mesh that has none")
}
if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
t.Fatalf("the refusal does not say what to do: %v", err)
}
}
func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
// Same rule as everywhere else. Picking one would be a guess about which database a person
// meant, and the wrong guess is somebody's data in the wrong place.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{Offered: map[string][]string{"database": {"anchor", "archive"}}})
if err == nil {
t.Fatal("one of two databases was picked silently")
}
for _, want := range []string{"anchor", "archive", "pin"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
func TestSayingWhichOneSettlesIt(t *testing.T) {
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{
Offered: map[string][]string{"database": {"anchor", "archive"}},
Pinned: map[string]string{"database": "archive"},
})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
t.Fatalf("the choice was not taken: %v", got.Needs)
}
}
func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
// Rather than falling back to one that does. A fallback would quietly move somebody's data to
// a machine they did not choose, which is the whole reason the question is asked.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{
Offered: map[string][]string{"database": {"anchor", "archive"}},
Pinned: map[string]string{"database": "somewhere-else"},
})
if err == nil {
t.Fatal("a machine was silently given a different database from the one chosen")
}
if !strings.Contains(err.Error(), "somewhere-else") {
t.Fatalf("the refusal does not say what was chosen: %v", err)
}
}
func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
// A single answer is normally taken silently. Not when somebody said they wanted a different
// one -- that is the mesh overruling a person, which it does nowhere else.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(),
World{
Offered: map[string][]string{"database": {"anchor"}},
Pinned: map[string]string{"database": "archive"},
})
if err == nil {
t.Fatal("the only database was used although another was chosen")
}
if !strings.Contains(err.Error(), "only anchor provides it") {
t.Fatalf("the refusal does not say what is available: %v", err)
}
}
func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
// If one module says a database is local and another says it is anywhere, the same
// requirement means two things depending on which one happens to answer it.
_, err := Resolve(shelf(
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("database")},
Manifest{Module: "sqlite", Version: "1", Provides: Offers("database")},
Manifest{Module: "meshboard", Version: "1", Requires: []string{"database"}},
), []string{"meshboard"}, workstation(), World{})
if err == nil {
t.Fatal("a catalogue that disagrees about where a database lives was accepted")
}
if !strings.Contains(err.Error(), "two things") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
// The change must not have made everything brokered. A shell is still installed here.
got, err := Resolve(shelf(
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}},
), []string{"tools"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
}
}
func TestTheShortFormStillMeansHere(t *testing.T) {
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
// silently changes meaning.
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
if err != nil {
t.Fatal(err)
}
if len(m.Provides) != 1 || m.Provides[0].At() != ScopeNode {
t.Fatalf("a plain name is no longer node-scoped: %v", m.Provides)
}
}
func TestASiteScopedProvisionIsRefused(t *testing.T) {
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
// provision, because nothing knows how to reach "the one at my site".
_, err := ParseManifest([]byte(
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
if err == nil {
t.Fatal("a site-scoped provision was accepted")
}
if !strings.Contains(err.Error(), "site") {
t.Fatalf("unhelpful refusal: %v", err)
}
}