Files
mesh-controller/internal/catalogue/machineside_test.go
T
jschoubben 53eb000a84 A container may not mount a path the module never declared
Closes the half of 04-ISSUES/026 that would otherwise come back. The
fourteen mounts across the forge, the mail system, the store and the
object store are all declared now — but nothing said they had to be, so
they were right by coincidence and the next volume added would not be.

A bind mount whose source does not exist is created by the container
runtime, as root, with a mode it picks. So `owner` and `mode` — which
exist precisely so a module can say who its data belongs to — were
silently not applied to the only directories holding data.

And the rule written for exactly this case did not reach them. A
directory the mesh declared and no longer wants is kept, not removed,
when it holds anything the mesh did not put there (ADR 0030). That is
the answer to *what happens to my data when a module goes away*, and it
is written in terms of declared directories: an undeclared one sits
outside it, because the mesh does not know it is there.

Refused where it is written rather than on the machine, which cannot
tell the difference — by the time the host sees the mount it is being
asked to make a directory, which it is perfectly able to do. The fault
is in the manifest, so it is named at the manifest. Same argument as the
action refusal directly above it.

A path under a declared directory counts as declared, as do the files a
module already names: its own secrets, its grants, what it receives.

Every real manifest is checked to still parse, and the refusal bites.
2026-09-01 19:36:01 +02:00

96 lines
4.2 KiB
Go

package catalogue
import (
"strings"
"testing"
)
// A module with nothing that publishes binds what it binds, and the mesh may not move it.
//
// This is the case that made novox/hq 04-ISSUES/028's fix wrong on its first pass: a port was
// assigned to every module that declared one, so a service listening directly had the rule set
// opened on a number nothing was listening on, and its real port shut. The firewall reported
// success and blocked the service, which is the exact failure the mechanism exists to prevent.
func TestAPortNothingPublishesIsNotTheMeshsToMove(t *testing.T) {
m := Manifest{Module: "talker", Listens: []Listening{{Port: 9101, From: FromMesh}}}
at, mayAssign := m.MachineSide(9101)
if mayAssign {
t.Fatal("the mesh took a port it cannot move: nothing translates it, so assigning one " +
"opens the wrong number and leaves the service unreachable")
}
if at != 9101 {
t.Fatalf("a port nothing publishes reaches the machine where it binds, not at %d", at)
}
}
// A container publishing in short form is exactly the case the mesh may choose.
func TestAContainerPublishingShortIsTheMeshsToChoose(t *testing.T) {
m := Manifest{Module: "store", Resources: []map[string]any{
{"type": "container", "id": "server", "ports": []any{"5432"}},
}}
if _, mayAssign := m.MachineSide(5432); !mayAssign {
t.Fatal("a container's mapping is what translates a port, so this one is the mesh's to " +
"choose; refusing it puts every module back on a number it guessed")
}
}
// A manifest that wrote its own mapping already chose, and the machine side is the outer one.
func TestAMappingTheManifestWroteIsNotReassigned(t *testing.T) {
m := Manifest{Module: "mail", Resources: []map[string]any{
{"type": "container", "id": "front", "ports": []any{"7080:80"}},
}}
for _, named := range []int{7080, 80} {
at, mayAssign := m.MachineSide(named)
if mayAssign {
t.Fatalf("%d was reassigned though the manifest published it explicitly, which "+
"would open a rule on a port the container does not publish", named)
}
if at != 7080 {
t.Fatalf("naming %d gave %d; the machine side of 7080:80 is 7080", named, at)
}
}
}
// A port some other container publishes is not this port.
func TestAPortNotInTheMappingIsNotFound(t *testing.T) {
m := Manifest{Module: "mail", Resources: []map[string]any{
{"type": "container", "id": "front", "ports": []any{"25", "7080:80"}},
}}
if at, mayAssign := m.MachineSide(993); mayAssign || at != 993 {
t.Fatalf("993 is published by nothing here, so it binds where it binds: got %d, %v",
at, mayAssign)
}
}
// A bind mount the module never declared is refused where it is written.
//
// The container runtime creates a missing bind source itself, as root, with a mode it picks. So
// the module's own owner and mode never reach the directory holding its data, and the rule that
// keeps data when a module goes away (novox/hq ADR 0030) does not cover it — that rule is written
// in terms of declared directories, and the mesh has never heard of this one.
func TestAMountNothingDeclaresIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"store","resources":[` +
`{"id":"server","type":"container","name":"store","image":"x@sha256:` +
`0000000000000000000000000000000000000000000000000000000000000000",` +
`"volumes":["/services/store/data:/var/lib/data"]}]}`))
if err == nil {
t.Fatal("a container mounting a path no resource declares was accepted; the runtime " +
"would create it as root and the module's owner and mode would never apply")
}
if !strings.Contains(err.Error(), "/services/store/data") {
t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err)
}
}
// And declaring it is enough — including declaring the directory above it.
func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"store","resources":[` +
`{"id":"state","type":"directory","path":"/services/store","mode":"0700"},` +
`{"id":"server","type":"container","name":"store","image":"x@sha256:` +
`0000000000000000000000000000000000000000000000000000000000000000",` +
`"volumes":["/services/store/data:/var/lib/data"]}]}`))
if err != nil {
t.Fatalf("a module that said where its data lives was refused anyway: %v", err)
}
}