Record a removal's outcome, publish it, and read one directory once
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

The record kept only the intent, so a failed unlink read as removed; each
link now gets a synced intent and outcome line, and the outcome is the
module's link.removal event so it is not only a file on one machine.
The put-back hint no longer advises a symlink, an aliased directory is
read once, a machine-wide removal from user scope is said, and the state
directory names no owner a machine without an account cannot resolve.
This commit is contained in:
jochen
2026-10-08 17:55:56 +02:00
parent 87c9d9f7e5
commit 1b368f9aaf
6 changed files with 286 additions and 29 deletions
+95 -19
View File
@@ -12,10 +12,21 @@ package main
// 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.
// **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"
@@ -29,15 +40,29 @@ import (
// 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.
// 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"`
Scope string `json:"scope"`
Unit string `json:"unit"`
Path string `json:"path"`
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: <why>", 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).
@@ -51,8 +76,9 @@ func (m *Manager) UnlinkDangling(scope Scope, unit, why string) (map[string]any,
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{}
links, unread, _ := m.links(scope, unit)
removed, notRemoved, left := []Unlinked{}, []Unlinked{}, []map[string]string{}
unrecorded, unpublished := []string{}, []string{}
for _, l := range links {
reason := ""
switch {
@@ -75,23 +101,58 @@ func (m *Manager) UnlinkDangling(scope Scope, unit, why string) (map[string]any,
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}
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: %w", l.Path, err)
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 {
return nil, fmt.Errorf("%s was recorded and not removed: %w (removed before it: %d)", l.Path, err, len(removed))
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)
}
removed = append(removed, rec)
}
answer := map[string]any{"unit": unit, "scope": string(scope), "removed": removed, "left": left,
"record": filepath.Join(m.State, UnlinkedRecord)}
"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 {
answer["note"] = "no dangling link named for " + unit + " was in the account's or the machine's own configuration: nothing was removed"
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.
@@ -100,7 +161,8 @@ func (m *Manager) UnlinkDangling(scope Scope, unit, why string) (map[string]any,
} 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"
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
}
@@ -111,6 +173,7 @@ func (m *Manager) now() time.Time {
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 {
@@ -127,9 +190,22 @@ func (m *Manager) record(rec Unlinked) error {
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 {