Refuse a glob as a unit, and make wanted-by say only what it saw
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:
@@ -244,11 +244,11 @@ func (m *Manager) Status(scope Scope, unit string) (map[string]any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
props := []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"}
|
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 {
|
for _, p := range props {
|
||||||
args = append(args, "--property="+p)
|
args = append(args, "--property="+p)
|
||||||
}
|
}
|
||||||
out, err := m.call(scope, "systemctl", args...)
|
out, err := m.call(scope, "systemctl", append(args, "--", unit)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
|
|||||||
if err := unitArg(unit); err != nil {
|
if err := unitArg(unit); err != nil {
|
||||||
return nil, err
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
after, err := m.Status(scope, unit)
|
after, err := m.Status(scope, unit)
|
||||||
@@ -368,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
|
// 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.
|
// systemctl show, which needs no escalation; a unit that does not exist has none.
|
||||||
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -402,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
|
// 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 {
|
func unitArg(unit string) error {
|
||||||
if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") {
|
if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") {
|
||||||
return fmt.Errorf("%q is not a unit's name", unit)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func TestASystemActEscalatesAndAnswersTheStateAfter(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("%v", got)
|
||||||
}
|
}
|
||||||
if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil {
|
if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil {
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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.
|
// 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"}
|
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 {
|
type Entry struct {
|
||||||
Name string
|
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
|
Dir bool
|
||||||
// Link is where a symbolic link points, as written; empty when the name is not a link.
|
// Link is where a symbolic link points, as written; empty when the name is not a link.
|
||||||
Link string
|
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
|
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.
|
// ConfigDir is one directory a manager reads units and enable links from, and whose it is.
|
||||||
type ConfigDir struct {
|
type ConfigDir struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Whose string `json:"whose"`
|
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.
|
// EnableLink is one link in a dependency directory that names the unit.
|
||||||
@@ -58,36 +64,72 @@ type EnableLink struct {
|
|||||||
Dependency string `json:"dependency"`
|
Dependency string `json:"dependency"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
Dangles bool `json:"dangles"`
|
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 is false for a plain file in a dependency directory, which the manager reads the same way.
|
||||||
Link bool `json:"link"`
|
Link bool `json:"link"`
|
||||||
Whose string `json:"whose"`
|
Whose string `json:"whose"`
|
||||||
|
|
||||||
|
removable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// configDirs are the directories a scope's manager reads enable links from, the person's first: the
|
// configDirs are the directories a scope's manager reads unit files and enable links from, in its own order
|
||||||
// account's own configuration in user scope, the machine's administrator's, the runtime's and the packages'.
|
// 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 {
|
func (m *Manager) configDirs(scope Scope) []ConfigDir {
|
||||||
if scope == User {
|
if scope == User {
|
||||||
|
account := "the operator account's own configuration"
|
||||||
dirs := []ConfigDir{}
|
dirs := []ConfigDir{}
|
||||||
if m.Home != "" {
|
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,
|
return append(dirs,
|
||||||
ConfigDir{"/etc/systemd/user", "the machine's configuration, for every account"},
|
ConfigDir{"/usr/local/lib/systemd/user", "a program installed by hand, for every account", false},
|
||||||
ConfigDir{"/usr/lib/systemd/user", "a package's"})
|
ConfigDir{"/usr/lib/systemd/user", "a package's", false})
|
||||||
}
|
}
|
||||||
return []ConfigDir{
|
return []ConfigDir{
|
||||||
{"/etc/systemd/system", "the machine's configuration"},
|
{"/etc/systemd/system.control", "the machine's configuration (set by a program through the manager)", true},
|
||||||
{"/run/systemd/system", "made at runtime, gone at the next boot"},
|
{"/run/systemd/transient", "made at runtime, gone at the next boot", false},
|
||||||
{"/usr/lib/systemd/system", "a package's"},
|
{"/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
|
// 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
|
// 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) {
|
func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) {
|
||||||
found, unread := []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)
|
entries, err := m.ReadDir(dir.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
@@ -96,21 +138,29 @@ func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.Dir {
|
dependency, of := "", ""
|
||||||
continue
|
|
||||||
}
|
|
||||||
dependency := ""
|
|
||||||
of := ""
|
|
||||||
for suffix, dep := range dependencyDirs {
|
for suffix, dep := range dependencyDirs {
|
||||||
if strings.HasSuffix(e.Name, suffix) {
|
if strings.HasSuffix(e.Name, suffix) {
|
||||||
dependency, of = dep, strings.TrimSuffix(e.Name, suffix)
|
dependency, of = dep, strings.TrimSuffix(e.Name, suffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dependency == "" {
|
if dependency == "" || (!e.Dir && e.Link == "") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sub := filepath.Join(dir.Path, e.Name)
|
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 err != nil {
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
unread = append(unread, sub+": "+err.Error())
|
unread = append(unread, sub+": "+err.Error())
|
||||||
@@ -122,7 +172,8 @@ func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
found = append(found, EnableLink{Path: filepath.Join(sub, l.Name), Of: of, Dependency: dependency,
|
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)}
|
answer := map[string]any{"unit": unit, "scope": string(scope)}
|
||||||
|
|
||||||
manager := map[string]any{}
|
manager := map[string]any{}
|
||||||
args := []string{"show", unit, "--no-pager"}
|
args := []string{"show", "--no-pager"}
|
||||||
for _, p := range append(append([]string{}, stateProperties...), reverseProperties...) {
|
for _, p := range append(append([]string{}, stateProperties...), reverseProperties...) {
|
||||||
args = append(args, "--property="+p)
|
args = append(args, "--property="+p)
|
||||||
}
|
}
|
||||||
loadState := ""
|
loadState := ""
|
||||||
dependents := 0
|
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()
|
manager["error"] = err.Error()
|
||||||
} else {
|
} else {
|
||||||
for _, line := range strings.Split(out, "\n") {
|
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 {
|
if len(unread) > 0 {
|
||||||
answer["unread"] = unread
|
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
|
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.
|
// 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{}
|
out := []string{}
|
||||||
dangling := 0
|
dangling, removable, unknown := 0, 0, 0
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
if l.Dangles {
|
switch {
|
||||||
|
case l.Dangles:
|
||||||
dangling++
|
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")
|
out = append(out, "the unit's file is not found, and "+plural(len(links), "enable link")+" still name it")
|
||||||
}
|
}
|
||||||
if dangling > 0 {
|
if removable > 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")
|
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" {
|
if loadState == "not-found" {
|
||||||
out = append(out, "nothing names "+unit+" any more: a failed record of it is stale, and `reset-failed` clears it")
|
out = append(out, "nothing names "+unit+" "+where+": a failed record of it is stale, and `reset-failed` clears it")
|
||||||
} else {
|
} else if !managerFailed {
|
||||||
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, "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 {
|
if managerFailed {
|
||||||
out = append(out, "the manager did not answer, so only the links on disk are known")
|
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 {
|
if len(unread) > 0 {
|
||||||
out = append(out, "some directories could not be read, so a link may be missing from this answer")
|
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"
|
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
|
// readDir lists a directory as enable links need it, by lstat: each name, whether it is itself a directory,
|
||||||
// followed), where a link points and whether that exists.
|
// 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) {
|
func readDir(dir string) ([]Entry, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -251,17 +321,25 @@ func readDir(dir string) ([]Entry, error) {
|
|||||||
}
|
}
|
||||||
out := make([]Entry, 0, len(entries))
|
out := make([]Entry, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
entry := Entry{Name: e.Name(), Dir: e.IsDir()}
|
out = append(out, entryOf(filepath.Join(dir, e.Name()), e.Name(), e.Type()))
|
||||||
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// What wants a unit, and a failed record cleared (novox/hq issue 332): over a fake runner and a fake
|
// 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.
|
// 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 (
|
import (
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,7 +76,7 @@ func TestWantedByFindsADanglingEnableLinkInTheAccountsOwnConfiguration(t *testin
|
|||||||
links := answer["links"].([]EnableLink)
|
links := answer["links"].([]EnableLink)
|
||||||
want := []EnableLink{{Path: home + "/.config/systemd/user/default.target.wants/greenclip.service", Of: "default.target",
|
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,
|
Dependency: "Wants", Target: "/usr/lib/systemd/user/greenclip.service", Dangles: true, Link: true,
|
||||||
Whose: "the operator account's own configuration"}}
|
Whose: "the operator account's own configuration", removable: true}}
|
||||||
if !reflect.DeepEqual(links, want) {
|
if !reflect.DeepEqual(links, want) {
|
||||||
t.Fatalf("links %+v", links)
|
t.Fatalf("links %+v", links)
|
||||||
}
|
}
|
||||||
@@ -135,7 +139,7 @@ func TestWantedBySaysWhenNothingNamesTheUnit(t *testing.T) {
|
|||||||
t.Fatalf("%v", answer["links"])
|
t.Fatalf("%v", answer["links"])
|
||||||
}
|
}
|
||||||
said := strings.Join(answer["findings"].([]string), "\n")
|
said := strings.Join(answer["findings"].([]string), "\n")
|
||||||
if !strings.Contains(said, "nothing names gone.service any more") {
|
if !strings.Contains(said, "nothing names gone.service in the directories searched:") {
|
||||||
t.Fatalf("%q", said)
|
t.Fatalf("%q", said)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ func TestResetFailedClearsOneUnitEscalatedOnlyInTheSystemScope(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(calls[0].args, []string{"--user", "reset-failed", "greenclip.service"}) || calls[0].cmd != "systemctl" {
|
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)
|
t.Fatalf("%s %v", calls[0].cmd, calls[0].args)
|
||||||
}
|
}
|
||||||
answer := out.(map[string]any)
|
answer := out.(map[string]any)
|
||||||
@@ -247,3 +251,194 @@ func TestResetFailedClearsOneUnitEscalatedOnlyInTheSystemScope(t *testing.T) {
|
|||||||
type stdioTool struct {
|
type stdioTool struct {
|
||||||
run func(map[string]any) (any, error)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user