Files
mesh-catalog/modules/systemd/cmd/systemd-tools/wanted_test.go
T
jochen 00f1d04616
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
Serve reset-failed and wanted-by in the systemd module (hq issue 332)
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.
2026-10-08 17:21:18 +02:00

250 lines
9.2 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.
import (
"io/fs"
"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"}}
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 any more") {
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)
}