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.
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
package catalogue_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-control/internal/catalogue"
|
|
"github.com/novox/mesh-control/internal/overlay"
|
|
)
|
|
|
|
// The manifests the control plane actually ships, resolved.
|
|
//
|
|
// Written because the earlier tests built their own manifests and passed while the real one was
|
|
// missing a claim — a whole mechanism could have been absent from what ships and every test would
|
|
// still have been green.
|
|
|
|
func provided(t *testing.T) map[string]catalogue.Manifest {
|
|
t.Helper()
|
|
out := map[string]catalogue.Manifest{}
|
|
for _, raw := range []map[string]any{
|
|
overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(),
|
|
} {
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := catalogue.ParseManifest(b)
|
|
if err != nil {
|
|
t.Fatalf("a manifest this control plane ships is not valid: %v", err)
|
|
}
|
|
out[m.Module] = m
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) {
|
|
got, err := catalogue.Resolve(provided(t), []string{overlay.Domain}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
|
|
|
if err != nil {
|
|
t.Fatalf("assigning %s does not work out of the box: %v", overlay.Domain, err)
|
|
}
|
|
var have []string
|
|
for _, m := range got.Modules {
|
|
have = append(have, m.Module)
|
|
}
|
|
for _, want := range []string{overlay.Domain, overlay.Name, overlay.Names} {
|
|
if !strings.Contains(strings.Join(have, " "), want) {
|
|
t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
|
|
// Without this, a person who chose another VPN gets WireGuard as well, dragged in by the
|
|
// names, and is not told. The claim is the only thing that catches it.
|
|
shipped := provided(t)
|
|
_, err := catalogue.Resolve(
|
|
withTailscale(shipped),
|
|
[]string{overlay.Domain, "tailscale"},
|
|
catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("a machine was given two private networks and nobody was told")
|
|
}
|
|
if !strings.Contains(err.Error(), overlay.TheNetwork) {
|
|
t.Fatalf("the refusal does not say what collided: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheShippedNamesModuleNeedsTheMeshsOwnAddresses(t *testing.T) {
|
|
// Over a VPN whose addresses the mesh does not hand out, it has no names to write. Refusing
|
|
// is what stops a machine getting a hosts file that means nothing on it.
|
|
shipped := provided(t)
|
|
delete(shipped, overlay.Name)
|
|
_, err := catalogue.Resolve(shipped, []string{overlay.Names}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("the mesh's names resolved with nothing handing out the mesh's addresses")
|
|
}
|
|
if !strings.Contains(err.Error(), overlay.Addressing) {
|
|
t.Fatalf("the refusal does not name what is missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest {
|
|
out := map[string]catalogue.Manifest{}
|
|
for k, v := range shelf {
|
|
out[k] = v
|
|
}
|
|
// Deliberately without name-resolution of its own, which is the case that used to install
|
|
// both VPNs: the names then needed the mesh's addressing, and only WireGuard has it.
|
|
out["tailscale"] = catalogue.Manifest{
|
|
Module: "tailscale", Version: "1",
|
|
Provides: catalogue.Offers(overlay.Requirement),
|
|
Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}},
|
|
}
|
|
return out
|
|
}
|