A module's containers that declare identity run as an identity the controller allocates once, mesh-wide, from 50000-59999; each access names its users and whether its folder is taken; an access wait is excused for a folder the mesh may not keep yet, and the operator is asked once per folder and machine, the folder taken only on the warrant. All of it is sent only to an engine at contract 5. A placement for an access a newer build brings is judged against that build (issue 575). mesh-host pinned to the ADR 0325 branch head.
340 lines
16 KiB
Go
340 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")
|
|
with.AccountUIDs = map[string]int{"jochen": 1000}
|
|
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 where its number is known,
|
|
// 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")
|
|
with.AccountUIDs = map[string]int{"jochen": 1000}
|
|
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","uid":1000,"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","uid":1000,"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","uid":1000,"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's number unknown: no entry for it, never one the engine refuses for uid 0.
|
|
got = byID(composeFolders(t, r, keepingFolders(identities)).Resources)
|
|
if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"}]`; users(movies) != want {
|
|
t.Errorf("with no number for the runner's account, the movies name %s; want %s", users(movies), want)
|
|
}
|
|
if _, has := got[music]["users"]; has {
|
|
t.Errorf("an access nobody with a known number uses names users: %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")
|
|
}
|
|
}
|