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.
135 lines
5.3 KiB
Go
135 lines
5.3 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A domain module is a module with requirements and no files of its own.
|
|
//
|
|
// Most people want the network working and do not want to choose a VPN. Some want a particular
|
|
// one. Both are the same mechanism: assigning `networking` takes the only answer to each of its
|
|
// requirements silently, and the day there are two answers the resolver refuses and names them,
|
|
// so choosing is assigning the one you want. There is no flavor field and nothing to configure.
|
|
|
|
func networkingShelf(extra ...Manifest) map[string]Manifest {
|
|
base := []Manifest{
|
|
{Module: "networking", Requires: []string{"private-network", "name-resolution"}},
|
|
{Module: "mesh-wireguard", Computed: "mesh-wireguard",
|
|
Provides: Offers("private-network", "mesh-addressing"),
|
|
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
|
{Module: "mesh-names", Computed: "mesh-names",
|
|
Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}},
|
|
}
|
|
return shelf(append(base, extra...)...)
|
|
}
|
|
|
|
func TestOneWordBringsUpTheNetwork(t *testing.T) {
|
|
// The case that has to stay easy. Nothing is asked, because with one answer to each
|
|
// requirement there was never a question.
|
|
got, err := Resolve(networkingShelf(), []string{"networking"}, workstation(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
have := strings.Join(names(got), " ")
|
|
for _, want := range []string{"networking", "mesh-wireguard", "mesh-names"} {
|
|
if !strings.Contains(have, want) {
|
|
t.Fatalf("assigning networking did not bring in %s: %s", want, have)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestASecondVPNTurnsItIntoAChoice(t *testing.T) {
|
|
// And the choice is offered rather than made. A default here would be the flavor field coming
|
|
// back under another name.
|
|
_, err := Resolve(networkingShelf(
|
|
Manifest{Module: "tailscale", Provides: Offers("private-network"),
|
|
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
|
), []string{"networking"}, workstation(), World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("two VPNs and one was picked silently")
|
|
}
|
|
for _, want := range []string{"mesh-wireguard", "tailscale"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("the refusal does not name %s: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChoosingIsAssigning(t *testing.T) {
|
|
// No second verb. Assigning the one you want answers the requirement, and the bundle takes it.
|
|
got, err := Resolve(networkingShelf(
|
|
Manifest{Module: "tailscale",
|
|
Provides: Offers("private-network", "name-resolution"),
|
|
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
|
), []string{"networking", "tailscale"}, workstation(), World{})
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
have := strings.Join(names(got), " ")
|
|
if !strings.Contains(have, "tailscale") {
|
|
t.Fatalf("the chosen VPN is not in the set: %s", have)
|
|
}
|
|
if strings.Contains(have, "mesh-wireguard") {
|
|
t.Fatalf("choosing tailscale still installed WireGuard: %s", have)
|
|
}
|
|
}
|
|
|
|
func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) {
|
|
// This happened. The person chose tailscale; the names module required the mesh's own
|
|
// addressing; only WireGuard provides that; so both were installed and nobody was told.
|
|
//
|
|
// The claim is what catches it. Providing a private network is not the singular part — a
|
|
// machine could run two VPNs for two purposes — but being *the* one the mesh runs over is.
|
|
_, err := Resolve(networkingShelf(
|
|
Manifest{Module: "tailscale", Provides: Offers("private-network"),
|
|
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
|
), []string{"networking", "tailscale"}, workstation(), World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("a machine was given two private networks without being told")
|
|
}
|
|
if !strings.Contains(err.Error(), "the-private-network") {
|
|
t.Fatalf("the refusal does not say what collided: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNamesNeedTheMeshsOwnAddresses(t *testing.T) {
|
|
// Names are computed from addresses the mesh handed out. Over a VPN that hands out its own,
|
|
// the mesh has nothing to write, so the names module requires the addressing rather than a
|
|
// private network in general — otherwise a machine gets a hosts file full of addresses that
|
|
// mean nothing on it.
|
|
_, err := Resolve(shelf(
|
|
Manifest{Module: "mesh-names", Computed: "mesh-names",
|
|
Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}},
|
|
Manifest{Module: "tailscale", Provides: Offers("private-network")},
|
|
), []string{"mesh-names", "tailscale"}, workstation(), World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("the mesh's names were installed over a VPN whose addresses it does not hand out")
|
|
}
|
|
if !strings.Contains(err.Error(), "mesh-addressing") {
|
|
t.Fatalf("the refusal does not say what is missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestARequirementWantedTwiceIsReportedOnce(t *testing.T) {
|
|
// Two identical lines make a person hunt for the difference between them before realising
|
|
// there is none.
|
|
_, err := Resolve(shelf(
|
|
Manifest{Module: "one", Requires: []string{"shell"}},
|
|
Manifest{Module: "two", Requires: []string{"shell"}},
|
|
Manifest{Module: "bash", Provides: Offers("shell")},
|
|
Manifest{Module: "zsh", Provides: Offers("shell")},
|
|
), []string{"one", "two"}, workstation(), World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("two shells and one was picked silently")
|
|
}
|
|
if n := strings.Count(err.Error(), `"shell" is wanted by`); n != 1 {
|
|
t.Fatalf("the same requirement was reported %d times:\n%v", n, err)
|
|
}
|
|
}
|