Files
mesh-catalog/modules/systemd/cmd/systemd-tools/main.go
T
jochen 00f1d04616
mesh/merge-gate fail: builds systemd → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: systemd claims node-serv…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
Serve reset-failed and wanted-by in the systemd module (hq issue 332)
So a leftover unit's trigger is found and its failed record cleared
through the seat, not a shell: wanted-by reads the manager's reverse
dependencies and the enable links naming the unit, saying which dangle.
2026-10-08 17:21:18 +02:00

196 lines
7.6 KiB
Go

// systemd's tools: the node-service-manager seat's eleven verbs — the units on this machine in both scopes,
// read and acted on by name, their journal, what has failed, what wants a unit, 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)
}},
// 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}
}
func main() {
if err := stdio.Serve("", tools(fromEnv())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}