Serve reset-failed and wanted-by in the systemd module (hq issue 332)
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
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.
This commit is contained in:
@@ -98,7 +98,7 @@ func execRunner(cmd string, args []string, env []string) Ran {
|
||||
const MeshUnitHeader = "# Generated by the mesh."
|
||||
|
||||
// acts are the verbs that change the system manager's state, which polkit keeps from a non-root account.
|
||||
var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true}
|
||||
var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true, "reset-failed": true}
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, or in the user scope, or
|
||||
// when the call is a systemctl read; else through sudo without a prompt — an act on the system manager,
|
||||
@@ -137,6 +137,10 @@ type Manager struct {
|
||||
Read func(path string) (string, error)
|
||||
// Env is this process's environment, the base of a user-scope call's.
|
||||
Env []string
|
||||
// Home is the operator account's home, whose configuration holds its own enable links.
|
||||
Home string
|
||||
// ReadDir lists a directory, to find the enable links naming a unit (wanted.go).
|
||||
ReadDir func(dir string) ([]Entry, error)
|
||||
}
|
||||
|
||||
var userBus = regexp.MustCompile(`(?i)Failed to connect to (user scope )?bus`)
|
||||
@@ -267,7 +271,8 @@ func (m *Manager) writtenByMesh(path string) bool {
|
||||
return err == nil && strings.HasPrefix(text, MeshUnitHeader)
|
||||
}
|
||||
|
||||
// Act starts, stops, restarts, enables or disables one unit, and answers with the state after.
|
||||
// Act starts, stops, restarts, enables or disables one unit, or resets its failed record, and answers with
|
||||
// the state after.
|
||||
func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -242,7 +242,7 @@ func TestAUnitsNameIsNeverAnOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest owns the systemd package, claims the seat's nine verbs, and lists exactly the tools served.
|
||||
// The manifest owns the systemd package, claims the seat's eleven verbs, and lists exactly the tools served.
|
||||
func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// systemd's tools: the node-service-manager seat's nine verbs — the units on this machine in both scopes,
|
||||
// read and acted on by name, their journal, and what has failed (novox/hq ADR 0177). The node
|
||||
// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
|
||||
// systemd's tools: the node-service-manager seat's eleven verbs — the units on this machine in both scopes,
|
||||
// read and acted on by name, their journal, what has failed, what wants a unit, and a failed record cleared
|
||||
// (novox/hq ADR 0177, issue 332). The node tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
|
||||
// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with
|
||||
// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers
|
||||
// about them. stdout is the MCP channel; this says nothing else.
|
||||
@@ -123,6 +123,28 @@ func tools(m *Manager) []stdio.Tool {
|
||||
}
|
||||
return m.Journal(scope, unit, q)
|
||||
}},
|
||||
// **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state,
|
||||
// and its start-limit count, in its manager until it is reset. A unit's name is required, so the
|
||||
// verb never resets every failed unit at once.
|
||||
act("reset-failed", "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing."),
|
||||
{Name: seat + ".wanted-by",
|
||||
Description: "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies " +
|
||||
"(WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest, and the units that pull those in) and every " +
|
||||
"enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds) — the " +
|
||||
"operator account's own ~/.config/systemd/user in user scope — each with where it points, whether it " +
|
||||
"dangles and whose directory it is in; and what that means, in words.",
|
||||
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
scope, err := scopeOf(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unit, err := unitOf(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.WantedBy(scope, unit)
|
||||
}},
|
||||
// Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so
|
||||
// whatever holds the role answers it and every machine is asked the same way.
|
||||
{Name: seat + ".failed",
|
||||
@@ -157,7 +179,12 @@ func fromEnv() *Manager {
|
||||
uid = n
|
||||
}
|
||||
}
|
||||
return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ()}
|
||||
home := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME"))
|
||||
if home == "" && account == name {
|
||||
home, _ = os.UserHomeDir()
|
||||
}
|
||||
return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ(),
|
||||
Home: home, ReadDir: readDir}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user