Files
mesh-controller/internal/link/enrol_shape_test.go
T
jschoubben 0af3ea1acf A consumer is a module on a machine, not a machine
novox/hq 04-ISSUES/022. A credential was keyed by provision, consumer
node and provider node, so "who is asking" was answered by naming a
host. The node this mesh exists to take over runs eight modules against
one database server.

The symptom had two halves and only one was loud. The provider refused,
naming the modules and explaining they would share one credential, which
reads as a decision rather than a limit. The consumer did not refuse: it
resolved cleanly, wrote one module's credential file and left the others
absent — a service that starts and cannot authenticate, with nothing
saying why. That is 021 again on a different axis.

Three modules wanting one database produced one need, carrying whichever
module mentioned it first, because the resolution walk is a work-list
over names. The fan-out now happens in one place, after the walk. The
record path already did this correctly and said why: a consumer here is
a module on a machine. It is the same rule.

Downstream: the secret's key gains the consuming module, the grant file
is named after both halves, needs are matched by provision and module
rather than provision alone, and the provisioners name the role and the
access key after the module. The refusal in ContributionsTo is gone
because there is nothing left to refuse.

Worth stating plainly: without that refusal, gitea's login would have
opened keycloak's database. From the provisioner's side it created
exactly what it was asked to create.

Existing secrets are discarded rather than backfilled. They cannot say
which module they were for, and a secret is remade and delivered to both
ends on the next push — so this costs one rotation and invents nothing.

Also guards the role name against PostgreSQL's 63-byte truncation, which
is a notice rather than an error and would reintroduce exactly this
collision at a length nobody tests.

Three faults injected — the fan-out removed, needs matched by name
alone, the grant file named after the machine — each caught.
2026-09-01 02:40:09 +02:00

127 lines
4.3 KiB
Go

package link_test
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"golang.org/x/crypto/nacl/box"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/link"
)
// What a node says when it joins, as that node's own code writes it.
//
// The two ends are separate structs in separate repositories, and a field renamed on one side
// fails silently: enrolment succeeds, a key is simply absent, and the node looks joined until the
// first thing sealed to it cannot be opened — by which time nobody is looking at enrolment.
//
// Skipped unless MESH_ENROL names the file the host's suite wrote:
//
// mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/
// mesh-control: MESH_ENROL=/tmp/enrol.json make check
//
// **What this does not cover**, said so nobody reads more into a pass than is there: the full
// enrolment path also issues a broker account, and that needs a broker. What is checked here is
// the shape the two sides agree on and that a key which arrives this way can actually be sealed
// to — which is the part that was newly wired and the part that fails quietly.
func TestWhatANodeSaysWhenItJoinsIsWhatThisMeshReads(t *testing.T) {
path := os.Getenv("MESH_ENROL")
if path == "" {
t.Skip("set MESH_ENROL to an enrolment request written by the host's suite")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var request link.EnrolRequest
if err := json.Unmarshal(raw, &request); err != nil {
t.Fatalf("this mesh cannot read what a node sends:\n%v", err)
}
for what, got := range map[string]string{
"node": request.Node,
"secret": request.Secret,
"overlay key": request.OverlayKey,
"sealing key": request.SealingKey,
} {
if got == "" {
t.Fatalf("the %s did not survive the crossing — a field name differs", what)
}
}
if len(request.PublicKey) != ed25519.PublicKeySize {
t.Fatalf("the identity arrived as %d bytes", len(request.PublicKey))
}
// And that a key arriving this way is one the mesh can actually seal to. Recording it is not
// the same as it being usable, and "recorded" is what a shape check on its own would prove.
inv := liveInventory(t)
ctx := context.Background()
node, err := inv.AddNode(ctx, request.Node)
if err != nil {
t.Fatal(err)
}
other, err := inv.AddNode(ctx, "the-other-end")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordSealingKey(ctx, node.ID, request.SealingKey); err != nil {
t.Fatal(err)
}
if err := inv.RecordSealingKey(ctx, other.ID, request.SealingKey); err != nil {
t.Fatal(err)
}
// A credential belongs to a module on a machine (novox/hq 04-ISSUES/022), so the module
// holding it has to exist before it can.
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "gitea", Version: "1"},
inventory.Source{}); err != nil {
t.Fatal(err)
}
secret, err := inv.SecretFor(ctx, "postgres-database", request.Node, "gitea", "the-other-end")
if err != nil {
t.Fatalf("nothing could be sealed to a key that arrived from a real node: %v", err)
}
// Opened with the private half the host's suite kept, so this asserts the node could read it
// rather than that a blob exists.
privateRaw, err := os.ReadFile(path + ".sealing-private")
if err != nil {
t.Skipf("no private half beside %s, so this can only check the shape", path)
}
private, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(privateRaw)))
if err != nil || len(private) != 32 {
t.Fatal("the private half beside the request is not a key")
}
public, err := base64.StdEncoding.DecodeString(request.SealingKey)
if err != nil {
t.Fatal(err)
}
var pub, priv [32]byte
copy(pub[:], public)
copy(priv[:], private)
blob, err := base64.StdEncoding.DecodeString(secret.ForConsumer)
if err != nil {
t.Fatal(err)
}
if _, ok := box.OpenAnonymous(nil, blob, &pub, &priv); !ok {
t.Fatal("the node could not open what this mesh sealed to the key it sent")
}
}
// liveInventory is a database of its own for this test, skipping where there is none.
func liveInventory(t *testing.T) *inventory.Inventory {
t.Helper()
if os.Getenv("MESH_TEST_POSTGRES") == "" {
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
}
return inventory.ForTest(t)
}