systemctl disable leaves a link whose unit file is gone, and the manager keeps asking for the unit. The verb removes only such links in the account's or the machine's own configuration, on the person's word, and records each with its reason before removing it (hq issue 332).
220 lines
9.0 KiB
Go
220 lines
9.0 KiB
Go
// systemd's tools: the node-service-manager seat's twelve verbs — the units on this machine in both scopes,
|
|
// read and acted on by name, their journal, what has failed, what wants a unit, a dangling enable link removed and a failed record cleared
|
|
// (novox/hq ADR 0177, issue 332). The node tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
|
|
// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with
|
|
// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers
|
|
// about them. stdout is the MCP channel; this says nothing else.
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"strconv"
|
|
"strings"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
const seat = "node-service-manager"
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
var (
|
|
scopeArg = str(`"system" (the default) or "user": the operator account's own manager`)
|
|
unitArgS = str("the unit's name, as the service manager knows it")
|
|
)
|
|
|
|
func scopeOf(a map[string]any) (Scope, error) {
|
|
s, _ := a["scope"].(string)
|
|
switch s {
|
|
case "", "system":
|
|
return System, nil
|
|
case "user":
|
|
return User, nil
|
|
}
|
|
return "", fmt.Errorf("scope %q: \"system\" or \"user\"", s)
|
|
}
|
|
|
|
func unitOf(a map[string]any) (string, error) {
|
|
u, _ := a["unit"].(string)
|
|
if u = strings.TrimSpace(u); u == "" {
|
|
return "", fmt.Errorf("a unit is required")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func tools(m *Manager) []stdio.Tool {
|
|
act := func(verb, description string) stdio.Tool {
|
|
return stdio.Tool{Name: seat + "." + verb, Description: description,
|
|
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unit, err := unitOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.Act(scope, verb, unit)
|
|
}}
|
|
}
|
|
return []stdio.Tool{
|
|
{Name: seat + ".units",
|
|
Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
|
Input: map[string]any{"scope": scopeArg, "pattern": str("a glob the unit's name must match (optional)")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pattern, _ := a["pattern"].(string)
|
|
units, err := m.Units(scope, pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"scope": string(scope), "units": units}, nil
|
|
}},
|
|
{Name: seat + ".status",
|
|
Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and mesh_declared — true when its unit file is one the mesh wrote (a unit the mesh only puts into a state is not recognised from here).",
|
|
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unit, err := unitOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.Status(scope, unit)
|
|
}},
|
|
act("start", "Start one unit. For a unit the mesh declares, the answer says the node-engine will restore what its declaration says at its next apply."),
|
|
act("stop", "Stop one unit; for a unit the mesh declares, the answer says the node-engine will restore its declared state."),
|
|
act("restart", "Restart one unit."),
|
|
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
|
|
act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
|
|
{Name: seat + ".journal",
|
|
Description: "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority " +
|
|
"and to lines holding a text when asked — a system service's included: the read is escalated, so it is the " +
|
|
"service's own lines and not only the operator account's. A secret the unit printed is shown as " +
|
|
"[redacted: <what it was>].",
|
|
Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
|
|
"lines": str("how many lines from the end of what matches (default 100, at most 2000)"),
|
|
"since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"),
|
|
"until": str("the window's end, in the same forms (optional; now when absent)"),
|
|
"match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"),
|
|
"priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unit, err := unitOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q, err := journalQuery(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.Journal(scope, unit, q)
|
|
}},
|
|
// **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state,
|
|
// and its start-limit count, in its manager until it is reset. A unit's name is required, so the
|
|
// verb never resets every failed unit at once.
|
|
act("reset-failed", "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."),
|
|
{Name: seat + ".wanted-by",
|
|
Description: "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies " +
|
|
"(WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest, and the units that pull those in) and every " +
|
|
"enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds) — the " +
|
|
"operator account's own ~/.config/systemd/user in user scope — each with where it points, whether it " +
|
|
"dangles and whose directory it is in; and what that means, in words.",
|
|
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unit, err := unitOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.WantedBy(scope, unit)
|
|
}},
|
|
// **A dangling enable link removed on the person's word** (novox/hq issue 332): `disable` leaves it
|
|
// once the unit's file is gone. Recorded before it is removed (unlink.go).
|
|
{Name: seat + ".unlink-dangling",
|
|
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 link is " +
|
|
"recorded first (its path, its target, the person's reason) in the module's state, then removed, and " +
|
|
"the manager reloaded. A link whose target exists, a package's, a generator's or the runtime's is left, " +
|
|
"and said. `wanted-by` shows what it would remove.",
|
|
Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
|
|
"why": str("the person's reason for removing the links, kept in the record")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
unit, err := unitOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
why, _ := a["why"].(string)
|
|
return m.UnlinkDangling(scope, unit, why)
|
|
}},
|
|
// Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so
|
|
// whatever holds the role answers it and every machine is asked the same way.
|
|
{Name: seat + ".failed",
|
|
Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.",
|
|
Input: map[string]any{"scope": str(`"system" or "user": only that manager (both when absent)`)},
|
|
Run: func(a map[string]any) (any, error) {
|
|
if s, _ := a["scope"].(string); s == "" {
|
|
return m.Failed(), nil
|
|
}
|
|
scope, err := scopeOf(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.Failed(scope), nil
|
|
}},
|
|
}
|
|
}
|
|
|
|
func fromEnv() *Manager {
|
|
me, _ := user.Current()
|
|
name := ""
|
|
if me != nil {
|
|
name = me.Username
|
|
}
|
|
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
|
|
if account == "" {
|
|
account = name
|
|
}
|
|
uid := os.Getuid()
|
|
if me != nil {
|
|
if n, err := strconv.Atoi(me.Uid); err == nil {
|
|
uid = n
|
|
}
|
|
}
|
|
home := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME"))
|
|
if home == "" && account == name {
|
|
home, _ = os.UserHomeDir()
|
|
}
|
|
return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ(),
|
|
Home: home, ReadDir: readDir, Lstat: lstatEntry, Remove: os.Remove,
|
|
State: strings.TrimSpace(os.Getenv("MESH_SYSTEMD_STATE"))}
|
|
}
|
|
|
|
func main() {
|
|
if err := stdio.Serve("", tools(fromEnv())); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|