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. // // **The record never claims what did not happen.** Each link gets two lines in the module's state, each // synced to disk before the next step: the intent (its path, where it pointed, what wanted the unit, the // person's reason) before it is removed, and the outcome — removed, or not removed and why — after. A line // that is an intent with no outcome after it is a removal interrupted, said as such. The outcome is also // published on the bus as this module's `link.removal` event, so the record is not only a file on one // machine; it carries no secret, only the names and the reason. // // The record's directory is the module's `state`, which the manifest gives no owner on purpose: a // manifest cannot say ${machine:account} on a machine that has no operator account. The controller makes a // directory a bundle's words name the account's where the machine has one (novox/hq ADR 0192), and there // the runtime runs as root and the directory stays root's — either way it is the tools' own. // // 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. A user-scope call that removes a machine-wide link (one in // /etc/systemd/user, read by every account's manager) says so. 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" // RemovalEvent is the event each attempted removal is published as (module.json's emits). const RemovalEvent = "link.removal" // Unlinked is one line of the record: an intent, or the outcome that follows it. type Unlinked struct { At string `json:"at"` Step string `json:"step"` // "intent" or "outcome" Scope string `json:"scope"` Unit string `json:"unit"` Path string `json:"path"` // Target is where the link pointed: what enabling the unit makes again, once the unit exists. Target string `json:"target"` Of string `json:"of"` Why string `json:"why"` // Outcome is "removed" or "not removed: ", on an outcome line only. Outcome string `json:"outcome,omitempty"` // MachineWide is whether the link was in a directory every account's manager reads. MachineWide bool `json:"machine_wide,omitempty"` } // machineWide is whether a user-scope link sits where every account's manager reads it. func (m *Manager) machineWide(scope Scope, path string) bool { return scope == User && !(m.Home != "" && strings.HasPrefix(path, filepath.Clean(m.Home)+string(filepath.Separator))) } // 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, notRemoved, left := []Unlinked{}, []Unlinked{}, []map[string]string{} unrecorded, unpublished := []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), Step: "intent", Scope: string(scope), Unit: unit, Path: l.Path, Target: l.Target, Of: l.Of, Why: why, MachineWide: m.machineWide(scope, l.Path)} if err := m.record(rec); err != nil { return nil, fmt.Errorf("the record of %s could not be kept, so it was not removed (removed before it: %d): %w", l.Path, len(removed), err) } rec.Step, rec.Outcome = "outcome", "removed" if err := m.unlink(l.Path); err != nil { rec.Outcome = "not removed: " + err.Error() } rec.At = m.now().UTC().Format(time.RFC3339) if err := m.record(rec); err != nil { unrecorded = append(unrecorded, l.Path+": "+err.Error()) } if err := m.emit(rec); err != nil { unpublished = append(unpublished, l.Path+": "+err.Error()) } if rec.Outcome == "removed" { removed = append(removed, rec) } else { notRemoved = append(notRemoved, rec) } } answer := map[string]any{"unit": unit, "scope": string(scope), "removed": removed, "left": left, "record": filepath.Join(m.State, UnlinkedRecord), "ok": len(notRemoved) == 0 && len(unrecorded) == 0} if len(notRemoved) > 0 { answer["not_removed"] = notRemoved } if len(unrecorded) > 0 { answer["unrecorded"] = unrecorded answer["record_note"] = "these outcomes are not in the record, which ends at their intent: the answer here is what happened" } if len(unpublished) > 0 { answer["unpublished"] = unpublished } if len(unread) > 0 { answer["unread"] = unread } var wide []string for _, r := range removed { if r.MachineWide { wide = append(wide, r.Path) } } if len(wide) > 0 { answer["machine_wide"] = wide answer["machine_wide_note"] = "these links were in the machine's configuration for every account, not only the operator account's own: " + "no account's manager asks for " + unit + " through them any more" } if len(removed) == 0 { if len(notRemoved) == 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. A removed link comes back by enabling the unit through " + "node-service-manager.enable, once its unit file exists again; the record says what each link was" return answer, nil } func (m *Manager) now() time.Time { if m.Now != nil { return m.Now() } return time.Now() } // record appends one line to the record and syncs it to disk before answering. 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 } if err := f.Sync(); err != nil { f.Close() return err } return f.Close() } // emit publishes an outcome as this module's event: the names and the reason, nothing else. func (m *Manager) emit(rec Unlinked) error { if m.Emit == nil { return fmt.Errorf("this process publishes no events") } return m.Emit(RemovalEvent, map[string]any{"unit": rec.Unit, "scope": rec.Scope, "path": rec.Path, "target": rec.Target, "of": rec.Of, "why": rec.Why, "outcome": rec.Outcome, "machine_wide": rec.MachineWide, "at": rec.At}) } // 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) }