Files
mesh-host/internal/liveness/liveness.go
T
jochen 9545121ec9
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
Clear an old pass on a wait, and say a change of waits (review of #78)
A resource that passed, then waited, then failed once after its grace was
still stated healthy from the old pass; and a change of what it waits for
alone raised no change event, so the controller could miss it.
2026-10-10 21:14:14 +02:00

592 lines
20 KiB
Go

// Package liveness is the node-engine judging whether what a module runs stays up (novox/hq ADR 0240
// rule 1, to-be 48 §1 and §4, Phase A).
//
// **A module's container that crash-looped behind every passing check** is what this exists for: the
// agent server restarted about a hundred times while the mesh read it applied and its tools served, and a
// person found it reading its log for something else (research 032). The release gate judged a module by
// what the mesh saw from outside; nothing looked at what the module runs.
//
// Every long-running resource — a container that stays up, a process the mesh runs that stays up, a
// service stated `running` — is judged on every look, with no declaration:
//
// - **alive** when it is running and has not restarted twice within the settle window after its grace;
// - **unhealthy** when it is not running after its grace (`down`), or restarted twice in the window
// (`restarting`);
// - **starting** inside its grace after a start — a new build, a recreate, a restart the engine or a
// person made — in which a restart is not counted: churn that stops is not a crash loop (issue 058);
// - **held** while an open maintenance window holds it still (ADR 0189), and judged from a fresh grace
// when the window ends;
// - **unknown** when nothing could be read.
//
// **The engine counts restarts itself and keeps the count**, in a file beside the node's state, across a
// recreate of the container and across its own restarts: the runtime's count is lost on every recreate
// and its event history is a minute long on a busy machine (research 032 §2). Neither is read for a
// verdict.
//
// **It reads; it never acts** (ADR 0240 rule 6). One read of every container's state per look, one of
// every unit's per service manager — tens of milliseconds on the busiest machine — and never an
// execution inside a container. Nothing here restarts, recreates or stops anything.
package liveness
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
"github.com/novox/mesh-host/internal/declaration"
)
// The bounds of ADR 0240 rule 1 and to-be 48 §1. Grace is the default a declaration will override in
// Phase B; the settle window is the gate's bound.
const (
DefaultGrace = 60 * time.Second
SettleWindow = 10 * time.Minute
// LookEvery is how often the engine looks. A crash loop is said within the grace, two restarts and a
// look — well inside the gate's bound — and a look is one read of the runtime.
LookEvery = 15 * time.Second
)
// The kinds of long-running resource.
const (
KindContainer = "container"
KindService = "service"
KindProcess = "process"
)
// The states, as the report says them (mesh-host internal/link and the controller agree on the words).
const (
Healthy = "healthy"
Unhealthy = "unhealthy"
Starting = "starting"
Held = "held"
Unknown = "unknown"
// Waiting is a resource whose tool check says it waits for the operator, and nothing else is wrong
// (novox/hq ADR 0283): a statement, not a fault, said with what it waits for.
Waiting = "waiting"
ReasonDown = "down"
ReasonRestarting = "restarting"
)
// Resource is one long-running resource of a module, as the judge needs it.
type Resource struct {
Module string `json:"module"`
ID string `json:"id"`
Kind string `json:"kind"`
// Target is the container's name, or the unit.
Target string `json:"target"`
// Scope and User are a service's manager: "user" and the account for a unit in an account's own.
Scope string `json:"scope,omitempty"`
User string `json:"user,omitempty"`
// Check is how it is ready, as its module declared (ADR 0240 rule 2, Phase B); nil judges it alive
// or not, and nothing more.
Check *declaration.Health `json:"check,omitempty"`
}
// LongRunning is every long-running resource a declaration asks this machine to run for a module: its
// containers that stay up, its services stated running and its processes that stay up. A step, anything
// on a schedule, a service whose lifecycle is the machine's, what the mesh declares in its own right (no
// module) and what an adopted machine holds as it was found are not judged here.
func LongRunning(d *declaration.Declaration, held map[string]bool) []Resource {
var out []Resource
if d == nil {
return nil
}
for _, r := range d.Resources {
module, ok := ModuleOf(r.Identity())
if !ok || held[r.Identity()] {
continue
}
switch v := r.(type) {
case *declaration.Container:
if v.RunOnce || v.Schedule != "" {
continue
}
out = append(out, Resource{Module: module, ID: v.ID, Kind: KindContainer, Target: v.Name, Check: v.Health})
case *declaration.Service:
if v.State != "running" {
continue
}
res := Resource{Module: module, ID: v.ID, Kind: KindService, Target: v.Unit, Check: v.Health}
if v.UserScoped() {
res.Scope, res.User = declaration.ScopeUser, v.User
}
out = append(out, res)
case *declaration.Process:
if v.RunOnce || v.Schedule != "" {
continue
}
out = append(out, Resource{Module: module, ID: v.ID, Kind: KindProcess, Target: v.Name + ".service",
Check: v.Health})
}
}
return out
}
// ModuleOf is the module a resource's id belongs to: everything before its last dot — a module's name may
// carry a dot, its resources' own ids never do (the apply's moduleOf, for the same reason). False for
// what the mesh declares in its own right.
func ModuleOf(id string) (string, bool) {
if strings.HasPrefix(id, declaration.AdoptionPrefix) {
return "", false
}
at := strings.LastIndex(id, ".")
if at <= 0 {
return "", false
}
return id[:at], true
}
// Observed is one resource as one read found it.
type Observed struct {
// Found is false for a container that is not there and a unit the manager does not know.
Found bool
// Identity is what changes when the thing is made again: the container's id, the unit's invocation.
Identity string
Running bool
// Restarting is the runtime or the manager restarting it after it exited.
Restarting bool
// Restarts is the runtime's own count of the restarts it made — read only to see it move.
Restarts int64
// Started is when the runtime says the current run started, as it says it: a change with no restart
// counted is a restart somebody made, which is a new start.
Started string
// Health is the runtime's own word on the check it runs as the container's — healthy, unhealthy,
// starting — empty when the container carries none; HealthSaid what its last look printed.
Health string
HealthSaid string
}
// Runtime is what one look reads: every container's state in one read, and every unit's per manager.
// An error is "could not be read" — said as unknown, never as down.
type Runtime interface {
Containers(ctx context.Context, names []string) (map[string]Observed, error)
Units(ctx context.Context, scope, user string, units []string) (map[string]Observed, error)
}
// State is one resource's state as the judge says it.
type State struct {
Resource
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Since time.Time `json:"since"`
Streak int `json:"streak,omitempty"`
Restarts int `json:"restarts,omitempty"`
// Waits is what a resource stated Waiting waits for (ADR 0283).
Waits []health.Wait `json:"waits,omitempty"`
}
// CheckOf and NeedsOf are a stated resource's declared check, in a word, and the provision it exercises.
func (s State) CheckOf() string {
if s.Check == nil {
return ""
}
return s.Check.Kind
}
func (s State) NeedsOf() string {
if s.Check == nil {
return ""
}
return s.Check.Needs
}
// Statement is one look at every long-running resource: when, and each resource's state.
type Statement struct {
At time.Time
Resources []State
}
// Healthy says every resource in it is healthy.
func (s Statement) Healthy() bool {
for _, r := range s.Resources {
if r.State != Healthy {
return false
}
}
return true
}
// kept is what the judge keeps about one resource, on disk, across its own restarts.
type kept struct {
Resource
// Seen says a read has found it at least once; Identity, RuntimeRestarts and RuntimeStarted are what
// the last read found, to see a restart or a recreate by.
Seen bool `json:"seen,omitempty"`
Identity string `json:"identity,omitempty"`
RuntimeRestarts int64 `json:"runtime-restarts,omitempty"`
RuntimeStarted string `json:"runtime-started,omitempty"`
// Started is when the current start began: its grace is counted from here.
Started time.Time `json:"started"`
// Counted is every restart counted after a grace, kept across recreates; Recent those inside the
// settle window since the current start.
Counted int `json:"counted,omitempty"`
Recent []time.Time `json:"recent,omitempty"`
WasHeld bool `json:"held,omitempty"`
State string `json:"state,omitempty"`
Reason string `json:"reason,omitempty"`
Since time.Time `json:"since"`
Streak int `json:"streak,omitempty"`
// Readiness since the current start (Phase B): whether the declared check has passed, how many of
// its looks after the grace failed in a row, and what the last failing one said.
Passed bool `json:"passed,omitempty"`
Failing int `json:"failing,omitempty"`
Why string `json:"why,omitempty"`
// Waits is what the last look of a tool check said the resource waits for (ADR 0283), nil when it said
// healthy or unhealthy; Why then is the answer's own words.
Waits []health.Wait `json:"waits,omitempty"`
// probing says a look of the engine's own is under way; due when the next is.
probing bool
due time.Time
}
// file is the judge's file beside the node's state.
type file struct {
Resources []Resource `json:"resources"`
Kept map[string]*kept `json:"kept"`
}
// FileName is the judge's file, beside the node's state.
const FileName = "liveness.json"
// Judge is the one judge of liveness on a machine. Safe for the apply and the looking loop at once.
type Judge struct {
path string
runtime Runtime
// Now, Grace and Settle are the clock and the bounds; replaced in tests.
Now func() time.Time
Grace time.Duration
Settle time.Duration
// HeldNow is the containers an open maintenance window holds still, by runtime name. Nil holds none.
HeldNow func(now time.Time) map[string]bool
mu sync.Mutex
f file
dirty bool
// said is the statement said last, to know a change by.
said map[string]string
// Probes make the looks the engine makes itself — http, tcp and a module's tool (Phase B). Nil
// makes them from this machine.
Probes *Probes
// Budget is the most looks a minute every declared check together may cost (ADR 0240: never more
// than measured on the busiest machine); over it the engine's own looks are spaced out.
Budget int
}
// Open is the judge whose file is at path, reading what it kept. A file that cannot be read is said and
// started afresh: a count lost is a crash loop judged from now, never a machine left unjudged.
func Open(path string, rt Runtime) (*Judge, error) {
j := &Judge{path: path, runtime: rt, Now: time.Now, Grace: DefaultGrace, Settle: SettleWindow,
f: file{Kept: map[string]*kept{}}, said: map[string]string{}, Budget: Budget}
raw, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
return j, nil
case err != nil:
return j, fmt.Errorf("the liveness kept at %s cannot be read, so restarts are counted from now: %w", path, err)
}
var f file
if err := json.Unmarshal(raw, &f); err != nil {
return j, fmt.Errorf("the liveness kept at %s cannot be read, so restarts are counted from now: %w", path, err)
}
if f.Kept == nil {
f.Kept = map[string]*kept{}
}
j.f = f
return j, nil
}
// Set is what this machine runs now, from the declaration the apply just applied. A resource no longer
// declared is forgotten; one newly declared is judged from its first look.
func (j *Judge) Set(resources []Resource) {
j.mu.Lock()
defer j.mu.Unlock()
sorted := append([]Resource(nil), resources...)
sort.Slice(sorted, func(a, b int) bool {
if sorted[a].Module != sorted[b].Module {
return sorted[a].Module < sorted[b].Module
}
return sorted[a].ID < sorted[b].ID
})
declared := map[string]bool{}
for _, r := range sorted {
declared[r.ID] = true
if k, ok := j.f.Kept[r.ID]; ok && (k.Kind != r.Kind || k.Target != r.Target || k.Scope != r.Scope || k.User != r.User) {
// The same id now names another thing: judged as a new one, its count kept.
counted := k.Counted
j.f.Kept[r.ID] = &kept{Resource: r, Counted: counted}
} else if ok && !sameCheck(k.Check, r.Check) {
// Its check changed — another kind, another port, another timing: what the old one found
// says nothing about the new, which looks again at once.
k.Check, k.Passed, k.Failing, k.Why, k.Waits, k.due = r.Check, false, 0, "", nil, time.Time{}
}
}
for id := range j.f.Kept {
if !declared[id] {
delete(j.f.Kept, id)
}
}
j.f.Resources = sorted
j.dirty = true
}
// Look reads every long-running resource once, judges each, keeps what it counted, and answers the
// statement and whether any resource's state or reason changed since the last look.
func (j *Judge) Look(ctx context.Context) (Statement, bool) {
j.mu.Lock()
defer j.mu.Unlock()
now := j.Now()
var held map[string]bool
if j.HeldNow != nil {
held = j.HeldNow(now)
}
observed, unread := j.read(ctx)
st := Statement{At: now}
changed := false
seen := map[string]bool{}
for _, r := range j.f.Resources {
k := j.f.Kept[r.ID]
if k == nil {
k = &kept{Resource: r}
j.f.Kept[r.ID] = k
}
k.Resource = r
why, blind := unread[groupOf(r)]
o := observed[keyOf(r)]
j.judge(k, o, now, r.Kind == KindContainer && held[r.Target], blind, why)
seen[r.ID] = true
st.Resources = append(st.Resources, State{Resource: k.Resource, State: k.State, Reason: k.Reason, Since: k.Since,
Streak: k.Streak, Restarts: k.Counted, Waits: waitsOf(k)})
// What it waits for is part of what is said: a change of waits alone is a change.
word := k.State + "/" + k.Reason + "/" + waitsWord(waitsOf(k))
if j.said[r.ID] != word {
changed = true
j.said[r.ID] = word
}
}
for id := range j.said {
if !seen[id] {
delete(j.said, id)
changed = true
}
}
j.save()
return st, changed
}
// judge is one resource's verdict on one look.
func (j *Judge) judge(k *kept, o Observed, now time.Time, held, blind bool, why string) {
set := func(state, reason string) {
if k.State != state || k.Reason != reason || k.Since.IsZero() {
k.State, k.Reason, k.Since = state, reason, now
}
if state == Unhealthy {
k.Streak++
} else {
k.Streak = 0
}
j.dirty = true
}
fresh := func(o Observed, started time.Time) {
k.Seen, k.Identity, k.RuntimeRestarts, k.RuntimeStarted = o.Found, o.Identity, o.Restarts, o.Started
k.Started, k.Recent = started, nil
// Every start is judged ready afresh (to-be 48 §4).
k.Passed, k.Failing, k.Why, k.Waits, k.due = false, 0, "", nil, time.Time{}
}
// **Held is neither alive nor dead**, and the window ending is a start: judged from a fresh grace.
if held {
k.WasHeld = true
set(Held, "")
return
}
if k.WasHeld {
k.WasHeld = false
fresh(o, now)
}
if blind {
set(Unknown, why)
return
}
switch {
case !k.Seen:
// First sight: of a resource just applied, or of one running before this engine judged. Grace is
// counted from when the runtime says it started, where it says, so an engine restarted beside
// a long-running container does not call it starting for a minute.
started := now
if t, err := time.Parse(time.RFC3339Nano, o.Started); err == nil && t.Before(now) && t.Year() > 1 {
started = t
}
switch {
case o.Found:
fresh(o, started)
case k.Started.IsZero():
// Not there at its first look: its grace runs from now, and it is down after it.
k.Started = now
}
case o.Found && o.Identity != "" && o.Identity != k.Identity && o.Restarts <= k.RuntimeRestarts,
o.Found && o.Identity == k.Identity && o.Restarts == k.RuntimeRestarts && o.Started != k.RuntimeStarted:
// Made again — recreated by an apply, or restarted by somebody — and not by the runtime's policy:
// a new start, judged from a fresh grace. The count is kept.
fresh(o, now)
case o.Found && o.Restarts > k.RuntimeRestarts:
// The runtime restarted it after it exited. Counted only after its grace.
delta := int(o.Restarts - k.RuntimeRestarts)
k.Identity, k.RuntimeRestarts, k.RuntimeStarted = o.Identity, o.Restarts, o.Started
if !now.Before(k.Started.Add(j.Grace)) {
k.Counted += delta
for i := 0; i < delta; i++ {
k.Recent = append(k.Recent, now)
}
}
j.dirty = true
}
// Restarts older than the settle window no longer say anything.
recent := k.Recent[:0]
for _, t := range k.Recent {
if now.Sub(t) <= j.Settle {
recent = append(recent, t)
}
}
k.Recent = recent
inGrace := now.Before(k.Started.Add(j.graceOf(k.Resource)))
switch {
case len(k.Recent) >= 2:
set(Unhealthy, ReasonRestarting)
case !inGrace && (!o.Found || !o.Running):
if o.Restarting {
set(Unhealthy, ReasonRestarting)
} else {
set(Unhealthy, ReasonDown)
}
case k.Check != nil:
// Alive, or still in its grace: how ready it is is the declared check's to say.
set(ready(k, o, inGrace))
case inGrace:
set(Starting, "")
default:
set(Healthy, "")
}
}
// graceOf is a resource's grace: its declared one, or the default (to-be 48 §1).
func (j *Judge) graceOf(r Resource) time.Duration {
if r.Check != nil {
return r.Check.GraceOf()
}
return j.Grace
}
// read is one read of everything: every container at once, every unit per manager. unread names each
// group that could not be read, with why.
func (j *Judge) read(ctx context.Context) (map[string]Observed, map[string]string) {
observed := map[string]Observed{}
unread := map[string]string{}
groups := map[string][]Resource{}
var order []string
for _, r := range j.f.Resources {
g := groupOf(r)
if _, ok := groups[g]; !ok {
order = append(order, g)
}
groups[g] = append(groups[g], r)
}
for _, g := range order {
rs := groups[g]
targets := make([]string, 0, len(rs))
for _, r := range rs {
targets = append(targets, r.Target)
}
var found map[string]Observed
var err error
if rs[0].Kind == KindContainer {
found, err = j.runtime.Containers(ctx, targets)
} else {
found, err = j.runtime.Units(ctx, rs[0].Scope, rs[0].User, targets)
}
if err != nil {
unread[g] = firstLine(err.Error())
continue
}
for _, r := range rs {
observed[keyOf(r)] = found[r.Target]
}
}
return observed, unread
}
// groupOf is the one read a resource is in: the containers, or one service manager's units.
func groupOf(r Resource) string {
if r.Kind == KindContainer {
return KindContainer
}
return "units:" + r.Scope + ":" + r.User
}
func keyOf(r Resource) string { return groupOf(r) + "/" + r.Target }
// save keeps what was judged, when anything moved. Written whole and renamed, so a reader never sees
// half of it; a failure is said by the next Open, which counts from then.
func (j *Judge) save() {
if !j.dirty || j.path == "" {
return
}
raw, err := json.Marshal(j.f)
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(j.path), 0o700); err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(j.path), ".liveness-*.json")
if err != nil {
return
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return
}
if err := tmp.Close(); err != nil {
return
}
if os.Rename(tmp.Name(), j.path) == nil {
j.dirty = false
}
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}
// waitsOf is what a resource stated Waiting waits for, and nothing for any other state.
func waitsOf(k *kept) []health.Wait {
if k.State != Waiting {
return nil
}
return append([]health.Wait(nil), k.Waits...)
}
// waitsWord is a resource's waits in one comparable word.
func waitsWord(ws []health.Wait) string {
var b strings.Builder
for _, w := range ws {
b.WriteString(w.Part + "|" + w.Secret + "|" + w.Setting + "|" + w.What + ";")
}
return b.String()
}