systemctl disable leaves a link whose unit file is gone, and the manager keeps asking for the unit. The verb removes only such links in the account's or the machine's own configuration, on the person's word, and records each with its reason before removing it (hq issue 332).
357 lines
14 KiB
Go
357 lines
14 KiB
Go
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 dangling link is unlink.go's; 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; `unlink-dangling` in this scope removes "+
|
|
"the dangling links named for the unit in the account's or the machine's own configuration, keeping a record of each; "+
|
|
"then `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
|
|
}
|
|
|
|
// lstatEntry is one path as readDir would list it, read again just before it is acted on.
|
|
func lstatEntry(path string) (Entry, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
return entryOf(path, filepath.Base(path), info.Mode().Type()), 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
|
|
}
|