Files
mesh-controller/internal/catalogue/module_identity_test.go
T

371 lines
18 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, "/storage/media/movies")) // its read-write folder taken: switched
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")
}
}
// A container is not switched to its identity while a read-write folder it mounts holds data the mesh has not taken
// (the operator's question on ADR 0325): the app would run as the new uid before the take adds its entry, and lose
// write on the existing files. Its identity is held — no `identity` sent, its directories keep their owner — while
// the take's users already name the identity, so the entries exist before the switch; once every such folder is
// taken (or kept by the engine), the identity is sent in one apply.
func TestAContainerKeepsItsOldIdentityUntilItsDataIsTaken(t *testing.T) {
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
held := byID(composeFolders(t, r, keepingFolders(identities)).Resources)
if _, has := held["librarian.app"][IdentityField]; has {
t.Fatalf("the container was told its identity while its movies are not taken: %v", held["librarian.app"][IdentityField])
}
if owner, has := held["librarian.config"]["owner"]; has {
t.Fatalf("its directory was given the owner %v while its identity is held", owner)
}
users, _ := json.Marshal(held["librarian."+AccessID("/storage/media/movies")]["users"])
if !strings.Contains(string(users), `"identity":"mesh-librarian","uid":50001`) {
t.Fatalf("the access does not name the identity to come, so the take would not add its entry: %s", users)
}
for name, with := range map[string]Rendering{
"taken": keepingFolders(identities, "/storage/media/movies"),
"kept by the engine": func() Rendering {
w := keepingFolders(identities)
w.ReadyFolders = map[string]bool{"/storage/media/movies": true}
return w
}(),
} {
got := byID(composeFolders(t, r, with).Resources)
if _, has := got["librarian.app"][IdentityField]; !has {
t.Errorf("%s: the container is not told its identity", name)
}
if got["librarian.config"]["owner"] != "50001:50001" {
t.Errorf("%s: its directory is owned by %v", name, got["librarian.config"]["owner"])
}
}
}