Files
mesh-controller/internal/catalogue/module_identity_test.go
T
jschoubben 1c117d1189 Read the engine's access wait by its reason and ACL, name the tool runner by name, and show a kept folder's entries (hq ADR 0325)
A keep wait on a filesystem that cannot carry ACLs is not excused, and nothing is asked; one whose
filesystem must first enable them says in the ask that the take sets it. The tool runner's account is sent
with no uid, for the engine to resolve by name.
2026-10-12 20:14:40 +02:00

334 lines
16 KiB
Go

package catalogue
import (
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/validate"
)
// Defends novox/hq ADR 0325 on the controller's side, row by row of its "How it is checked" table: the module check
// (rows 1, 2), the identity composed (row 3), the tool runner's entry (row 4), no identity root (row 11) — and none
// of it sent to a node-engine older than the contract, which would refuse the whole declaration.
const identityImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
// aLibrarian files films: a container told its identity by PUID and PGID that mounts its config and the movies it
// writes; a sidecar that declares none and reads the music; a directory only its tools write; and tools served by the
// machine's runtime.
func aLibrarian(t *testing.T) Manifest {
t.Helper()
m := aToolsModule(t, "librarian", "tools/index.js")
m.Accesses = []Access{
{ID: "movies", Path: "/storage/media/movies", Mode: AccessReadWrite},
{ID: "music", Path: "/storage/media/music", Mode: AccessRead},
}
m.Resources = []map[string]any{
{"id": "config", "type": "directory", "mode": "0750"},
{"id": "notes", "type": "directory", "mode": "0700"},
{"id": "app", "type": "container", "name": "librarian", "image": identityImage,
"identity": map[string]any{"user": "env:PUID", "group": "env:PGID"},
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}},
{"id": "sidecar", "type": "container", "name": "librarian-sidecar", "image": identityImage,
"volumes": []any{"${access:music}:/music:ro"}},
}
return m
}
// aPlayer reads the movies the librarian writes, as its own identity through the runtime's user option, and has
// tools of its own that read them too.
func aPlayer(t *testing.T) Manifest {
t.Helper()
m := aToolsModule(t, "player", "tools/index.js")
m.Accesses = []Access{{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}}
m.Resources = []map[string]any{
{"id": "app", "type": "container", "name": "player", "image": identityImage,
"identity": map[string]any{"user": "run"}, "volumes": []any{"${access:movies}:/movies:ro"}},
}
return m
}
func keepingFolders(ids map[string]ModuleIdentity, kept ...string) Rendering {
with := Rendering{KeepsFolders: true, Identities: ids, KeptFolders: map[string]bool{}}
for _, k := range kept {
with.KeptFolders[k] = true
}
return with
}
func composeFolders(t *testing.T, r Resolution, with Rendering) Composed {
t.Helper()
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
with.ArtifactStore = "anchor.internal:5101"
composed, err := r.Compose(with)
if err != nil {
t.Fatal(err)
}
return composed
}
var identities = map[string]ModuleIdentity{"librarian": {UID: 50001, GID: 50001}, "player": {UID: 50002, GID: 50002}}
// Row 3: the identity composed — the same on every machine, the container told it, the module's directories its
// containers mount owned by it, and the declaration's identities naming the account.
func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) {
var seen []string
for _, node := range []string{"ace", "novox"} {
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
composed := composeFolders(t, r, keepingFolders(identities))
got := byID(composed.Resources)
app := got["librarian.app"]
ident, _ := app[IdentityField].(map[string]any)
want := map[string]any{"mode": "env", "user_env": "PUID", "group_env": "PGID", "uid": 50001, "gid": 50001}
if fmt.Sprint(ident) != fmt.Sprint(want) {
t.Errorf("%s: the container is told %v; want %v", node, ident, want)
}
player, _ := got["player.app"][IdentityField].(map[string]any)
if fmt.Sprint(player) != fmt.Sprint(map[string]any{"mode": "run", "uid": 50002, "gid": 50002}) {
t.Errorf("%s: the player is told %v", node, player)
}
if _, has := got["librarian.sidecar"][IdentityField]; has {
t.Errorf("%s: a container declaring no identity was given one", node)
}
if owner := got["librarian.config"]["owner"]; owner != "50001:50001" {
t.Errorf("%s: the directory its identity's container mounts is owned by %v", node, owner)
}
if owner, has := got["librarian.notes"]["owner"]; has {
t.Errorf("%s: a directory no identity container mounts was given the owner %v: what writes it is not "+
"the identity", node, owner)
}
raw, _ := json.Marshal(composed.Identities)
seen = append(seen, string(raw))
if !strings.Contains(string(raw), `"name":"mesh-librarian"`) || !strings.Contains(string(raw), `"uid":50001`) {
t.Errorf("%s: the declaration's identities are %s", node, raw)
}
}
// What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused.
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
with := keepingFolders(identities, "/storage/media/movies")
composed := composeFolders(t, r, with)
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
"identities": composed.Identities})
if err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(body); len(problems) > 0 {
t.Fatalf("the pinned node-engine refuses it: %v", problems)
}
if seen[0] != seen[1] {
t.Fatalf("the identities differ between machines: %s and %s", seen[0], seen[1])
}
}
// Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring
// identity mounts it, never a container declaring none — and the tool runner's account by name, with no number,
// each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so
// the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken.
func TestAnAccessNamesItsUsers(t *testing.T) {
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
with := keepingFolders(identities, "/storage/media/movies")
got := byID(composeFolders(t, r, with).Resources)
users := func(id string) string {
raw, _ := json.Marshal(got[id]["users"])
return string(raw)
}
movies := "librarian." + AccessID("/storage/media/movies")
if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` +
`{"identity":"jochen","use":"read-write","from":"tool-runner"}]`; users(movies) != want {
t.Errorf("the librarian's movies name %s; want %s", users(movies), want)
}
played := "player." + AccessID("/storage/media/movies")
if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` +
`{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(played) != want {
t.Errorf("the player's movies name %s; want %s", users(played), want)
}
// The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools.
music := "librarian." + AccessID("/storage/media/music")
if want := `[{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(music) != want {
t.Errorf("the music names %s; want only the tool runner's read", users(music))
}
if got[movies]["taken"] != true || got[music]["taken"] != false {
t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"])
}
// The runner is named with no number: the engine resolves it by name, never as uid 0 sent from here.
if strings.Contains(users(music), `"uid"`) {
t.Errorf("the tool runner's entry carries a number: %s", users(music))
}
}
// The contract: to an engine that does not state link.IdentityContract nothing of ADR 0325 is sent — no identities, no
// container identity, no users, no taken, no owner from an identity — and what is sent is what the pinned node-engine
// accepts, as before.
func TestNothingOfTheIdentityIsSentToAnOlderEngine(t *testing.T) {
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
composed := composeFolders(t, r, Rendering{Identities: identities})
if len(composed.Identities) > 0 {
t.Fatalf("identities were composed for an older engine: %v", composed.Identities)
}
for _, res := range composed.Resources {
for _, field := range []string{IdentityField, "users", "taken"} {
if _, has := res[field]; has {
t.Errorf("%v carries %s for an older engine", res["id"], field)
}
}
if res["id"] == "librarian.config" && res["owner"] != nil {
t.Errorf("the directory was given the owner %v for an older engine", res["owner"])
}
}
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources})
if err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(body); len(problems) > 0 {
t.Fatalf("the pinned node-engine refuses it: %v", problems)
}
// An engine that keeps folders, with no allocation yet (a question asked before the first send): composed
// without an identity, never with a made-up one.
none := composeFolders(t, r, keepingFolders(nil))
if len(none.Identities) > 0 || byID(none.Resources)["librarian.app"][IdentityField] != nil {
t.Fatal("an identity was composed with no allocation")
}
}
// Row 11: no identity outside the range is ever sent — 0 above all — and the composition says why.
func TestNoModuleIdentityIsRoot(t *testing.T) {
for _, n := range []int{0, 4, 10, 1000, IdentityLow - 1, IdentityHigh + 1} {
if AllocatableID(n) {
t.Errorf("%d is allocatable", n)
}
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
with := keepingFolders(map[string]ModuleIdentity{"librarian": {UID: n, GID: n}})
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
with.ArtifactStore = "anchor.internal:5101"
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§11") {
t.Errorf("an identity of %d composed: %v", n, err)
}
}
for _, n := range []int{IdentityLow, IdentityHigh} {
if !AllocatableID(n) {
t.Errorf("%d is not allocatable", n)
}
}
}
func manifestJSON(t *testing.T, m map[string]any) []byte {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return raw
}
func aContainerModule(name string, resources ...map[string]any) map[string]any {
return map[string]any{"module": name, "version": "1", "capabilities": []any{"container-runtime"},
"accesses": []any{map[string]any{"id": "movies", "path": "/storage/media/movies", "mode": "read-write"}},
"resources": resources}
}
// Rows 1 and 2: the module check accepts identity as env:<NAME> or run and refuses every other shape and an owner in
// it; a module that declares identity is refused a directory naming its owner and a container declaring none that
// mounts its own directory or a read-write access; a directory's mode is accepted.
func TestTheModuleCheckHoldsAnIdentityModuleToTheDecision(t *testing.T) {
ok := func(identity any) map[string]any {
return map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
"identity": identity, "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
}
config := map[string]any{"id": "config", "type": "directory", "mode": "0750"}
for _, identity := range []any{
map[string]any{"user": "env:PUID", "group": "env:PGID"},
map[string]any{"user": "run"},
} {
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(identity)))); err != nil {
t.Errorf("identity %v refused: %v", identity, err)
}
}
for _, c := range []struct {
identity any
want string
}{
{map[string]any{"user": "1000", "group": "1000"}, `"env:<NAME>" or "run"`},
{map[string]any{"user": "env:PUID"}, "told its group the same way"},
{map[string]any{"user": "run", "group": "env:PGID"}, "takes no group"},
{map[string]any{"user": "env:puid", "group": "env:PGID"}, "environment variable's name"},
{map[string]any{"user": "env:PUID", "group": "env:PGID", "owner": "1000:1000"}, "never an owner or a number"},
{"run", "identity is"},
} {
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(c.identity))))
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "ADR 0325") {
t.Errorf("identity %v: %v, want it refused saying %q", c.identity, err, c.want)
}
}
// An identity on anything but a container.
onAFile := map[string]any{"id": "f", "type": "directory", "mode": "0750", "identity": map[string]any{"user": "run"}}
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", onAFile))); err == nil {
t.Error("an identity on a directory was accepted")
}
// Held to the decision whole once it declares identity (§1, §2, §3).
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
bare := map[string]any{"id": "helper", "type": "container", "name": "helper", "image": identityImage,
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", owned, ok(map[string]any{"user": "run"}), bare)))
for _, want := range []string{"names its owner 1000:1000", "declares no identity and mounts the module's own directory config",
"declares no identity and mounts the read-write access movies"} {
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("an identity module: %v, want it refused saying %q", err, want)
}
}
// A setting's owner too.
settingOwned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "${setting:puid}:${setting:pgid}"}
identityModule := aContainerModule("app", settingOwned, ok(map[string]any{"user": "run"}))
identityModule["settings"] = map[string]any{"puid": map[string]any{"default": "1000"}, "pgid": map[string]any{"default": "1000"}}
if _, err := ParseManifest(manifestJSON(t, identityModule)); err == nil || !strings.Contains(err.Error(), "names its owner") {
t.Errorf("a setting's owner in an identity module: %v", err)
}
}
// Rows 1 and 2 for what is registered today: a module that declares no identity is warned, never refused — a
// directory naming its owner, a container mounting its own directory or a read-write access — and each warning
// names ADR 0325. A placement's owner is refused for an identity module and warned of for any other.
func TestAModuleWithoutIdentityIsWarnedNotRefused(t *testing.T) {
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
bare := map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
m, err := ParseManifest(manifestJSON(t, aContainerModule("legacy", owned, bare)))
if err != nil {
t.Fatalf("a registered shape was refused: %v", err)
}
warnings := IdentityWarnings(m)
sort.Strings(warnings)
if len(warnings) != 3 {
t.Fatalf("warned %d times: %q", len(warnings), warnings)
}
for _, w := range warnings {
if !strings.Contains(w, "ADR 0325") || !strings.Contains(w, "refused once they have moved") {
t.Errorf("a warning that does not name ADR 0325 or say refusal comes later: %q", w)
}
}
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
"config": map[string]any{"path": "/srv/legacy", "owner": "1000:1000"}}}}}
if err := PlacesOwnerProblem(m, placed); err != nil {
t.Errorf("a placement's owner refused for a module without identity: %v", err)
}
if w := PlacesOwnerWarnings(m, placed); len(w) != 1 || !strings.Contains(w[0], "places.config.owner") {
t.Errorf("a placement's owner warned %q", w)
}
withIdentity := aLibrarian(t)
placedLibrarian := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
"config": map[string]any{"path": "/srv/librarian", "owner": "1000:1000"}}}}}
if err := PlacesOwnerProblem(withIdentity, placedLibrarian); err == nil || !strings.Contains(err.Error(), "ADR 0325") {
t.Errorf("a placement's owner for an identity module: %v", err)
}
if len(IdentityWarnings(withIdentity)) != 0 {
t.Error("an identity module was warned rather than refused")
}
}