A joining module's own directories belong to its own group, and its tools run in the tool runner, which could not read them. An identity may now name the tool runner's account: the engine puts it in mesh-<module> as it puts it in a library's group, says the change as identity:<module> for the runner's unit to restart once, takes it back when no identity names it, and refuses an account that is missing or root's. The engine states contract 7.
924 lines
38 KiB
Go
924 lines
38 KiB
Go
//go:build linux
|
|
|
|
package apply
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// The engine's half of ADR 0328's checks (novox/hq ADR 0328, "How it is checked", issue 567). The engine runs as
|
|
// root; a test may not, and a non-root test gives its folders a group it is in itself.
|
|
|
|
// testGID is a group the test may give its folders: any number as root, else one of its own added groups that is
|
|
// no root group's.
|
|
func testGID(t *testing.T) int {
|
|
t.Helper()
|
|
if os.Geteuid() == 0 {
|
|
return 61000
|
|
}
|
|
groups, _ := os.Getgroups()
|
|
for _, g := range groups {
|
|
if g != os.Getgid() && g != 0 && rootGroup(g) == "" {
|
|
return g
|
|
}
|
|
}
|
|
t.Skip("the test runs as no root and in no group of its own beside its primary one, so it can give no " +
|
|
"folder a library's group")
|
|
return 0
|
|
}
|
|
|
|
// libraries has the test keep its own record of libraries and judge mounts as the test says.
|
|
func libraries(t *testing.T, points, targets []string) {
|
|
t.Helper()
|
|
was, wasPoints, wasTargets := Libraries, MountPoints, MountTargets
|
|
Libraries = NewLibraryMemory()
|
|
forgetMountHistory()
|
|
MountPoints = func() []string { return append([]string{"/"}, points...) }
|
|
MountTargets = func(context.Context) []string { return targets }
|
|
t.Cleanup(func() {
|
|
Libraries, MountPoints, MountTargets = was, wasPoints, wasTargets
|
|
forgetMountHistory()
|
|
})
|
|
}
|
|
|
|
func forgetMountHistory() {
|
|
mountHistory.Lock()
|
|
mountHistory.seen = map[string]bool{}
|
|
mountHistory.Unlock()
|
|
}
|
|
|
|
// libraryDecl is a declaration with one library, its group, and resources.
|
|
func libraryDecl(t *testing.T, lib string, resources ...string) *declaration.Declaration {
|
|
t.Helper()
|
|
var l declaration.Library
|
|
if err := jsonInto(lib, &l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := fmt.Sprintf(`{"declaration":1,"groups":[{"name":%q,"gid":%d}],"libraries":[%s],"resources":[%s]}`,
|
|
l.Group, l.GID, lib, strings.Join(resources, ","))
|
|
if len(resources) == 0 {
|
|
raw = strings.Replace(raw, `"resources":[]`, `"owns_nothing":true,"resources":[]`, 1)
|
|
}
|
|
d, err := declaration.Parse([]byte(raw))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// libJSON is a library's declaration.
|
|
func libJSON(name, root string, gid int, home, taken bool, paths ...string) string {
|
|
quoted := make([]string, len(paths))
|
|
for i, p := range paths {
|
|
quoted[i] = strconv.Quote(p)
|
|
}
|
|
return fmt.Sprintf(`{"library":%q,"root":%q,"group":"mesh-%s","gid":%d,"home":%v,"taken":%v,"paths":[%s]}`,
|
|
name, root, name, gid, home, taken, strings.Join(quoted, ","))
|
|
}
|
|
|
|
// applyLib applies d with a runner that answers the group database for the library group (already there) and runs
|
|
// nothing else.
|
|
func applyLib(t *testing.T, d *declaration.Declaration, known store.State) (Report, store.State, error) {
|
|
t.Helper()
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
if name == "getent" && args[0] == "group" {
|
|
for _, g := range d.Groups {
|
|
if args[1] == g.Name || args[1] == strconv.Itoa(g.GID) {
|
|
return fmt.Sprintf("%s:x:%d:\n", g.Name, g.GID), nil
|
|
}
|
|
}
|
|
return "", errors.New("getent exited 2")
|
|
}
|
|
return "", nil
|
|
}
|
|
return Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
|
}
|
|
|
|
func statOf(t *testing.T, path string) unix.Stat_t {
|
|
t.Helper()
|
|
var st unix.Stat_t
|
|
if err := unix.Lstat(path, &st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// Row 6: the root and every requested sub-path kept in the library's group with group rwx and setgid on every apply —
|
|
// those folders alone, never what is in them; owner, owner's and others' bits unchanged.
|
|
func TestALibraryIsKeptInItsGroup(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.Mkdir(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies"))
|
|
if _, _, err := applyLib(t, d, store.State{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Kept since it was empty; an app then narrows a requested folder and writes beneath it.
|
|
movies := filepath.Join(root, "movies")
|
|
if err := os.Chown(movies, -1, os.Getgid()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(movies, 0o705); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
film := filepath.Join(movies, "film")
|
|
if err := os.WriteFile(film, []byte("data"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filmBefore := statOf(t, film)
|
|
before := statOf(t, movies)
|
|
report, _, err := applyLib(t, d, store.State{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "updated" {
|
|
t.Fatalf("a kept library was not put back: %+v", o)
|
|
}
|
|
after := statOf(t, movies)
|
|
if int(after.Gid) != gid || after.Mode&0o7777 != 0o2775 {
|
|
t.Fatalf("a requested path is not kept in the group with group rwx and setgid: gid %d, mode %o", after.Gid,
|
|
after.Mode&0o7777)
|
|
}
|
|
if after.Uid != before.Uid || after.Mode&0o707 != before.Mode&0o707 {
|
|
t.Errorf("the owner or the owner's and others' bits changed: %o to %o", before.Mode&0o7777, after.Mode&0o7777)
|
|
}
|
|
if f := statOf(t, film); f.Mode != filmBefore.Mode || f.Gid != filmBefore.Gid {
|
|
t.Error("a file under a kept path was changed without the take act")
|
|
}
|
|
if rec, _ := libraryRecord("media"); !rec.Kept || rec.How != store.KeptEmpty {
|
|
t.Errorf("not recorded kept as empty: %+v", rec)
|
|
}
|
|
}
|
|
|
|
// Row 9: a missing sub-path is made with the root's owner, the library's group, group rwx and setgid.
|
|
func TestAMissingSubPathIsMadeAsTheRootIs(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.Mkdir(root, 0o751); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(root, 0o751); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false, "tv/shows"))
|
|
if _, _, err := applyLib(t, d, store.State{}); err != nil {
|
|
t.Fatalf("an empty root was not kept: %v", err)
|
|
}
|
|
rst := statOf(t, root)
|
|
for _, p := range []string{filepath.Join(root, "tv"), filepath.Join(root, "tv", "shows")} {
|
|
st := statOf(t, p)
|
|
if st.Uid != rst.Uid || int(st.Gid) != gid || st.Mode&0o7777 != 0o2771 {
|
|
t.Errorf("%s made as uid %d gid %d mode %o, and the root's owner, the group and 2771 were wanted", p,
|
|
st.Uid, st.Gid, st.Mode&0o7777)
|
|
}
|
|
}
|
|
if rec, _ := libraryRecord("media"); rec.How != store.KeptEmpty {
|
|
t.Errorf("an empty root is not kept as empty: %+v", rec)
|
|
}
|
|
}
|
|
|
|
// Row 7 (keep wait) and §8: a root that holds data not in the group waits with reason keep, and nothing is changed.
|
|
func TestARootThatHoldsDataWaitsForTheOperatorsWord(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.MkdirAll(filepath.Join(root, "movies"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := statOf(t, root)
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies"))
|
|
report, _, err := applyLib(t, d, store.State{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) {
|
|
t.Fatalf("a root holding data did not wait for the operator: %+v", o)
|
|
}
|
|
if after := statOf(t, root); after.Gid != before.Gid || after.Mode != before.Mode {
|
|
t.Fatal("the root was changed without a warrant")
|
|
}
|
|
j := JudgeLibrary(context.Background(), d.Libraries[0])
|
|
if j.Kind != VerdictWaitKeep || j.Placed == "" || strings.Contains(KeepWords("media")+DiskWords("media"), "/") {
|
|
t.Fatalf("the look does not wait for the operator, naming the filesystem with no path: %+v", j)
|
|
}
|
|
}
|
|
|
|
// Row 7, the act: group, group write and setgid set under the root; no link followed; no other filesystem entered;
|
|
// a file with another name elsewhere left; owner, owner's and others' bits unchanged; nothing without a warrant
|
|
// (the act's ask, internal/link); an undeclared library refused; a walk over its bound goes on from its cursor.
|
|
func TestTheTakeActSetsTheGroupUnderTheRoot(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
base := t.TempDir()
|
|
root := filepath.Join(base, "media")
|
|
outside := filepath.Join(base, "outside")
|
|
for _, d := range []string{filepath.Join(root, "movies", "a film"), outside} {
|
|
if err := os.MkdirAll(d, 0o705); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
file := filepath.Join(root, "movies", "a film", "film.mkv")
|
|
if err := os.WriteFile(file, []byte("data"), 0o604); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(file, 0o4604); err != nil { // its own setuid bit, which a group change drops
|
|
t.Fatal(err)
|
|
}
|
|
secret := filepath.Join(outside, "secret")
|
|
if err := os.WriteFile(secret, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(secret, filepath.Join(root, "movies", "hardlink")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if os.Geteuid() == 0 { // an account's file, as an *arr app's hardlinked download is
|
|
if err := os.Chown(secret, 65534, -1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies"))
|
|
if _, _, err := applyLib(t, d, store.State{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := TakeLibrary(context.Background(), d, "library:books", root, "w", "op"); err == nil {
|
|
t.Fatal("an undeclared library was taken")
|
|
}
|
|
// Bounded: three entries a walk, gone on from the cursor until done.
|
|
was := ActLimits
|
|
ActLimits = WalkLimits{Entries: 3, Time: time.Minute}
|
|
defer func() { ActLimits = was }()
|
|
done, said, err := TakeLibrary(context.Background(), d, "library:media", root, "w-1", "the operator")
|
|
if err != nil {
|
|
t.Fatalf("the take act was refused: %v", err)
|
|
}
|
|
<-done
|
|
if !strings.Contains(said, "w-1") {
|
|
t.Errorf("the act does not say its warrant: %s", said)
|
|
}
|
|
rec, _ := libraryRecord("media")
|
|
if rec.Act == nil || rec.Act.State != "stopped" || len(rec.Cursor) == 0 {
|
|
t.Fatalf("a walk over its bound does not say where it stopped: %+v %+v", rec.Act, rec.Cursor)
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
ReconcileLibraries(context.Background(), d)
|
|
if rec, _ = libraryRecord("media"); rec.Act.State == "done" {
|
|
break
|
|
}
|
|
}
|
|
if rec.Act.State != "done" || rec.Act.Linked != 1 || rec.Act.Stripped != 1 || !rec.Kept || rec.How != store.KeptByWarrant ||
|
|
rec.KeptOn == "" {
|
|
t.Fatalf("the act is not recorded done under its warrant on its filesystem: %+v %+v", rec, rec.Act)
|
|
}
|
|
for _, p := range []string{root, filepath.Join(root, "movies"), filepath.Join(root, "movies", "a film")} {
|
|
if st := statOf(t, p); int(st.Gid) != gid || st.Mode&0o7777 != 0o2775 {
|
|
t.Errorf("%s: gid %d mode %o, and the group with 2775 (705 plus group rwx and setgid) was wanted", p,
|
|
st.Gid, st.Mode&0o7777)
|
|
}
|
|
}
|
|
if st := statOf(t, file); int(st.Gid) != gid || st.Mode&0o7777 != 0o0664 {
|
|
t.Errorf("a file: gid %d mode %o, and the group with 0664 (its setuid bit taken by the group change and "+
|
|
"never put back) was wanted", st.Gid, st.Mode&0o7777)
|
|
}
|
|
if st := statOf(t, secret); int(st.Gid) != gid || st.Mode&0o7777 != 0o660 {
|
|
t.Error("an account's file with another name (a hardlinked download) was not given the group")
|
|
}
|
|
if st := statOf(t, outside); int(st.Gid) == gid {
|
|
t.Error("the act followed a link out of the library")
|
|
}
|
|
}
|
|
|
|
// Row 7, the act: another filesystem is not entered — a folder whose device is not the root's is passed by.
|
|
func TestTheTakeActEntersNoOtherFilesystem(t *testing.T) {
|
|
gid := testGID(t)
|
|
root := t.TempDir()
|
|
mount := filepath.Join(root, "elsewhere")
|
|
if err := os.Mkdir(mount, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stats := walkStats{
|
|
at: func(dirfd int, name string, st *unix.Stat_t) error {
|
|
err := unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW)
|
|
if name == "elsewhere" {
|
|
st.Dev++
|
|
}
|
|
return err
|
|
},
|
|
fd: unix.Fstat,
|
|
}
|
|
w, err := walkRoot(root, ActLimits, stats, nil, groupVisit(gid))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w.counts.Mounts != 1 || int(statOf(t, mount).Gid) == gid {
|
|
t.Fatalf("the walk entered another filesystem: %+v", w.counts)
|
|
}
|
|
}
|
|
|
|
// Row 9: a root that is absent, or whose disk is not mounted, makes nothing, holds the containers that mount it,
|
|
// and waits with reason disk; off its home, a mount that is not there holds them too.
|
|
func TestALibraryWhoseDiskIsNotThereHoldsItsContainers(t *testing.T) {
|
|
gid := testGID(t)
|
|
base := t.TempDir()
|
|
disk := filepath.Join(base, "disk")
|
|
if err := os.Mkdir(disk, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
why, root string
|
|
home bool
|
|
targets []string
|
|
}{
|
|
{"its root absent", filepath.Join(base, "gone", "media"), true, nil},
|
|
{"its disk not mounted", filepath.Join(disk, "media"), true, []string{disk}},
|
|
{"its mount not there", disk, false, nil},
|
|
} {
|
|
libraries(t, nil, tc.targets)
|
|
d := libraryDecl(t, libJSON("media", tc.root, gid, tc.home, false, "movies"),
|
|
`{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`","volumes":["`+
|
|
filepath.Join(tc.root, "movies")+`:/movies"]}`)
|
|
var started bool
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
if len(args) > 0 && args[0] == "run" {
|
|
started = true
|
|
}
|
|
if name == "getent" && args[0] == "group" {
|
|
return fmt.Sprintf("mesh-media:x:%d:\n", gid), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
report, _, _ := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("media")) {
|
|
t.Errorf("%s: the library does not wait for its disk: %+v", tc.why, o)
|
|
}
|
|
if o := outcomeOf(report, "radarr.app"); o.Action != "waiting" || started {
|
|
t.Errorf("%s: a container that mounts it was not held: %+v", tc.why, o)
|
|
}
|
|
if !WaitingOnLibrary(d)["radarr.app"] {
|
|
t.Errorf("%s: liveness is not told the container waits", tc.why)
|
|
}
|
|
if j := JudgeLibrary(context.Background(), d.Libraries[0]); j.Kind != VerdictWaitDisk {
|
|
t.Errorf("%s: the look judges it %s", tc.why, j.Kind)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tc.root, "movies")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("%s: something was made", tc.why)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Row 3: the library's group made with its number; a name or number that is another's refused; what was made, and
|
|
// only that, removed when no longer declared.
|
|
func TestALibrarysGroupIsMadeWithItsNumber(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{"media": {2000, 0}}}
|
|
d := withGroups(t, `{"name":"mesh-media","gid":62000}`, ``, ``)
|
|
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(m.ran, "\n") != "groupadd --system --gid 62000 mesh-media" {
|
|
t.Fatalf("made with: %v", m.ran)
|
|
}
|
|
if o := outcomeOf(report, "group mesh-media"); o.Action != "created" || len(known.Groups) != 1 {
|
|
t.Fatalf("not recorded as made: %+v %+v", o, known.Groups)
|
|
}
|
|
clash := withGroups(t, `{"name":"mesh-books","gid":2000}`, ``, ``)
|
|
if _, _, err := Apply(context.Background(), archHost(t), clash, known, store.OriginDeclared, m.run, nil, nil); err == nil ||
|
|
!strings.Contains(err.Error(), "media") {
|
|
t.Errorf("a number that is another group's was not refused naming it: %v", err)
|
|
}
|
|
m.ran = nil
|
|
none := withGroups(t, ``, ``, `{"id":"x.d","type":"directory","path":"`+t.TempDir()+`/x"}`)
|
|
if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, still := m.groups["mesh-media"]; still || len(known.Groups) != 0 {
|
|
t.Fatalf("a library group no longer declared was not removed: %v", m.ran)
|
|
}
|
|
if _, kept := m.groups["media"]; !kept {
|
|
t.Fatal("a group the mesh did not make was removed")
|
|
}
|
|
}
|
|
|
|
// Row 4: a module's user with its own group (its uid's number), its first library's group as primary and its other
|
|
// libraries' as added groups.
|
|
func TestAModulesUserIsInItsLibrariesGroups(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
|
|
d := withGroups(t, `{"name":"mesh-media","gid":62000},{"name":"mesh-books","gid":62001}`,
|
|
`{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":62000,"groups":[62001]}`, ``)
|
|
if _, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil,
|
|
nil); err != nil {
|
|
t.Fatal(err)
|
|
} else if len(known.Identities) != 1 {
|
|
t.Fatalf("the identity is not recorded: %+v", known.Identities)
|
|
}
|
|
want := []string{
|
|
"groupadd --system --gid 62000 mesh-media",
|
|
"groupadd --system --gid 62001 mesh-books",
|
|
"groupadd --system --gid 61001 mesh-radarr",
|
|
"useradd --system --uid 61001 --gid 62000 --groups mesh-books --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"))
|
|
}
|
|
}
|
|
|
|
// Row 11: the tool runner's account is put in each library's group once, its unit restarted for it (a change of
|
|
// library:<name>), and taken out once no library puts it there.
|
|
func TestTheToolRunnerIsInItsLibrariesGroups(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, []string{"/srv/media"}, nil)
|
|
m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}}
|
|
lib := `{"library":"media","root":"/srv/media","group":"mesh-media","gid":` + strconv.Itoa(gid) +
|
|
`,"home":false,"runner":"nox"}`
|
|
d := libraryDecl(t, lib)
|
|
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(strings.Join(m.ran, "\n"), "usermod --append --groups mesh-media nox") {
|
|
t.Fatalf("the tool runner was not put in the library's group: %v", m.ran)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "updated" || len(known.Members) != 1 {
|
|
t.Fatalf("its joining is not a change of library:media, or not recorded: %+v %+v", o, known.Members)
|
|
}
|
|
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 strings.Contains(strings.Join(m.ran, "\n"), "usermod") {
|
|
t.Fatal("the tool runner was put in its group again")
|
|
}
|
|
none := withGroups(t, ``, ``, `{"id":"x.d","type":"directory","path":"`+t.TempDir()+`/x"}`)
|
|
if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(strings.Join(m.ran, "\n"), "gpasswd --delete nox mesh-media") || len(known.Members) != 0 {
|
|
t.Fatalf("the tool runner was not taken out of a group no library puts it in: %v", m.ran)
|
|
}
|
|
}
|
|
|
|
// Row 15: a library group, an identity's added group or an image told a root group's number is refused with its
|
|
// reason, and nothing is made or started.
|
|
func TestNoLibraryGroupIsRoot(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}`, ``, `{"id":"m.d","type":"directory","path":"`+dir+`/d"}`),
|
|
withGroups(t, `{"name":"mesh-media","gid":10}`,
|
|
`{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"groups":[10]}`,
|
|
`{"id":"m.d","type":"directory","path":"`+dir+`/d"}`),
|
|
} {
|
|
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") {
|
|
t.Errorf("a root group was not refused naming why: %v", err)
|
|
}
|
|
if len(m.ran) != 0 {
|
|
t.Errorf("something was made: %v", m.ran)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "d")); err == nil {
|
|
t.Error("a resource of a declaration naming a root group was applied")
|
|
}
|
|
}
|
|
if _, err := declaration.Parse([]byte(`{"declaration":1,"groups":[{"name":"mesh-media","gid":0}],` +
|
|
`"owns_nothing":true,"resources":[]}`)); err == nil || !strings.Contains(err.Error(), "never root") {
|
|
t.Errorf("a library group of number 0 was not refused on arrival: %v", err)
|
|
}
|
|
}
|
|
|
|
// A library's filesystem that cannot be named this time is unknown, never a move; another filesystem at the root is.
|
|
func TestALibraryNotNamedIsUnknownAndAnotherFilesystemIsAMove(t *testing.T) {
|
|
gid := testGID(t)
|
|
base := t.TempDir()
|
|
pool := filepath.Join(base, "pool")
|
|
root := filepath.Join(pool, "media")
|
|
if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
libraries(t, []string{pool}, nil)
|
|
uuid := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33"
|
|
wasUUID := MountUUID
|
|
MountUUID = func(p string) string {
|
|
if p == pool {
|
|
return uuid
|
|
}
|
|
return "x"
|
|
}
|
|
defer func() { MountUUID = wasUUID }()
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false))
|
|
if _, _, err := applyLib(t, d, store.State{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done, _, err := TakeLibrary(context.Background(), d, "library:media", root, "w-1", "op")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
taken := libraryDecl(t, libJSON("media", root, gid, true, true))
|
|
before, _ := libraryRecord("media")
|
|
uuid = "" // nothing names it now
|
|
wasFsid := MountFsid
|
|
MountFsid = func(string) string { return "" }
|
|
wasSource := MountSource
|
|
MountSource = func(string) (string, string) { return "", "" }
|
|
report, _, _ := applyLib(t, taken, store.State{})
|
|
MountFsid, MountSource = wasFsid, wasSource
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, "could not be named") {
|
|
t.Fatalf("an unnamed filesystem is not said so: %+v", o)
|
|
}
|
|
if after, _ := libraryRecord("media"); !after.Kept || after.KeptOn != before.KeptOn {
|
|
t.Fatalf("a name not read reset the record: %+v", after)
|
|
}
|
|
uuid = "00000000-1111-2222-3333-444444444444" // another pool at the same point, its root not in the group
|
|
if err := os.Chown(root, -1, os.Getgid()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, _, _ = applyLib(t, taken, store.State{})
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) {
|
|
t.Fatalf("another filesystem at the root was kept on the old warrant: %+v", o)
|
|
}
|
|
}
|
|
|
|
// Old-shape parity: a controller older than ADR 0328 sends no groups, identities or libraries, and an access is
|
|
// applied exactly as before ADR 0325 — mounted when present, refused when absent, nothing kept, made or held.
|
|
func TestAnOlderControllersDeclarationIsAppliedAsBefore(t *testing.T) {
|
|
libraries(t, nil, nil)
|
|
dir := t.TempDir()
|
|
present := filepath.Join(dir, "library")
|
|
absent := filepath.Join(dir, "media", "movies")
|
|
if err := os.Mkdir(present, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := statOf(t, present)
|
|
resources := []string{
|
|
`{"id":"radarr.library","type":"access","path":"` + present + `","mode":"read-write","users":[` +
|
|
`{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"}],"taken":true}`,
|
|
`{"id":"plex.movies","type":"access","path":"` + absent + `"}`,
|
|
}
|
|
report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{},
|
|
store.OriginDeclared, noServices, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+
|
|
"must provide it") {
|
|
t.Fatalf("an absent access is not refused as before: %v", err)
|
|
}
|
|
if o := outcomeOf(report, "radarr.library"); o.Action != "unchanged" ||
|
|
o.Detail != "operator-owned; present, read-write, nothing managed" {
|
|
t.Errorf("a present access is not as before: %+v", o)
|
|
}
|
|
if after := statOf(t, present); after.Gid != before.Gid || after.Mode != before.Mode {
|
|
t.Error("a present access was changed")
|
|
}
|
|
if n, _ := unix.Getxattr(present, "system.posix_acl_access", nil); n > 0 {
|
|
t.Error("an ACL was set on an access")
|
|
}
|
|
if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("an absent access was made")
|
|
}
|
|
}
|
|
|
|
func jsonInto(raw string, into any) error { return json.Unmarshal([]byte(raw), into) }
|
|
|
|
// Row 4: a module that joins a library later is given its group as primary on the account the mesh made; an account
|
|
// the mesh did not make is never changed, and is refused if it is not as declared.
|
|
func TestAModuleThatJoinsALibraryLaterIsGivenItsGroup(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
|
|
own := withGroups(t, `{"name":"mesh-media","gid":62000}`,
|
|
`{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, ``)
|
|
_, known, err := Apply(context.Background(), archHost(t), own, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.ran = nil
|
|
joined := withGroups(t, `{"name":"mesh-media","gid":62000}`,
|
|
`{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":62000}`, ``)
|
|
if _, known, err = Apply(context.Background(), archHost(t), joined, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(m.ran, "\n") != "usermod --gid 62000 --groups mesh-radarr" || m.users["mesh-radarr"][1] != 62000 {
|
|
t.Fatalf("the mesh's account was not given its library's group: %v", m.ran)
|
|
}
|
|
theirs := &accountsMachine{users: map[string][2]int{"mesh-radarr": {61001, 61001}},
|
|
groups: map[string][2]int{"mesh-radarr": {61001, 0}, "mesh-media": {62000, 0}}}
|
|
if _, _, err := Apply(context.Background(), archHost(t), joined, store.State{}, store.OriginDeclared, theirs.run,
|
|
nil, nil); err == nil || !strings.Contains(err.Error(), "was not made by the mesh") {
|
|
t.Fatalf("an account the mesh did not make was changed, or not refused: %v %v", err, theirs.ran)
|
|
}
|
|
}
|
|
|
|
// A library's root is never in the machine's own trees, as an access's never is (novox/hq issue 339).
|
|
func TestALibraryIsNeverTheMachinesOwnTree(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
for _, root := range []string{"/etc", "/usr/share", "/var/lib/mesh-host/x"} {
|
|
d := libraryDecl(t, libJSON("sys", root, gid, true, true))
|
|
if _, _, err := applyLib(t, d, store.State{}); err == nil {
|
|
t.Errorf("a library at %s was not refused", root)
|
|
}
|
|
if _, _, err := TakeLibrary(context.Background(), d, "library:sys", root, "w", "op"); err == nil {
|
|
t.Errorf("a library at %s was taken", root)
|
|
}
|
|
}
|
|
}
|
|
|
|
// §7: a root in shape with old data beneath it not in the group is asked about, never kept without a warrant; a root
|
|
// and sample all in shape are kept; once taken, never asked again on that filesystem.
|
|
func TestARootInShapeWithDataBelowNotInTheGroupWaits(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
movies := filepath.Join(root, "movies")
|
|
if err := os.MkdirAll(movies, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := filepath.Join(movies, "old film")
|
|
if err := os.WriteFile(old, []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{root, movies} {
|
|
if err := os.Chown(p, -1, gid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(p, 0o2775); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies"))
|
|
report, _, err := applyLib(t, d, store.State{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) {
|
|
t.Fatalf("a root in shape with an old file below not in the group was kept without asking: %+v", o)
|
|
}
|
|
// The old file in the group too: the sample is in shape, and the library is kept.
|
|
if err := os.Chown(old, -1, gid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(old, 0o664); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report, _, _ = applyLib(t, d, store.State{}); outcomeOf(report, "library:media").Action == "waiting" {
|
|
t.Fatal("a root and sample in shape were not kept")
|
|
}
|
|
// Once kept, a file an app later leaves out of the group asks nothing again on that filesystem.
|
|
if err := os.WriteFile(filepath.Join(movies, "new"), nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report, _, _ = applyLib(t, d, store.State{}); outcomeOf(report, "library:media").Action == "waiting" {
|
|
t.Fatal("a kept library asked again")
|
|
}
|
|
}
|
|
|
|
// Review: a file with another name that root owns is left as it is (its other name may be anywhere), and a setgid
|
|
// file never keeps its setgid bit once regrouped.
|
|
func TestRootsLinkedFilesAreLeftAndSetgidIsNeverKept(t *testing.T) {
|
|
gid := testGID(t)
|
|
f, err := os.CreateTemp(t.TempDir(), "file")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if err := unix.Chmod(f.Name(), 0o2755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := statOf(t, f.Name())
|
|
rootLinked := st
|
|
rootLinked.Uid, rootLinked.Nlink = 0, 2
|
|
if did, err := groupVisit(gid)(int(f.Fd()), rootLinked, false); err != nil || did != visitShared {
|
|
t.Fatalf("a root-owned file with another name was not left: %d %v", did, err)
|
|
}
|
|
if after := statOf(t, f.Name()); after.Gid != st.Gid || after.Mode != st.Mode {
|
|
t.Fatal("a root-owned file with another name was changed")
|
|
}
|
|
did, err := groupVisit(gid)(int(f.Fd()), st, false)
|
|
if err != nil || did&visitStripped == 0 {
|
|
t.Fatalf("a setgid file's bit is not said taken: %d %v", did, err)
|
|
}
|
|
if after := statOf(t, f.Name()); after.Mode&(unix.S_ISGID|unix.S_ISUID) != 0 || int(after.Gid) != gid {
|
|
t.Fatalf("a setgid file kept its bit in the library's group: %o", after.Mode&0o7777)
|
|
}
|
|
}
|
|
|
|
// Review: a sub-path asked for after the library was kept without a warrant, which holds data not in the group,
|
|
// waits for the operator's word as the root would, and nothing of it is changed; an empty one is kept.
|
|
func TestASubPathHoldingOldDataWaitsForTheOperatorsWord(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.Mkdir(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies")), store.State{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Someone puts old data at a sub-path the library is asked for only now.
|
|
music := filepath.Join(root, "music")
|
|
if err := os.MkdirAll(filepath.Join(music, "album"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := statOf(t, music)
|
|
report, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies", "music")), store.State{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) {
|
|
t.Fatalf("a sub-path holding old data was kept without a warrant: %+v", o)
|
|
}
|
|
if after := statOf(t, music); after.Gid != before.Gid || after.Mode != before.Mode {
|
|
t.Fatal("a sub-path holding old data was changed")
|
|
}
|
|
// An empty one asked for is kept.
|
|
if err := os.RemoveAll(filepath.Join(music, "album")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, _, _ = applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies", "music")), store.State{})
|
|
if o := outcomeOf(report, "library:media"); o.Action == "waiting" {
|
|
t.Fatalf("an empty sub-path was not kept: %+v", o)
|
|
}
|
|
}
|
|
|
|
// Review: an apply does not wait for a take walk that is running: it leaves the library to it.
|
|
func TestAnApplyDoesNotWaitForATakeWalk(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.Mkdir(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock := libraryLock("media")
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
libraryWalks.Store("media", "fs-test")
|
|
defer libraryWalks.Delete("media")
|
|
done := make(chan Report, 1)
|
|
go func() {
|
|
r, _, _ := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
|
done <- r
|
|
}()
|
|
select {
|
|
case r := <-done:
|
|
if o := outcomeOf(r, "library:media"); !strings.Contains(o.Detail, "take act") {
|
|
t.Fatalf("the apply does not say it left the library to the walk: %+v", o)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("the apply waited for the take walk")
|
|
}
|
|
}
|
|
|
|
// Review: a root in shape whose data beneath — in no requested sub-path — is not in the group waits: the sample, not
|
|
// the root alone, says whether the data is the library's already (§7).
|
|
func TestTheSampleBeneathARootInShapeIsJudged(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "media")
|
|
if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "old", "film"), []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chown(root, -1, gid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := unix.Chmod(root, 0o2775); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" {
|
|
t.Fatalf("a root in shape with data beneath not in the group was kept: %+v", o)
|
|
}
|
|
}
|
|
|
|
// Review: an apply that finds the library's lock held by something that is no take walk (a take act about to be
|
|
// refused) waits for it and judges the library as always: a library whose disk is not there still holds.
|
|
func TestAnApplyWaitsForALockThatIsNoWalk(t *testing.T) {
|
|
gid := testGID(t)
|
|
libraries(t, nil, nil)
|
|
root := filepath.Join(t.TempDir(), "gone", "media")
|
|
lock := libraryLock("media")
|
|
lock.Lock()
|
|
go func() { time.Sleep(50 * time.Millisecond); lock.Unlock() }()
|
|
report, _, _ := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
|
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("media")) {
|
|
t.Fatalf("a library whose root is not there was read as kept while its lock was held: %+v", o)
|
|
}
|
|
}
|
|
|
|
// ADR 0334 §2: the tool runner's account joins a module's own group once, its unit restarted for it (a change of
|
|
// identity:<module>), and is taken out once no identity names it; an account not here, or root's, is refused.
|
|
func TestTheToolRunnerJoinsAModulesOwnGroup(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}}
|
|
d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"nox"}`, ``)
|
|
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(strings.Join(m.ran, "\n"), "usermod --append --groups mesh-radarr nox") {
|
|
t.Fatalf("the tool runner was not put in the module's own group: %v", m.ran)
|
|
}
|
|
if o := outcomeOf(report, "identity mesh-radarr"); !strings.Contains(o.Detail, "tool runner") ||
|
|
len(known.Members) != 1 || known.Members[0].Module != "radarr" {
|
|
t.Fatalf("its joining is not said, or not recorded: %+v %+v", o, known.Members)
|
|
}
|
|
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 strings.Contains(strings.Join(m.ran, "\n"), "usermod") {
|
|
t.Fatal("the tool runner was put in its group again")
|
|
}
|
|
none := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, ``)
|
|
if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(strings.Join(m.ran, "\n"), "gpasswd --delete nox mesh-radarr") || len(known.Members) != 0 {
|
|
t.Fatalf("the tool runner was not taken out of a group no identity puts it in: %v", m.ran)
|
|
}
|
|
|
|
was := RootGroupIDs
|
|
RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} }
|
|
defer func() { RootGroupIDs = was }()
|
|
for runner, says := range map[string]string{"ghost": "not on this machine", "root": "never root",
|
|
"admin": "root group"} {
|
|
m := &accountsMachine{users: map[string][2]int{"root": {0, 0}, "admin": {1001, 10}}, groups: map[string][2]int{}}
|
|
d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"`+
|
|
runner+`"}`, ``)
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), says) {
|
|
t.Errorf("%s: not refused saying %q: %v", runner, says, err)
|
|
}
|
|
if strings.Contains(strings.Join(m.ran, "\n"), "usermod --append") {
|
|
t.Errorf("%s: put in a group", runner)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0334 §2: a service naming identity:<module> under restart-on is restarted when the tool runner joins the
|
|
// module's own group, and not on an apply where nothing moved.
|
|
func TestTheToolRunnerIsRestartedOnceForTheOwnGroup(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}}
|
|
d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"nox"}`,
|
|
`{"id":"node-tools.unit","type":"service","unit":"node-tools.service","state":"running",`+
|
|
`"restart-on":["identity:radarr"]}`)
|
|
var verbs []string
|
|
systemd := systemctlStub(t, "loaded", "active", "enabled", &verbs)
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
if name == "systemctl" {
|
|
return systemd(ctx, name, args...)
|
|
}
|
|
return m.run(ctx, name, args...)
|
|
}
|
|
stops := func() int {
|
|
n := 0
|
|
for _, v := range verbs {
|
|
if v == "stop" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
_, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stops() != 1 {
|
|
t.Fatalf("the tool runner was restarted %d times for joining the own group, and once was wanted", stops())
|
|
}
|
|
verbs = nil
|
|
if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stops() != 0 {
|
|
t.Fatalf("the tool runner was restarted %d times with nothing moved", stops())
|
|
}
|
|
}
|