diff --git a/internal/catalogue/operators_machine_test.go b/internal/catalogue/operators_machine_test.go index 18fba542..2705285a 100644 --- a/internal/catalogue/operators_machine_test.go +++ b/internal/catalogue/operators_machine_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "slices" "strings" "testing" ) @@ -42,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"} + want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed", "reset-failed", "wanted-by"} var got []string for _, v := range seat.Serves { got = append(got, v.Name) @@ -80,13 +81,68 @@ 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} for _, v := range seat.Serves { - if v.Optional != (v.Name == "failed") { + if v.Optional != optional[v.Name] { t.Errorf("%s optional: %v", v.Name, v.Optional) } } } +// **`reset-failed` and `wanted-by` join the seat optional** (novox/hq issue 332, ADR 0246 step 1): the +// holder running today, serving the nine verbs, still holds the seat; a holder serving all eleven is not +// refused; each replaces the shell command it names; and read back from the store's row they stay optional. +func TestResetFailedAndWantedByAreOptionalVerbsOfTheServiceManager(t *testing.T) { + defer UseSeats(DefaultSeats()) + nine := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed"} + holder := func(serves ...string) Manifest { + return Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, Serves: serves}}} + } + seat, _ := SeatNamed(ServiceManagerSeat) + if err := CanHold(holder(nine...), seat); err != nil { + t.Fatalf("today's holder, without the two verbs, is refused: %v", err) + } + 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, "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"} + for _, v := range seat.Serves { + want, ok := replaces[v.Name] + if !ok { + continue + } + if !slices.Contains(v.Replaces, want) { + t.Errorf("%s does not say it replaces %q: %v", v.Name, want, v.Replaces) + } + props, _ := v.Input["properties"].(map[string]any) + if _, has := props["unit"]; !has { + t.Errorf("%s takes no unit", v.Name) + } + delete(replaces, v.Name) + } + if len(replaces) > 0 { + t.Fatalf("the seat does not promise %v", replaces) + } + // The row as seeding stores it: every verb, the mark not a column. + var rows []Seat + for _, s := range DefaultSeats() { + row := s + row.Serves = nil + for _, v := range s.Serves { + row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input}) + } + rows = append(rows, row) + } + UseSeats(rows) + seat, _ = SeatNamed(ServiceManagerSeat) + if err := CanHold(holder(nine...), seat); err != nil { + t.Fatalf("read back from the row, the two verbs are required: %v", err) + } +} + // The optional mark is not stored, so a seat set read back from the store's rows takes it from the // compiled seat: otherwise `failed`, seeded into the row, would come back required. func TestAnOptionalVerbStaysOptionalInASetReadFromTheStore(t *testing.T) { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 01643dd5..9c2bfa3a 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -620,7 +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, and is optional (Verb.Optional). +// when none is named. `failed`, `reset-failed` and `wanted-by` 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"} @@ -669,6 +670,21 @@ func serviceManagerVerbs() []Verb { "account's; a manager that does not answer is reported with its error, never as nothing failed.", Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil), Replaces: []string{"systemctl --failed"}}, + // **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose + // file is gone stays failed in its manager until the record is reset, and what keeps asking for it + // is a dependency or an enable link no verb could show. Both optional while their holders catch up + // (ADR 0246 step 1); a later change requires them. + {Name: "reset-failed", Optional: true, Description: "Clear one unit's failed record in its manager — " + + "its failed state and its start-limit count — and answer its state after. It removes no file and " + + "starts nothing.", + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}}, + {Name: "wanted-by", Optional: true, Description: "What wants, requires or triggers one unit, read-only: the " + + "manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " + + "every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " + + "each with where it points and whether it dangles — the account's own directory included in user scope.", + Input: scoped(unit, []string{"unit"}), + Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy", + "ls ~/.config/systemd/user/*.wants"}}, } }