mesh/merge-gate head 281837847e03 merged into trunk 303d4037e84e — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 281837847e03 merged into trunk 303d4037e84e — pass: its merge-check.sh passed
mesh/delivery delivered
An earlier change dropped alpine's RemoveAccount and RemoveGroup, so alpine made no module identity at all. The sample beneath a root in shape had no test of its own. And an apply that found the library's lock held read the library as kept even when what held it was a take act about to be refused for a missing disk; it now does so only while a take walk is running, and otherwise waits and judges as always.
386 lines
14 KiB
Go
386 lines
14 KiB
Go
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"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// 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)
|
|
// in is each account's added groups, by name; homes each account's home, "/" when not set.
|
|
in map[string][]string
|
|
homes map[string]string
|
|
ran []string
|
|
}
|
|
|
|
// groupNamed is the name of gid in m, or the number.
|
|
func (m *accountsMachine) groupNamed(gid int) string {
|
|
for n, ids := range m.groups {
|
|
if ids[0] == gid {
|
|
return n
|
|
}
|
|
}
|
|
return strconv.Itoa(gid)
|
|
}
|
|
|
|
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
|
|
}
|
|
home := m.homes[n]
|
|
if home == "" {
|
|
home = "/"
|
|
}
|
|
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::" + home + ":/usr/bin/nologin\n", nil
|
|
}
|
|
}
|
|
return "", errors.New("getent exited 2")
|
|
case "id":
|
|
name := args[len(args)-1]
|
|
u, ok := m.users[name]
|
|
if !ok {
|
|
return "", errors.New("id exited 1")
|
|
}
|
|
return strings.Join(append([]string{m.groupNamed(u[1])}, m.in[name]...), " ") + "\n", nil
|
|
case "groupadd":
|
|
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
|
|
case "useradd":
|
|
name := args[len(args)-1]
|
|
m.users[name] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
|
|
if args[5] == "--groups" {
|
|
m.setIn(name, strings.Split(args[6], ","))
|
|
}
|
|
if m.homes == nil {
|
|
m.homes = map[string]string{}
|
|
}
|
|
m.homes[name] = "/nonexistent"
|
|
case "usermod":
|
|
name := args[len(args)-1]
|
|
if args[0] == "--append" {
|
|
m.setIn(name, append(m.in[name], args[2]))
|
|
} else if args[0] == "--home" {
|
|
if m.homes == nil {
|
|
m.homes = map[string]string{}
|
|
}
|
|
m.homes[name] = args[1]
|
|
} else {
|
|
u := m.users[name]
|
|
m.users[name] = [2]int{u[0], mustAtoi(args[1])}
|
|
m.setIn(name, strings.Split(args[3], ","))
|
|
}
|
|
case "gpasswd":
|
|
var kept []string
|
|
for _, g := range m.in[args[1]] {
|
|
if g != args[2] {
|
|
kept = append(kept, g)
|
|
}
|
|
}
|
|
m.setIn(args[1], kept)
|
|
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 (m *accountsMachine) setIn(name string, groups []string) {
|
|
if m.in == nil {
|
|
m.in = map[string][]string{}
|
|
}
|
|
var out []string
|
|
for _, g := range groups {
|
|
if g != "" {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
m.in[name] = out
|
|
}
|
|
|
|
func mustAtoi(s string) int {
|
|
n, _ := strconv.Atoi(s)
|
|
return n
|
|
}
|
|
|
|
// withGroups is a declaration with library groups, identities and resources.
|
|
func withGroups(t *testing.T, groups, identities, resources string) *declaration.Declaration {
|
|
t.Helper()
|
|
nothing := ""
|
|
if resources == "" {
|
|
nothing = `"owns_nothing":true,`
|
|
}
|
|
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[` + groups + `],"identities":[` + identities +
|
|
`],` + nothing + `"resources":[` + resources + `]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
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 /nonexistent --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{
|
|
withGroups(t, `{"name":"mesh-media","gid":10}`, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
|
|
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
|
|
withGroups(t, `{"name":"mesh-media","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)
|
|
}
|
|
}
|
|
|
|
// Every system the engine runs on that makes accounts makes module identities: arch and alpine alike.
|
|
func TestArchAndAlpineMakeModuleIdentities(t *testing.T) {
|
|
for _, name := range []string{"arch", "alpine"} {
|
|
sys, err := system.For(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := toolFor(sys); err != nil {
|
|
t.Errorf("%s makes no module identity: %v", name, err)
|
|
}
|
|
}
|
|
}
|