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.
445 lines
17 KiB
Go
445 lines
17 KiB
Go
package main
|
|
|
|
// 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 (the mesh makes no symbolic links, its tests
|
|
// included); the real listing is tested on what a temporary directory holds without one.
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// tree is a fake file system of directories, each a list of entries; a directory not in it does not exist.
|
|
type tree map[string][]Entry
|
|
|
|
func (t tree) readDir(dir string) ([]Entry, error) {
|
|
if e, ok := t[dir]; ok {
|
|
if e == nil {
|
|
return nil, fs.ErrPermission
|
|
}
|
|
return e, nil
|
|
}
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
const home = "/home/operator"
|
|
|
|
func withTree(m *Manager, t tree) *Manager {
|
|
m.Home, m.ReadDir = home, t.readDir
|
|
return m
|
|
}
|
|
|
|
// The leftover of a removed package: its file is gone, an enable link in the account's own configuration
|
|
// still names it, and the manager still has default.target wanting it.
|
|
func greenclip() tree {
|
|
user := home + "/.config/systemd/user"
|
|
return tree{
|
|
user: {{Name: "default.target.wants", Dir: true}, {Name: "timers.target.wants", Dir: true}, {Name: "clip.service"}},
|
|
user + "/default.target.wants": {
|
|
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true},
|
|
{Name: "pipewire.service", Link: "/usr/lib/systemd/user/pipewire.service"},
|
|
},
|
|
user + "/timers.target.wants": {{Name: "backup.timer", Link: "/usr/lib/systemd/user/backup.timer"}},
|
|
"/usr/lib/systemd/user": {{Name: "pipewire.service"}, {Name: "sockets.target.wants", Dir: true}},
|
|
"/usr/lib/systemd/user/sockets.target.wants": {{Name: "pipewire.socket", Link: "../pipewire.socket"}},
|
|
}
|
|
}
|
|
|
|
func showAndReverse(show, reverse string) func(c call) Ran {
|
|
return func(c call) Ran {
|
|
for _, a := range c.args {
|
|
if a == "show" {
|
|
return Ran{Stdout: show}
|
|
}
|
|
if a == "list-dependencies" {
|
|
return Ran{Stdout: reverse}
|
|
}
|
|
}
|
|
return Ran{}
|
|
}
|
|
}
|
|
|
|
func TestWantedByFindsADanglingEnableLinkInTheAccountsOwnConfiguration(t *testing.T) {
|
|
var calls []call
|
|
run := fake(showAndReverse(
|
|
"LoadState=not-found\nActiveState=failed\nSubState=failed\nUnitFileState=\nFragmentPath=\nWantedBy=default.target\nRequiredBy=\nTriggeredBy=\n",
|
|
"greenclip.service\n default.target\n"), &calls)
|
|
answer, err := withTree(operator(run), greenclip()).WantedBy(User, "greenclip.service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
links := answer["links"].([]EnableLink)
|
|
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,
|
|
Whose: "the operator account's own configuration", removable: true}}
|
|
if !reflect.DeepEqual(links, want) {
|
|
t.Fatalf("links %+v", links)
|
|
}
|
|
manager := answer["manager"].(map[string]any)
|
|
if !reflect.DeepEqual(manager["WantedBy"], []string{"default.target"}) || manager["LoadState"] != "not-found" {
|
|
t.Fatalf("manager %+v", manager)
|
|
}
|
|
if _, empty := manager["RequiredBy"]; empty {
|
|
t.Fatal("an empty property is answered as a dependency")
|
|
}
|
|
if !reflect.DeepEqual(manager["reverse"], []string{"default.target"}) {
|
|
t.Fatalf("reverse %v", manager["reverse"])
|
|
}
|
|
said := strings.Join(answer["findings"].([]string), "\n")
|
|
if !strings.Contains(said, "default.target.wants/greenclip.service makes default.target want greenclip.service") ||
|
|
!strings.Contains(said, "which does not exist") || !strings.Contains(said, "reset-failed") {
|
|
t.Fatalf("findings %q", said)
|
|
}
|
|
if _, has := answer["unread"]; has {
|
|
t.Fatalf("a directory that does not exist was said as unread: %v", answer["unread"])
|
|
}
|
|
// Read-only: the manager is only asked.
|
|
for _, c := range calls {
|
|
if c.args[1] != "show" && c.args[1] != "list-dependencies" {
|
|
t.Fatalf("WantedBy ran %v", c.args)
|
|
}
|
|
if c.args[0] != "--user" {
|
|
t.Fatalf("a user-scope question went to the system manager: %v", c.args)
|
|
}
|
|
}
|
|
// The unit comes after "--", so a name that slipped past would still not be an option.
|
|
last := calls[len(calls)-1].args
|
|
if last[len(last)-2] != "--" {
|
|
t.Fatalf("%v", last)
|
|
}
|
|
}
|
|
|
|
func TestWantedByMatchesALinkByWhatItPointsAt(t *testing.T) {
|
|
user := home + "/.config/systemd/user"
|
|
fsys := tree{
|
|
user: {{Name: "graphical-session.target.requires", Dir: true}},
|
|
user + "/graphical-session.target.requires": {{Name: "clipboard.service", Link: "/usr/lib/systemd/user/greenclip.service"}},
|
|
}
|
|
answer, err := withTree(operator(fake(showAndReverse("LoadState=loaded\n", "greenclip.service\n"), nil)), fsys).WantedBy(User, "greenclip.service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
links := answer["links"].([]EnableLink)
|
|
if len(links) != 1 || links[0].Dependency != "Requires" || links[0].Of != "graphical-session.target" || links[0].Dangles {
|
|
t.Fatalf("%+v", links)
|
|
}
|
|
}
|
|
|
|
func TestWantedBySaysWhenNothingNamesTheUnit(t *testing.T) {
|
|
answer, err := withTree(operator(fake(showAndReverse("LoadState=not-found\nActiveState=failed\nWantedBy=\n", "gone.service\n"), nil)), greenclip()).WantedBy(User, "gone.service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(answer["links"].([]EnableLink)) != 0 {
|
|
t.Fatalf("%v", answer["links"])
|
|
}
|
|
said := strings.Join(answer["findings"].([]string), "\n")
|
|
if !strings.Contains(said, "nothing names gone.service in the directories searched:") {
|
|
t.Fatalf("%q", said)
|
|
}
|
|
}
|
|
|
|
func TestWantedBySaysAnUnreadableDirectoryAndAManagerThatDoesNotAnswer(t *testing.T) {
|
|
fsys := greenclip()
|
|
fsys["/etc/systemd/user"] = nil // unreadable
|
|
run := fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to connect to bus: No such file or directory"} }, nil)
|
|
answer, err := withTree(operator(run), fsys).WantedBy(User, "greenclip.service")
|
|
if err != nil {
|
|
t.Fatalf("an unreachable manager failed the whole answer: %v", err)
|
|
}
|
|
manager := answer["manager"].(map[string]any)
|
|
if e, _ := manager["error"].(string); !strings.Contains(e, "does not answer") {
|
|
t.Fatalf("%+v", manager)
|
|
}
|
|
if len(answer["links"].([]EnableLink)) != 1 {
|
|
t.Fatal("the links on disk were not read without the manager")
|
|
}
|
|
unread, _ := answer["unread"].([]string)
|
|
if len(unread) != 1 || !strings.HasPrefix(unread[0], "/etc/systemd/user: ") {
|
|
t.Fatalf("%v", unread)
|
|
}
|
|
said := strings.Join(answer["findings"].([]string), "\n")
|
|
if !strings.Contains(said, "only the links on disk are known") || !strings.Contains(said, "could not be read") {
|
|
t.Fatalf("%q", said)
|
|
}
|
|
if strings.Contains(said, "nothing names") {
|
|
t.Fatal("an incomplete answer said nothing names the unit")
|
|
}
|
|
}
|
|
|
|
func TestWantedByInTheSystemScopeReadsTheMachinesDirectoriesUnescalated(t *testing.T) {
|
|
var calls []call
|
|
fsys := tree{
|
|
"/etc/systemd/system": {{Name: "multi-user.target.wants", Dir: true}},
|
|
"/etc/systemd/system/multi-user.target.wants": {{Name: "sshd.service", Link: "/usr/lib/systemd/system/sshd.service"}},
|
|
}
|
|
m := withTree(operator(fake(showAndReverse("LoadState=loaded\nWantedBy=multi-user.target\n", "sshd.service\nmulti-user.target\ngraphical.target\n"), &calls)), fsys)
|
|
answer, err := m.WantedBy(System, "sshd.service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range calls {
|
|
if c.cmd != "systemctl" {
|
|
t.Fatalf("a read was escalated: %s %v", c.cmd, c.args)
|
|
}
|
|
}
|
|
links := answer["links"].([]EnableLink)
|
|
if len(links) != 1 || links[0].Whose != "the machine's configuration" {
|
|
t.Fatalf("%+v", links)
|
|
}
|
|
if !reflect.DeepEqual(answer["manager"].(map[string]any)["reverse"], []string{"multi-user.target", "graphical.target"}) {
|
|
t.Fatalf("%v", answer["manager"])
|
|
}
|
|
for _, d := range answer["searched"].([]ConfigDir) {
|
|
if strings.HasPrefix(d.Path, home) {
|
|
t.Fatal("the system scope searched the account's home")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWantedByRefusesAUnitThatReadsAsAnOption(t *testing.T) {
|
|
m := withTree(operator(fake(func(call) Ran { t.Fatal("ran"); return Ran{} }, nil)), tree{})
|
|
if _, err := m.WantedBy(User, "--all"); err == nil {
|
|
t.Fatal("an option was taken as a unit")
|
|
}
|
|
}
|
|
|
|
func TestResetFailedClearsOneUnitEscalatedOnlyInTheSystemScope(t *testing.T) {
|
|
if p, a := escalated("systemctl", []string{"reset-failed", "x.mount"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "reset-failed", "x.mount"}) {
|
|
t.Fatalf("%s %v", p, a)
|
|
}
|
|
var calls []call
|
|
m := operator(fake(func(c call) Ran {
|
|
if c.args[1] == "show" {
|
|
return Ran{Stdout: "LoadState=not-found\nActiveState=inactive\nSubState=dead\nUnitFileState=\nFragmentPath=\n"}
|
|
}
|
|
return Ran{}
|
|
}, &calls))
|
|
var reset stdioTool
|
|
for _, tool := range tools(m) {
|
|
if tool.Name == seat+".reset-failed" {
|
|
reset = stdioTool{tool.Run}
|
|
}
|
|
}
|
|
if reset.run == nil {
|
|
t.Fatal("reset-failed is not served")
|
|
}
|
|
out, err := reset.run(map[string]any{"unit": "greenclip.service", "scope": "user"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
answer := out.(map[string]any)
|
|
if answer["active"] != "inactive" || answer["verb"] != "reset-failed" {
|
|
t.Fatalf("%v", answer)
|
|
}
|
|
if _, err := reset.run(map[string]any{"scope": "user"}); err == nil {
|
|
t.Fatal("reset-failed without a unit would reset every failed unit")
|
|
}
|
|
if _, err := reset.run(map[string]any{"unit": "-a", "scope": "user"}); err == nil {
|
|
t.Fatal("an option was taken as a unit")
|
|
}
|
|
}
|
|
|
|
type stdioTool struct {
|
|
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"])
|
|
}
|
|
}
|