Files
mesh-controller/examples/modules/modules_test.go
T
jschoubben be2dca27ab The example modules put their credentials where the programs read them
Every one of these declared `own-secrets` pointing at a path called
`.env` and then mounted it as `env-file`. The file's whole content is
the password. Docker reads that as a malformed line and the container
starts with no password set — which is not a failure to start, it is a
service running with the wrong credential.

They parsed, they resolved, and none of them could ever have worked.
That is what a manifest checked only by the parser buys.

Each now keeps the sealed file as what it is — a password, alone — and
declares a file beside it whose content says ${secret:name}. The host
fills the hole on the machine, which is the only place both halves
exist. The provisioners mount the bare file, because they read a
password file and always did.

Two tests, both driven from the manifests on disk rather than from
fixtures: every ${secret:x} must name something the module declared, and
nothing may read a bare password file as an env file. Injecting the
shipped bug reproduces it word for word.

Keycloak, Gitea and Mailu still cannot connect to their databases, for
the reason in 04-ISSUES/023 — the user name is the provisioner's
invention and the bound values cannot reach a config file. Their own
credentials are right now; that half was independent and is done.
2026-09-01 02:52:00 +02:00

362 lines
13 KiB
Go

package modules
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/overlay"
)
// The examples are manifests, so the thing to check is that the catalogue accepts them.
//
// A manifest that only ever appears in a document is a manifest nobody has run through the parser,
// and the parser refuses unknown keys — so a typo here would be discovered by whoever first tried
// to use one, which is the opposite of what an example is for.
func read(t *testing.T, name string) catalogue.Manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Fatal(err)
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
t.Fatalf("%s is not a manifest this mesh accepts: %v", name, err)
}
return m
}
func TestEveryExampleIsAManifestTheMeshAccepts(t *testing.T) {
found, err := filepath.Glob("*.json")
if err != nil {
t.Fatal(err)
}
if len(found) == 0 {
t.Fatal("no examples, so this test proves nothing")
}
for _, name := range found {
read(t, name)
}
}
// The serving module reads what the mesh writes, and restarts when the mesh rewrites it.
//
// Without the second it would serve the names it started with for ever — every machine that
// joined afterwards unreachable by name, and every check passing.
func TestTheResolverReadsTheMeshsNamesAndFollowsThem(t *testing.T) {
m := read(t, "dnsmasq.json")
var config, service map[string]any
for _, r := range m.Resources {
switch r["id"] {
case "config":
config = r
case "service":
service = r
}
}
if config == nil || service == nil {
t.Fatal("the module has no configuration or no service")
}
if !strings.Contains(config["content"].(string), overlay.ResolverPath) {
t.Fatalf("it does not read what the mesh writes at %s", overlay.ResolverPath)
}
var follows bool
for _, id := range service["restart-on"].([]any) {
if id.(string) == overlay.Resolver+".nodes" {
follows = true
}
}
if !follows {
t.Fatalf("it does not restart when the mesh rewrites the names: %v", service["restart-on"])
}
}
// It binds names the mesh chose, so it needs to know nothing about the machine it is on.
//
// That is the whole reason these can be static manifests: a resolver must bind somewhere and a
// stub must be pointed somewhere, and neither address is knowable in advance — unless the mesh
// named it.
func TestTheResolverNeedsToKnowNothingAboutItsMachine(t *testing.T) {
config := read(t, "dnsmasq.json").Resources[1]["content"].(string)
// The directive, not the word: the comment above it names the interface too, so a plain
// Contains passes whatever the module actually binds. It did.
if !strings.Contains(config, "interface="+overlay.Interface+"\n") {
t.Fatalf("it does not bind the private network's interface %q:\n%s",
overlay.Interface, config)
}
// That it binds one, not which. Which address it is belongs in the manifests, where the
// asking modules can be checked against it — naming it here too would be a fourth place to
// keep in step, and the one nobody would think to change.
if !strings.Contains(config, "\nlisten-address=127.0.0.") {
t.Fatalf("it answers on no address for the machine's own use:\n%s", config)
}
// Not an address that belongs to something else.
//
// **systemd-resolved holds .53 AND .54** — the stub and the proxy stub. This module asserted
// .54 was free, in a comment that read as reasoned, and a machine said otherwise: dnsmasq
// could not start at all. A unit test cannot know which addresses a machine has spare, but it
// can hold on to what one has already told us.
for _, taken := range []string{"127.0.0.1", "127.0.0.53", "127.0.0.54"} {
if strings.Contains(config, "listen-address="+taken) {
t.Fatalf("it takes %s, which belongs to something else:\n%s", taken, config)
}
}
}
// Everything that points resolution at the mesh points at the same place.
//
// Three files name this address — one binds it and two send queries to it — and a change to one
// of them alone is a resolver answering where nobody asks.
func TestTheAskingModulesPointAtWhereTheResolverAnswers(t *testing.T) {
serving := read(t, "dnsmasq.json").Resources[1]["content"].(string)
var at string
for _, line := range strings.Split(serving, "\n") {
if rest, found := strings.CutPrefix(strings.TrimSpace(line), "listen-address="); found {
at = rest
}
}
if at == "" {
t.Fatal("the resolver binds no address for the machine's own use")
}
for _, asking := range []string{"resolved-split-dns.json", "resolv-conf.json"} {
m := read(t, asking)
var mentions bool
for _, r := range m.Resources {
if content, ok := r["content"].(string); ok && strings.Contains(content, at) {
mentions = true
}
}
if !mentions {
t.Fatalf("%s does not point at %s, where the resolver answers", asking, at)
}
}
}
// The two ways of deciding what a machine asks claim the same thing, so the mesh refuses the pair.
func TestTwoWaysOfOwningTheResolverCannotBothBeAssigned(t *testing.T) {
shelf := map[string]catalogue.Manifest{}
for _, name := range []string{"resolved-split-dns.json", "resolv-conf.json", "dnsmasq.json"} {
m := read(t, name)
shelf[m.Module] = m
}
// Something has to answer `wildcard-resolution`, or they are refused for that instead and the
// test would pass without ever reaching the claim.
shelf["dnsmasq"] = read(t, "dnsmasq.json")
_, err := catalogue.Resolve(shelf,
[]string{"dnsmasq", "resolved-split-dns", "resolv-conf"},
catalogue.Node{Name: "anchor", Capabilities: map[string]bool{}},
catalogue.World{Unchecked: true})
if err == nil {
t.Fatal("both ways of owning the resolver were assigned to one machine")
}
said := err.Error()
if !strings.Contains(said, "the-resolver-configuration") {
t.Fatalf("the refusal does not name what they both want: %v", said)
}
}
// And the two roles are not the same claim: a machine runs one resolver AND one thing deciding
// what it asks, so serving and asking must be assignable together.
func TestServingAndAskingAreAssignableTogether(t *testing.T) {
shelf := map[string]catalogue.Manifest{}
for _, name := range []string{"dnsmasq.json", "resolved-split-dns.json"} {
m := read(t, name)
shelf[m.Module] = m
}
if _, err := catalogue.Resolve(shelf,
[]string{"dnsmasq", "resolved-split-dns"},
catalogue.Node{Name: "anchor", Capabilities: map[string]bool{}},
catalogue.World{Unchecked: true}); err != nil {
t.Fatalf("a resolver and the thing pointing at it cannot both be assigned: %v", err)
}
}
// The examples are JSON a person edits, so a stray comma is worth catching here rather than on a
// machine.
func TestTheExamplesAreWellFormed(t *testing.T) {
found, _ := filepath.Glob("*.json")
for _, name := range found {
raw, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
var any map[string]any
if err := json.Unmarshal(raw, &any); err != nil {
t.Fatalf("%s is not JSON: %v", name, err)
}
}
}
// The resolver must not look up its own upstreams.
//
// Whatever points a machine at the mesh writes that address into resolv.conf, so a resolver that
// read it would find itself — and every query it could not answer locally would loop until its
// receive queue filled. It did: 15KB of queries backed up and every lookup on the machine hung.
//
// It needs no upstream because it is never asked for anything else: the asking module routes only
// the mesh's suffix here and leaves the rest where the machine already sent it.
func TestTheResolverDoesNotAskItselfForUpstreams(t *testing.T) {
config := read(t, "dnsmasq.json").Resources[1]["content"].(string)
if !strings.Contains(config, "\nno-resolv\n") {
t.Fatalf("it reads resolv.conf for upstreams, which now points at itself:\n%s", config)
}
// And names no upstream of its own: choosing one would send every query this machine cannot
// answer somewhere nobody agreed to.
for _, line := range strings.Split(config, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "server=") {
t.Fatalf("it forwards to %q, which is not the mesh's to choose", line)
}
}
}
// The two halves of an object-store edge, as a pair.
//
// A provider and a consumer that only ever appear separately are two manifests nobody has checked
// against each other: the name one provides has to be the name the other requires, and the key a
// consumer contributes has to be the one the provisioner reads. Both were got wrong while writing
// them, and neither would have been caught by parsing either file alone.
func TestTheObjectStoreEdgeFitsTogether(t *testing.T) {
provider := read(t, "object-store.json")
consumer := read(t, "photos.json")
const provision = "s3-bucket"
var provides bool
for _, offer := range provider.Provides {
if offer.Name == provision {
provides = true
}
}
if !provides {
t.Fatalf("the provider does not offer %q", provision)
}
if !strings.Contains(strings.Join(consumer.Requires, ","), provision) {
t.Fatalf("the consumer does not require %q", provision)
}
// Where each side wants to be told. A provider that receives nowhere is a provider the mesh
// writes nothing for, and a provisioner with nothing to read.
if provider.Receives[provision] == "" {
t.Error("the provider says nowhere to write what its consumers asked for")
}
if provider.Grants[provision] == "" {
t.Error("the provider says nowhere to write its consumers' credentials")
}
if consumer.Binds[provision] == "" {
t.Error("the consumer says nowhere to be told where its bucket is")
}
if consumer.Secrets[provision] == "" {
t.Error("the consumer says nowhere to be given its key")
}
// The key the provisioner reads out of `values`. It looks for `bucket`, so a consumer
// contributing `name` — which is what the database one contributes — resolves cleanly and
// then fails on the machine with "asked for a bucket and did not name it".
if _, named := consumer.Contributes[provision]["bucket"]; !named {
t.Errorf("the consumer contributes %v, and the provisioner reads \"bucket\"",
consumer.Contributes[provision])
}
}
// Every hole an example leaves for a credential can be filled from what that module declared.
//
// **A manifest that parses is not a manifest that works.** These say `${secret:x}` in a file and
// declare `x` under `own-secrets`; if the two ever disagree the mesh refuses the whole declaration
// at push time, on the machine, with the module's name and nothing else to go on. Checking it here
// costs nothing and moves the answer to whoever edited the file.
//
// This is also the shape that was missing entirely until 2026-09-01: an own secret arrives as a
// file whose whole content is the password, and every one of these programs reads `KEY=value`. The
// manifests said `own-secrets` pointed at a `.env` and it did not — it pointed at a password.
func TestEveryCredentialHoleCanBeFilledByTheModuleThatLeftIt(t *testing.T) {
found, err := filepath.Glob("*.json")
if err != nil {
t.Fatal(err)
}
var checked int
for _, name := range found {
m := read(t, name)
has := map[string]bool{}
for own := range m.OwnSecrets {
has[own] = true
}
for required := range m.Secrets {
has[required] = true
}
for _, r := range m.Resources {
content, ok := r["content"].(string)
if !ok {
continue
}
for _, wanted := range secretsUsedForTest(content) {
checked++
if !has[wanted] {
t.Errorf(
"%s: %v says ${secret:%s}, and %s neither owns a secret by that name "+
"nor requires anything that grants one",
name, r["id"], wanted, m.Module)
}
}
}
}
if checked == 0 {
t.Fatal("no example puts a credential into a file, so this test proves nothing")
}
}
// A secret file is a password and nothing else, so nothing may read one as an env file.
//
// The fault this catches is the one these manifests shipped with: `own-secrets` pointing at a
// path called `.env`, mounted as `env-file`, holding a bare password. Docker reads that as a
// malformed line and the container starts with no password at all.
func TestNoContainerReadsABarePasswordAsAnEnvFile(t *testing.T) {
found, _ := filepath.Glob("*.json")
for _, name := range found {
m := read(t, name)
bare := map[string]bool{}
for _, where := range m.OwnSecrets {
bare[where] = true
}
for _, where := range m.Secrets {
bare[where] = true
}
for _, r := range m.Resources {
files, ok := r["env-file"].([]any)
if !ok {
continue
}
for _, f := range files {
if bare[fmt.Sprint(f)] {
t.Errorf(
"%s: %v reads %s as an env file, and that path holds a bare password — "+
"declare a file whose content says ${secret:...} and read that instead",
name, r["id"], f)
}
}
}
}
}
// The same expression the control plane and the host both match.
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
func secretsUsedForTest(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range placeholder.FindAllStringSubmatch(content, -1) {
if !seen[m[1]] {
seen[m[1]] = true
used = append(used, m[1])
}
}
return used
}