Files
mesh-host/internal/witness/contract.go
T
jochen 0c405b70cc Roll a core build back by a witness that is not the new build (hq to-be 45 Phase 4)
The launcher trusted a counter only a by-hand reconcile ever cleared and a
known-good nothing in the daemon wrote, so no machine could roll its host back;
the controller and the node tools were replaced in place with nothing kept.

- The launcher runs a delivered host that is not known-good on trial: one that
  crashes, stops for nothing, or does not report within ten minutes goes back
  to known-good, once per version, recorded in rolled-back. The host proves
  itself when the mesh takes a report under its own build, says every standing
  verdict on its reports, never stands aside for a rolled-back version, and
  restarts its service once when its launcher was replaced on disk.
- The engine keeps the controller's and the node tools' previous build beside
  the new one and judges the new one: the lease taken by the controller it
  started (read-only direct get of mesh-controller_lease/holder), or this
  machine's runtime answering $SRV.PING.node-tools.<node>, within sixty seconds
  of time it could ask. Not healthy: the previous restored, once, said. Proved:
  the previous deleted. A build declared not-reversible is never rolled back.
- Retire never removes a version newer than the running one.
2026-10-06 18:23:56 +02:00

156 lines
6.2 KiB
Go

// Package witness is the node-engine watching a core build it placed — the controller on the control
// node, the node tools on every machine — and restoring the build before it when the new one is not
// healthy in bound (novox/hq to-be 45 §8, ADR 0227 rule 8: the component being replaced is never the
// only witness of its successor).
//
// **The contract is this file.** What the engine reads to call a build healthy, from where, in what
// shape, within which bound — said once, here, and held by contract_test.go. The controller's side
// writes what is read here (mesh-controller internal/lease Holder); a change on either side is a
// change to this file and its test.
package witness
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/novox/mesh-host/internal/link"
)
// The two witnesses, as a process declares which watches it (`witness`). A process that says none is
// placed as ever; one that says nothing is judged by its name's default (Default).
const (
// ByLease: healthy when the controller this machine started holds the controller's lease.
ByLease = "lease"
// ByPing: healthy when this machine's runtime answers the services protocol's PING.
ByPing = "ping"
// ByNone: not judged.
ByNone = "none"
)
// The controller's lease, as the host reads it (novox/hq ADR 0229): the key `holder` in the bucket
// `mesh-controller_lease`, whose keys live fifteen seconds unless renewed, renewed every five.
const (
LeaseBucket = "mesh-controller_lease"
LeaseKey = "holder"
// LeaseAge is the bucket's age for its key. A holder whose last renewal is older than this is
// not holding it, whatever the key still says.
LeaseAge = 15 * time.Second
// Skew is how far the controller's clock and this host's may disagree about when it took the
// lease. One machine, one clock, in practice: a margin, not a tolerance.
Skew = 2 * time.Second
)
// Bounds (to-be 45 §8). A build is given Within of time the witness could ask in; asked every Every.
const (
// ControllerWithin: the controller holds the lease within sixty seconds of starting.
ControllerWithin = 60 * time.Second
// NodeToolsWithin: the runtime is announced and answering within sixty seconds of starting,
// each PING answered within PingWithin.
NodeToolsWithin = 60 * time.Second
PingWithin = 5 * time.Second
Every = 5 * time.Second
// GiveUp is how long a witness goes on when it cannot ask at all before it says the build is
// unwitnessed: the grant missing, the bucket missing, the bus away the whole time.
GiveUp = 30 * time.Minute
)
// ControllerLease is the lease's value as the controller writes it — mesh-controller internal/lease
// Holder, field for field by its JSON names. Only what the witness reads is required; a field the
// controller adds later is ignored here.
type ControllerLease struct {
// Instance names one controller process: "controller@<machine> pid <pid> since <RFC 3339>".
Instance string `json:"instance"`
// Host is the machine it runs on, as its own hostname says.
Host string `json:"host,omitempty"`
// Build is the controller's build as it knows it; not read here — the toolchain stamps no
// version, so it says "development build" — and a bundle's identity is its digest, which the
// host already knows.
Build string `json:"build,omitempty"`
Epoch uint64 `json:"epoch,omitempty"`
// Taken is when this instance took the key, Renewed when it last renewed it, by its own clock.
Taken time.Time `json:"taken"`
Renewed time.Time `json:"renewed"`
}
// ParseLease reads the key's value.
func ParseLease(value []byte) (ControllerLease, error) {
var l ControllerLease
if err := json.Unmarshal(value, &l); err != nil {
return ControllerLease{}, fmt.Errorf("the lease's holder is not the controller's lease value: %w", err)
}
return l, nil
}
// HeldBySince says whether the lease is held by a controller on machine `host` that took it at or
// after `since` — the controller this machine started then, and not the one before it — and is
// held now. Why says what it saw when it is not.
func (l ControllerLease) HeldBySince(host string, since, now time.Time) (bool, string) {
switch {
case l.Instance == "":
return false, "the lease names no holder"
case host != "" && !sameMachine(l.Host, host):
return false, fmt.Sprintf("the lease is held by %s, on %s and not this machine", l.Instance, l.Host)
case l.Taken.Before(since.Add(-Skew)):
return false, fmt.Sprintf("the lease is held by %s, taken %s — before the new build started at %s",
l.Instance, l.Taken.UTC().Format(time.RFC3339), since.UTC().Format(time.RFC3339))
case now.Sub(latest(l.Taken, l.Renewed)) > LeaseAge:
return false, fmt.Sprintf("the lease names %s and was last renewed %s ago, past its %s",
l.Instance, now.Sub(latest(l.Taken, l.Renewed)).Round(time.Second), LeaseAge)
}
return true, fmt.Sprintf("%s holds the lease, epoch %d", l.Instance, l.Epoch)
}
// sameMachine compares two hostnames as names, so a short name and its fully qualified form agree.
func sameMachine(a, b string) bool {
short := func(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if i := strings.IndexByte(s, '.'); i > 0 {
s = s[:i]
}
return s
}
return short(a) == short(b)
}
func latest(a, b time.Time) time.Time {
if b.After(a) {
return b
}
return a
}
// NodeToolsService is the runtime's name on the services protocol, and its instance is this
// machine's node name: what `$SRV.PING.<service>.<node>` asks, so only this machine's runtime can
// answer (mesh-tools node-tools internal/runtime, announce.Service{Name: module, ID: node}).
const NodeToolsService = "node-tools"
// Default is the witness a process is judged by when it names none: the two core processes the mesh
// composes, by the names it composes them under, and nothing else.
func Default(process string) string {
switch process {
case "mesh-controller":
return ByLease
case NodeToolsService:
return ByPing
}
return ByNone
}
// Within is the bound for a witness.
func Within(by string) time.Duration {
if by == ByLease {
return ControllerWithin
}
return NodeToolsWithin
}
// Component names what a witness judges, as a rollback says it.
func Component(by string) string {
if by == ByLease {
return link.ComponentController
}
return link.ComponentNodeTools
}