371 lines
18 KiB
Go
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"])
|
|
}
|
|
}
|
|
}
|