diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index 2e3d5d2e..9b5af653 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -98,7 +98,7 @@ func execRunner(cmd string, args []string, env []string) Ran { const MeshUnitHeader = "# Generated by the mesh." // acts are the verbs that change the system manager's state, which polkit keeps from a non-root account. -var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true} +var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true, "reset-failed": true} // escalated is the command as it is run: as given when this process is root, or in the user scope, or // when the call is a systemctl read; else through sudo without a prompt — an act on the system manager, @@ -137,6 +137,10 @@ type Manager struct { Read func(path string) (string, error) // Env is this process's environment, the base of a user-scope call's. Env []string + // Home is the operator account's home, whose configuration holds its own enable links. + Home string + // ReadDir lists a directory, to find the enable links naming a unit (wanted.go). + ReadDir func(dir string) ([]Entry, error) } var userBus = regexp.MustCompile(`(?i)Failed to connect to (user scope )?bus`) @@ -240,11 +244,11 @@ func (m *Manager) Status(scope Scope, unit string) (map[string]any, error) { return nil, err } props := []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"} - args := []string{"show", unit, "--no-pager"} + args := []string{"show", "--no-pager"} for _, p := range props { args = append(args, "--property="+p) } - out, err := m.call(scope, "systemctl", args...) + out, err := m.call(scope, "systemctl", append(args, "--", unit)...) if err != nil { return nil, err } @@ -267,12 +271,13 @@ func (m *Manager) writtenByMesh(path string) bool { return err == nil && strings.HasPrefix(text, MeshUnitHeader) } -// Act starts, stops, restarts, enables or disables one unit, and answers with the state after. +// Act starts, stops, restarts, enables or disables one unit, or resets its failed record, and answers with +// the state after. func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) { if err := unitArg(unit); err != nil { return nil, err } - if _, err := m.call(scope, "systemctl", verb, unit); err != nil { + if _, err := m.call(scope, "systemctl", verb, "--", unit); err != nil { return nil, err } after, err := m.Status(scope, unit) @@ -363,7 +368,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string] // unitSecrets are the values of the unit's own Environment= that must not be answered. Read with // systemctl show, which needs no escalation; a unit that does not exist has none. func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) { - out, err := m.call(scope, "systemctl", "show", unit, "--no-pager", "--property=Environment", "--value") + out, err := m.call(scope, "systemctl", "show", "--no-pager", "--property=Environment", "--value", "--", unit) if err != nil { return nil, err } @@ -397,11 +402,16 @@ func (m *Manager) Failed(scopes ...Scope) map[string]any { } // unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be -// root's option. +// root's option — and **a glob** (novox/hq issue 332): systemctl expands *, ? and [ in a unit argument to +// every loaded unit matching it, so `stop` or `reset-failed` of "*" would act on all of them. Every verb +// names one unit; `units` alone takes a pattern, as its own argument. The name is also given after "--". func unitArg(unit string) error { if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") { return fmt.Errorf("%q is not a unit's name", unit) } + if strings.ContainsAny(unit, "*?[") { + return fmt.Errorf("%q is a pattern, not one unit's name: the service manager would act on every unit it matches", unit) + } return nil } diff --git a/modules/systemd/cmd/systemd-tools/client_test.go b/modules/systemd/cmd/systemd-tools/client_test.go index 7c791897..cfa90a0b 100644 --- a/modules/systemd/cmd/systemd-tools/client_test.go +++ b/modules/systemd/cmd/systemd-tools/client_test.go @@ -139,7 +139,7 @@ func TestASystemActEscalatesAndAnswersTheStateAfter(t *testing.T) { if err != nil { t.Fatal(err) } - if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "restart", "sshd.service"}) { + if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "restart", "--", "sshd.service"}) { t.Fatalf("%v", got) } if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil { @@ -242,7 +242,7 @@ func TestAUnitsNameIsNeverAnOption(t *testing.T) { } } -// The manifest owns the systemd package, claims the seat's nine verbs, and lists exactly the tools served. +// The manifest owns the systemd package, claims the seat's eleven verbs, and lists exactly the tools served. func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go index 1506b19c..02cc47a6 100644 --- a/modules/systemd/cmd/systemd-tools/main.go +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -1,6 +1,6 @@ -// systemd's tools: the node-service-manager seat's nine verbs — the units on this machine in both scopes, -// read and acted on by name, their journal, and what has failed (novox/hq ADR 0177). The node -// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193); +// systemd's tools: the node-service-manager seat's eleven verbs — the units on this machine in both scopes, +// read and acted on by name, their journal, what has failed, what wants a unit, and a failed record cleared +// (novox/hq ADR 0177, issue 332). The node tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193); // it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with // sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers // about them. stdout is the MCP channel; this says nothing else. @@ -123,6 +123,28 @@ func tools(m *Manager) []stdio.Tool { } return m.Journal(scope, unit, q) }}, + // **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state, + // and its start-limit count, in its manager until it is reset. A unit's name is required, so the + // verb never resets every failed unit at once. + act("reset-failed", "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing."), + {Name: seat + ".wanted-by", + Description: "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies " + + "(WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest, and the units that pull those in) and every " + + "enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds) — the " + + "operator account's own ~/.config/systemd/user in user scope — each with where it points, whether it " + + "dangles and whose directory it is in; and what that means, in words.", + Input: map[string]any{"scope": scopeArg, "unit": unitArgS}, + Run: func(a map[string]any) (any, error) { + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + unit, err := unitOf(a) + if err != nil { + return nil, err + } + return m.WantedBy(scope, unit) + }}, // Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so // whatever holds the role answers it and every machine is asked the same way. {Name: seat + ".failed", @@ -157,7 +179,12 @@ func fromEnv() *Manager { uid = n } } - return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ()} + home := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")) + if home == "" && account == name { + home, _ = os.UserHomeDir() + } + return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ(), + Home: home, ReadDir: readDir} } func main() { diff --git a/modules/systemd/cmd/systemd-tools/wanted.go b/modules/systemd/cmd/systemd-tools/wanted.go new file mode 100644 index 00000000..4fa67f80 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/wanted.go @@ -0,0 +1,345 @@ +package main + +// What wants, requires or triggers one unit (novox/hq issue 332), read-only. Two sources, because each misses +// what the other sees: +// +// - **the manager**, asked for the unit's reverse dependencies (systemctl show's WantedBy, RequiredBy and +// the rest, and list-dependencies --reverse for what pulls it in through another unit). It knows what is +// loaded now, including a dependency a unit file states in its own [Unit] section; +// - **the configuration directories**, read for an enable link naming the unit in a *.wants, *.requires or +// *.upholds directory. A link outlives the unit file it pointed at: once the package that shipped the unit +// is gone, the link dangles, and the manager asks for a unit it cannot find at every boot or login. That +// is the case a failed record of a unit whose load state is not-found comes from, and the answer says +// each link's target and whether it dangles. +// +// Nothing here changes anything. Clearing the record is `reset-failed`'s. + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// reverseProperties are the unit's properties that name a unit depending on it. +var reverseProperties = []string{"WantedBy", "RequiredBy", "RequisiteOf", "BoundBy", "UpheldBy", "ConsistsOf", "TriggeredBy", "PartOf"} + +// stateProperties say what the manager knows of the unit itself, so a not-found unit is recognised. +var stateProperties = []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath"} + +// dependencyDirs are the suffixes of the directories an enable link sits in, and the dependency each makes. +var dependencyDirs = map[string]string{".wants": "Wants", ".requires": "Requires", ".upholds": "Upholds"} + +// Entry is one name in a directory as lstat sees it: a link is never followed to say what it is. +type Entry struct { + Name string + // Dir is whether the name is itself a directory; a link to one is not. + Dir bool + // Link is where a symbolic link points, as written; empty when the name is not a link. + Link string + // Dangles is whether a link points at nothing: what it names does not exist. + Dangles bool + // Error is why a link's target could not be looked at, when that is anything but its not existing. + Error string +} + +// ConfigDir is one directory a manager reads units and enable links from, and whose it is. +type ConfigDir struct { + Path string `json:"path"` + Whose string `json:"whose"` + // Removable is whether a dangling link here is a person's or an administrator's to remove: their own + // configuration, not a package's, a generator's or the runtime's, which put theirs back. + Removable bool `json:"-"` +} + +// EnableLink is one link in a dependency directory that names the unit. +type EnableLink struct { + Path string `json:"path"` + // Of is the unit that wants (or requires, or upholds) this one: the directory's name less its suffix. + Of string `json:"of"` + Dependency string `json:"dependency"` + Target string `json:"target,omitempty"` + Dangles bool `json:"dangles"` + // Error is why the target could not be looked at, other than its not existing: neither dangling nor not. + Error string `json:"error,omitempty"` + // Link is false for a plain file in a dependency directory, which the manager reads the same way. + Link bool `json:"link"` + Whose string `json:"whose"` + + removable bool +} + +// configDirs are the directories a scope's manager reads unit files and enable links from, in its own order +// of precedence (systemd.unit(5), "Unit File Load Path"). The account's own are left out, and said, when its +// home is not known. +func (m *Manager) configDirs(scope Scope) []ConfigDir { + if scope == User { + account := "the operator account's own configuration" + dirs := []ConfigDir{} + if m.Home != "" { + dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".config", "systemd", "user.control"), account + " (set by a program through the manager)", true}) + } + dirs = append(dirs, ConfigDir{fmt.Sprintf("/run/user/%d/systemd/user", m.UID), "made at runtime, gone at the next login", false}) + if m.Home != "" { + dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".config", "systemd", "user"), account, true}) + } + dirs = append(dirs, + ConfigDir{"/etc/systemd/user", "the machine's configuration, for every account", true}, + ConfigDir{"/etc/xdg/systemd/user", "the machine's configuration, for every account", true}) + if m.Home != "" { + dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".local", "share", "systemd", "user"), "a program installed in the account's own home", false}) + } + return append(dirs, + ConfigDir{"/usr/local/lib/systemd/user", "a program installed by hand, for every account", false}, + ConfigDir{"/usr/lib/systemd/user", "a package's", false}) + } + return []ConfigDir{ + {"/etc/systemd/system.control", "the machine's configuration (set by a program through the manager)", true}, + {"/run/systemd/transient", "made at runtime, gone at the next boot", false}, + {"/run/systemd/generator.early", "a generator's, made again at every boot and reload", false}, + {"/etc/systemd/system", "the machine's configuration", true}, + {"/run/systemd/system", "made at runtime, gone at the next boot", false}, + {"/run/systemd/generator", "a generator's, made again at every boot and reload", false}, + {"/usr/local/lib/systemd/system", "a program installed by hand", false}, + {"/usr/lib/systemd/system", "a package's", false}, + {"/run/systemd/generator.late", "a generator's, made again at every boot and reload", false}, + } +} + +// within is whether a path lies in one of the directories. +func within(path string, dirs []ConfigDir) bool { + path = filepath.Clean(path) + for _, d := range dirs { + if path == d.Path || strings.HasPrefix(path, d.Path+string(filepath.Separator)) { + return true + } + } + return false +} + +// links finds every enable link naming the unit under the scope's configuration directories: by its own +// name, or by the name of the file it points at. A directory that cannot be read is said, never skipped as +// empty; one that does not exist is the ordinary case and is not. **A dependency directory that is itself a +// link is followed only when it leads into the searched directories**: elsewhere it is said, and not read, +// so a link cannot point the search, or a removal after it, at any directory on the machine. +func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) { + found, unread := []EnableLink{}, []string{} + dirs := m.configDirs(scope) + for _, dir := range dirs { + entries, err := m.ReadDir(dir.Path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + unread = append(unread, dir.Path+": "+err.Error()) + } + continue + } + for _, e := range entries { + dependency, of := "", "" + for suffix, dep := range dependencyDirs { + if strings.HasSuffix(e.Name, suffix) { + dependency, of = dep, strings.TrimSuffix(e.Name, suffix) + } + } + if dependency == "" || (!e.Dir && e.Link == "") { + continue + } + sub := filepath.Join(dir.Path, e.Name) + read := sub + if e.Link != "" { + target := e.Link + if !filepath.IsAbs(target) { + target = filepath.Join(dir.Path, target) + } + if !within(target, dirs) { + unread = append(unread, sub+": a link to "+e.Link+", outside the directories searched, not followed") + continue + } + read = filepath.Clean(target) + } + inside, err := m.ReadDir(read) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + unread = append(unread, sub+": "+err.Error()) + } + continue + } + for _, l := range inside { + if l.Name != unit && (l.Link == "" || filepath.Base(l.Link) != unit) { + continue + } + found = append(found, EnableLink{Path: filepath.Join(sub, l.Name), Of: of, Dependency: dependency, + Target: l.Link, Dangles: l.Link != "" && l.Dangles, Error: l.Error, Link: l.Link != "", + Whose: dir.Whose, removable: dir.Removable && e.Link == ""}) + } + } + } + sort.Slice(found, func(i, j int) bool { return found[i].Path < found[j].Path }) + return found, unread +} + +// WantedBy is what wants, requires or triggers one unit: the manager's reverse dependencies and the enable +// links naming it, with a finding in words. A manager that does not answer is said beside the links, which +// are read from disk and do not need it. +func (m *Manager) WantedBy(scope Scope, unit string) (map[string]any, error) { + if err := unitArg(unit); err != nil { + return nil, err + } + answer := map[string]any{"unit": unit, "scope": string(scope)} + + manager := map[string]any{} + args := []string{"show", "--no-pager"} + for _, p := range append(append([]string{}, stateProperties...), reverseProperties...) { + args = append(args, "--property="+p) + } + loadState := "" + dependents := 0 + if out, err := m.call(scope, "systemctl", append(args, "--", unit)...); err != nil { + manager["error"] = err.Error() + } else { + for _, line := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok || k == "" { + continue + } + if isReverse(k) { + if names := strings.Fields(v); len(names) > 0 { + manager[k] = names + dependents += len(names) + } + continue + } + manager[k] = v + } + loadState, _ = manager["LoadState"].(string) + } + // Through other units: list-dependencies --reverse follows the same edges from the units that pull in + // those that pull this one in. Its first line is the unit itself. + if out, err := m.call(scope, "systemctl", "list-dependencies", "--reverse", "--plain", "--no-legend", "--no-pager", "--", unit); err != nil { + manager["reverse_error"] = err.Error() + } else { + chain := []string{} + for i, line := range strings.Split(out, "\n") { + name := strings.TrimSpace(strings.TrimLeft(line, " │├└─●○×*")) + if i == 0 || name == "" || name == unit { + continue + } + chain = append(chain, name) + } + if len(chain) > 0 { + manager["reverse"] = chain + } + } + answer["manager"] = manager + + links, unread := m.links(scope, unit) + answer["links"] = links + answer["searched"] = m.configDirs(scope) + if len(unread) > 0 { + answer["unread"] = unread + } + answer["findings"] = findings(scope, unit, loadState, dependents, links, unread, manager["error"] != nil, m.Home == "") + return answer, nil +} + +func isReverse(property string) bool { + for _, p := range reverseProperties { + if p == property { + return true + } + } + return false +} + +// findings say what the answer means, so a caller does not have to put the parts together. +func findings(scope Scope, unit, loadState string, dependents int, links []EnableLink, unread []string, managerFailed, noHome bool) []string { + out := []string{} + dangling, removable, unknown := 0, 0, 0 + for _, l := range links { + switch { + case l.Dangles: + dangling++ + if l.removable { + removable++ + } + out = append(out, l.Path+" makes "+l.Of+" "+strings.TrimSuffix(strings.ToLower(l.Dependency), "s")+" "+unit+" and points at "+l.Target+", which does not exist ("+l.Whose+")") + case l.Error != "": + unknown++ + out = append(out, l.Path+" points at "+l.Target+", which could not be looked at: "+l.Error) + } + } + if loadState == "not-found" && len(links) > 0 && dangling == 0 && unknown == 0 { + out = append(out, "the unit's file is not found, and "+plural(len(links), "enable link")+" still name it") + } + if removable > 0 { + out = append(out, "`disable` does not remove a link whose unit file is gone, and no verb of this seat removes one yet "+ + "(novox/hq issue 332); once it is gone, `reset-failed` clears the unit's failed record") + } else if dangling > 0 { + out = append(out, "the dangling links are in a package's, a generator's or the runtime's directory, which puts them back: they are not the person's to remove") + } + incomplete := managerFailed || len(unread) > 0 || unknown > 0 || (scope == User && noHome) + if len(links) == 0 && dependents == 0 { + where := "in the directories searched" + if incomplete { + where += ", which are not all of them (below)" + } + if loadState == "not-found" { + out = append(out, "nothing names "+unit+" "+where+": a failed record of it is stale, and `reset-failed` clears it") + } else if !managerFailed { + out = append(out, "no unit and no enable link pulls "+unit+" in "+where+": it starts only when asked by name, by a timer, socket or path unit the manager does not list, or by a program") + } + } + if managerFailed { + out = append(out, "the manager did not answer, so only the links on disk are known") + } + if scope == User && noHome { + out = append(out, "the account's home is not known to this process, so its own directories were not searched") + } + if len(unread) > 0 { + out = append(out, "some directories could not be read, so a link may be missing from this answer") + } + return out +} + +func plural(n int, what string) string { + if n == 1 { + return "1 " + what + } + return strconv.Itoa(n) + " " + what + "s" +} + +// readDir lists a directory as enable links need it, by lstat: each name, whether it is itself a directory, +// where a link points, and whether what it points at does not exist — any other failure to look is said as +// the error it was, never as dangling. +func readDir(dir string) ([]Entry, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + out = append(out, entryOf(filepath.Join(dir, e.Name()), e.Name(), e.Type())) + } + return out, nil +} + +func entryOf(full, name string, mode fs.FileMode) Entry { + entry := Entry{Name: name, Dir: mode.IsDir()} + if mode&fs.ModeSymlink == 0 { + return entry + } + target, err := os.Readlink(full) + if err != nil { + target, entry.Error = "(unreadable)", err.Error() + } + entry.Link = target + if _, err := os.Stat(full); errors.Is(err, fs.ErrNotExist) { + entry.Dangles = true + } else if err != nil { + entry.Error = err.Error() + } + return entry +} diff --git a/modules/systemd/cmd/systemd-tools/wanted_test.go b/modules/systemd/cmd/systemd-tools/wanted_test.go new file mode 100644 index 00000000..c8d0cb8f --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/wanted_test.go @@ -0,0 +1,444 @@ +package main + +// What wants a unit, and a failed record cleared (novox/hq issue 332): over a fake runner and a fake +// directory listing, so no link is made to test them (the mesh makes no symbolic links, its tests +// included); the real listing is tested on what a temporary directory holds without one. + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// tree is a fake file system of directories, each a list of entries; a directory not in it does not exist. +type tree map[string][]Entry + +func (t tree) readDir(dir string) ([]Entry, error) { + if e, ok := t[dir]; ok { + if e == nil { + return nil, fs.ErrPermission + } + return e, nil + } + return nil, fs.ErrNotExist +} + +const home = "/home/operator" + +func withTree(m *Manager, t tree) *Manager { + m.Home, m.ReadDir = home, t.readDir + return m +} + +// The leftover of a removed package: its file is gone, an enable link in the account's own configuration +// still names it, and the manager still has default.target wanting it. +func greenclip() tree { + user := home + "/.config/systemd/user" + return tree{ + user: {{Name: "default.target.wants", Dir: true}, {Name: "timers.target.wants", Dir: true}, {Name: "clip.service"}}, + user + "/default.target.wants": { + {Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, + {Name: "pipewire.service", Link: "/usr/lib/systemd/user/pipewire.service"}, + }, + user + "/timers.target.wants": {{Name: "backup.timer", Link: "/usr/lib/systemd/user/backup.timer"}}, + "/usr/lib/systemd/user": {{Name: "pipewire.service"}, {Name: "sockets.target.wants", Dir: true}}, + "/usr/lib/systemd/user/sockets.target.wants": {{Name: "pipewire.socket", Link: "../pipewire.socket"}}, + } +} + +func showAndReverse(show, reverse string) func(c call) Ran { + return func(c call) Ran { + for _, a := range c.args { + if a == "show" { + return Ran{Stdout: show} + } + if a == "list-dependencies" { + return Ran{Stdout: reverse} + } + } + return Ran{} + } +} + +func TestWantedByFindsADanglingEnableLinkInTheAccountsOwnConfiguration(t *testing.T) { + var calls []call + run := fake(showAndReverse( + "LoadState=not-found\nActiveState=failed\nSubState=failed\nUnitFileState=\nFragmentPath=\nWantedBy=default.target\nRequiredBy=\nTriggeredBy=\n", + "greenclip.service\n default.target\n"), &calls) + answer, err := withTree(operator(run), greenclip()).WantedBy(User, "greenclip.service") + if err != nil { + t.Fatal(err) + } + links := answer["links"].([]EnableLink) + want := []EnableLink{{Path: home + "/.config/systemd/user/default.target.wants/greenclip.service", Of: "default.target", + Dependency: "Wants", Target: "/usr/lib/systemd/user/greenclip.service", Dangles: true, Link: true, + Whose: "the operator account's own configuration", removable: true}} + if !reflect.DeepEqual(links, want) { + t.Fatalf("links %+v", links) + } + manager := answer["manager"].(map[string]any) + if !reflect.DeepEqual(manager["WantedBy"], []string{"default.target"}) || manager["LoadState"] != "not-found" { + t.Fatalf("manager %+v", manager) + } + if _, empty := manager["RequiredBy"]; empty { + t.Fatal("an empty property is answered as a dependency") + } + if !reflect.DeepEqual(manager["reverse"], []string{"default.target"}) { + t.Fatalf("reverse %v", manager["reverse"]) + } + said := strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "default.target.wants/greenclip.service makes default.target want greenclip.service") || + !strings.Contains(said, "which does not exist") || !strings.Contains(said, "reset-failed") { + t.Fatalf("findings %q", said) + } + if _, has := answer["unread"]; has { + t.Fatalf("a directory that does not exist was said as unread: %v", answer["unread"]) + } + // Read-only: the manager is only asked. + for _, c := range calls { + if c.args[1] != "show" && c.args[1] != "list-dependencies" { + t.Fatalf("WantedBy ran %v", c.args) + } + if c.args[0] != "--user" { + t.Fatalf("a user-scope question went to the system manager: %v", c.args) + } + } + // The unit comes after "--", so a name that slipped past would still not be an option. + last := calls[len(calls)-1].args + if last[len(last)-2] != "--" { + t.Fatalf("%v", last) + } +} + +func TestWantedByMatchesALinkByWhatItPointsAt(t *testing.T) { + user := home + "/.config/systemd/user" + fsys := tree{ + user: {{Name: "graphical-session.target.requires", Dir: true}}, + user + "/graphical-session.target.requires": {{Name: "clipboard.service", Link: "/usr/lib/systemd/user/greenclip.service"}}, + } + answer, err := withTree(operator(fake(showAndReverse("LoadState=loaded\n", "greenclip.service\n"), nil)), fsys).WantedBy(User, "greenclip.service") + if err != nil { + t.Fatal(err) + } + links := answer["links"].([]EnableLink) + if len(links) != 1 || links[0].Dependency != "Requires" || links[0].Of != "graphical-session.target" || links[0].Dangles { + t.Fatalf("%+v", links) + } +} + +func TestWantedBySaysWhenNothingNamesTheUnit(t *testing.T) { + answer, err := withTree(operator(fake(showAndReverse("LoadState=not-found\nActiveState=failed\nWantedBy=\n", "gone.service\n"), nil)), greenclip()).WantedBy(User, "gone.service") + if err != nil { + t.Fatal(err) + } + if len(answer["links"].([]EnableLink)) != 0 { + t.Fatalf("%v", answer["links"]) + } + said := strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "nothing names gone.service in the directories searched:") { + t.Fatalf("%q", said) + } +} + +func TestWantedBySaysAnUnreadableDirectoryAndAManagerThatDoesNotAnswer(t *testing.T) { + fsys := greenclip() + fsys["/etc/systemd/user"] = nil // unreadable + run := fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to connect to bus: No such file or directory"} }, nil) + answer, err := withTree(operator(run), fsys).WantedBy(User, "greenclip.service") + if err != nil { + t.Fatalf("an unreachable manager failed the whole answer: %v", err) + } + manager := answer["manager"].(map[string]any) + if e, _ := manager["error"].(string); !strings.Contains(e, "does not answer") { + t.Fatalf("%+v", manager) + } + if len(answer["links"].([]EnableLink)) != 1 { + t.Fatal("the links on disk were not read without the manager") + } + unread, _ := answer["unread"].([]string) + if len(unread) != 1 || !strings.HasPrefix(unread[0], "/etc/systemd/user: ") { + t.Fatalf("%v", unread) + } + said := strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "only the links on disk are known") || !strings.Contains(said, "could not be read") { + t.Fatalf("%q", said) + } + if strings.Contains(said, "nothing names") { + t.Fatal("an incomplete answer said nothing names the unit") + } +} + +func TestWantedByInTheSystemScopeReadsTheMachinesDirectoriesUnescalated(t *testing.T) { + var calls []call + fsys := tree{ + "/etc/systemd/system": {{Name: "multi-user.target.wants", Dir: true}}, + "/etc/systemd/system/multi-user.target.wants": {{Name: "sshd.service", Link: "/usr/lib/systemd/system/sshd.service"}}, + } + m := withTree(operator(fake(showAndReverse("LoadState=loaded\nWantedBy=multi-user.target\n", "sshd.service\nmulti-user.target\ngraphical.target\n"), &calls)), fsys) + answer, err := m.WantedBy(System, "sshd.service") + if err != nil { + t.Fatal(err) + } + for _, c := range calls { + if c.cmd != "systemctl" { + t.Fatalf("a read was escalated: %s %v", c.cmd, c.args) + } + } + links := answer["links"].([]EnableLink) + if len(links) != 1 || links[0].Whose != "the machine's configuration" { + t.Fatalf("%+v", links) + } + if !reflect.DeepEqual(answer["manager"].(map[string]any)["reverse"], []string{"multi-user.target", "graphical.target"}) { + t.Fatalf("%v", answer["manager"]) + } + for _, d := range answer["searched"].([]ConfigDir) { + if strings.HasPrefix(d.Path, home) { + t.Fatal("the system scope searched the account's home") + } + } +} + +func TestWantedByRefusesAUnitThatReadsAsAnOption(t *testing.T) { + m := withTree(operator(fake(func(call) Ran { t.Fatal("ran"); return Ran{} }, nil)), tree{}) + if _, err := m.WantedBy(User, "--all"); err == nil { + t.Fatal("an option was taken as a unit") + } +} + +func TestResetFailedClearsOneUnitEscalatedOnlyInTheSystemScope(t *testing.T) { + if p, a := escalated("systemctl", []string{"reset-failed", "x.mount"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "reset-failed", "x.mount"}) { + t.Fatalf("%s %v", p, a) + } + var calls []call + m := operator(fake(func(c call) Ran { + if c.args[1] == "show" { + return Ran{Stdout: "LoadState=not-found\nActiveState=inactive\nSubState=dead\nUnitFileState=\nFragmentPath=\n"} + } + return Ran{} + }, &calls)) + var reset stdioTool + for _, tool := range tools(m) { + if tool.Name == seat+".reset-failed" { + reset = stdioTool{tool.Run} + } + } + if reset.run == nil { + t.Fatal("reset-failed is not served") + } + out, err := reset.run(map[string]any{"unit": "greenclip.service", "scope": "user"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(calls[0].args, []string{"--user", "reset-failed", "--", "greenclip.service"}) || calls[0].cmd != "systemctl" { + t.Fatalf("%s %v", calls[0].cmd, calls[0].args) + } + answer := out.(map[string]any) + if answer["active"] != "inactive" || answer["verb"] != "reset-failed" { + t.Fatalf("%v", answer) + } + if _, err := reset.run(map[string]any{"scope": "user"}); err == nil { + t.Fatal("reset-failed without a unit would reset every failed unit") + } + if _, err := reset.run(map[string]any{"unit": "-a", "scope": "user"}); err == nil { + t.Fatal("an option was taken as a unit") + } +} + +type stdioTool struct { + run func(map[string]any) (any, error) +} + +// **A glob is not a unit** (the review of mesh-catalog #137): systemctl expands it to every loaded unit it +// matches, so `reset-failed` or `stop` of "*" would act on all of them. Refused before anything runs, for +// every verb taking a unit. +func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) { + for _, bad := range []string{"*", "*.service", "foo?.service", "a[bc].mount"} { + if unitArg(bad) == nil { + t.Errorf("%q accepted", bad) + } + } + var calls []call + m := withTree(operator(fake(func(call) Ran { return Ran{} }, &calls)), tree{}) + for _, tool := range tools(m) { + verb := strings.TrimPrefix(tool.Name, seat+".") + if verb == "units" || verb == "failed" { + continue // a pattern, and no unit + } + _, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"}) + if err == nil { + t.Errorf("%s accepted {\"unit\":\"*\"}", verb) + } + } + if len(calls) != 0 { + t.Fatalf("a glob reached the manager: %v", calls) + } +} + +// The unit is given after "--" to show as well as to an act, so nothing after it is read as an option. +func TestTheUnitComesAfterTheEndOfOptions(t *testing.T) { + var calls []call + m := operator(fake(func(call) Ran { return Ran{Stdout: "LoadState=loaded\n"} }, &calls)) + if _, err := m.Status(System, "sshd.service"); err != nil { + t.Fatal(err) + } + a := calls[0].args + if a[0] != "show" || a[len(a)-2] != "--" || a[len(a)-1] != "sshd.service" { + t.Fatalf("%v", a) + } +} + +// A target that could not be looked at, for any reason but its not existing, is neither dangling nor not: +// it is said as the error it was, and the answer does not say nothing names the unit. +func TestALinkWhoseTargetCannotBeLookedAtIsNotSaidToDangle(t *testing.T) { + user := home + "/.config/systemd/user" + fsys := tree{ + user: {{Name: "default.target.wants", Dir: true}}, + user + "/default.target.wants": {{Name: "x.service", Link: "/mnt/away/x.service", Error: "stat /mnt/away/x.service: permission denied"}}, + } + answer, err := withTree(operator(fake(showAndReverse("LoadState=loaded\n", "x.service\n"), nil)), fsys).WantedBy(User, "x.service") + if err != nil { + t.Fatal(err) + } + links := answer["links"].([]EnableLink) + if len(links) != 1 || links[0].Dangles || links[0].Error == "" { + t.Fatalf("%+v", links) + } + said := strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "could not be looked at: stat /mnt/away/x.service: permission denied") || strings.Contains(said, "does not exist") { + t.Fatalf("%q", said) + } +} + +// A *.wants directory that is itself a link is followed only into the directories searched: one leading +// elsewhere is said and not read, so neither the search nor a removal after it can be pointed anywhere. +func TestALinkedDependencyDirectoryIsFollowedOnlyIntoTheSearchedDirectories(t *testing.T) { + user := home + "/.config/systemd/user" + fsys := tree{ + user: { + {Name: "default.target.wants", Link: "/srv/elsewhere"}, + {Name: "graphical-session.target.wants", Link: "/etc/systemd/user/shared.wants"}, + }, + "/srv/elsewhere": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}}, + "/etc/systemd/user/shared.wants": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}}, + } + read := []string{} + m := withTree(operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil)), fsys) + inner := m.ReadDir + m.ReadDir = func(dir string) ([]Entry, error) { read = append(read, dir); return inner(dir) } + answer, err := m.WantedBy(User, "x.service") + if err != nil { + t.Fatal(err) + } + for _, d := range read { + if d == "/srv/elsewhere" { + t.Fatal("a linked .wants outside the searched directories was followed") + } + } + links := answer["links"].([]EnableLink) + if len(links) != 1 || links[0].Path != user+"/graphical-session.target.wants/x.service" || links[0].removable { + t.Fatalf("%+v", links) + } + unread, _ := answer["unread"].([]string) + if len(unread) != 1 || !strings.Contains(unread[0], "outside the directories searched, not followed") { + t.Fatalf("%v", unread) + } +} + +// "Nothing names it" is only ever said of the directories searched, and the account's own are not among +// them when its home is not known — which is said. +func TestNothingNamesItIsSaidOfTheDirectoriesSearchedAndTheHomeWhenUnknown(t *testing.T) { + m := withTree(operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil)), tree{}) + m.Home = "" + answer, err := m.WantedBy(User, "x.service") + if err != nil { + t.Fatal(err) + } + for _, d := range answer["searched"].([]ConfigDir) { + if strings.Contains(d.Path, ".config") || strings.Contains(d.Path, ".local") || d.Path == "" { + t.Fatalf("searched %q without a home", d.Path) + } + } + said := strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "nothing names x.service in the directories searched, which are not all of them") || + !strings.Contains(said, "the account's home is not known") { + t.Fatalf("%q", said) + } + m.Home = home + answer, _ = m.WantedBy(User, "x.service") + said = strings.Join(answer["findings"].([]string), "\n") + if !strings.Contains(said, "nothing names x.service in the directories searched:") { + t.Fatalf("%q", said) + } +} + +// Every directory systemd reads units from, in both scopes (systemd.unit(5)). +func TestTheSearchedDirectoriesAreTheManagersLoadPath(t *testing.T) { + m := withTree(operator(nil), tree{}) + paths := func(s Scope) string { + var out []string + for _, d := range m.configDirs(s) { + out = append(out, d.Path) + } + return strings.Join(out, "\n") + } + for _, want := range []string{"/etc/systemd/system.control", "/run/systemd/transient", "/run/systemd/generator.early", + "/etc/systemd/system", "/run/systemd/system", "/run/systemd/generator", "/usr/local/lib/systemd/system", + "/usr/lib/systemd/system", "/run/systemd/generator.late"} { + if !strings.Contains(paths(System)+"\n", want+"\n") { + t.Errorf("system scope does not search %s", want) + } + } + for _, want := range []string{home + "/.config/systemd/user.control", "/run/user/1000/systemd/user", home + "/.config/systemd/user", + "/etc/systemd/user", "/etc/xdg/systemd/user", home + "/.local/share/systemd/user", "/usr/local/lib/systemd/user", "/usr/lib/systemd/user"} { + if !strings.Contains(paths(User)+"\n", want+"\n") { + t.Errorf("user scope does not search %s", want) + } + } +} + +// The real listing, by lstat, on what a temporary directory holds: a directory, a file, and a directory +// that does not exist or cannot be read. +func TestReadDirListsByLstat(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "default.target.wants"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "x.service"), []byte("[Unit]\n"), 0o644); err != nil { + t.Fatal(err) + } + entries, err := readDir(dir) + if err != nil { + t.Fatal(err) + } + want := []Entry{{Name: "default.target.wants", Dir: true}, {Name: "x.service"}} + if !reflect.DeepEqual(entries, want) { + t.Fatalf("%+v", entries) + } + if _, err := readDir(filepath.Join(dir, "absent")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("a missing directory: %v", err) + } + if os.Getuid() != 0 { + locked := filepath.Join(dir, "locked") + if err := os.Mkdir(locked, 0o000); err != nil { + t.Fatal(err) + } + defer os.Chmod(locked, 0o755) + if _, err := readDir(locked); err == nil || errors.Is(err, fs.ErrNotExist) { + t.Fatalf("an unreadable directory read as %v", err) + } + } + // And through the search: a real tree with no link in it names nothing, and says so. + m := operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil)) + m.Home, m.ReadDir = dir, readDir + answer, err := m.WantedBy(User, "x.service") + if err != nil { + t.Fatal(err) + } + if len(answer["links"].([]EnableLink)) != 0 { + t.Fatalf("%v", answer["links"]) + } +} diff --git a/modules/systemd/module.json b/modules/systemd/module.json index e7b879ab..f48d983f 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -18,7 +18,9 @@ "enable", "disable", "journal", - "failed" + "failed", + "reset-failed", + "wanted-by" ] } ],