Refuse a glob as a unit, and make wanted-by say only what it saw
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

systemctl expands *, ? and [ in a unit argument, so reset-failed or stop
of "*" would have acted on every unit. wanted-by called a link dangling on
any stat error, followed a linked .wants anywhere, and missed most of the
manager's load path; it now says each of these, and says nothing names a
unit only of the directories it searched.
This commit is contained in:
jochen
2026-10-08 17:38:48 +02:00
parent 00f1d04616
commit 2ed6264b8f
4 changed files with 337 additions and 59 deletions
+127 -49
View File
@@ -12,10 +12,11 @@ package main
// 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.
// Nothing here changes anything. Clearing the record is `reset-failed`'s.
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -33,21 +34,26 @@ var stateProperties = []string{"LoadState", "ActiveState", "SubState", "UnitFile
// 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.
// 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 a directory (followed, when it is a link to one).
// 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 that exists.
// 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.
@@ -58,36 +64,72 @@ type EnableLink struct {
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 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'.
// 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"), "the operator account's own configuration"})
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{"/etc/systemd/user", "the machine's configuration, for every account"},
ConfigDir{"/usr/lib/systemd/user", "a package's"})
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", "the machine's configuration"},
{"/run/systemd/system", "made at runtime, gone at the next boot"},
{"/usr/lib/systemd/system", "a package's"},
{"/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.
// 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{}
for _, dir := range m.configDirs(scope) {
dirs := m.configDirs(scope)
for _, dir := range dirs {
entries, err := m.ReadDir(dir.Path)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
@@ -96,21 +138,29 @@ func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) {
continue
}
for _, e := range entries {
if !e.Dir {
continue
}
dependency := ""
of := ""
dependency, of := "", ""
for suffix, dep := range dependencyDirs {
if strings.HasSuffix(e.Name, suffix) {
dependency, of = dep, strings.TrimSuffix(e.Name, suffix)
}
}
if dependency == "" {
if dependency == "" || (!e.Dir && e.Link == "") {
continue
}
sub := filepath.Join(dir.Path, e.Name)
inside, err := m.ReadDir(sub)
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())
@@ -122,7 +172,8 @@ func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) {
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})
Target: l.Link, Dangles: l.Link != "" && l.Dangles, Error: l.Error, Link: l.Link != "",
Whose: dir.Whose, removable: dir.Removable && e.Link == ""})
}
}
}
@@ -140,13 +191,13 @@ func (m *Manager) WantedBy(scope Scope, unit string) (map[string]any, error) {
answer := map[string]any{"unit": unit, "scope": string(scope)}
manager := map[string]any{}
args := []string{"show", unit, "--no-pager"}
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", args...); err != nil {
if out, err := m.call(scope, "systemctl", append(args, "--", unit)...); err != nil {
manager["error"] = err.Error()
} else {
for _, line := range strings.Split(out, "\n") {
@@ -190,7 +241,7 @@ func (m *Manager) WantedBy(scope Scope, unit string) (map[string]any, error) {
if len(unread) > 0 {
answer["unread"] = unread
}
answer["findings"] = findings(unit, loadState, dependents, links, unread, manager["error"] != nil)
answer["findings"] = findings(scope, unit, loadState, dependents, links, unread, manager["error"] != nil, m.Home == "")
return answer, nil
}
@@ -204,31 +255,49 @@ func isReverse(property string) bool {
}
// 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 {
func findings(scope Scope, unit, loadState string, dependents int, links []EnableLink, unread []string, managerFailed, noHome bool) []string {
out := []string{}
dangling := 0
dangling, removable, unknown := 0, 0, 0
for _, l := range links {
if l.Dangles {
switch {
case 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 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 {
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 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 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")
}
if len(links) == 0 && dependents == 0 && !managerFailed && len(unread) == 0 {
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+" 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")
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")
}
@@ -242,8 +311,9 @@ func plural(n int, what string) string {
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.
// 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 {
@@ -251,17 +321,25 @@ func readDir(dir string) ([]Entry, error) {
}
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)
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
}