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. Removing a link is `disable`'s; clearing the record is `reset-failed`'s. import ( "errors" "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 much of it as an enable link needs. type Entry struct { Name string // Dir is whether the name is a directory (followed, when it is a link to one). 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 that exists. Dangles bool } // 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"` } // 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"` // 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"` } // configDirs are the directories a scope's manager reads enable links from, the person's first: the // account's own configuration in user scope, the machine's administrator's, the runtime's and the packages'. func (m *Manager) configDirs(scope Scope) []ConfigDir { if scope == User { dirs := []ConfigDir{} if m.Home != "" { dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".config", "systemd", "user"), "the operator account's own configuration"}) } return append(dirs, ConfigDir{"/etc/systemd/user", "the machine's configuration, for every account"}, ConfigDir{"/usr/lib/systemd/user", "a package's"}) } return []ConfigDir{ {"/etc/systemd/system", "the machine's configuration"}, {"/run/systemd/system", "made at runtime, gone at the next boot"}, {"/usr/lib/systemd/system", "a package's"}, } } // 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. func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) { found, unread := []EnableLink{}, []string{} for _, dir := range m.configDirs(scope) { 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 { if !e.Dir { continue } dependency := "" of := "" for suffix, dep := range dependencyDirs { if strings.HasSuffix(e.Name, suffix) { dependency, of = dep, strings.TrimSuffix(e.Name, suffix) } } if dependency == "" { continue } sub := filepath.Join(dir.Path, e.Name) inside, err := m.ReadDir(sub) 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, Link: l.Link != "", Whose: dir.Whose}) } } } 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", unit, "--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", args...); 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(unit, loadState, dependents, links, unread, manager["error"] != nil) 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(unit, loadState string, dependents int, links []EnableLink, unread []string, managerFailed bool) []string { out := []string{} dangling := 0 for _, l := range links { if l.Dangles { dangling++ out = append(out, l.Path+" makes "+l.Of+" want "+unit+" and points at "+l.Target+", which does not exist ("+l.Whose+")") } } if loadState == "not-found" && len(links) > 0 && dangling == 0 { out = append(out, "the unit's file is not found, and "+plural(len(links), "enable link")+" still name it") } if dangling > 0 { out = append(out, "a dangling link is the manager's configuration, not the mesh's: `disable` in this scope removes the links naming the unit where the manager still accepts its name — ask this verb again to see that it is gone — and `reset-failed` then clears the unit's failed record") } if len(links) == 0 && dependents == 0 && !managerFailed && len(unread) == 0 { if loadState == "not-found" { out = append(out, "nothing names "+unit+" any more: a failed record of it is stale, and `reset-failed` clears it") } else { out = append(out, "no unit and no enable link pulls "+unit+" in: 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 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: each name, whether it is a directory (a link to one // followed), where a link points and whether that exists. 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 { entry := Entry{Name: e.Name(), Dir: e.IsDir()} if e.Type()&fs.ModeSymlink != 0 { full := filepath.Join(dir, e.Name()) if target, err := os.Readlink(full); err == nil { entry.Link = target } info, err := os.Stat(full) entry.Dangles = err != nil entry.Dir = err == nil && info.IsDir() } out = append(out, entry) } return out, nil }