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 ) 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) }