From b5f2c3b961c5b4ae60fbf052688d33cb40edd8e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 17:39:08 +0200 Subject: [PATCH] Promise unlink-dangling on the service manager, optional (hq issue 332) disable cannot remove an enable link whose unit file is gone, so a leftover unit stays wanted at every login with no verb to end it. Optional until the systemd module serves it (ADR 0246 step 1). --- internal/catalogue/operators_machine_test.go | 16 +++++++++++++--- internal/catalogue/seats.go | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/internal/catalogue/operators_machine_test.go b/internal/catalogue/operators_machine_test.go index 2705285a..3bcc9086 100644 --- a/internal/catalogue/operators_machine_test.go +++ b/internal/catalogue/operators_machine_test.go @@ -43,7 +43,7 @@ func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) { if seat.Scope != ScopeNode { t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope) } - want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed", "reset-failed", "wanted-by"} + want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed", "reset-failed", "wanted-by", "unlink-dangling"} var got []string for _, v := range seat.Serves { got = append(got, v.Name) @@ -81,7 +81,7 @@ func TestFailedIsAnOptionalVerbOfTheServiceManager(t *testing.T) { if err := CanHold(holder(eight[1:]...), seat); err == nil || !strings.Contains(err.Error(), "does not serve units") { t.Fatalf("a holder missing a required verb was accepted: %v", err) } - optional := map[string]bool{"failed": true, "reset-failed": true, "wanted-by": true} + optional := map[string]bool{"failed": true, "reset-failed": true, "wanted-by": true, "unlink-dangling": true} for _, v := range seat.Serves { if v.Optional != optional[v.Name] { t.Errorf("%s optional: %v", v.Name, v.Optional) @@ -105,10 +105,14 @@ func TestResetFailedAndWantedByAreOptionalVerbsOfTheServiceManager(t *testing.T) if err := CanHold(holder(append(nine, "reset-failed", "wanted-by")...), seat); err != nil { t.Fatalf("a holder serving both is refused: %v", err) } + if err := CanHold(holder(append(nine, "reset-failed", "wanted-by", "unlink-dangling")...), seat); err != nil { + t.Fatalf("a holder serving unlink-dangling too is refused: %v", err) + } if err := CanHold(holder(append(nine, "why-started")...), seat); err == nil || !strings.Contains(err.Error(), "does not promise") { t.Fatalf("a verb the seat does not promise was accepted: %v", err) } - replaces := map[string]string{"reset-failed": "systemctl reset-failed", "wanted-by": "systemctl list-dependencies --reverse"} + replaces := map[string]string{"reset-failed": "systemctl reset-failed", "wanted-by": "systemctl list-dependencies --reverse", + "unlink-dangling": "rm ~/.config/systemd/user/*.wants/"} for _, v := range seat.Serves { want, ok := replaces[v.Name] if !ok { @@ -121,6 +125,12 @@ func TestResetFailedAndWantedByAreOptionalVerbsOfTheServiceManager(t *testing.T) if _, has := props["unit"]; !has { t.Errorf("%s takes no unit", v.Name) } + if v.Name == "unlink-dangling" { + // Removing is the person's act: their reason is required, and kept with the record. + if required, _ := v.Input["required"].([]string); !slices.Contains(required, "why") { + t.Errorf("unlink-dangling does not require why: %v", v.Input["required"]) + } + } delete(replaces, v.Name) } if len(replaces) > 0 { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 9c2bfa3a..32d80f5e 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -620,8 +620,8 @@ func SeatsWithAProtocol() []Seat { // 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an // optional scope — "system" when absent, "user" for the operator account's own manager — so a // caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers -// when none is named. `failed`, `reset-failed` and `wanted-by` are optional (Verb.Optional) until every -// holder serves them (ADR 0246). +// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional +// (Verb.Optional) until every holder serves them (ADR 0246). func serviceManagerVerbs() []Verb { scoped := func(more map[string]string, required []string) map[string]any { props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"} @@ -685,6 +685,18 @@ func serviceManagerVerbs() []Verb { Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy", "ls ~/.config/systemd/user/*.wants"}}, + // **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable` + // leaves a link whose unit file is gone, so the manager keeps asking for the unit. Optional until its + // holders serve it (ADR 0246 step 1). + {Name: "unlink-dangling", Optional: true, Description: "Remove the dangling enable links named for one " + + "unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " + + "configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " + + "the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " + + "removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " + + "runtime placed, is left and said.", + Input: scoped(map[string]string{"unit": unit["unit"], + "why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}), + Replaces: []string{"rm ~/.config/systemd/user/*.wants/", "unlink /etc/systemd/system/*.wants/"}}, } }