Files
mesh-controller/internal/catalogue/provided_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

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
}