mesh/merge-gate fail: builds systemd → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: systemd claims node-serv…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
So a leftover unit's trigger is found and its failed record cleared through the seat, not a shell: wanted-by reads the manager's reverse dependencies and the enable links naming the unit, saying which dangle.
268 lines
9.8 KiB
Go
268 lines
9.8 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 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
|
|
}
|