A walk run while a dataset was unmounted wrote onto root's empty mount point, or "dropped" entries from it and forgot the ones on the data; a folder kept while empty below an unnamed mount was then swept whole once the disk mounted, with no warrant. Every walk is now judged as an apply is, a changed mount judges the folder afresh, and systemd .mount units and mounts seen are read for the first-sight rule. Widening a mask caps the entries it limited, so the owning group gains nothing. The watch lets go of removed folders, re-arms on lost notice, reads users under its lock and ends with its context. A drop that cannot read an ACL is counted, a broken folder's words carry no path, a grant's group that could not be left is tried again, and a folder's stat is bounded.
288 lines
12 KiB
Go
288 lines
12 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|