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.
180 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|