A module's containers ran as whatever their image chose and shared folders were made writable by putting accounts in the folder's group (ADR 0312), which ADR 0325 supersedes. The engine now makes each declared module identity as a system account with the controller's numbers, runs a container declaring `identity` as it, and keeps each placed shared folder usable through one POSIX ACL entry per user, set through the kernel's xattrs on descriptors it opened itself. A folder holding data is kept only after the operator's warrant (the take act); a missing folder is made where its data lives and waits where its disk is not; root's data is never kept. A watch and a bounded sweep put back what an app narrows. The group grant is removed, and what it put an account in is taken back. Reuses the descriptor walks, the create act, the missing-path wait and the warrant- backed act message from the paused issue 567 branch.
252 lines
10 KiB
Go
252 lines
10 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)
|
|
}
|
|
}
|