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.
223 lines
9.2 KiB
Go
223 lines
9.2 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"
|
|
"path/filepath"
|
|
"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; a user-scope call that removes a machine-wide link says so. Each outcome is also published " +
|
|
"as the module's link.removal event. `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")), Resolve: filepath.EvalSymlinks,
|
|
Emit: func(eventType string, body any) error { return stdio.Emit(eventType, body) }}
|
|
}
|
|
|
|
func main() {
|
|
if err := stdio.Serve("", tools(fromEnv())); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|