Files
mesh-host/internal/liveness/runtime.go
T
jochen 1fc1e74cc3
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/a-module-says-how-it-is-healthy delivered: every member is delivered
Judge whether what a module runs stays up, and say it (hq ADR 0240, to-be 48 Phase A)
A container that crash-looped after its compose applied passed every check the
gate had: nothing looked at what a module runs. The node-engine now judges every
long-running resource on every look — one read of the runtime, one per service
manager — keeps the restarts it counts across recreates and its own restarts,
and says the state in every report and as an event on change, again every minute
while not healthy. It reads only; nothing is restarted for being unhealthy.
2026-10-07 02:28:15 +02:00

135 lines
4.8 KiB
Go

package liveness
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
)
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look
// reads the machine exactly as an apply does.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Exec reads the container runtime and the service manager through their command lines: one inspect of
// every container, one show of every unit per manager. **Reads only** — the whole of what this file may
// ask either of them is `container inspect`, `version` and `show` (ADR 0240 rule 6, and a test holds it).
type Exec struct {
Run Runner
mu sync.Mutex
// cri is the runtime that answered, once one has.
cri string
}
// runtimes are the container runtimes the apply knows, asked for their version in the form each
// understands (apply.containerRuntimes): the first that answers is the machine's.
var runtimes = []struct {
command string
probe []string
}{
{"docker", []string{"version", "--format", "{{.Server.Version}}"}},
{"podman", []string{"version", "--format", "{{.Version}}"}},
}
func (e *Exec) runtime(ctx context.Context) (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.cri != "" {
return e.cri, nil
}
var tried []string
for _, rt := range runtimes {
if _, err := e.Run(ctx, rt.command, rt.probe...); err == nil {
e.cri = rt.command
return rt.command, nil
}
tried = append(tried, rt.command)
}
return "", fmt.Errorf("no container runtime answers on this machine (tried %s)", strings.Join(tried, ", "))
}
// inspectFormat is what one inspect says of each container: its name, id, status, the runtime's restart
// count and when its current run started.
const inspectFormat = "{{.Name}}\t{{.Id}}\t{{.State.Status}}\t{{.RestartCount}}\t{{.State.StartedAt}}"
// Containers is one inspect of every container named. A name the runtime does not have is not found;
// a runtime that does not answer is an error — unknown, never down.
func (e *Exec) Containers(ctx context.Context, names []string) (map[string]Observed, error) {
out := map[string]Observed{}
if len(names) == 0 {
return out, nil
}
cri, err := e.runtime(ctx)
if err != nil {
return nil, err
}
args := append([]string{"container", "inspect", "--format", inspectFormat}, names...)
said, err := e.Run(ctx, cri, args...)
if err != nil && !missing(err) {
// Asked again next look, in case another runtime is what answers now.
e.mu.Lock()
e.cri = ""
e.mu.Unlock()
return nil, fmt.Errorf("the container runtime could not be read: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(said), "\n") {
parts := strings.Split(line, "\t")
if len(parts) < 5 {
continue
}
name := strings.TrimPrefix(strings.TrimSpace(parts[0]), "/")
restarts, _ := strconv.ParseInt(strings.TrimSpace(parts[3]), 10, 64)
status := strings.TrimSpace(parts[2])
out[name] = Observed{Found: true, Identity: strings.TrimSpace(parts[1]), Running: status == "running",
Restarting: status == "restarting", Restarts: restarts, Started: strings.TrimSpace(parts[4])}
}
return out, nil
}
// missing is an inspect that failed only because a name is not there: what it printed for the others is
// still the answer.
func missing(err error) bool {
s := strings.ToLower(err.Error())
return strings.Contains(s, "no such container") || strings.Contains(s, "no such object")
}
// unitProperties are what one show says of each unit.
const unitProperties = "Id,LoadState,ActiveState,SubState,NRestarts,InvocationID"
// Units is one show of every unit named, in the manager it is in: the machine's, or an account's own.
func (e *Exec) Units(ctx context.Context, scope, user string, units []string) (map[string]Observed, error) {
out := map[string]Observed{}
if len(units) == 0 {
return out, nil
}
args := []string{"show", "--property=" + unitProperties, "--"}
if scope == "user" && user != "" {
args = append([]string{"--user", "--machine=" + user + "@"}, args...)
}
said, err := e.Run(ctx, "systemctl", append(args, units...)...)
if err != nil {
return nil, fmt.Errorf("the service manager could not be read: %w", err)
}
blocks := strings.Split(strings.TrimSpace(said), "\n\n")
if len(blocks) != len(units) {
return nil, errors.New("the service manager answered for a different number of units than were asked")
}
for i, block := range blocks {
props := map[string]string{}
for _, line := range strings.Split(block, "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
props[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
restarts, _ := strconv.ParseInt(props["NRestarts"], 10, 64)
out[units[i]] = Observed{Found: props["LoadState"] != "not-found", Identity: props["InvocationID"],
Running: props["ActiveState"] == "active",
Restarting: props["ActiveState"] == "activating" && props["SubState"] == "auto-restart",
Restarts: restarts}
}
return out, nil
}