diff --git a/cmd/mesh-host/libraries.go b/cmd/mesh-host/libraries.go index 8588901..f0addb1 100644 --- a/cmd/mesh-host/libraries.go +++ b/cmd/mesh-host/libraries.go @@ -163,10 +163,25 @@ func (j *libraryJudge) look(now time.Time) ([]link.ResourceHealth, bool) { read[i] = v } + // And every tool runner's account the last apply could not put in a group, as a resource of its own (ADR 0334 + // §2): unhealthy, never failing the apply. + warnings := apply.RunnerWarnings() + j.mu.Lock() defer j.mu.Unlock() changed := false - out := make([]link.ResourceHealth, 0, len(ls)) + out := make([]link.ResourceHealth, 0, len(ls)+len(warnings)) + for _, w := range warnings { + v := link.ResourceHealth{Module: w.Module, Resource: w.Resource, Kind: link.KindRunner, State: link.StateUnhealthy, + Reason: "the tool runner's account joins no group: " + w.Why, Since: now.UTC(), Streak: 1} + if was, had := j.was[w.Resource]; had && was.Reason == v.Reason { + v.Since, v.Streak = was.Since, was.Streak+1 + } else { + changed = true + } + j.was[w.Resource] = v + out = append(out, v) + } for i, l := range ls { r := read[i] id := apply.LibraryAccess(l.Library) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index bb36d36..dbad871 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -41,6 +41,9 @@ type Outcome struct { ID string `json:"id"` Type string `json:"type"` Target string `json:"target"` + // changes is what a tool runner leaving a group changes (`library:`, `identity:`): what the runner's + // unit names under restart-on. Never said. + changes string // Action is created · updated · unchanged · corrected · removed — and held-still, a container // a maintenance window is holding stopped, left for the next apply after it (novox/hq issue 224). // @@ -581,6 +584,8 @@ func ApplyMindingWindows( // cannot be made — its name or numbers another's here — fails, naming whose, and what needs it is not // attempted; the rest of the machine is. var identityFailures []*Error + // warnings is every tool runner's account that joins no group this apply: said, never failing it. + var warnings []RunnerWarning brokenGroups := map[int]bool{} if len(d.Groups) > 0 || len(d.Identities) > 0 { tool, toolErr := toolFor(sys) @@ -632,8 +637,12 @@ func ApplyMindingWindows( if id.Runner != "" { moved, said, err := joinGroup(ctx, sys, id.Runner, id.Name, store.Membership{Module: id.Module}, &known, run) if err != nil { - identityFailures = append(identityFailures, &Error{Resource: "identity " + id.Name, Err: err}) - log(fmt.Sprintf(" failed identity %s's runner: %v", id.Name, err)) + // A warning, never a failure of the apply or of the module: the module runs; its tools may not + // read its directories until the account is right (said as runner:). + warnings = append(warnings, RunnerWarning{Resource: "runner:" + id.Module, Module: id.Module, + Why: err.Error()}) + outcome.Detail = joinDetail(outcome.Detail, "its tool runner joins no group: "+err.Error()) + log(fmt.Sprintf(" warning identity %s's runner: %v", id.Name, err)) } else if moved { changed[IdentityChange(id.Module)] = true outcome.Detail = joinDetail(outcome.Detail, said) @@ -659,7 +668,15 @@ func ApplyMindingWindows( if err == nil { var moved bool var said string - moved, said, err = joinRunner(ctx, sys, l, &known, run) + var refused error + moved, said, refused = joinRunner(ctx, sys, l, &known, run) + if refused != nil { + // A warning, never a failure: the library is kept as it is, and said as runner:library:. + warnings = append(warnings, RunnerWarning{Resource: "runner:" + LibraryAccess(l.Library), + Why: refused.Error()}) + outcome.Detail = joinDetail(outcome.Detail, "its tool runner joins no group: "+refused.Error()) + log(fmt.Sprintf(" warning %s's runner: %v", LibraryAccess(l.Library), refused)) + } if moved { changed[LibraryAccess(l.Library)] = true outcome.Detail = joinDetail(outcome.Detail, said) @@ -679,6 +696,18 @@ func ApplyMindingWindows( } } in.held = heldRoots(verdicts, d.Libraries) + // A tool runner no longer put in a group leaves it before anything is applied, so its unit, which names the + // change under restart-on, is restarted in this same apply to lose it (ADR 0328 §11, ADR 0334 §2, ADR 0252 §3). + if origin == store.OriginDeclared { + for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) { + if o.Action == "restored" { + changed[o.changes] = true + } + report.Outcomes = append(report.Outcomes, o) + log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) + } + } + setRunnerWarnings(warnings) for i, resource := range ordered { if !orphansRemoved && i == guardFirst { // **Only a guard that is up may let the filter go.** Removing the derived filter's @@ -1065,11 +1094,6 @@ func ApplyMindingWindows( // Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder. if origin == store.OriginDeclared { - // A tool runner no longer put in a library's group leaves it (ADR 0328 §11, ADR 0252 §3). - for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) { - report.Outcomes = append(report.Outcomes, o) - log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) - } // And an identity, then a library group, no longer declared goes, once what ran as it went above (ADR 0328 // §3, §4). if tool, err := toolFor(sys); err == nil { @@ -1268,6 +1292,44 @@ func begin(r declaration.Resource) Outcome { return Outcome{ID: r.Identity(), Type: string(r.Kind()), Target: r.Target()} } +// dirModeOf is a directory's mode as declared: its permission bits, and the setgid bit when it is 2xxx (novox/hq ADR +// 0334 §1), as os.ModeSetgid. +func dirModeOf(spec string, fallback os.FileMode) (os.FileMode, error) { + mode, err := modeOf(spec, fallback) + if err != nil { + return 0, err + } + out := mode & os.ModePerm + if mode&0o2000 != 0 { + out |= os.ModeSetgid + } + return out, nil +} + +// dirBits is what of a directory's mode the mesh keeps: its permission bits and its setgid bit. +func dirBits(m os.FileMode) os.FileMode { return m & (os.ModePerm | os.ModeSetgid) } + +// unixDirMode is dirBits as a chmod takes it. +func unixDirMode(m os.FileMode) uint32 { + out := uint32(m.Perm()) + if m&os.ModeSetgid != 0 { + out |= 0o2000 + } + return out +} + +// statDirMode is a directory's mode as the kernel says it, in dirBits' terms. +func statDirMode(raw uint32) os.FileMode { + out := os.FileMode(raw & 0o777) + if raw&0o2000 != 0 { + out |= os.ModeSetgid + } + return out +} + +// modeText is a directory's mode as a person writes it: 0750, 2750. +func modeText(m os.FileMode) string { return fmt.Sprintf("%04o", unixDirMode(m)) } + func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) { if spec == "" { return fallback, nil @@ -1292,7 +1354,7 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) { // already has the declared owner and mode, so a person who sets them by hand at the machine hands it over. func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore bool) (Outcome, error) { out := begin(r) - mode, err := modeOf(r.Mode, 0o755) + mode, err := dirModeOf(r.Mode, 0o755) if err != nil { return out, err } @@ -1336,20 +1398,20 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore if err != nil { return out, err } - if !owned || before.Mode().Perm() != mode.Perm() { + if !owned || dirBits(before.Mode()) != dirBits(mode) { out.Action, out.asFound = "unchanged", true owner := r.Owner if owner == "" { owner = "root" } - out.asFoundDeclared = fmt.Sprintf("%s, mode %o", owner, mode.Perm()) + out.asFoundDeclared = fmt.Sprintf("%s, mode %s", owner, modeText(mode)) out.asFoundAt = time.Now().UTC() if previous.AsFound && filepath.Clean(previous.Target) == filepath.Clean(r.Path) && !previous.FoundAt.IsZero() { out.asFoundAt = previous.FoundAt } - out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner %s and mode %o are "+ + out.Detail = fmt.Sprintf("found here before the mesh and used as found: its owner %s and mode %s are "+ "left, though %s were declared (novox/hq issue 339); `nox node hand-over %s` on the "+ - "control-node hands it to the mesh", ownerText(before), before.Mode().Perm(), out.asFoundDeclared, r.Path) + "control-node hands it to the mesh", ownerText(before), modeText(dirBits(before.Mode())), out.asFoundDeclared, r.Path) return out, nil } } @@ -1380,8 +1442,8 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore if !after.IsDir() { return out, fmt.Errorf("%s is not a directory after applying", r.Path) } - if after.Mode().Perm() != mode.Perm() { - return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, after.Mode().Perm(), mode.Perm()) + if dirBits(after.Mode()) != dirBits(mode) { + return out, fmt.Errorf("%s is mode %s after setting %s", r.Path, modeText(dirBits(after.Mode())), modeText(mode)) } ownedAlready, err := ownedBy(r.Path, r.Owner) @@ -1399,9 +1461,9 @@ func applyDirectory(r *declaration.Directory, previous store.Applied, wasBefore out.Action = "created" } else if !ownedAlready { out.Action = "updated" - } else if before.Mode().Perm() != mode.Perm() { + } else if dirBits(before.Mode()) != dirBits(mode) { out.Action = "updated" - out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm()) + out.Detail = fmt.Sprintf("mode %s to %s", modeText(dirBits(before.Mode())), modeText(mode)) } return out, nil } @@ -1437,10 +1499,10 @@ func applyHandedOver(r *declaration.Directory, out Outcome, mode os.FileMode) (O return out, err } out.Action = "unchanged" - if before.Perm() != mode.Perm() || (chown && (beforeUID != uid || beforeGID != gid)) { + if dirBits(before) != dirBits(mode) || (chown && (beforeUID != uid || beforeGID != gid)) { out.Action = "updated" - out.Detail = fmt.Sprintf("handed to the mesh: owner %d:%d, mode %o, given what was declared", beforeUID, beforeGID, - before.Perm()) + out.Detail = fmt.Sprintf("handed to the mesh: owner %d:%d, mode %s, given what was declared", beforeUID, beforeGID, + modeText(dirBits(before))) } return out, nil } diff --git a/internal/apply/homes_linux.go b/internal/apply/homes_linux.go index 79d2844..80ea742 100644 --- a/internal/apply/homes_linux.go +++ b/internal/apply/homes_linux.go @@ -137,7 +137,7 @@ func chmodUnderAs(home, path string, mode os.FileMode, want int) error { return err } defer unix.Close(fd) - if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil { + if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil { return &os.PathError{Op: "chmod", Path: path, Err: err} } return nil @@ -256,8 +256,8 @@ func giveHandedOver(path string, mode os.FileMode, uid, gid int, chown bool) (be if err := unix.Fstat(fd, &st); err != nil { return 0, 0, 0, &os.PathError{Op: "fstat", Path: path, Err: err} } - before, beforeUID, beforeGID = os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid) - if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil { + before, beforeUID, beforeGID = statDirMode(st.Mode), int(st.Uid), int(st.Gid) + if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil { return 0, 0, 0, &os.PathError{Op: "chmod", Path: path, Err: err} } if chown && (beforeUID != uid || beforeGID != gid) { diff --git a/internal/apply/library.go b/internal/apply/library.go index d1235f9..a198813 100644 --- a/internal/apply/library.go +++ b/internal/apply/library.go @@ -466,9 +466,11 @@ func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Lib keep = append(keep, m) continue } - o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account} + o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account, + changes: LibraryAccess(m.Library)} if m.Module != "" { o.ID, o.Type = "identity "+declaration.IdentityPrefix+m.Module+" runner", "identity" + o.changes = IdentityChange(m.Module) } if others := wanted.othersAsk(m.Account, m.Group, ""); len(others) > 0 { o.Action, o.Detail = "forgotten", fmt.Sprintf("left %s in %s, which %s asks for", m.Account, m.Group, @@ -534,3 +536,28 @@ func unionPaths(a, b []string) []string { sort.Strings(a) return a } + +// RunnerWarning is a tool runner's account that joins no group (missing, or root's): said as a resource of its own, +// `runner:` or `runner:library:`, unhealthy with words with no path — never a failure of the apply. +type RunnerWarning struct { + Resource, Module, Why string +} + +var runnerWarnings struct { + sync.Mutex + list []RunnerWarning +} + +// setRunnerWarnings is the warnings the last apply found, in place of the ones before. +func setRunnerWarnings(ws []RunnerWarning) { + runnerWarnings.Lock() + defer runnerWarnings.Unlock() + runnerWarnings.list = append([]RunnerWarning(nil), ws...) +} + +// RunnerWarnings is every tool runner's account the last apply could not put in a group. +func RunnerWarnings() []RunnerWarning { + runnerWarnings.Lock() + defer runnerWarnings.Unlock() + return append([]RunnerWarning(nil), runnerWarnings.list...) +} diff --git a/internal/apply/library_test.go b/internal/apply/library_test.go index 4bb756b..29ce381 100644 --- a/internal/apply/library_test.go +++ b/internal/apply/library_test.go @@ -872,9 +872,17 @@ func TestTheToolRunnerJoinsAModulesOwnGroup(t *testing.T) { 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) + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Errorf("%s: a refused runner failed the apply: %v", runner, err) + } + ws := RunnerWarnings() + if len(ws) != 1 || ws[0].Resource != "runner:radarr" || ws[0].Module != "radarr" || + !strings.Contains(ws[0].Why, says) || absPath.MatchString(ws[0].Why) { + t.Errorf("%s: not said as runner:radarr saying %q, with no path: %+v", runner, says, ws) + } + if o := outcomeOf(report, "identity mesh-radarr"); o.Action != "created" { + t.Errorf("%s: the identity was not made beside the refused runner: %+v", runner, o) } if strings.Contains(strings.Join(m.ran, "\n"), "usermod --append") { t.Errorf("%s: put in a group", runner) @@ -921,3 +929,96 @@ func TestTheToolRunnerIsRestartedOnceForTheOwnGroup(t *testing.T) { t.Fatalf("the tool runner was restarted %d times with nothing moved", stops()) } } + +// Review: a library's runner that is refused keeps the library's outcome, is said as runner:library:, and never +// fails the apply; a runner that leaves a group restarts the tool runner once, in the apply it leaves. +func TestARefusedLibraryRunnerIsAWarningAndLeavingRestarts(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 := func(runner string) string { + return `{"library":"media","root":"/srv/media","group":"mesh-media","gid":` + strconv.Itoa(gid) + + `,"home":false,"runner":"` + runner + `"}` + } + report, _, err := Apply(context.Background(), archHost(t), libraryDecl(t, lib("ghost")), store.State{}, + store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Fatalf("a refused library runner failed the apply: %v", err) + } + if o := outcomeOf(report, "library:media"); o.ID == "" { + t.Fatal("the library's outcome was dropped") + } + if ws := RunnerWarnings(); len(ws) != 1 || ws[0].Resource != "runner:library:media" { + t.Fatalf("not said as runner:library:media: %+v", ws) + } + + // Joins, then leaves: the tool runner's unit, naming library:media, is restarted on each. + 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...) + } + unit := `{"id":"node-tools.unit","type":"service","unit":"node-tools.service","state":"running",` + + `"restart-on":["library:media"]}` + _, known, err := Apply(context.Background(), archHost(t), libraryDecl(t, lib("nox"), unit), store.State{}, + store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(RunnerWarnings()) != 0 { + t.Error("a warning stayed once the runner was right") + } + verbs = nil + gone := `{"library":"media","root":"/srv/media","group":"mesh-media","gid":` + strconv.Itoa(gid) + `,"home":false}` + if _, _, err := Apply(context.Background(), archHost(t), libraryDecl(t, gone, unit), known, store.OriginDeclared, run, + nil, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(m.ran, "\n"), "gpasswd --delete nox mesh-media") || + strings.Count(strings.Join(verbs, " "), "stop") != 1 { + t.Fatalf("leaving the group did not restart the tool runner once: %v %v", verbs, m.ran) + } +} + +// ADR 0334 §1: a module's own directory may carry the setgid bit, so what is made beneath it keeps its own group; +// it is set, held across applies, put back and said when it is taken off. Setuid and sticky are refused. +func TestADirectorysSetgidBitIsKept(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + d := declare(t, `{"id":"radarr.state","type":"directory","path":"`+dir+`","mode":"2750"}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, + nil); err != nil { + t.Fatalf("a directory with setgid was refused: %v", err) + } + if st := statOf(t, dir); st.Mode&0o7777 != 0o2750 { + t.Fatalf("made at %o, and 2750 was declared", st.Mode&0o7777) + } + _, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if st := statOf(t, dir); st.Mode&0o7777 != 0o2750 { + t.Fatalf("the setgid bit did not hold across applies: %o", st.Mode&0o7777) + } + if err := unix.Chmod(dir, 0o750); err != nil { + t.Fatal(err) + } + report, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "radarr.state"); o.Action != "updated" || !strings.Contains(o.Detail, "0750 to 2750") { + t.Fatalf("the setgid bit taken off was not put back and said: %+v", o) + } + if st := statOf(t, dir); st.Mode&0o7777 != 0o2750 { + t.Fatalf("the setgid bit was not put back: %o", st.Mode&0o7777) + } + for _, mode := range []string{"4750", "1750", "6750"} { + if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.d","type":"directory","path":"` + + dir + `","mode":"` + mode + `"}]}`)); err == nil { + t.Errorf("a directory mode %s was accepted", mode) + } + } +} diff --git a/internal/apply/mesh_dirs_apply_linux.go b/internal/apply/mesh_dirs_apply_linux.go index 754c845..af56f41 100644 --- a/internal/apply/mesh_dirs_apply_linux.go +++ b/internal/apply/mesh_dirs_apply_linux.go @@ -77,9 +77,10 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, if err := unix.Fstat(fd, &st); err != nil { return out, &os.PathError{Op: "fstat", Path: r.Path, Err: err} } - beforeMode, beforeUID, beforeGID := os.FileMode(st.Mode&0o777), int(st.Uid), int(st.Gid) - // Set explicitly even when it existed: a mode given at creation is not a mode maintained. - if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil { + beforeMode, beforeUID, beforeGID := statDirMode(st.Mode), int(st.Uid), int(st.Gid) + // Set explicitly even when it existed: a mode given at creation is not a mode maintained — its setgid bit + // with it (novox/hq ADR 0334 §1). + if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil { return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err} } ownedAlready := !chown || (beforeUID == uid && beforeGID == gid) @@ -87,6 +88,10 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, if err := unix.Fchown(fd, uid, gid); err != nil { return out, fmt.Errorf("cannot give %s to %q: %w", r.Path, r.Owner, &os.PathError{Op: "chown", Path: r.Path, Err: err}) } + // A chown may take the setgid bit: the mode is set again after it. + if err := unix.Fchmod(fd, unixDirMode(mode)); err != nil { + return out, &os.PathError{Op: "chmod", Path: r.Path, Err: err} + } } // Read back, from the descriptor that was changed. @@ -96,8 +101,8 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, if st.Mode&unix.S_IFMT != unix.S_IFDIR { return out, fmt.Errorf("%s is not a directory after applying", r.Path) } - if got := os.FileMode(st.Mode & 0o777); got != mode.Perm() { - return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, got, mode.Perm()) + if got := statDirMode(st.Mode); got != dirBits(mode) { + return out, fmt.Errorf("%s is mode %s after setting %s", r.Path, modeText(got), modeText(mode)) } out.Action = "unchanged" @@ -106,9 +111,9 @@ func applyMeshDirectory(r *declaration.Directory, out Outcome, mode os.FileMode, out.Action = "created" case !ownedAlready: out.Action = "updated" - case beforeMode != mode.Perm(): + case beforeMode != dirBits(mode): out.Action = "updated" - out.Detail = fmt.Sprintf("mode %o to %o", beforeMode, mode.Perm()) + out.Detail = fmt.Sprintf("mode %s to %s", modeText(beforeMode), modeText(mode)) } return out, nil } diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 5bd7ff6..4e54fc7 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -133,7 +133,13 @@ func (d *Directory) validate(where string, _ bool) []string { if d.Path == "" { problems = append(problems, where+": a directory needs a path") } - return append(problems, checkMode(where, d.Mode)...) + // A directory's mode may carry the setgid bit (2xxx), so what is made beneath it keeps its group (novox/hq ADR + // 0334 §1); never setuid or sticky, which mean nothing the mesh asks of a directory it owns. + mode := d.Mode + if len(mode) == 4 && mode[0] == '2' { + mode = "0" + mode[1:] + } + return append(problems, checkMode(where, mode)...) } // File is a file with literal content. The host renders nothing. diff --git a/internal/link/messages.go b/internal/link/messages.go index 69f2c1c..08b237c 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -327,6 +327,11 @@ const KindAccess = "access" // reason keep or disk, unknown while its filesystem could not be named, unhealthy otherwise. const KindLibrary = "library" +// KindRunner is a tool runner's account the engine could not put in a group (novox/hq ADR 0334 §2, ADR 0328 §11): +// missing, or root's. Said unhealthy as `runner:` (with its module) or `runner:library:`, words with no +// path, never failing an apply; gone once the account is right. +const KindRunner = "runner" + // ReasonUsedAsFound starts the reason of a directory used as found. const ReasonUsedAsFound = "used as found:"