systemd: port to Go, and read a system unit's journal as root
The journal verb ran journalctl as the operator account, which outside the journal's group sees only its own entries: every system service read '-- No entries --', and a person reached for a shell. The read now escalates with sudo -n like the acts; ported to Go with every test. hq issue 255.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
|
||||
//
|
||||
// Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4), and
|
||||
// launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words: HOME, a PATH,
|
||||
// MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words.
|
||||
//
|
||||
// The system manager is the machine's. Reading its units needs nothing; acting on it (start, stop, restart,
|
||||
// enable, disable) is refused by polkit to an account that is not root, so those acts go through `sudo -n`,
|
||||
// as the packet filter's and the intrusion prevention's do, and a refusal is named by how it failed.
|
||||
//
|
||||
// **So does reading a system unit's journal** (novox/hq issue 255). journalctl shows an account that is
|
||||
// neither root nor in the journal's group only that account's own entries, and answers "-- No entries --" —
|
||||
// which read as a quiet service, not as a refusal. Every system service's journal was empty through this
|
||||
// verb, and a person reached for a shell to read it.
|
||||
//
|
||||
// The user manager is the operator account's own, and this process IS that account. systemctl and
|
||||
// journalctl find it by the account's runtime directory, /run/user/<uid>, which the runtime's environment
|
||||
// does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus there. It answers only
|
||||
// while the account's manager runs — a login, or lingering enabled — and when it does not, that is said,
|
||||
// never read as "no units".
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scope is which service manager: the machine's, or the operator account's own.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
System Scope = "system"
|
||||
User Scope = "user"
|
||||
)
|
||||
|
||||
// Unit is one unit as list-units answers it.
|
||||
type Unit struct {
|
||||
Unit string `json:"unit"`
|
||||
Load string `json:"load"`
|
||||
Active string `json:"active"`
|
||||
Sub string `json:"sub"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Ran is what a command did: its output, its exit status, and why it did not run to an answer.
|
||||
type Ran struct {
|
||||
Stdout, Stderr string
|
||||
Status int
|
||||
// Error is "ENOENT" when the program is not there, or that it took too long.
|
||||
Error string
|
||||
}
|
||||
|
||||
// Runner runs a command, so the verbs can be tested without a service manager.
|
||||
type Runner func(cmd string, args []string, env []string) Ran
|
||||
|
||||
// CallTimeout is how long one systemctl or journalctl may take: below the runtime's thirty-second call
|
||||
// limit, so a manager that hangs is answered as such rather than as a call the runtime gave up on.
|
||||
const CallTimeout = 20 * time.Second
|
||||
|
||||
func execRunner(cmd string, args []string, env []string) Ran {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
if env != nil {
|
||||
c.Env = env
|
||||
}
|
||||
var out, errb bytes.Buffer
|
||||
c.Stdout, c.Stderr = &out, &errb
|
||||
err := c.Run()
|
||||
r := Ran{Stdout: out.String(), Stderr: errb.String()}
|
||||
switch {
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
|
||||
case errors.Is(err, exec.ErrNotFound):
|
||||
r.Status, r.Error = 127, "ENOENT"
|
||||
case err != nil:
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Status = exit.ExitCode()
|
||||
} else {
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// MeshUnitHeader is the first line of a unit file the host writes for a module's own process (mesh-host
|
||||
// internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh declares,
|
||||
// and the host writes it back at its next apply.
|
||||
const MeshUnitHeader = "# Generated by the mesh."
|
||||
|
||||
// acts are the verbs that change the system manager's state, which polkit keeps from a non-root account.
|
||||
var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true}
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, or in the user scope, or
|
||||
// when the call is a systemctl read; else through sudo without a prompt — an act on the system manager,
|
||||
// or a read of the system journal (issue 255).
|
||||
func escalated(cmd string, args []string, scope Scope, uid int) (string, []string) {
|
||||
if uid == 0 || scope == User {
|
||||
return cmd, args
|
||||
}
|
||||
if cmd == "journalctl" || (cmd == "systemctl" && len(args) > 0 && acts[args[0]]) {
|
||||
return "sudo", append([]string{"-n", cmd}, args...)
|
||||
}
|
||||
return cmd, args
|
||||
}
|
||||
|
||||
// sessionEnv is the words that let systemctl and journalctl reach the account's own manager.
|
||||
func sessionEnv(uid int, base []string) []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", uid)
|
||||
out := []string{}
|
||||
for _, kv := range base {
|
||||
if !strings.HasPrefix(kv, "XDG_RUNTIME_DIR=") && !strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return append(out, "XDG_RUNTIME_DIR="+runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path="+runtime+"/bus")
|
||||
}
|
||||
|
||||
// Manager asks the service managers.
|
||||
type Manager struct {
|
||||
// Account is the operator account, as the mesh told the runtime.
|
||||
Account string
|
||||
// UID and User are this process's.
|
||||
UID int
|
||||
User string
|
||||
Run Runner
|
||||
// Read reads a unit file, to tell whether the mesh wrote it.
|
||||
Read func(path string) (string, error)
|
||||
// Env is this process's environment, the base of a user-scope call's.
|
||||
Env []string
|
||||
}
|
||||
|
||||
var userBus = regexp.MustCompile(`(?i)Failed to connect to (user scope )?bus`)
|
||||
|
||||
// call is one call to systemctl or journalctl in a scope, failing with what went wrong named.
|
||||
func (m *Manager) call(scope Scope, cmd string, args ...string) (string, error) {
|
||||
var env []string
|
||||
if scope == User {
|
||||
// The user manager is the account's, and only the account's own process reaches it with plain
|
||||
// --user. The runtime is that account; anything else is a runtime this was not written for, and
|
||||
// is said rather than answered from the wrong manager.
|
||||
if m.User != m.Account {
|
||||
return "", fmt.Errorf("the user scope is %s's service manager, and this runs as %s", m.Account, m.User)
|
||||
}
|
||||
env = sessionEnv(m.UID, m.Env)
|
||||
args = append([]string{"--user"}, args...)
|
||||
}
|
||||
program, argv := escalated(cmd, args, scope, m.UID)
|
||||
r := m.Run(program, argv, env)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
// systemctl answers a user manager it cannot reach on stderr and still exits 0 for some verbs
|
||||
// (list-units among them): that is a failure, not an empty answer.
|
||||
if scope == User && userBus.MatchString(r.Stderr) {
|
||||
return "", m.unreachable(r.Stderr)
|
||||
}
|
||||
return r.Stdout, nil
|
||||
}
|
||||
return "", m.failure(cmd, program, scope, r)
|
||||
}
|
||||
|
||||
func (m *Manager) unreachable(said string) error {
|
||||
return fmt.Errorf("%s's own service manager does not answer at /run/user/%d — the account has no session "+
|
||||
"and does not linger (loginctl enable-linger %s): %s", m.Account, m.UID, m.Account, firstLine(said))
|
||||
}
|
||||
|
||||
var (
|
||||
sudoSaid = regexp.MustCompile(`(?m)^sudo:`)
|
||||
polkit = regexp.MustCompile(`(?i)interactive authentication`)
|
||||
)
|
||||
|
||||
// failure names what failed by how it failed: sudo missing is a spawn error, sudo refusing speaks on its
|
||||
// own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is the
|
||||
// tool's own first line.
|
||||
func (m *Manager) failure(cmd, program string, scope Scope, r Ran) error {
|
||||
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
|
||||
if r.Error == "ENOENT" {
|
||||
if program == "sudo" {
|
||||
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", cmd)
|
||||
}
|
||||
if r.Error != "" {
|
||||
return fmt.Errorf("%s did not answer: %s", cmd, r.Error)
|
||||
}
|
||||
if program == "sudo" && sudoSaid.MatchString(said) {
|
||||
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
|
||||
}
|
||||
if polkit.MatchString(said) {
|
||||
return fmt.Errorf("the service manager refused the runtime's account: %s", firstLine(said))
|
||||
}
|
||||
if scope == User && userBus.MatchString(said) {
|
||||
return m.unreachable(said)
|
||||
}
|
||||
if line := firstLine(said); line != "" {
|
||||
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
|
||||
}
|
||||
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
|
||||
}
|
||||
|
||||
var spaces = regexp.MustCompile(`\s+`)
|
||||
|
||||
// Units is the units a manager knows in a scope, narrowed to a pattern when one is given.
|
||||
func (m *Manager) Units(scope Scope, pattern string) ([]Unit, error) {
|
||||
args := []string{"list-units", "--all", "--no-legend", "--plain", "--no-pager"}
|
||||
if pattern != "" {
|
||||
args = append(args, "--", pattern)
|
||||
}
|
||||
out, err := m.call(scope, "systemctl", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
units := []Unit{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := spaces.Split(strings.TrimSpace(line), -1)
|
||||
if len(f) < 4 || f[0] == "" {
|
||||
continue
|
||||
}
|
||||
units = append(units, Unit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")})
|
||||
}
|
||||
return units, nil
|
||||
}
|
||||
|
||||
// Status is one unit's state, and whether the mesh declares it.
|
||||
//
|
||||
// **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every unit of a
|
||||
// module's own process whole, under its own header, and writes it back at its next apply. That is the
|
||||
// case a person's act is undone in, so it is the one the answer must name. A unit the mesh only puts into
|
||||
// a state through the `service` shape — a package's own unit — carries no mark; such a unit answers false.
|
||||
func (m *Manager) Status(scope Scope, unit string) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props := []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"}
|
||||
args := []string{"show", unit, "--no-pager"}
|
||||
for _, p := range props {
|
||||
args = append(args, "--property="+p)
|
||||
}
|
||||
out, err := m.call(scope, "systemctl", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"unit": unit, "scope": string(scope)}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok && k != "" {
|
||||
answer[k] = v
|
||||
}
|
||||
}
|
||||
fragment, _ := answer["FragmentPath"].(string)
|
||||
answer["mesh_declared"] = m.writtenByMesh(fragment)
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (m *Manager) writtenByMesh(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
text, err := m.Read(path)
|
||||
return err == nil && strings.HasPrefix(text, MeshUnitHeader)
|
||||
}
|
||||
|
||||
// Act starts, stops, restarts, enables or disables one unit, and answers with the state after.
|
||||
func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.call(scope, "systemctl", verb, unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := m.Status(scope, unit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"unit": unit, "scope": string(scope), "verb": verb, "ok": true,
|
||||
"active": after["ActiveState"], "boot": after["UnitFileState"], "mesh_declared": after["mesh_declared"]}
|
||||
if after["mesh_declared"] == true {
|
||||
answer["note"] = "the mesh declares this unit: the host restores its declared state at its next apply"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Journal is the last lines of one unit's journal.
|
||||
func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil
|
||||
}
|
||||
|
||||
// Failed is every failed unit in both managers. A manager that does not answer is reported as such,
|
||||
// beside the other's answer — never as "nothing failed".
|
||||
func (m *Manager) Failed() map[string]any {
|
||||
in := func(scope Scope) any {
|
||||
units, err := m.Units(scope, "")
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}
|
||||
}
|
||||
failed := []Unit{}
|
||||
for _, u := range units {
|
||||
if u.Active == "failed" {
|
||||
failed = append(failed, u)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
return map[string]any{"system": in(System), "user": in(User)}
|
||||
}
|
||||
|
||||
// unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be
|
||||
// root's option.
|
||||
func unitArg(unit string) error {
|
||||
if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") {
|
||||
return fmt.Errorf("%q is not a unit's name", unit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLine(text string) string {
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readFile(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
return string(raw), err
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
// The service manager's verbs over a fake runner (novox/hq ADR 0177, to-be 41 WP4), ported with the
|
||||
// TypeScript module's tests: which manager a call reaches and how, acts on the system manager escalated,
|
||||
// failures named rather than read as empty answers, whether the mesh declares a unit — and the system
|
||||
// journal read escalated (novox/hq issue 255).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type call struct {
|
||||
cmd string
|
||||
args []string
|
||||
env []string
|
||||
}
|
||||
|
||||
func fake(answer func(c call) Ran, calls *[]call) Runner {
|
||||
return func(cmd string, args []string, env []string) Ran {
|
||||
c := call{cmd, args, env}
|
||||
if calls != nil {
|
||||
*calls = append(*calls, c)
|
||||
}
|
||||
return answer(c)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
list = "sshd.service loaded active running OpenSSH Daemon\nbroken.service loaded failed failed A broken thing\n"
|
||||
showMesh = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=42\nExecMainStatus=0\nDescription=showcase, a mesh daemon\nFragmentPath=/etc/systemd/system/showcase.service\n"
|
||||
showPackage = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=7\nExecMainStatus=0\nDescription=OpenSSH Daemon\nFragmentPath=/usr/lib/systemd/system/sshd.service\n"
|
||||
)
|
||||
|
||||
var files = map[string]string{
|
||||
"/etc/systemd/system/showcase.service": MeshUnitHeader + " Do not edit — this file is replaced whenever the\n[Unit]\n",
|
||||
"/usr/lib/systemd/system/sshd.service": "[Unit]\nDescription=OpenSSH Daemon\n",
|
||||
}
|
||||
|
||||
func read(p string) (string, error) {
|
||||
if s, ok := files[p]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return "", errors.New("ENOENT")
|
||||
}
|
||||
|
||||
func manager(run Runner, uid int, name string) *Manager {
|
||||
return &Manager{Account: "operator", UID: uid, User: name, Run: run, Read: read, Env: []string{"HOME=/h"}}
|
||||
}
|
||||
|
||||
func operator(run Runner) *Manager { return manager(run, 1000, "operator") }
|
||||
|
||||
func TestAnActAndASystemJournalReadGoThroughSudoUnlessRoot(t *testing.T) {
|
||||
for _, verb := range []string{"start", "stop", "restart", "enable", "disable"} {
|
||||
if p, _ := escalated("systemctl", []string{verb, "x.service"}, System, 1000); p != "sudo" {
|
||||
t.Errorf("%s was not escalated", verb)
|
||||
}
|
||||
}
|
||||
if p, a := escalated("systemctl", []string{"restart", "sshd.service"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "restart", "sshd.service"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
if p, _ := escalated("systemctl", []string{"restart", "sshd.service"}, System, 0); p != "systemctl" {
|
||||
t.Error("root escalated")
|
||||
}
|
||||
for _, args := range [][]string{{"show", "sshd.service"}, {"list-units", "restart"}} {
|
||||
if p, _ := escalated("systemctl", args, System, 1000); p != "systemctl" {
|
||||
t.Errorf("a read was escalated: %v", args)
|
||||
}
|
||||
}
|
||||
if p, _ := escalated("systemctl", []string{"--user", "restart", "x.service"}, User, 1000); p != "systemctl" {
|
||||
t.Error("the user scope was escalated")
|
||||
}
|
||||
// The system journal is read as root: unescalated, an account outside the journal's group sees only
|
||||
// its own entries and every service's journal reads empty (issue 255).
|
||||
if p, a := escalated("journalctl", []string{"-u", "x"}, System, 1000); p != "sudo" || a[1] != "journalctl" {
|
||||
t.Errorf("the system journal read was not escalated: %s %v", p, a)
|
||||
}
|
||||
if p, _ := escalated("journalctl", []string{"--user", "-u", "x"}, User, 1000); p != "journalctl" {
|
||||
t.Error("the account's own journal was escalated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing.T) {
|
||||
var calls []call
|
||||
m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, &calls), 1234, "operator")
|
||||
if _, err := m.Units(User, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls[0].cmd != "systemctl" || calls[0].args[0] != "--user" {
|
||||
t.Fatalf("%+v", calls[0])
|
||||
}
|
||||
env := strings.Join(calls[0].env, "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") {
|
||||
t.Fatalf("%v", calls[0].env)
|
||||
}
|
||||
if _, err := m.Journal(User, "watcher.service", 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" {
|
||||
t.Fatalf("%+v", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSystemScopeIsGivenNoSessionWords(t *testing.T) {
|
||||
var calls []call
|
||||
if _, err := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls)).Units(System, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls[0].env != nil {
|
||||
t.Fatalf("%v", calls[0].env)
|
||||
}
|
||||
for _, a := range calls[0].args {
|
||||
if a == "--user" {
|
||||
t.Fatal("--user in a system call")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserScopeFromAnotherAccountIsRefused(t *testing.T) {
|
||||
m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, nil), 0, "root")
|
||||
if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's service manager, and this runs as root") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASystemActEscalatesAndAnswersTheStateAfter(t *testing.T) {
|
||||
var calls []call
|
||||
m := operator(fake(func(c call) Ran {
|
||||
if contains(c.args, "show") {
|
||||
return Ran{Stdout: showPackage}
|
||||
}
|
||||
return Ran{}
|
||||
}, &calls))
|
||||
r, err := m.Act(System, "restart", "sshd.service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "restart", "sshd.service"}) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRestoreNoteIsOnlyOnAUnitTheMeshDeclares(t *testing.T) {
|
||||
m := operator(fake(func(c call) Ran {
|
||||
if contains(c.args, "show") {
|
||||
return Ran{Stdout: showMesh}
|
||||
}
|
||||
return Ran{}
|
||||
}, nil))
|
||||
r, _ := m.Act(System, "stop", "showcase.service")
|
||||
if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "host restores its declared state") {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSaysWhetherTheMeshDeclaresTheUnit(t *testing.T) {
|
||||
answer := func(s string) Runner { return fake(func(call) Ran { return Ran{Stdout: s} }, nil) }
|
||||
mesh, _ := operator(answer(showMesh)).Status(System, "showcase.service")
|
||||
pkg, _ := operator(answer(showPackage)).Status(System, "sshd.service")
|
||||
none, _ := operator(answer("LoadState=not-found\nFragmentPath=\n")).Status(System, "nope.service")
|
||||
if mesh["mesh_declared"] != true || mesh["MainPID"] != "42" || pkg["mesh_declared"] != false || none["mesh_declared"] != false {
|
||||
t.Fatalf("%v %v %v", mesh, pkg, none)
|
||||
}
|
||||
if MeshUnitHeader != "# Generated by the mesh." {
|
||||
t.Fatal("the header is not the one the host writes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalsAreNamed(t *testing.T) {
|
||||
refused := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil))
|
||||
if _, err := refused.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "may not run it without a prompt: sudo: a password is required") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
missing := operator(fake(func(call) Ran { return Ran{Status: 127, Error: "ENOENT"} }, nil))
|
||||
if _, err := missing.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "sudo is not installed here") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
polkitRefused := manager(fake(func(call) Ran {
|
||||
return Ran{Status: 1, Stderr: "Failed to stop x.service: Access denied as the requested operation requires interactive authentication.\n"}
|
||||
}, nil), 0, "root")
|
||||
if _, err := polkitRefused.Act(System, "stop", "x.service"); err == nil || !strings.Contains(err.Error(), "the service manager refused the runtime's account") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
failing := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to list units: Connection timed out\n"} }, nil))
|
||||
if _, err := failing.Units(System, ""); err == nil || !strings.Contains(err.Error(), "systemctl failed (1): Failed to list units: Connection timed out") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreachableUserManagerIsSaidEvenWhenSystemctlExitsZero(t *testing.T) {
|
||||
said := "Failed to connect to user scope bus via local transport: No such file or directory\n"
|
||||
m := operator(fake(func(call) Ran { return Ran{Stderr: said} }, nil))
|
||||
if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's own service manager does not answer at /run/user/1000") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
nonzero := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: said} }, nil))
|
||||
if _, err := nonzero.Status(User, "x.service"); err == nil || !strings.Contains(err.Error(), "does not answer") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedReportsEachManagerAndOneThatDoesNotAnswerByItsError(t *testing.T) {
|
||||
m := operator(fake(func(c call) Ran {
|
||||
if c.args[0] == "--user" {
|
||||
return Ran{Status: 1, Stderr: "Failed to connect to user scope bus via local transport: No such file or directory\n"}
|
||||
}
|
||||
return Ran{Stdout: list}
|
||||
}, nil))
|
||||
r := m.Failed()
|
||||
system, _ := json.Marshal(r["system"])
|
||||
if string(system) != `[{"unit":"broken.service","load":"loaded","active":"failed","sub":"failed","description":"A broken thing"}]` {
|
||||
t.Fatalf("%s", system)
|
||||
}
|
||||
if e, ok := r["user"].(map[string]string); !ok || !strings.Contains(e["error"], "does not answer") {
|
||||
t.Fatalf("%v", r["user"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUnitsNameIsNeverAnOption(t *testing.T) {
|
||||
for _, bad := range []string{"--host=elsewhere", "a b", ""} {
|
||||
if unitArg(bad) == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
var calls []call
|
||||
m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls))
|
||||
if _, err := m.Act(System, "stop", "-H"); err == nil || len(calls) != 0 {
|
||||
t.Fatalf("an option ran as a unit: %v %d", err, len(calls))
|
||||
}
|
||||
if _, err := m.Units(System, "-x*"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := calls[0].args; a[len(a)-2] != "--" || a[len(a)-1] != "-x*" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served.
|
||||
func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Tools []string `json:"tools"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Resources []struct {
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
owns := false
|
||||
for _, r := range m.Resources {
|
||||
owns = owns || (r.Type == "package" && r.Package == "systemd")
|
||||
}
|
||||
if !owns || !contains(m.Capabilities, "package-manager") {
|
||||
t.Fatal("the manifest does not own the systemd package")
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(operator(nil)) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, v := range m.Claims[0].Serves {
|
||||
want[seat+"."+v] = true
|
||||
}
|
||||
for _, n := range m.Tools {
|
||||
want[n] = true
|
||||
}
|
||||
if !reflect.DeepEqual(served, want) {
|
||||
t.Fatalf("served %v, the manifest says %v", served, want)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes,
|
||||
// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). 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 host 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 host 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) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.",
|
||||
Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
|
||||
"lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}},
|
||||
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
|
||||
}
|
||||
// Bounded so the answer stays well below what the runtime carries back in one reply.
|
||||
n := 100
|
||||
if v, ok := a["lines"].(float64); ok && v >= 1 {
|
||||
n = min(int(v), 2000)
|
||||
}
|
||||
return m.Journal(scope, unit, n)
|
||||
}},
|
||||
{Name: "systemd_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.",
|
||||
Run: func(map[string]any) (any, error) { return m.Failed(), 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
|
||||
}
|
||||
}
|
||||
return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ()}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(fromEnv())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user