systemctl disable leaves a link whose unit file is gone, and the manager keeps asking for the unit. The verb removes only such links in the account's or the machine's own configuration, on the person's word, and records each with its reason before removing it (hq issue 332).
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
package main
|
|
|
|
// Removing a dangling enable link (novox/hq issue 332), on a person's word. `systemctl disable` does not do it
|
|
// once the unit's file is gone: it reports the unit file as not existing and leaves the link, and the manager
|
|
// goes on asking for the unit at every boot or login.
|
|
//
|
|
// It removes as little as can be named, and nothing else:
|
|
//
|
|
// - only a symbolic link, named for the unit, in a *.wants, *.requires or *.upholds directory under the
|
|
// scope's searched directories (wanted.go), whose target does not exist;
|
|
// - only in the account's or the machine's own configuration — never a package's, a generator's or the
|
|
// runtime's, which put theirs back — and never through a dependency directory that is itself a link;
|
|
// - each link read again just before it goes, and left when it changed since it was found.
|
|
//
|
|
// A record of each (its path, where it pointed, what wanted the unit) and the person's reason is written to
|
|
// the module's state before the link is removed, so nothing is removed that is not recorded, and the answer
|
|
// says how to put it back. The account's own link is removed by this process, which is that account; any
|
|
// other through sudo -n, as every act on the machine's manager is.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// UnlinkedRecord is the file in the module's state each removal is recorded in, one JSON object a line.
|
|
const UnlinkedRecord = "unlinked-links.jsonl"
|
|
|
|
// Unlinked is one removal, as recorded.
|
|
type Unlinked struct {
|
|
At string `json:"at"`
|
|
Scope string `json:"scope"`
|
|
Unit string `json:"unit"`
|
|
Path string `json:"path"`
|
|
Target string `json:"target"`
|
|
Of string `json:"of"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// UnlinkDangling removes the dangling enable links named for one unit, on the person's word (why).
|
|
func (m *Manager) UnlinkDangling(scope Scope, unit, why string) (map[string]any, error) {
|
|
if err := unitArg(unit); err != nil {
|
|
return nil, err
|
|
}
|
|
if why = strings.TrimSpace(why); why == "" {
|
|
return nil, fmt.Errorf("why is required: removing a link is the person's act, and their reason is kept with the record")
|
|
}
|
|
if m.State == "" {
|
|
return nil, fmt.Errorf("this module has no state directory to keep the record in, so nothing is removed")
|
|
}
|
|
links, unread := m.links(scope, unit)
|
|
removed, left := []Unlinked{}, []map[string]string{}
|
|
for _, l := range links {
|
|
reason := ""
|
|
switch {
|
|
case !l.Link:
|
|
reason = "a file, not a link"
|
|
case !l.Dangles:
|
|
reason = "its target exists: `disable` is the verb for a unit that is there"
|
|
case filepath.Base(l.Path) != unit:
|
|
reason = "named for another unit, pointing at this one's file"
|
|
case !l.removable:
|
|
reason = "in " + l.Whose + " directory, which puts it back, or reached through a linked directory"
|
|
}
|
|
if reason == "" {
|
|
now, err := m.Lstat(l.Path)
|
|
if err != nil || now.Link != l.Target || !now.Dangles {
|
|
reason = "it changed since it was read"
|
|
}
|
|
}
|
|
if reason != "" {
|
|
left = append(left, map[string]string{"path": l.Path, "why_left": reason})
|
|
continue
|
|
}
|
|
rec := Unlinked{At: m.now().UTC().Format(time.RFC3339), Scope: string(scope), Unit: unit, Path: l.Path,
|
|
Target: l.Target, Of: l.Of, Why: why}
|
|
if err := m.record(rec); err != nil {
|
|
return nil, fmt.Errorf("the record of %s could not be kept, so it was not removed: %w", l.Path, err)
|
|
}
|
|
if err := m.unlink(l.Path); err != nil {
|
|
return nil, fmt.Errorf("%s was recorded and not removed: %w (removed before it: %d)", l.Path, err, len(removed))
|
|
}
|
|
removed = append(removed, rec)
|
|
}
|
|
answer := map[string]any{"unit": unit, "scope": string(scope), "removed": removed, "left": left,
|
|
"record": filepath.Join(m.State, UnlinkedRecord)}
|
|
if len(unread) > 0 {
|
|
answer["unread"] = unread
|
|
}
|
|
if len(removed) == 0 {
|
|
answer["note"] = "no dangling link named for " + unit + " was in the account's or the machine's own configuration: nothing was removed"
|
|
return answer, nil
|
|
}
|
|
// The manager keeps the dependency it loaded from the link until it reloads.
|
|
if _, err := m.call(scope, "systemctl", "daemon-reload"); err != nil {
|
|
answer["reload"] = "the manager was not reloaded, so it still lists the dependency until it is: " + err.Error()
|
|
} else {
|
|
answer["reload"] = "the manager reloaded"
|
|
}
|
|
answer["next"] = "`reset-failed` clears the unit's failed record; each link can be made again from the record (ln -s <target> <path>) if it was wanted"
|
|
return answer, nil
|
|
}
|
|
|
|
func (m *Manager) now() time.Time {
|
|
if m.Now != nil {
|
|
return m.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func (m *Manager) record(rec Unlinked) error {
|
|
line, err := json.Marshal(rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(m.State, 0o700); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(filepath.Join(m.State, UnlinkedRecord), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(append(line, '\n')); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// unlink removes one link: the account's own by this process, which is the account; another as root,
|
|
// through sudo -n. unlink(1) takes one path and removes it without following it.
|
|
func (m *Manager) unlink(path string) error {
|
|
own := m.Home != "" && strings.HasPrefix(path, filepath.Clean(m.Home)+string(filepath.Separator))
|
|
if own || m.UID == 0 {
|
|
return m.Remove(path)
|
|
}
|
|
r := m.Run("sudo", []string{"-n", "unlink", path}, nil)
|
|
if r.Status == 0 && r.Error == "" {
|
|
return nil
|
|
}
|
|
return m.failure("unlink", "sudo", System, r)
|
|
}
|