package apply import ( "context" "errors" "os" "path/filepath" "strconv" "strings" "testing" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) // The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with // the numbers the controller allocated, a container told to run as it, and never a root number. // accountsMachine is a user and group database the runner answers from, and every command that changed it. type accountsMachine struct { users, groups map[string][2]int // name → id, primary gid (a group's second is unused) ran []string } func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) { line := name + " " + strings.Join(args, " ") switch name { case "getent": db, key := args[0], args[1] table := m.users if db == "group" { table = m.groups } for n, ids := range table { if n == key || strconv.Itoa(ids[0]) == key { if db == "group" { return n + ":x:" + strconv.Itoa(ids[0]) + ":\n", nil } return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::/:/usr/bin/nologin\n", nil } } return "", errors.New("getent exited 2") case "groupadd": m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0} case "useradd": m.users[args[len(args)-1]] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])} case "userdel": delete(m.users, args[0]) case "groupdel": delete(m.groups, args[0]) default: return "", nil } m.ran = append(m.ran, line) return "", nil } func mustAtoi(s string) int { n, _ := strconv.Atoi(s) return n } func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration { t.Helper() d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` + resources + `]}`)) if err != nil { t.Fatal(err) } return d } // Row 3: the account made as a system account with exactly its numbers, recorded, and removed — only what was // made — when no longer declared. func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing.T) { m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}} dir := t.TempDir() d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`) report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) if err != nil { t.Fatal(err) } want := []string{ "groupadd --system --gid 61001 mesh-radarr", "useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir / --shell /usr/bin/nologin mesh-radarr", } if strings.Join(m.ran, "\n") != strings.Join(want, "\n") { t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n")) } if o := outcomeOf(report, "identity mesh-radarr"); o.Action != "created" { t.Errorf("the identity's outcome: %+v", o) } if len(known.Identities) != 1 || !known.Identities[0].MadeUser || !known.Identities[0].MadeGroup { t.Fatalf("what was made is not recorded: %+v", known.Identities) } // Applied again: nothing is made twice. m.ran = nil if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil { t.Fatal(err) } if len(m.ran) != 0 { t.Fatalf("an identity already here was made again: %v", m.ran) } // Undeclared, with a group the machine had before for another identity: only what was made goes. m.groups["mesh-sonarr"] = [2]int{61002, 0} other := withIdentities(t, `{"module":"sonarr","name":"mesh-sonarr","uid":61002,"gid":61002}`, `{"id":"x.dir","type":"directory","path":"`+dir+`/x"}`) if _, known, err = Apply(context.Background(), archHost(t), other, known, store.OriginDeclared, m.run, nil, nil); err != nil { t.Fatal(err) } if _, has := m.users["mesh-radarr"]; has { t.Error("an identity no longer declared kept its account") } if _, has := m.groups["mesh-radarr"]; has { t.Error("an identity no longer declared kept its group") } if rec := madeIdentity(&known, "mesh-sonarr"); rec.MadeGroup { t.Error("a group that was there before is recorded as the mesh's") } } // Row 3: a name that exists with other numbers, or numbers another name holds, is refused naming whose, and the // module is not attempted; the rest of the machine is. func TestAnIdentityWhoseNameOrNumbersAreAnothersIsRefused(t *testing.T) { dir := t.TempDir() for _, tc := range []struct { users, groups map[string][2]int says string }{ {users: map[string][2]int{"mesh-radarr": {1500, 1500}}, groups: map[string][2]int{}, says: "uid 1500"}, {users: map[string][2]int{"someone": {61001, 100}}, groups: map[string][2]int{}, says: "someone"}, {users: map[string][2]int{}, groups: map[string][2]int{"media": {61001, 0}}, says: "media"}, } { m := &accountsMachine{users: tc.users, groups: tc.groups} d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, `{"id":"radarr.config","type":"directory","path":"`+dir+`/radarr"},`+ `{"id":"plex.config","type":"directory","path":"`+dir+`/plex"}`) report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) if err == nil || !strings.Contains(err.Error(), tc.says) { t.Errorf("not refused naming %s: %v", tc.says, err) } if len(m.ran) != 0 { t.Errorf("something was made for an identity that is another's: %v", m.ran) } if o := outcomeOf(report, "radarr.config"); o.Action != "skipped" { t.Errorf("the module of a refused identity was attempted: %+v", o) } if _, err := os.Stat(filepath.Join(dir, "plex")); err != nil { t.Errorf("the rest of the machine was not applied: %v", err) } os.RemoveAll(filepath.Join(dir, "plex")) } } // Row 11: a declaration naming a root group for an account or an image is refused with its reason, and nothing is // made or started. A number 0 is refused on arrival, by the parser. func TestNoModuleIdentityIsRoot(t *testing.T) { was := RootGroupIDs RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} } defer func() { RootGroupIDs = was }() dir := t.TempDir() for _, d := range []*declaration.Declaration{ withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`), withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+ `{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+ `"identity":{"mode":"run","uid":61001,"gid":10}}`), } { m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}} _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) if err == nil || !strings.Contains(err.Error(), "wheel") || !strings.Contains(err.Error(), "never") { t.Errorf("a root group was not refused naming why: %v", err) } if len(m.ran) != 0 { t.Errorf("something was made or started: %v", m.ran) } if _, err := os.Stat(filepath.Join(dir, "config")); err == nil { t.Error("a resource of a declaration naming a root group was applied") } } for _, raw := range []string{ `{"declaration":1,"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]}`, `{"declaration":1,"resources":[{"id":"m.app","type":"container","name":"m","image":"` + pinned + `",` + `"identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":0}}]}`, } { if _, err := declaration.Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "never root") { t.Errorf("a number 0 was not refused on arrival: %v", err) } } } // Row 3: a container declaring identity is told it — by the variables its image reads, or the runtime's user // option — and a changed identity is a container recreated. func TestAContainerIsToldItsModulesIdentity(t *testing.T) { env := &declaration.Container{ID: "radarr.app", Name: "radarr", Image: pinned, RunAs: &declaration.ContainerIdentity{ Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID", UID: 61001, GID: 61001}} run := &declaration.Container{ID: "plex.app", Name: "plex", Image: pinned, RunAs: &declaration.ContainerIdentity{ Mode: declaration.IdentityByRun, UID: 61002, GID: 61002}} if got := strings.Join(foregroundRunArgs(env, "x"), " "); !strings.Contains(got, "--env PUID=61001 --env PGID=61001") { t.Errorf("an image told by environment was not: %s", got) } if got := strings.Join(foregroundRunArgs(run, "x"), " "); !strings.Contains(got, "--user 61002:61002") { t.Errorf("an image told by the runtime's user option was not: %s", got) } plain := *env plain.RunAs = nil moved := *env moved.RunAs = &declaration.ContainerIdentity{Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID", UID: 61009, GID: 61009} if containerSpec(env, inputs{}) == containerSpec(&plain, inputs{}) || containerSpec(env, inputs{}) == containerSpec(&moved, inputs{}) { t.Error("an identity told, or renumbered, does not recreate the container") } } // ADR 0325 §5 supersedes ADR 0312's group grant: what a grant put an account in is taken back on the first apply of // the access, and nothing is granted again. func TestWhatAGroupGrantPutAnAccountInIsTakenBack(t *testing.T) { dir := t.TempDir() groups := "nox media" var ran []string run := func(_ context.Context, name string, args ...string) (string, error) { ran = append(ran, name+" "+strings.Join(args, " ")) switch name { case "id": return groups + "\n", nil case "gpasswd": groups = "nox" } return "", nil } known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir, Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}} d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write","grant":{"account":"nox"}}`) report, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil) if err != nil { t.Fatal(err) } if groups != "nox" || !strings.Contains(strings.Join(ran, "\n"), "gpasswd --delete nox media") { t.Fatalf("the grant's group was not taken back: %v", ran) } if o := outcomeOf(report, "importer.books"); o.Action != "updated" || !strings.Contains(o.Detail, "taken back") { t.Errorf("the take-back is not said: %+v", o) } if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 { t.Errorf("the record still holds the grant's groups: %+v", rec) } } // Review L7: a group the grant put an account in that could not be left stays recorded, and is tried again. func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) { dir := t.TempDir() refuse := true groups := "nox media" run := func(_ context.Context, name string, args ...string) (string, error) { switch name { case "id": return groups + "\n", nil case "gpasswd": if refuse { return "", errors.New("gpasswd exited 1: locked") } groups = "nox" } return "", nil } known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir, Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}} d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write"}`) _, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil) if err != nil { t.Fatal(err) } if rec, _ := known.Find("importer.books"); rec.User != "nox" || len(rec.Groups) != 1 { t.Fatalf("a group that could not be left was forgotten: %+v", rec) } refuse = false if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil { t.Fatal(err) } if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 || groups != "nox" { t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups) } }