Files
mesh-controller/internal/catalogue/domain_test.go
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

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)
}
}