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.
This commit is contained in:
jochen
2026-10-06 18:23:56 +02:00
parent 8d853791b2
commit 0c405b70cc
23 changed files with 2636 additions and 115 deletions
+29 -6
View File
@@ -648,8 +648,10 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw
//
// A failure is said and does not fail the apply, for the reason above: what is lost is disk, and
// hiding it would make a machine quietly fill up.
if version != "" {
if retired, err := upgrade.Retire(upgrade.VersionsDir(""), version); err != nil {
// Asked with the version this host RUNS, read from where it sits: the link-time stamp names no
// delivered version, and retiring around a name that is not there would remove the one running.
if v := runningVersion(); v != "" {
if retired, err := upgrade.Retire(upgrade.VersionsDir(""), v); err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not retire an older host: %v\n", err)
} else if len(retired) > 0 {
fmt.Fprintf(os.Stderr, "mesh-host: retired the host version(s) %s\n",
@@ -1078,7 +1080,7 @@ func runLink(ctx context.Context, opts options) error {
// a delivered host never matched the newest delivered version, so it stood aside on every
// push for ever, and standing aside then cancelled the report, so the mesh never heard from it
// again (novox/hq 04-ISSUES/163).
switch next, waiting, err := upgrade.Successor(upgrade.VersionsDir(""), runningVersion()); {
switch next, waiting, err := upgrade.Successor(upgrade.VersionsDir(""), runningVersion(), rolledBackHosts(opts.state)...); {
case err != nil:
// Said, not fatal. A host that cannot read the delivered versions is still running this
// machine correctly; what it has lost is the ability to be replaced.
@@ -1102,6 +1104,7 @@ func runLink(ctx context.Context, opts options) error {
// its firewall, its outward links — are said by the same worker, in the order they are made
// (novox/hq ADR 0100, issue 267).
watch := &adoptionWatch{}
proof := &proving{statePath: opts.state, version: runningVersion(), say: say}
queue := &link.Queue{
Membership: membership,
Apply: applier,
@@ -1109,7 +1112,11 @@ func runLink(ctx context.Context, opts options) error {
News: func(r link.Report) bool { return worthSaying(r) && watch.differs(r) },
// Counted as said only once the broker has taken it: queued and lost — the link down, the
// publish refused — the change would never be said again (novox/hq ADR 0100).
Heard: watch.said,
// And the first account under this build is what proves it to the launcher (to-be 45 §8).
Heard: func(r link.Report) {
watch.said(r)
proof.heard(r)
},
Unsaid: unsaidBeside(opts.state),
Numbers: numbersBeside(opts.state),
Say: say,
@@ -1123,8 +1130,18 @@ func runLink(ctx context.Context, opts options) error {
// **A host starting is a reason to reconcile** (to-be 45 §6: the self-update hand-over is one of
// the four): its scheduled steps are armed from the declaration the node kept by the first apply,
// and a successor that waited five minutes for it left them unarmed for five.
queue.ReconcileDue()
//
// **A host on trial says its account whether or not it is news** (to-be 45 §8): the launcher
// waits for this build to report its declaration, and a converged machine with nothing new to
// say would otherwise not say anything until the mesh next sent it something.
if unproved(opts.state, runningVersion()) {
queue.ReportAsked()
} else {
queue.ReconcileDue()
}
go holdTheMachine(ctx, queue)
// And the core builds this host placed are judged, whichever host placed them (to-be 45 §8).
go watchWhatThisHostPlaced(aside, mine.Node, queue, say)
held := link.HoldRoused(aside, membership, queue, say, opts.timeout, rousedBySignal(ctx))
// The act in hand finishes and is kept before this process exits: an apply that stood aside with
@@ -1225,6 +1242,10 @@ func adoptionFingerprint(r link.Report) string {
for _, f := range r.Filters {
parts = append(parts, "filter "+f.Owner+" "+f.Where+" "+f.Refuses)
}
// And a witness's verdict (to-be 45 §8): one reached or one ended is said at the next reconcile.
for _, v := range r.Rollbacks {
parts = append(parts, fmt.Sprintf("rollback %s %s %s %s", v.Component, v.From, v.To, v.Outcome))
}
if r.FoundFirewall != nil {
parts = append(parts, fmt.Sprintf("found-firewall %s active=%v retired-by=%s", r.FoundFirewall.Kind,
r.FoundFirewall.Active, r.FoundFirewall.RetiredBy))
@@ -1369,7 +1390,8 @@ func worthSaying(report link.Report) bool {
return false
}
return len(report.Held) > 0 || report.Firewall != "" || len(report.Outward) > 0 ||
len(report.Filters) > 0 || report.FoundFirewall != nil || len(report.Windows) > 0
len(report.Filters) > 0 || report.FoundFirewall != nil || len(report.Windows) > 0 ||
len(report.Rollbacks) > 0
}
// announceOr is what the apply writes its detail with, given what the caller has to say things with.
@@ -1528,6 +1550,7 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
}
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw), Order: order, Host: runningVersion(),
Rollbacks: standingRollbacks(opts.state), Witness: link.WitnessContract,
Profile: profileAsReported(profile.Detect(ctx, profile.Default(nil), opts.timeout))}
// Which of this machine's links face outside, for the filter the mesh writes around them
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
"github.com/novox/mesh-host/internal/witness"
)
// The node-engine's part in core upgrades that roll back (novox/hq to-be 45 §8, ADR 0227 rule 8):
// proving its own build to the launcher that witnesses it, witnessing the controller and the node
// tools it places, and saying every verdict on its reports.
// standingRollbacks is every verdict that still stands on this machine: the launcher's about this
// host, and the engine's about the processes it witnesses. Said on every report.
func standingRollbacks(statePath string) []link.Rollback {
var out []link.Rollback
verdicts, err := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
}
for _, v := range verdicts {
out = append(out, link.Rollback{Component: link.ComponentEngine, From: v.From, To: v.To,
Outcome: v.Outcome, Why: v.Why, At: v.At})
}
return append(out, witness.Standing(apply.DaemonRoot())...)
}
// rolledBackHosts are the host versions the launcher will not start again, so this host never stands
// aside for one.
func rolledBackHosts(statePath string) []string {
verdicts, _ := upgrade.ReadRolledBack(upgrade.RolledBackPath(statePath))
return upgrade.RolledBackVersions(verdicts)
}
// proving is this host waiting for the mesh to take a report it made under its own build — the
// evidence its launcher's trial waits for — and acting on it once.
type proving struct {
statePath string
version string
say func(string)
once sync.Once
}
// unproved says whether this host's version is not yet known-good here: a host on trial.
func unproved(statePath, version string) bool {
known, err := upgrade.ReadKnownGood(upgrade.KnownGoodPath(statePath))
return err != nil || known != version
}
// heard is told every account of the machine the mesh has taken. The first one about a declaration,
// made by this build, proves it.
func (p *proving) heard(r link.Report) {
if r.Declared == "" || r.Refused != "" || r.Host != p.version {
return
}
p.once.Do(func() {
retired, err := upgrade.Proved(p.statePath, upgrade.VersionsDir(""), p.version)
if err != nil {
p.say(fmt.Sprintf("this host reported under its own build %s, and proving it was not complete: %v", p.version, err))
}
if len(retired) > 0 {
p.say(fmt.Sprintf("host %s is proved; retired the host version(s) %s", p.version, strings.Join(retired, ", ")))
}
renewLauncher(p.say)
})
}
// The launcher, as the service manager runs it.
const (
launcherPath = upgrade.DefaultLibexec + "/launch"
hostUnit = "nox-mesh-host.service"
)
// renewLauncher asks the service manager to restart this host's service when the launcher running
// it was replaced on disk since it started (novox/hq to-be 45 §8): a delivered launcher otherwise
// takes effect only at the next boot, and the witness it carries with it. A launcher from
// this one on runs its successor itself, at the host's next clean exit; this is for the launcher
// before it. Said, and only when it is certain: the parent is the launcher, and the file it reads
// is gone from under it.
func renewLauncher(say func(string)) {
if _, err := os.Stat("/run/systemd/system"); err != nil {
return
}
if !launcherReplaced(os.Getppid(), "/proc", launcherPath) {
return
}
say("the launcher running this host was replaced on disk; asking the service manager to run the new one")
if _, err := apply.ExecRunner(context.Background(), "systemctl", "restart", "--no-block", hostUnit); err != nil {
say("could not ask for the new launcher, so it runs from the next boot: " + err.Error())
}
}
// launcherReplaced is whether process pid is a shell reading the launcher at path, from a file that
// is no longer there — the one a delivery renamed a new launcher over.
func launcherReplaced(pid int, proc, path string) bool {
if pid <= 1 {
return false
}
base := filepath.Join(proc, strconv.Itoa(pid))
cmdline, err := os.ReadFile(filepath.Join(base, "cmdline"))
if err != nil || !strings.Contains(string(cmdline), path) {
return false
}
fds, err := os.ReadDir(filepath.Join(base, "fd"))
if err != nil {
return false
}
for _, fd := range fds {
target, err := os.Readlink(filepath.Join(base, "fd", fd.Name()))
if err == nil && target == path+" (deleted)" {
return true
}
}
return false
}
// watchWhatThisHostPlaced judges the core builds this host placed, until ctx ends (to-be 45 §8).
func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Queue, say func(string)) {
host, _ := os.Hostname()
w := &witness.Watcher{
Root: apply.DaemonRoot(), Node: node, Host: host,
Asker: func() link.Asker { return queue.Asker() },
Run: witness.Runner(apply.ExecRunner),
Hold: func(f func()) {
applying.Lock()
defer applying.Unlock()
f()
},
// Said now, not at the next report: the verdict is on every report from here, and the mesh
// is asked for one at once.
Concluded: func(link.Rollback) { queue.ReportAsked() },
Say: say,
}
w.Watch(ctx)
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/upgrade"
)
// The first account the mesh takes from this build, about a declaration, proves it to the launcher;
// nothing else does — a refusal, a report about no declaration, another build's report (to-be 45 §8).
func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
t.Setenv("MESH_HOST_LIBEXEC", t.TempDir())
var said []string
p := &proving{statePath: state, version: "2.0", say: func(s string) { said = append(said, s) }}
for _, r := range []link.Report{
{Host: "2.0", Refused: "no"},
{Host: "2.0"},
{Host: "1.0", Declared: "abc"},
} {
p.heard(r)
if !unproved(state, "2.0") {
t.Fatalf("%+v proved this build", r)
}
}
p.heard(link.Report{Host: "2.0", Declared: "abc"})
if unproved(state, "2.0") {
t.Fatalf("an account of a declaration under this build did not prove it (%v)", said)
}
if got, _ := upgrade.ReadKnownGood(upgrade.KnownGoodPath(state)); got != "2.0" {
t.Fatalf("known-good is %q", got)
}
}
// Every verdict that stands is said, and a reconcile that has one says it unasked.
func TestAStandingRollbackIsSaidOnEveryReport(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
line := "2.0\t1.0\t1759744800\trolled-back\tit did not report within 600s of starting\n"
if err := os.WriteFile(upgrade.RolledBackPath(state), []byte(line), 0o644); err != nil {
t.Fatal(err)
}
got := standingRollbacks(state)
if len(got) == 0 || got[0].Component != link.ComponentEngine || got[0].From != "2.0" || got[0].To != "1.0" ||
got[0].Outcome != link.RolledBack {
t.Fatalf("what the report says is %+v", got)
}
if !worthSaying(link.Report{Rollbacks: got}) {
t.Fatal("a reconcile with a rollback standing does not say it")
}
if adoptionFingerprint(link.Report{Rollbacks: got}) == adoptionFingerprint(link.Report{}) {
t.Fatal("a rollback reached or ended is not news to the reconcile")
}
}
// The launcher running this host is known to have been replaced only when it certainly was: a shell
// reading the launcher, from a file renamed over. Asked of a real process, not a model of one.
func TestAReplacedLauncherIsSeen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launch")
if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 5\necho done\n"), 0o755); err != nil {
t.Fatal(err)
}
shell := exec.Command("sh", path)
if err := shell.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = shell.Process.Kill(); _ = shell.Wait() })
time.Sleep(200 * time.Millisecond)
if launcherReplaced(shell.Process.Pid, "/proc", path) {
t.Fatal("a launcher still on disk reads as replaced")
}
// Delivered as the mesh writes a file: a new one renamed over it.
if err := os.WriteFile(path+".new", []byte("#!/bin/sh\necho new\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+".new", path); err != nil {
t.Fatal(err)
}
if !launcherReplaced(shell.Process.Pid, "/proc", path) {
fds, _ := os.ReadDir(filepath.Join("/proc", strconv.Itoa(shell.Process.Pid), "fd"))
t.Skipf("this shell does not keep its script open (%d fds), so a replaced launcher cannot be seen here", len(fds))
}
if launcherReplaced(shell.Process.Pid, "/proc", filepath.Join(dir, "other")) {
t.Fatal("a process reading another script reads as this launcher")
}
}
+81 -12
View File
@@ -8,9 +8,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/witness"
)
// Running the mesh's own code, without the module choosing how.
@@ -105,20 +107,50 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
return out, nil
}
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
// previous version would be loaded by a runtime that walks a directory.
if err := os.RemoveAll(at); err != nil {
return out, err
// **A core process keeps the build before it until the new one is proved** (novox/hq to-be 45
// §8): the engine's witness judges the new build and restores the kept one when it is not healthy
// in bound. Placing decides what happens to the directory first — the running build moved aside,
// a build on trial discarded, a build rolled back here refused, the running build kept when it is
// the one declared — and keeps that, so a restoration later knows which build is where.
by := witnessOf(r)
placing := witness.Placing{Unpack: true}
if by == witness.ByNone {
// Nothing judged. Whatever was kept for a process that was judged before goes.
if err := witness.Forget(daemonRoot, r.Name); err != nil {
return out, err
}
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
// previous version would be loaded by a runtime that walks a directory.
if err := os.RemoveAll(at); err != nil {
return out, err
}
} else {
placing, err = witness.Place(daemonRoot, r.Name, by, got, digestWritten(previous.Wrote), r.NotReversible, time.Now())
if err != nil {
return out, err
}
}
if err := os.MkdirAll(at, 0o755); err != nil {
return out, err
written := 0
if placing.Unpack {
if err := os.MkdirAll(at, 0o755); err != nil {
return out, err
}
if written, err = unpack(body, at); err != nil {
return out, err
}
if err := ownAll(at, r.User); err != nil {
return out, err
}
}
written, err := unpack(body, at)
if err != nil {
return out, err
}
if err := ownAll(at, r.User); err != nil {
return out, err
if placing.Commit != nil {
// Kept whatever the start below does: a build that would not start is still the build placed,
// and it is the witness's to judge.
defer func() {
if err := placing.Commit(); err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: placed %s, and what the witness keeps about it could not be saved: %v\n",
r.Name, err)
}
}()
}
// **A step is run to completion, not installed.** What follows it is gated on it finishing,
@@ -201,9 +233,15 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
out.Action = "created"
}
out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name)
if !placing.Unpack {
out.Detail = fmt.Sprintf("its build already in place, running as %s.service", r.Name)
}
if because != "" {
out.Detail += ", restarted because " + because + " changed"
}
if placing.Detail != "" {
out.Detail += "; " + placing.Detail
}
out.wrote = want
return out, nil
}
@@ -358,6 +396,13 @@ func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, st
}
}
bundle := filepath.Join(daemonRoot, name)
// And the build kept before it, with what the witness knew (novox/hq to-be 45 §8).
if _, err := os.Stat(witness.Dir(daemonRoot, name)); err == nil {
found = true
if err := witness.Forget(daemonRoot, name); err != nil {
return "", "", err
}
}
if _, err := os.Stat(bundle); err == nil {
found = true
if err := os.RemoveAll(bundle); err != nil {
@@ -381,3 +426,27 @@ func runFrom(r *declaration.Process) []string {
}
return run
}
// witnessOf is how a process's new builds are judged: as it declares, or by its name's default — the
// mesh's two core processes (novox/hq to-be 45 §8). A step or a scheduled run is never judged.
func witnessOf(r *declaration.Process) string {
if r.RunOnce || r.Schedule != "" {
return witness.ByNone
}
if r.Witness != "" {
return r.Witness
}
return witness.Default(r.Name)
}
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
// wrote, "sha256:<hex> <unit>".
func digestWritten(wrote string) string {
if fields := strings.Fields(wrote); len(fields) > 0 && strings.HasPrefix(fields[0], "sha256:") {
return fields[0]
}
return ""
}
// DaemonRoot is where unpacked daemons live — and what a witness keeps beside them.
func DaemonRoot() string { return daemonRoot }
+105
View File
@@ -0,0 +1,105 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/witness"
)
// A core process keeps the build before it while the new one is judged (novox/hq to-be 45 §8): the
// applier places the new build where the unit runs it from, and moves the old one aside rather than
// deleting it. A process nobody judges is replaced as ever.
func TestACoreProcessKeepsTheBuildBeforeItWhileTheNewOneIsJudged(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
run := func(ctx context.Context, name string, args ...string) (string, error) { return "", nil }
oldBody, oldDigest := anArchive(t, map[string]string{"node-tools": "old\n"})
newBody, newDigest := anArchive(t, map[string]string{"node-tools": "new\n"})
runtime := func(body []byte, digest string) string {
return `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["./node-tools"]}`
}
_, state, err := Apply(context.Background(), archHost(t), declare(t, runtime(oldBody, oldDigest)), store.State{},
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(witness.Trials(bundles)) != 0 {
t.Fatal("a first placement went on trial with nothing to go back to")
}
_, _, err = Apply(context.Background(), archHost(t), declare(t, runtime(newBody, newDigest)), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(bundles, "node-tools", "node-tools")); string(got) != "new\n" {
t.Fatalf("the new build is not where the unit runs it from: %q", got)
}
if got, _ := os.ReadFile(filepath.Join(witness.Dir(bundles, "node-tools"), "previous", "node-tools")); string(got) != "old\n" {
t.Fatalf("the build before was not kept beside it: %q", got)
}
trials := witness.Trials(bundles)
if len(trials) != 1 || trials[0].Running != newDigest || trials[0].Previous != oldDigest || trials[0].Witness != witness.ByPing {
t.Fatalf("the new build is not on trial against the old one: %+v", trials)
}
// Undeclared: everything kept about it goes with it.
if _, _, err := removeProcess(context.Background(), store.Applied{Target: "node-tools"}, run); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(witness.Dir(bundles, "node-tools")); !os.IsNotExist(err) {
t.Fatalf("what the witness kept outlived the process: %v", err)
}
}
// A build rolled back on this machine is not placed again while the mesh still declares it; the
// restored build keeps running, and the apply says why.
func TestARolledBackBuildIsNotPlacedAgain(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
run := func(ctx context.Context, name string, args ...string) (string, error) { return "", nil }
oldBody, oldDigest := anArchive(t, map[string]string{"mesh-controller": "old\n"})
newBody, newDigest := anArchive(t, map[string]string{"mesh-controller": "new\n"})
controller := func(body []byte, digest, env string) string {
return `{"id":"mesh-controller.controller","type":"process","name":"mesh-controller","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["./mesh-controller","serve"],"env":{"X":"` + env + `"}}`
}
_, state, err := Apply(context.Background(), archHost(t), declare(t, controller(oldBody, oldDigest, "1")), store.State{},
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
_, state, err = Apply(context.Background(), archHost(t), declare(t, controller(newBody, newDigest, "1")), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if _, err := witness.Restore(context.Background(), bundles, "mesh-controller", "never took the lease", witness.Runner(run),
time.Now()); err != nil {
t.Fatal(err)
}
// The mesh still declares the new build, and its unit changes: the process is re-placed.
report, _, err := Apply(context.Background(), archHost(t), declare(t, controller(newBody, newDigest, "2")), state,
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(bundles, "mesh-controller", "mesh-controller")); string(got) != "old\n" {
t.Fatalf("the rolled-back build was placed again: %q", got)
}
if o := outcomeOf(report, "mesh-controller.controller"); !strings.Contains(o.Detail, "rolled back on this machine") {
t.Fatalf("the apply does not say why it kept the build before: %+v", o)
}
}
+26
View File
@@ -590,6 +590,19 @@ type Process struct {
// For a process that stays up; a step or a scheduled run is not running a moment later by
// design, so there is nothing to hand over to.
Replaces []string `json:"replaces,omitempty"`
// Witness is how the node-engine judges a new build of this process, and restores the build
// before it when the new one is not healthy in bound (novox/hq to-be 45 §8): "lease" — the
// controller this machine started holds the controller's lease; "ping" — this machine's runtime
// answers the services protocol's PING; "none". Absent is the default for the process's name:
// the mesh's two core processes are judged, nothing else is. For a process that stays up.
Witness string `json:"witness,omitempty"`
// NotReversible says why this build may not be rolled back, when it may not: the build before it
// would run against what this one changes — a migration it runs that the older build cannot read
// (to-be 45 §8, rule 8). A build so declared that is not healthy in bound is left running and said
// as urgent; the build before it is never started against the newer data.
NotReversible string `json:"not-reversible,omitempty"`
}
func (d *Process) Identity() string { return d.ID }
@@ -637,6 +650,19 @@ func (d *Process) validate(where string, _ bool) []string {
if len(d.Run) == 0 {
problems = append(problems, where+": a process needs to say what to run")
}
switch d.Witness {
case "", "lease", "ping", "none":
default:
problems = append(problems, fmt.Sprintf("%s: witness %q is not one this host keeps: lease, ping or none",
where, d.Witness))
}
if d.Witness != "" && d.Witness != "none" && (d.RunOnce || d.Schedule != "") {
problems = append(problems, where+": a witness judges a process that stays up; a step or a "+
"scheduled run is not running between its runs")
}
if strings.ContainsAny(d.NotReversible, "\n\r") {
problems = append(problems, where+": not-reversible is one line")
}
for _, part := range d.Run {
if part == "" {
problems = append(problems, where+": a process command has an empty element")
+63
View File
@@ -194,6 +194,69 @@ type Report struct {
// Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
Rekey *Rekey `json:"rekey,omitempty"`
// Rollbacks is what this machine's witnesses decided about a core build that was not healthy
// in bound (novox/hq to-be 45 §8, ADR 0227 rule 8): the node-engine's launcher about the
// engine, the engine about the controller and the node tools. **Said on every report while it
// stands** — while the build it judged is still the one the mesh asks this machine to run — so
// a report lost, or a controller restarted, never makes a rollback silent. Empty once a newer
// build has proved itself.
Rollbacks []Rollback `json:"rollbacks,omitempty"`
// Witness is the version of the witness contract this node-engine keeps (to-be 45 §8): its
// presence says it reads a process's `witness` and `not-reversible`, which a strict decoder
// older than it refuses — so the controller sends either only to a machine whose report carries
// it, as it sends `epoch` only where `report_sequence` was seen (ADR 0229).
Witness int `json:"witness,omitempty"`
}
// WitnessContract is the version of the witness contract this host keeps: the fields above, the
// process fields `witness` and `not-reversible`, and what each witness reads (internal/witness).
const WitnessContract = 1
// The core components a witness judges (to-be 45 §8), as a rollback names them.
const (
ComponentEngine = "node-engine"
ComponentController = "controller"
ComponentNodeTools = "node-tools"
)
// What a witness concluded, as a rollback says it.
const (
// RolledBack is the previous build restored and running.
RolledBack = "rolled-back"
// NotReversible is a build that failed its health and was declared not reversible: the
// previous build would run against what the new one already changed (a migration that ran), so
// nothing was restored. The failing build is left as it is, and this is urgent.
NotReversible = "not-reversible"
// NothingToRestore is a build that failed its health with no previous build kept to go back to.
NothingToRestore = "nothing-to-restore"
// RestoreFailed is a restoration that was attempted and did not complete.
RestoreFailed = "restore-failed"
// Unwitnessed is a build whose health could not be judged at all within the bound — the
// witness could not ask (no grant, no lease bucket) — so it is neither proved nor rolled back.
Unwitnessed = "unwitnessed"
// Halted is the node-engine's launcher giving up: the build it would go back to fails too, so
// the fault is the machine's and not a build's.
Halted = "halted"
)
// Rollback is one witness's verdict on one core build (to-be 45 §8). The controller raises
// `core.<component>.<node>.<outcome>` from it; RolledBack, NotReversible, RestoreFailed and Halted
// are urgent.
type Rollback struct {
// Component is which core component: node-engine, controller or node-tools.
Component string `json:"component"`
// From is the build that was judged: a host version for the node-engine, the bundle's digest for
// a process.
From string `json:"from"`
// To is the build restored, and empty when none was.
To string `json:"to,omitempty"`
// Outcome is one of the words above.
Outcome string `json:"outcome"`
// Why is what the witness saw, in words for a person.
Why string `json:"why"`
At time.Time `json:"at"`
}
// Order is where a declaration stands among everything the mesh has sent this node (novox/hq to-be
+6
View File
@@ -128,6 +128,12 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
[]string{"id", "module", "kind", "target", "since", "changed", "kept"}},
{Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "c", Published: true, ContainerPort: 80},
[]string{"protocol", "address", "port", "by", "published", "container-port"}},
// novox/hq to-be 45 §8: a witness's verdicts, said on every report while they stand, and the
// witness contract this host keeps.
{Report{Node: "n", Rollbacks: []Rollback{{Component: ComponentController}}, Witness: WitnessContract},
[]string{"node", "rollbacks", "witness"}},
{Rollback{Component: ComponentNodeTools, From: "sha256:b", To: "sha256:a", Outcome: RolledBack, Why: "w"},
[]string{"component", "from", "to", "outcome", "why", "at"}},
} {
raw, err := json.Marshal(c.value)
if err != nil {
+29
View File
@@ -0,0 +1,29 @@
# A bus with the grants a witness needs (novox/hq to-be 45 §8), for witnessing_nats_test.go:
#
# docker run -d --rm --name w -p 14224:4222 -v $PWD/internal/link/testdata:/c:ro nats:2.11-alpine -js -c /c/witness-grants.conf
# MESH_TEST_NATS_GRANTS=nats://127.0.0.1:14224 go test ./internal/link/ -run TestNatsWitness
#
# `node.anchor` is a machine's host with exactly the two subjects the witness asks added to what a
# host already has (its inbox); `node.bare` is a host without them. `runtime` stands in for the tool
# runtime, `admin` for the controller writing the lease.
jetstream: enabled
accounts {
MESH {
jetstream: enabled
users = [
{ user: "node.anchor", password: "a", permissions: {
publish: { allow: ["$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder", "$SRV.PING.node-tools.anchor"] }
subscribe: { allow: ["_INBOX.node.anchor.>"] }
} }
{ user: "node.bare", password: "b", permissions: {
publish: { allow: ["mesh.control.bare.>"] }
subscribe: { allow: ["_INBOX.node.bare.>"] }
} }
{ user: "runtime", password: "r", permissions: {
subscribe: { allow: ["$SRV.PING.node-tools.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "admin", password: "x" }
]
}
}
+125
View File
@@ -0,0 +1,125 @@
package link
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/nats-io/nats.go"
)
// What a witness asks the bus (novox/hq to-be 45 §8): the node-engine judging the controller and the
// node tools it placed on this machine, by what the bus says about them rather than by what they
// say about themselves.
//
// **Two questions, both read-only, both on the host's own link.** The controller's lease key, read
// by JetStream's direct get — one subject, no stream information, nothing written; and this
// machine's tool runtime, asked the NATS services protocol's PING by its name and this machine's —
// so only this machine's runtime can answer it.
// ErrCannotAsk is a question the bus did not let this host put, or did not answer: no grant for it,
// no such bucket, the bus slow. **It says nothing about the build being judged**, so a witness
// counts none of it against the build's bound.
var ErrCannotAsk = errors.New("this host cannot ask the bus")
// ErrNoAnswer is a question the bus delivered and nobody answered in time: the build being judged is
// not there to answer. Counted against its bound.
var ErrNoAnswer = errors.New("nothing answered")
// Asker is what a witness asks through. The link open now implements it; nil while there is none.
type Asker interface {
// ReadKey is a key-value bucket's current value for a key. Found false is nobody holding it —
// absent, deleted or expired; an error wrapping ErrCannotAsk is no reading at all.
ReadKey(ctx context.Context, bucket, key string) (value []byte, found bool, err error)
// Ping asks the service `service`'s instance `id` whether it is there. Nil is an answer;
// ErrNoAnswer is none in time; ErrCannotAsk is no asking.
Ping(ctx context.Context, service, id string) error
}
// DirectGetSubject is the one subject a host asks a bucket's key on: JetStream's direct get of the
// bucket's stream, for the key's own subject. What a host's grant names exactly — no wildcard.
func DirectGetSubject(bucket, key string) string {
return "$JS.API.DIRECT.GET.KV_" + bucket + ".$KV." + bucket + "." + key
}
// PingSubject is the services protocol's PING of one instance of one service.
func PingSubject(service, id string) string { return "$SRV.PING." + service + "." + id }
// Asker is the link open now, when there is one and it can ask.
func (q *Queue) Asker() Asker {
q.init()
q.mu.Lock()
defer q.mu.Unlock()
if a, ok := q.bus.(Asker); ok {
return a
}
return nil
}
func (l *natsLink) ReadKey(ctx context.Context, bucket, key string) ([]byte, bool, error) {
subject := DirectGetSubject(bucket, key)
msg, err := l.conn.RequestWithContext(ctx, subject, nil)
switch {
case errors.Is(err, nats.ErrNoResponders):
return nil, false, fmt.Errorf("%w: the bus has no bucket %s that answers a direct get", ErrCannotAsk, bucket)
case err != nil:
return nil, false, fmt.Errorf("%w: reading %s from %s: %v%s", ErrCannotAsk, key, bucket, err, l.refusal(subject))
}
if msg.Header != nil {
switch status := msg.Header.Get("Status"); status {
case "":
case "404":
return nil, false, nil
default:
return nil, false, fmt.Errorf("%w: reading %s from %s: the bus answered %s %s", ErrCannotAsk, key,
bucket, status, msg.Header.Get("Description"))
}
switch msg.Header.Get("KV-Operation") {
case "DEL", "PURGE":
return nil, false, nil
}
}
if len(msg.Data) == 0 {
return nil, false, nil
}
return msg.Data, true, nil
}
func (l *natsLink) Ping(ctx context.Context, service, id string) error {
subject := PingSubject(service, id)
msg, err := l.conn.RequestWithContext(ctx, subject, nil)
switch {
case errors.Is(err, nats.ErrNoResponders):
// The bus delivered the question and nothing subscribes to it: the runtime is not on the bus.
return fmt.Errorf("%w: no %s on this machine is on the bus", ErrNoAnswer, service)
case err != nil:
if refused := l.refusal(subject); refused != "" {
return fmt.Errorf("%w: asking %s: %v%s", ErrCannotAsk, subject, err, refused)
}
return fmt.Errorf("%w: %s did not answer: %v", ErrNoAnswer, subject, err)
}
var ping struct {
Name string `json:"name"`
ID string `json:"id"`
}
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Name != service || ping.ID != id {
return fmt.Errorf("%w: what answered %s is not %s %s", ErrNoAnswer, subject, service, id)
}
return nil
}
// refusal is the bus's last word on this connection when it refused a publish to subject, said as
// the end of an error; empty when it did not.
func (l *natsLink) refusal(subject string) string {
last := l.conn.LastError()
if last == nil {
return ""
}
words := strings.ToLower(last.Error())
if strings.Contains(words, "permissions violation") && strings.Contains(last.Error(), subject) {
return " — the bus refused it: this host's grant does not name " + subject
}
return ""
}
+125
View File
@@ -0,0 +1,125 @@
package link
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// What a witness asks, asked of a real server with the grants the mesh composes (novox/hq to-be 45 §8):
// whether the direct get and the PING work as the two subjects named, and whether a host without them
// is told apart — "cannot ask", never "the build did not answer". See testdata/witness-grants.conf.
func TestNatsWitnessAsksWhatItIsGranted(t *testing.T) {
url := os.Getenv("MESH_TEST_NATS_GRANTS")
if url == "" {
t.Skip("MESH_TEST_NATS_GRANTS unset")
}
dial := func(user, password, inbox string) *nats.Conn {
t.Helper()
conn, err := nats.Connect(url, nats.UserInfo(user, password), nats.CustomInboxPrefix(inbox))
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
return conn
}
admin := dial("admin", "x", "_INBOX.admin")
js, err := admin.JetStream()
if err != nil {
t.Fatal(err)
}
_ = js.DeleteKeyValue("mesh-controller_lease")
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_lease", TTL: 15 * time.Second, History: 1})
if err != nil {
t.Fatal(err)
}
anchor := &natsLink{conn: dial("node.anchor", "a", "_INBOX.node.anchor")}
bare := &natsLink{conn: dial("node.bare", "b", "_INBOX.node.bare")}
ask := func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 2*time.Second)
}
// Nobody holds it.
ctx, cancel := ask()
if _, found, err := anchor.ReadKey(ctx, "mesh-controller_lease", "holder"); err != nil || found {
t.Fatalf("an empty lease read as %v, %v", found, err)
}
cancel()
// Held.
if _, err := kv.Put("holder", []byte(`{"instance":"controller@anchor pid 1 since now","epoch":3}`)); err != nil {
t.Fatal(err)
}
ctx, cancel = ask()
value, found, err := anchor.ReadKey(ctx, "mesh-controller_lease", "holder")
cancel()
if err != nil || !found {
t.Fatalf("a held lease read as %v, %v", found, err)
}
var holder struct {
Instance string `json:"instance"`
}
if json.Unmarshal(value, &holder); holder.Instance == "" {
t.Fatalf("the lease's value is %s", value)
}
// Given back.
if err := kv.Delete("holder"); err != nil {
t.Fatal(err)
}
ctx, cancel = ask()
if _, found, err := anchor.ReadKey(ctx, "mesh-controller_lease", "holder"); err != nil || found {
t.Fatalf("a lease given back read as %v, %v", found, err)
}
cancel()
// A host without the grant cannot ask — which is not "nobody holds it".
ctx, cancel = ask()
if _, _, err := bare.ReadKey(ctx, "mesh-controller_lease", "holder"); !errors.Is(err, ErrCannotAsk) {
t.Fatalf("a host without the grant read the lease as %v, want it unable to ask", err)
}
cancel()
// No runtime on the bus: not there to answer.
ctx, cancel = ask()
if err := anchor.Ping(ctx, "node-tools", "anchor"); !errors.Is(err, ErrNoAnswer) {
t.Fatalf("a PING nobody serves is %v, want no answer", err)
}
cancel()
// The runtime, answering as the services protocol does.
runtime := dial("runtime", "r", "_INBOX.runtime")
sub, err := runtime.Subscribe("$SRV.PING.node-tools.anchor", func(m *nats.Msg) {
_ = m.Respond([]byte(`{"type":"io.nats.micro.v1.ping_response","name":"node-tools","id":"anchor","version":"0.1.0"}`))
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
if err := runtime.Flush(); err != nil {
t.Fatal(err)
}
ctx, cancel = ask()
if err := anchor.Ping(ctx, "node-tools", "anchor"); err != nil {
t.Fatalf("this machine's runtime answered and the witness heard %v", err)
}
cancel()
// And a host not granted the PING cannot ask, though the runtime is there.
ctx, cancel = ask()
if err := bare.Ping(ctx, "node-tools", "anchor"); !errors.Is(err, ErrCannotAsk) {
t.Fatalf("a host without the grant heard %v, want it unable to ask", err)
}
cancel()
// No lease bucket at all — a controller from before the lease: cannot ask, not "nobody holds it".
if err := js.DeleteKeyValue("mesh-controller_lease"); err != nil {
t.Fatal(err)
}
ctx, cancel = ask()
if _, _, err := anchor.ReadKey(ctx, "mesh-controller_lease", "holder"); !errors.Is(err, ErrCannotAsk) {
t.Fatalf("a bus with no lease bucket read as %v, want unable to ask", err)
}
cancel()
}
+33 -13
View File
@@ -260,15 +260,29 @@ func Versions(dir string) ([]Delivered, error) {
// Empty when the running version is the newest, which is the ordinary answer. The host asks this
// between reconciles and nowhere else: standing aside mid-apply is the half-configured machine the
// host exists to prevent (novox/hq ADR 0141).
func Successor(dir, running string) (Delivered, bool, error) {
//
// **Never a version the launcher rolled back** (novox/hq to-be 45 §8): standing aside for one would
// hand the launcher a version it refuses to start, and the host would stand aside after every apply
// for ever. rolledBack is the versions the launcher's record names; nil is none.
func Successor(dir, running string, rolledBack ...string) (Delivered, bool, error) {
delivered, err := Versions(dir)
if err != nil {
return Delivered{}, false, err
}
if len(delivered) == 0 {
refused := map[string]bool{}
for _, v := range rolledBack {
refused[v] = true
}
var candidates []Delivered
for _, d := range delivered {
if !refused[d.Version] {
candidates = append(candidates, d)
}
}
if len(candidates) == 0 {
return Delivered{}, false, nil
}
newest := delivered[0]
newest := candidates[0]
// A machine whose running version is not among the delivered ones is the machine every mesh has
// one of: the host was put there by hand before any of this existed. Treating that as "stand
// aside" is correct — what was delivered is what the mesh asked for.
@@ -295,19 +309,25 @@ func Retire(dir, running string) ([]string, error) {
}
keep := map[string]bool{running: true}
at := -1
for i, d := range delivered {
if d.Version != running {
continue
if d.Version == running {
at = i
break
}
// Its predecessor is the next one down the list, which is the next oldest.
if i+1 < len(delivered) {
keep[delivered[i+1].Version] = true
}
break
}
// A running version that was never delivered has no predecessor among these, so the newest
// delivered one is what a rollback would reach for. Keep it.
if len(keep) == 1 && len(delivered) > 0 {
switch {
case at >= 0:
// **Newer than the running one is never retired** (novox/hq to-be 45 §8): it is a successor
// delivered and not yet started, or one the launcher rolled back that the mesh still declares —
// deleting either would have the next apply deliver it again. And its predecessor, the next one
// down the list, is what a rollback starts.
for i := 0; i <= at+1 && i < len(delivered); i++ {
keep[delivered[i].Version] = true
}
case len(delivered) > 0:
// A running version that was never delivered has no predecessor among these, so the newest
// delivered one is what a rollback would reach for. Keep it.
keep[delivered[0].Version] = true
}
+131
View File
@@ -0,0 +1,131 @@
package upgrade
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// The host's own successor, witnessed by its launcher (novox/hq to-be 45 §8, ADR 0227 rule 8).
//
// The launcher runs a delivered host that is not the known-good one **on trial**, and rolls back
// from one that crashes repeatedly, stops for nothing, or does not report within its bound. Two
// files are the whole conversation between them, both plain enough for a shell:
//
// - known-good — written by this host when the mesh has taken a report it made about its
// declaration under its own build. That is what ends a trial.
// - rolled-back — written by the launcher: one line per verdict, tab-separated — from, to, when
// (seconds since the epoch), outcome, why. Read here and said on every report while it stands.
// RolledBackName is the launcher's record of its verdicts, beside the state.
const RolledBackName = "rolled-back"
// RolledBackPath is where it lives, given where the state lives.
func RolledBackPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), RolledBackName)
}
// Verdict is one line of the launcher's record.
type Verdict struct {
From, To, Outcome, Why string
At time.Time
}
// ReadRolledBack is the launcher's record, oldest first. A line it cannot read is skipped and named
// in the error, never guessed at; absence is no verdicts.
func ReadRolledBack(path string) ([]Verdict, error) {
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer file.Close()
var out []Verdict
var bad []string
lines := bufio.NewScanner(file)
for n := 1; lines.Scan(); n++ {
line := strings.TrimRight(lines.Text(), "\r")
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.SplitN(line, "\t", 5)
if len(fields) != 5 || fields[0] == "" {
bad = append(bad, strconv.Itoa(n))
continue
}
seconds, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil {
bad = append(bad, strconv.Itoa(n))
continue
}
out = append(out, Verdict{From: fields[0], To: fields[1], At: time.Unix(seconds, 0).UTC(),
Outcome: fields[3], Why: fields[4]})
}
if err := lines.Err(); err != nil {
return out, err
}
if len(bad) > 0 {
return out, fmt.Errorf("the launcher's record %s has line(s) %s that are not from, to, when, outcome, why",
path, strings.Join(bad, ", "))
}
return out, nil
}
// RolledBackVersions are the versions the launcher refuses to start again.
func RolledBackVersions(verdicts []Verdict) []string {
var out []string
for _, v := range verdicts {
out = append(out, v.From)
}
return out
}
// Proved is this host's version seen to report its declaration under its own build: the evidence a
// trial waits for, and the only evidence known-good has ever claimed (novox/hq ADR 0005).
//
// - known-good becomes this version, which ends the launcher's trial;
// - the start counter is cleared, so months of ordinary restarts never add up to a rollback;
// - versions older than this one's predecessor are retired — never one newer, never this one;
// - and when this version is not the one a rollback went back to, the launcher's verdicts end: a
// newer host has proved itself, and what was concluded about an older one no longer stands.
//
// Returns the versions retired. Every step is attempted; the errors are joined.
func Proved(statePath, versionsDir, version string) ([]string, error) {
if strings.TrimSpace(version) == "" {
return nil, errors.New("a host that does not know its own version cannot prove it")
}
var errs []error
if err := RecordKnownGood(KnownGoodPath(statePath), version); err != nil {
errs = append(errs, fmt.Errorf("recording %s as known-good: %w", version, err))
}
if err := ClearAttempts(AttemptsPath(statePath)); err != nil {
errs = append(errs, fmt.Errorf("clearing the start counter: %w", err))
}
retired, err := Retire(versionsDir, version)
if err != nil {
errs = append(errs, err)
}
verdicts, err := ReadRolledBack(RolledBackPath(statePath))
if err != nil {
errs = append(errs, err)
}
wentBackTo := false
for _, v := range verdicts {
if v.To == version {
wentBackTo = true
}
}
if len(verdicts) > 0 && !wentBackTo {
if err := os.Remove(RolledBackPath(statePath)); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, err)
}
}
return retired, errors.Join(errs...)
}
+138
View File
@@ -0,0 +1,138 @@
package upgrade
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// The launcher's record, read as the launcher writes it: the launcher itself writes it here, so the
// two cannot drift (novox/hq to-be 45 §8).
func TestTheLaunchersRecordIsReadAsTheLauncherWritesIt(t *testing.T) {
state := t.TempDir()
libexec := t.TempDir()
versions := filepath.Join(libexec, VersionsDirName)
base := time.Now().Add(-2 * time.Hour)
for i, v := range []string{"1.0", "2.0"} {
binary := deliver(t, versions, v, base.Add(time.Duration(i)*time.Hour))
// Each fails, so the one run iteration ends.
if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(state, KnownGoodName), []byte("1.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(state, AttemptsName), []byte("3\n"), 0o644); err != nil {
t.Fatal(err)
}
launch := exec.Command("sh", "../../packaging/nox-mesh-host-launch")
launch.Env = append(os.Environ(), "MESH_HOST_STATE_DIR="+state, "MESH_HOST_LIBEXEC="+libexec,
"MESH_HOST_BIN=/nonexistent", "MESH_HOST_RUN_ONCE=1", "MESH_HOST_BACKOFF=0")
_ = launch.Run()
verdicts, err := ReadRolledBack(RolledBackPath(filepath.Join(state, "state.json")))
if err != nil {
t.Fatal(err)
}
if len(verdicts) == 0 {
t.Fatal("the launcher rolled back and nothing was read from its record")
}
v := verdicts[0]
if v.From != "2.0" || v.To != "1.0" || v.Outcome != "rolled-back" || !strings.Contains(v.Why, "failed 3 times") ||
time.Since(v.At) > time.Minute {
t.Fatalf("the record reads as %+v", v)
}
}
// A version the launcher rolled back is never what this host stands aside for: it would be refused,
// and the host would stand aside after every apply for ever.
func TestTheHostNeverStandsAsideForARolledBackVersion(t *testing.T) {
dir := t.TempDir()
base := time.Now().Add(-2 * time.Hour)
deliver(t, dir, "1.0", base)
deliver(t, dir, "2.0", base.Add(time.Hour))
if _, waiting, err := Successor(dir, "1.0", "2.0"); err != nil || waiting {
t.Fatalf("standing aside for a rolled-back version (%v, %v)", waiting, err)
}
deliver(t, dir, "3.0", base.Add(90*time.Minute))
if next, waiting, err := Successor(dir, "1.0", "2.0"); err != nil || !waiting || next.Version != "3.0" {
t.Fatalf("a newer version after a rollback is not stood aside for: %+v %v %v", next, waiting, err)
}
}
// Nothing newer than the running version is retired: a successor waiting, or one rolled back that the
// mesh still declares — either would only be delivered again.
func TestRetireNeverRemovesANewerVersion(t *testing.T) {
dir := t.TempDir()
base := time.Now().Add(-4 * time.Hour)
for i, v := range []string{"one", "two", "three", "four"} {
deliver(t, dir, v, base.Add(time.Duration(i)*time.Hour))
}
removed, err := Retire(dir, "two")
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("retired %v while running two: one is its predecessor, three and four are newer", removed)
}
}
// Proved: known-good, the counter cleared, older versions retired, and the launcher's verdicts ended —
// unless this is the version a rollback went back to, about which they still stand.
func TestProvingAHostEndsTheLaunchersVerdictsOnlyWhenItIsNewer(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
dir := t.TempDir()
base := time.Now().Add(-4 * time.Hour)
for i, v := range []string{"0.9", "1.0", "2.0", "3.0"} {
deliver(t, dir, v, base.Add(time.Duration(i)*time.Hour))
}
record := "2.0\t1.0\t1759744800\trolled-back\tit failed 3 times in a row\n"
if err := os.WriteFile(RolledBackPath(state), []byte(record), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(AttemptsPath(state), []byte("2\n"), 0o644); err != nil {
t.Fatal(err)
}
// The version gone back to reports: it is known-good, and the rollback still stands.
if _, err := Proved(state, dir, "1.0"); err != nil {
t.Fatal(err)
}
if v, _ := ReadRolledBack(RolledBackPath(state)); len(v) != 1 {
t.Fatal("proving the version a rollback went back to ended the rollback")
}
if got, _ := ReadKnownGood(KnownGoodPath(state)); got != "1.0" {
t.Fatalf("known-good is %q", got)
}
if raw, _ := os.ReadFile(AttemptsPath(state)); strings.TrimSpace(string(raw)) != "0" {
t.Fatalf("the start counter was not cleared: %q", raw)
}
// A newer one reports: the rollback ends, and what is older than its predecessor goes.
retired, err := Proved(state, dir, "3.0")
if err != nil {
t.Fatal(err)
}
if v, _ := ReadRolledBack(RolledBackPath(state)); len(v) != 0 {
t.Fatalf("a newer host proved itself and the old rollback still stands: %+v", v)
}
if !reflect.DeepEqual(retired, []string{"0.9", "1.0"}) {
t.Fatalf("retired %v, want 0.9 and 1.0 — 2.0 is 3.0's predecessor", retired)
}
}
// A line the launcher's record cannot be read by is named, never guessed at.
func TestAnUnreadableRecordLineIsNamed(t *testing.T) {
path := filepath.Join(t.TempDir(), RolledBackName)
if err := os.WriteFile(path, []byte("2.0\t1.0\t1759744800\trolled-back\twhy\ngarbage\n"), 0o644); err != nil {
t.Fatal(err)
}
verdicts, err := ReadRolledBack(path)
if len(verdicts) != 1 || err == nil || !strings.Contains(err.Error(), "line(s) 2") {
t.Fatalf("read %+v, %v", verdicts, err)
}
}
+155
View File
@@ -0,0 +1,155 @@
// 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
}
+115
View File
@@ -0,0 +1,115 @@
package witness
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
)
// The contract, held. Each of these is a line the controller's side relies on or writes; a change on
// either side changes this test (novox/hq to-be 45 §8).
// The lease is read where the controller keeps it (ADR 0229), on the one subject a host's grant names.
func TestTheLeaseIsReadWhereTheControllerKeepsIt(t *testing.T) {
if LeaseBucket != "mesh-controller_lease" || LeaseKey != "holder" {
t.Fatalf("the lease is read from %s/%s; the controller keeps it in mesh-controller_lease/holder",
LeaseBucket, LeaseKey)
}
if got := link.DirectGetSubject(LeaseBucket, LeaseKey); got != "$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder" {
t.Fatalf("the host asks %s for the lease; its grant names exactly the direct get of the key", got)
}
if LeaseAge != 15*time.Second {
t.Fatalf("the lease's age is %s; the controller's bucket keeps a key fifteen seconds", LeaseAge)
}
}
// What the controller writes — mesh-controller internal/lease Holder, by its JSON names — is what is
// read. The value below is the shape that Holder marshals to.
func TestTheLeaseValueIsTheControllersHolder(t *testing.T) {
written := `{"instance":"controller@anchor pid 4242 since 2026-10-06T10:00:00Z","host":"anchor",` +
`"build":"development build","epoch":57,"taken":"2026-10-06T10:00:01Z","renewed":"2026-10-06T10:00:31Z"}`
l, err := ParseLease([]byte(written))
if err != nil {
t.Fatal(err)
}
if l.Instance == "" || l.Host != "anchor" || l.Epoch != 57 || l.Taken.IsZero() || l.Renewed.IsZero() {
t.Fatalf("the lease was not read whole: %+v", l)
}
// And a field the controller adds later does not stop it being read.
if _, err := ParseLease([]byte(`{"instance":"i","taken":"2026-10-06T10:00:01Z","renewed":"2026-10-06T10:00:01Z","bundle":"sha256:x"}`)); err != nil {
t.Fatalf("a lease value with a field this host does not know was refused: %v", err)
}
}
// Healthy is: held now, on this machine, by an instance that took it after the new build started.
func TestTheNewControllerHoldsTheLeaseOnlyWhenItTookItAfterItStarted(t *testing.T) {
started := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
now := started.Add(30 * time.Second)
held := ControllerLease{Instance: "controller@anchor pid 2 since …", Host: "anchor.example",
Taken: started.Add(3 * time.Second), Renewed: now.Add(-2 * time.Second), Epoch: 58}
for _, c := range []struct {
name string
l ControllerLease
want bool
says string
}{
{"held by the new instance", held, true, "holds the lease"},
{"nobody", ControllerLease{}, false, "no holder"},
{"the old instance, taken before the restart", func() ControllerLease {
l := held
l.Taken = started.Add(-time.Hour)
return l
}(), false, "before the new build started"},
{"another machine's controller", func() ControllerLease {
l := held
l.Host = "home-server"
return l
}(), false, "not this machine"},
{"taken and no longer renewed", func() ControllerLease {
l := held
l.Renewed = now.Add(-20 * time.Second)
return l
}(), false, "past its"},
{"taken within the clocks' skew of the start", func() ControllerLease {
l := held
l.Taken = started.Add(-time.Second)
return l
}(), true, "holds the lease"},
} {
got, why := c.l.HeldBySince("anchor", started, now)
if got != c.want || !strings.Contains(why, c.says) {
t.Errorf("%s: healthy %v (%s), want %v saying %q", c.name, got, why, c.want, c.says)
}
}
}
// The runtime is asked by its own name and this machine's: only this machine's runtime can answer.
func TestTheRuntimeIsAskedByItsNameAndThisMachines(t *testing.T) {
if got := link.PingSubject(NodeToolsService, "anchor"); got != "$SRV.PING.node-tools.anchor" {
t.Fatalf("the host asks %s; the runtime answers $SRV.PING.node-tools.<node>", got)
}
}
// What is judged by default is the mesh's two core processes, by the names the controller composes
// them under, and nothing else.
func TestOnlyTheCoreProcessesAreJudgedByDefault(t *testing.T) {
for name, want := range map[string]string{"mesh-controller": ByLease, "node-tools": ByPing, "greeter": ByNone} {
if got := Default(name); got != want {
t.Errorf("%s is judged by %q, want %q", name, got, want)
}
}
if ControllerWithin != 60*time.Second || NodeToolsWithin != 60*time.Second || PingWithin != 5*time.Second {
t.Fatal("the bounds are to-be 45 §8's: the lease within sixty seconds, a PING answered within five")
}
}
// A verdict on the wire names its component as the controller's condition does.
func TestAVerdictNamesItsComponent(t *testing.T) {
raw, _ := json.Marshal(link.Rollback{Component: Component(ByLease), Outcome: link.RolledBack})
if !strings.Contains(string(raw), `"component":"controller"`) || Component(ByPing) != "node-tools" {
t.Fatalf("a verdict names its component as %s", raw)
}
}
+371
View File
@@ -0,0 +1,371 @@
package witness
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/novox/mesh-host/internal/link"
)
// What the engine keeps beside a witnessed process (to-be 45 §8): the build before the running one,
// and what it knows about the running one — on trial or proved, and what was concluded.
//
// <root>/<name> the running build, where its unit runs it from
// <root>/.witness/<name>/previous/ the build before it, kept until the running one is proved
// <root>/.witness/<name>/state.json State
//
// **Never deleted before the new build is proved**, and deleted once it is: the previous build has
// exactly one reader — a restoration — and none once the build after it has been seen healthy.
// The running build's directory never moves while it is judged, so its unit is the same unit
// throughout, and restoring is two renames and a restart.
// Dir is where the engine keeps what it knows about a witnessed process.
func Dir(root, name string) string { return filepath.Join(root, ".witness", name) }
func previousDir(root, name string) string { return filepath.Join(Dir(root, name), "previous") }
func statePath(root, name string) string { return filepath.Join(Dir(root, name), "state.json") }
// State is one witnessed process, as the engine keeps it.
type State struct {
Process string `json:"process"`
Witness string `json:"witness"`
// Running is the digest of the build at <root>/<name>; Proven, whether it has been seen healthy
// (or ran before any witness watched it, or is a build restored — judged once already).
Running string `json:"running"`
Proven bool `json:"proven"`
// Previous is the digest of the build kept to go back to, while Running is on trial.
Previous string `json:"previous,omitempty"`
// Started is when Running was placed; Watched how long the witness has judged it while it could
// ask, Unasked how long it could not. Within is its bound.
Started time.Time `json:"started,omitempty"`
Watched time.Duration `json:"watched,omitempty"`
Unasked time.Duration `json:"unasked,omitempty"`
Within time.Duration `json:"within,omitempty"`
// NotReversible is why Running may not be rolled back, as its declaration said: the build
// before it would run against what this one changed.
NotReversible string `json:"not-reversible,omitempty"`
// Verdicts are what the witness concluded and still stands: said on every report until the mesh
// asks for another build, or a build is proved.
Verdicts []link.Rollback `json:"verdicts,omitempty"`
// Refused are builds rolled back here: one rollback per build, so a build in this list is not
// placed again until a newer one is proved.
Refused []string `json:"refused,omitempty"`
}
// OnTrial says whether the running build is still being judged.
func (s State) OnTrial() bool {
if s.Proven || s.Running == "" {
return false
}
for _, v := range s.Verdicts {
if v.From == s.Running {
return false
}
}
return true
}
func (s State) refuses(digest string) bool {
for _, d := range s.Refused {
if d == digest {
return true
}
}
return false
}
// Load is what the engine keeps about one process; a zero State when it keeps nothing.
func Load(root, name string) (State, error) {
raw, err := os.ReadFile(statePath(root, name))
if errors.Is(err, os.ErrNotExist) {
return State{}, nil
}
if err != nil {
return State{}, err
}
var s State
if err := json.Unmarshal(raw, &s); err != nil {
return State{}, fmt.Errorf("what the engine keeps about %s cannot be read: %w", name, err)
}
return s, nil
}
// Save keeps it, whole or not at all.
func Save(root string, s State) error {
dir := Dir(root, s.Process)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".state-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), statePath(root, s.Process))
}
// Forget is a witnessed process no longer declared: everything kept about it goes with it.
func Forget(root, name string) error { return os.RemoveAll(Dir(root, name)) }
// Placing is what the applier is to do with a declared build of a witnessed process.
type Placing struct {
// Unpack is whether the declared build is to be unpacked at <root>/<name>; false when the build
// there already is the one to run.
Unpack bool
// Detail is what to say about it, when anything.
Detail string
// Commit records the placement once the build is unpacked and started.
Commit func() error
}
// Place readies <root>/<name> for a declared build of a witnessed process, before anything is
// unpacked there. `recorded` is the digest the host's record says it placed last, for a process the
// engine keeps nothing about yet — every one on the day this ships.
//
// - the declared build is the one running (restored here, or declared again): nothing is unpacked.
// - it was rolled back here and nothing newer has been proved: refused, and the running build stays.
// - the running build is proved: it is moved aside as the previous one, and the new one goes on trial.
// - the running build is itself on trial: it is discarded, and the previous one stays the one to go
// back to — the last build seen healthy, never one that was not.
// - nothing runs there yet: a first placement, with nothing to go back to and nothing to judge.
func Place(root, name, by, declared, recorded, notReversible string, now time.Time) (Placing, error) {
at := filepath.Join(root, name)
s, err := Load(root, name)
if err != nil {
return Placing{}, err
}
if s.Process == "" {
// Nothing kept: what is there is whatever the record says, and it ran before any witness —
// it is the build a trial would go back to.
s = State{Process: name, Running: recorded, Proven: true}
}
s.Witness = by
present := false
if info, err := os.Stat(at); err == nil && info.IsDir() {
present = true
}
if present && s.Running == declared {
// Asked for the build already running. A restoration followed by the mesh asking for that
// same build again ends what was concluded about the build it replaced: the mesh asks for what
// runs. What was concluded about this build itself stands.
var standing []link.Rollback
for _, v := range s.Verdicts {
if v.From == s.Running {
standing = append(standing, v)
}
}
s.Verdicts = standing
return Placing{Commit: func() error { return Save(root, s) }}, nil
}
if present && s.refuses(declared) {
return Placing{
Detail: fmt.Sprintf("kept build %s: %s was rolled back on this machine and is not placed again "+
"until a newer build has proved itself (novox/hq to-be 45 §8)", short(s.Running), short(declared)),
Commit: func() error { return Save(root, s) },
}, nil
}
previous := s.Previous
switch {
case !present || s.Running == "":
// A first placement, or a directory that went missing: nothing to keep.
if err := os.RemoveAll(at); err != nil {
return Placing{}, err
}
s = State{Process: name, Witness: by, Running: declared, Proven: true, Refused: s.Refused}
return Placing{Unpack: true, Commit: func() error { return Save(root, s) }}, nil
case s.OnTrial():
// The running build has not been seen healthy: it is not what anybody goes back to.
if err := os.RemoveAll(at); err != nil {
return Placing{}, err
}
default:
if err := os.RemoveAll(previousDir(root, name)); err != nil {
return Placing{}, err
}
if err := os.MkdirAll(Dir(root, name), 0o700); err != nil {
return Placing{}, err
}
if err := os.Rename(at, previousDir(root, name)); err != nil {
return Placing{}, fmt.Errorf("cannot keep %s's running build to go back to: %w", name, err)
}
previous = s.Running
}
s = State{Process: name, Witness: by, Running: declared, Previous: previous, Started: now.UTC(),
Within: Within(by), NotReversible: notReversible, Refused: s.Refused}
// Kept as soon as the old build is aside, so a host that stops here knows on its return which
// build is where.
if err := Save(root, s); err != nil {
return Placing{}, err
}
return Placing{Unpack: true, Commit: func() error { return Save(root, s) }}, nil
}
// Proved is the running build seen healthy: the build before it is deleted — it has no reader now —
// and what was concluded and refused before it ends.
func Proved(root, name string) error {
s, err := Load(root, name)
if err != nil || s.Process == "" {
return err
}
if err := os.RemoveAll(previousDir(root, name)); err != nil {
return err
}
s.Proven, s.Previous, s.Verdicts, s.Refused = true, "", nil, nil
s.Watched, s.Unasked = 0, 0
return Save(root, s)
}
// Runner is how the engine asks the machine's service manager, as the applier does.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Restore is the running build not healthy in bound: the previous one put back and started, once —
// or, when the running build is declared not reversible or nothing is kept, nothing done and that
// said. The verdict is kept, and returned to be said.
func Restore(ctx context.Context, root, name, why string, run Runner, now time.Time) (link.Rollback, error) {
s, err := Load(root, name)
if err != nil {
return link.Rollback{}, err
}
v := link.Rollback{Component: Component(s.Witness), From: s.Running, Why: why, At: now.UTC()}
conclude := func(v link.Rollback) (link.Rollback, error) {
s.Verdicts = append(s.Verdicts, v)
return v, Save(root, s)
}
switch {
case s.NotReversible != "":
v.Outcome = link.NotReversible
v.Why = why + "; not rolled back: this build is declared not reversible (" + s.NotReversible +
"), so the build before it would run against what it changed. It is left running. A person decides"
return conclude(v)
case s.Previous == "":
v.Outcome = link.NothingToRestore
v.Why = why + "; no build before it is kept on this machine"
return conclude(v)
}
if _, err := os.Stat(previousDir(root, name)); err != nil {
v.Outcome = link.NothingToRestore
v.Why = fmt.Sprintf("%s; the build before it (%s) is not where it was kept: %v", why, short(s.Previous), err)
return conclude(v)
}
unit := name + ".service"
// Stopped first, so the failing build is not running while its files are moved; a stop that fails
// is not fatal — the restart below replaces whatever runs.
_, _ = run(ctx, "systemctl", "stop", unit)
at := filepath.Join(root, name)
failed := filepath.Join(Dir(root, name), "failed")
if err := os.RemoveAll(failed); err != nil {
return restoreFailed(root, s, v, err)
}
if err := os.Rename(at, failed); err != nil && !errors.Is(err, os.ErrNotExist) {
return restoreFailed(root, s, v, err)
}
if err := os.Rename(previousDir(root, name), at); err != nil {
// Put back what was there, so the machine is no worse than before the attempt.
_ = os.Rename(failed, at)
return restoreFailed(root, s, v, err)
}
_ = os.RemoveAll(failed)
v.To, v.Outcome = s.Previous, link.RolledBack
s.Refused = append(s.Refused, s.Running)
// The restored build was proved before; it is not judged a second time. One rollback per build.
s.Running, s.Previous, s.Proven = s.Previous, "", true
if _, err := run(ctx, "systemctl", "restart", unit); err != nil {
v.Outcome = link.RestoreFailed
v.Why = fmt.Sprintf("%s; the build before it (%s) was put back and would not start: %v", why, short(v.To), err)
}
return conclude(v)
}
func restoreFailed(root string, s State, v link.Rollback, err error) (link.Rollback, error) {
v.Outcome = link.RestoreFailed
v.Why = fmt.Sprintf("%s; putting the build before it (%s) back failed: %v", v.Why, short(s.Previous), err)
s.Verdicts = append(s.Verdicts, v)
return v, Save(root, s)
}
// Conclude keeps a verdict that restores nothing — a build that could not be judged at all.
func Conclude(root, name string, v link.Rollback) error {
s, err := Load(root, name)
if err != nil {
return err
}
s.Verdicts = append(s.Verdicts, v)
return Save(root, s)
}
// Standing is every verdict that still stands, on every witnessed process under root, in a stable
// order — what every report says.
func Standing(root string) []link.Rollback {
var out []link.Rollback
for _, s := range all(root) {
out = append(out, s.Verdicts...)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Component != out[j].Component {
return out[i].Component < out[j].Component
}
return out[i].At.Before(out[j].At)
})
return out
}
// Trials is every witnessed process whose running build is being judged.
func Trials(root string) []State {
var out []State
for _, s := range all(root) {
if s.OnTrial() {
out = append(out, s)
}
}
return out
}
func all(root string) []State {
entries, err := os.ReadDir(filepath.Join(root, ".witness"))
if err != nil {
return nil
}
var out []State
for _, e := range entries {
if !e.IsDir() {
continue
}
if s, err := Load(root, e.Name()); err == nil && s.Process != "" {
out = append(out, s)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Process < out[j].Process })
return out
}
func short(digest string) string {
if len(digest) > len("sha256:")+12 {
return digest[:len("sha256:")+12]
}
return digest
}
+183
View File
@@ -0,0 +1,183 @@
package witness
import (
"context"
"errors"
"fmt"
"time"
"github.com/novox/mesh-host/internal/link"
)
// Watcher judges every witnessed build on trial on this machine, every Every, until each is proved or
// concluded (to-be 45 §8).
//
// **Time counts only while it could ask.** A build's bound is spent only on looks that got an answer
// from the bus — the lease read, or the PING delivered — so a machine whose own link is down, or a
// bus that refuses the question, never rolls a build back for the host's own trouble. A look that
// could not ask counts towards GiveUp instead, and at GiveUp the build is said to be unwitnessed:
// neither proved nor rolled back, and said.
type Watcher struct {
Root string
// Node is this machine's node name, the instance its runtime answers PING as; Host its hostname,
// as the controller's lease names its machine.
Node, Host string
// Asker is the link open now, or nil.
Asker func() link.Asker
Run Runner
// Hold runs a change to what is placed on the machine in turn with every apply: the restoration
// moves the directory an apply writes into.
Hold func(func())
// Concluded is told every verdict, once, as it is reached: to say it now rather than at the next
// report.
Concluded func(link.Rollback)
Say func(string)
Now func() time.Time
Every time.Duration
}
// Watch looks every Every until ctx ends.
func (w *Watcher) Watch(ctx context.Context) {
every := w.Every
if every <= 0 {
every = Every
}
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.Look(ctx)
}
}
}
// Look judges every build on trial once.
func (w *Watcher) Look(ctx context.Context) {
for _, s := range Trials(w.Root) {
w.look(ctx, s)
}
}
func (w *Watcher) look(ctx context.Context, s State) {
every := w.Every
if every <= 0 {
every = Every
}
now := w.now()
healthy, why, err := w.judge(ctx, s, now)
w.hold(func() {
// Read again in turn: an apply may have placed another build meanwhile, which starts its own
// trial — this look was about the one before it.
current, loadErr := Load(w.Root, s.Process)
if loadErr != nil || current.Running != s.Running || !current.OnTrial() {
return
}
switch {
case err != nil:
current.Unasked += every
if current.Unasked < GiveUp {
_ = Save(w.Root, current)
return
}
v := link.Rollback{Component: Component(current.Witness), From: current.Running, Outcome: link.Unwitnessed,
Why: fmt.Sprintf("its health could not be judged for %s: %v. The build before it is kept", GiveUp, err),
At: now.UTC()}
current.Verdicts = append(current.Verdicts, v)
if Save(w.Root, current) == nil {
w.concluded(v)
}
case healthy:
if Proved(w.Root, s.Process) == nil {
w.say(fmt.Sprintf("%s %s is healthy: %s; the build before it is deleted", s.Process, short(s.Running), why))
}
default:
current.Watched += every
if current.Watched < current.Within {
_ = Save(w.Root, current)
return
}
if err := Save(w.Root, current); err != nil {
return
}
bound := fmt.Sprintf("%s %s was not healthy within %s of starting: %s",
s.Process, short(s.Running), current.Within, why)
v, err := Restore(ctx, w.Root, s.Process, bound, w.Run, now)
if err != nil {
w.say(fmt.Sprintf("%s; and what was concluded could not be kept: %v", bound, err))
}
w.concluded(v)
}
})
}
// judge asks once. An error is a look that could not ask; otherwise healthy, and why not when not.
func (w *Watcher) judge(ctx context.Context, s State, now time.Time) (bool, string, error) {
var asker link.Asker
if w.Asker != nil {
asker = w.Asker()
}
if asker == nil {
return false, "", errors.New("this host is not linked to the bus")
}
asking, cancel := context.WithTimeout(ctx, PingWithin)
defer cancel()
switch s.Witness {
case ByLease:
value, found, err := asker.ReadKey(asking, LeaseBucket, LeaseKey)
if err != nil {
return false, "", err
}
if !found {
return false, "nobody holds the controller's lease", nil
}
lease, err := ParseLease(value)
if err != nil {
return false, err.Error(), nil
}
held, why := lease.HeldBySince(w.Host, s.Started, now)
return held, why, nil
case ByPing:
err := asker.Ping(asking, s.Process, w.Node)
switch {
case err == nil:
return true, "it answered PING", nil
case errors.Is(err, link.ErrNoAnswer):
return false, err.Error(), nil
default:
return false, "", err
}
}
return false, "", fmt.Errorf("%s names no witness this host knows (%q)", s.Process, s.Witness)
}
func (w *Watcher) hold(f func()) {
if w.Hold == nil {
f()
return
}
w.Hold(f)
}
func (w *Watcher) now() time.Time {
if w.Now == nil {
return time.Now()
}
return w.Now()
}
func (w *Watcher) say(line string) {
if w.Say != nil {
w.Say(line)
}
}
func (w *Watcher) concluded(v link.Rollback) {
w.say(fmt.Sprintf("%s %s: %s — %s", v.Component, v.Outcome, short(v.From), v.Why))
if w.Concluded != nil {
w.Concluded(v)
}
}
+346
View File
@@ -0,0 +1,346 @@
package witness
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-host/internal/link"
)
// Each rollback path, induced against a real directory: a controller that never takes the lease, a
// runtime that never answers PING, a build declared not reversible; and a healthy update, which
// deletes nothing before it is proved and the build before it once it is (novox/hq to-be 45 §8).
const (
buildA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
buildB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
buildC = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
)
// place does what the applier does with a declared build: ask Place, unpack when told, commit.
func place(t *testing.T, root, name, by, build, recorded, notReversible string, at time.Time) Placing {
t.Helper()
p, err := Place(root, name, by, build, recorded, notReversible, at)
if err != nil {
t.Fatal(err)
}
if p.Unpack {
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "build"), []byte(build), 0o644); err != nil {
t.Fatal(err)
}
}
if err := p.Commit(); err != nil {
t.Fatal(err)
}
return p
}
func running(t *testing.T, root, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(root, name, "build"))
if err != nil {
return "nothing"
}
return string(raw)
}
func kept(root, name string) bool {
_, err := os.Stat(previousDir(root, name))
return err == nil
}
// asker answers as the build it is told is running would.
type asker struct {
mu sync.Mutex
lease func() ([]byte, bool, error)
ping func() error
asked int
}
func (a *asker) ReadKey(context.Context, string, string) ([]byte, bool, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.asked++
return a.lease()
}
func (a *asker) Ping(context.Context, string, string) error {
a.mu.Lock()
defer a.mu.Unlock()
a.asked++
return a.ping()
}
type machine struct {
commands []string
failRestart bool
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
m.commands = append(m.commands, name+" "+strings.Join(args, " "))
if m.failRestart && len(args) > 0 && args[0] == "restart" {
return "", fmt.Errorf("exit status 1")
}
return "", nil
}
func watcher(root string, a link.Asker, m *machine, clock *time.Time, verdicts *[]link.Rollback) *Watcher {
return &Watcher{Root: root, Node: "anchor", Host: "anchor",
Asker: func() link.Asker {
if a == nil {
return nil
}
return a
},
Run: m.run, Now: func() time.Time { return *clock },
Concluded: func(v link.Rollback) { *verdicts = append(*verdicts, v) },
Every: Every,
}
}
// look runs the watcher n times, the clock moving Every each time.
func look(w *Watcher, clock *time.Time, n int) {
for i := 0; i < n; i++ {
*clock = clock.Add(Every)
w.Look(context.Background())
}
}
func leaseHeldBy(instance string, taken time.Time, clock *time.Time) func() ([]byte, bool, error) {
return func() ([]byte, bool, error) {
return []byte(fmt.Sprintf(`{"instance":%q,"host":"anchor","epoch":9,"taken":%q,"renewed":%q}`,
instance, taken.Format(time.RFC3339Nano), clock.Add(-time.Second).Format(time.RFC3339Nano))), true, nil
}
}
// A controller that starts and never takes the lease: restored to the build before, once, said once.
func TestAControllerThatNeverTakesTheLeaseIsRolledBackOnce(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "mesh-controller", ByLease, buildA, "", "", clock)
place(t, root, "mesh-controller", ByLease, buildB, buildA, "", clock)
if running(t, root, "mesh-controller") != buildB || !kept(root, "mesh-controller") {
t.Fatal("the new controller is not running with the build before it kept beside it")
}
// The old instance's lease, taken an hour ago, is all the bus ever shows.
a := &asker{lease: leaseHeldBy("controller@anchor pid 1 since earlier", clock.Add(-time.Hour), &clock)}
m := &machine{}
var verdicts []link.Rollback
w := watcher(root, a, m, &clock, &verdicts)
look(w, &clock, int(ControllerWithin/Every)-1)
if len(verdicts) != 0 || running(t, root, "mesh-controller") != buildB {
t.Fatalf("rolled back before its bound: %+v", verdicts)
}
look(w, &clock, 1)
if len(verdicts) != 1 || verdicts[0].Outcome != link.RolledBack || verdicts[0].From != buildB || verdicts[0].To != buildA ||
verdicts[0].Component != link.ComponentController {
t.Fatalf("the verdict is %+v, want controller rolled back from B to A", verdicts)
}
if running(t, root, "mesh-controller") != buildA {
t.Fatalf("the controller running is %s, want the build before", running(t, root, "mesh-controller"))
}
if strings.Join(m.commands, "; ") != "systemctl stop mesh-controller.service; systemctl restart mesh-controller.service" {
t.Fatalf("the machine was asked %v", m.commands)
}
if !strings.Contains(verdicts[0].Why, "not healthy within 1m0s") || !strings.Contains(verdicts[0].Why, "before the new build started") {
t.Fatalf("the verdict does not say why: %s", verdicts[0].Why)
}
// Once: the restored build is not judged again, and nothing more is said or done.
look(w, &clock, 30)
if len(verdicts) != 1 || len(m.commands) != 2 {
t.Fatalf("judged again after a rollback: %d verdicts, %v", len(verdicts), m.commands)
}
// Standing, so every report says it — until the mesh asks for another build.
if s := Standing(root); len(s) != 1 || s[0].From != buildB {
t.Fatalf("what every report says is %+v", s)
}
// The mesh still declares B: refused, A kept running, the verdict still standing.
p := place(t, root, "mesh-controller", ByLease, buildB, buildB, "", clock)
if p.Unpack || running(t, root, "mesh-controller") != buildA || !strings.Contains(p.Detail, "rolled back") {
t.Fatalf("a rolled-back build was placed again: %+v, running %s", p, running(t, root, "mesh-controller"))
}
if len(Standing(root)) != 1 || len(Trials(root)) != 0 {
t.Fatal("refusing the rolled-back build changed what stands or started a trial")
}
// A newer build is a new trial, and ends what was concluded once it is proved.
place(t, root, "mesh-controller", ByLease, buildC, buildB, "", clock)
if running(t, root, "mesh-controller") != buildC || len(Trials(root)) != 1 {
t.Fatal("a newer build after a rollback was not placed on trial")
}
if err := Proved(root, "mesh-controller"); err != nil {
t.Fatal(err)
}
if len(Standing(root)) != 0 || kept(root, "mesh-controller") {
t.Fatal("a newer build proved and the rollback still stands, or the build before it is still kept")
}
}
// A runtime that never answers PING: restored, once.
func TestARuntimeThatNeverAnswersIsRolledBackOnce(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "node-tools", ByPing, buildA, "", "", clock)
place(t, root, "node-tools", ByPing, buildB, buildA, "", clock)
a := &asker{ping: func() error { return fmt.Errorf("%w: no node-tools on this machine is on the bus", link.ErrNoAnswer) }}
m := &machine{}
var verdicts []link.Rollback
w := watcher(root, a, m, &clock, &verdicts)
look(w, &clock, int(NodeToolsWithin/Every)+20)
if len(verdicts) != 1 || verdicts[0].Outcome != link.RolledBack || verdicts[0].Component != link.ComponentNodeTools {
t.Fatalf("the verdict is %+v, want node-tools rolled back once", verdicts)
}
if running(t, root, "node-tools") != buildA || kept(root, "node-tools") {
t.Fatal("the runtime running is not the build before, or a copy of it is still kept")
}
}
// A healthy update: nothing deleted before it is proved; the build before it deleted once it is.
func TestAHealthyUpdateDeletesNothingUntilItIsProved(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
// The controller placed by a host from before any witness: a record, and no state.
if err := os.MkdirAll(filepath.Join(root, "mesh-controller"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "mesh-controller", "build"), []byte(buildA), 0o644); err != nil {
t.Fatal(err)
}
place(t, root, "mesh-controller", ByLease, buildB, buildA, "", clock)
started := clock
a := &asker{lease: func() ([]byte, bool, error) { return nil, false, nil }}
m := &machine{}
var verdicts []link.Rollback
w := watcher(root, a, m, &clock, &verdicts)
look(w, &clock, 5)
if !kept(root, "mesh-controller") {
t.Fatal("the build before was deleted while the new one was still on trial")
}
// A third build while the second is on trial: the one kept is still the one seen healthy.
place(t, root, "mesh-controller", ByLease, buildC, buildB, "", clock)
if s, _ := Load(root, "mesh-controller"); s.Previous != buildA {
t.Fatalf("the build kept to go back to is %s, want A — the last one seen healthy", s.Previous)
}
started = clock
a.lease = leaseHeldBy("controller@anchor pid 3 since now", started.Add(4*time.Second), &clock)
look(w, &clock, 2)
if len(verdicts) != 0 || len(m.commands) != 0 {
t.Fatalf("a healthy update was judged or acted on: %+v %v", verdicts, m.commands)
}
if kept(root, "mesh-controller") || len(Trials(root)) != 0 || running(t, root, "mesh-controller") != buildC {
t.Fatal("the build before was not deleted once the new one was proved")
}
}
// A build declared not reversible is never rolled back: the build before never starts against what it
// changed, and the verdict is urgent and stands.
func TestANotReversibleBuildIsNeverRolledBack(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "mesh-controller", ByLease, buildA, "", "", clock)
place(t, root, "mesh-controller", ByLease, buildB, buildA, "migration 0073 cannot be undone", clock)
a := &asker{lease: func() ([]byte, bool, error) { return nil, false, nil }}
m := &machine{}
var verdicts []link.Rollback
w := watcher(root, a, m, &clock, &verdicts)
look(w, &clock, int(ControllerWithin/Every)+20)
if len(verdicts) != 1 || verdicts[0].Outcome != link.NotReversible || verdicts[0].To != "" {
t.Fatalf("the verdict is %+v, want not-reversible with nothing restored", verdicts)
}
if !strings.Contains(verdicts[0].Why, "migration 0073 cannot be undone") {
t.Fatalf("the verdict does not say why it may not be rolled back: %s", verdicts[0].Why)
}
if len(m.commands) != 0 || running(t, root, "mesh-controller") != buildB {
t.Fatalf("the build before was started against the newer data: %v, running %s", m.commands,
running(t, root, "mesh-controller"))
}
if len(Standing(root)) != 1 {
t.Fatal("the not-reversible verdict does not stand")
}
}
// A witness that cannot ask counts nothing against the build, and says so at GiveUp.
func TestAWitnessThatCannotAskNeverRollsBack(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "mesh-controller", ByLease, buildA, "", "", clock)
place(t, root, "mesh-controller", ByLease, buildB, buildA, "", clock)
a := &asker{lease: func() ([]byte, bool, error) {
return nil, false, fmt.Errorf("%w: this host's grant does not name the lease", link.ErrCannotAsk)
}}
m := &machine{}
var verdicts []link.Rollback
w := watcher(root, a, m, &clock, &verdicts)
look(w, &clock, int(GiveUp/Every)-1)
if len(verdicts) != 0 || len(m.commands) != 0 || running(t, root, "mesh-controller") != buildB {
t.Fatalf("a build was judged on questions that were never asked: %+v %v", verdicts, m.commands)
}
look(w, &clock, 1)
if len(verdicts) != 1 || verdicts[0].Outcome != link.Unwitnessed || len(m.commands) != 0 {
t.Fatalf("the verdict at GiveUp is %+v, want unwitnessed with nothing done", verdicts)
}
if !kept(root, "mesh-controller") {
t.Fatal("the build before was deleted though the new one was never seen healthy")
}
// And a host with no link at all is the same: nothing counted.
root2 := t.TempDir()
place(t, root2, "node-tools", ByPing, buildA, "", "", clock)
place(t, root2, "node-tools", ByPing, buildB, buildA, "", clock)
var none []link.Rollback
w2 := watcher(root2, nil, m, &clock, &none)
look(w2, &clock, int(NodeToolsWithin/Every)*3)
if len(none) != 0 || running(t, root2, "node-tools") != buildB {
t.Fatal("a build was rolled back while this host had no link to ask with")
}
}
// A restoration whose build will not start is said as such — not as a rollback that worked.
func TestARestorationThatDoesNotStartIsSaid(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "node-tools", ByPing, buildA, "", "", clock)
place(t, root, "node-tools", ByPing, buildB, buildA, "", clock)
v, err := Restore(context.Background(), root, "node-tools", "it never answered", (&machine{failRestart: true}).run, clock)
if err != nil {
t.Fatal(err)
}
if v.Outcome != link.RestoreFailed || running(t, root, "node-tools") != buildA {
t.Fatalf("the verdict is %+v, running %s", v, running(t, root, "node-tools"))
}
}
// What the engine keeps survives the engine: a host that stands aside mid-trial resumes it.
func TestATrialSurvivesTheHostRestarting(t *testing.T) {
root := t.TempDir()
clock := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
place(t, root, "node-tools", ByPing, buildA, "", "", clock)
place(t, root, "node-tools", ByPing, buildB, buildA, "", clock)
a := &asker{ping: func() error { return link.ErrNoAnswer }}
var verdicts []link.Rollback
half := int(NodeToolsWithin/Every) / 2
look(watcher(root, a, &machine{}, &clock, &verdicts), &clock, half)
// A new watcher, as a successor host would start.
look(watcher(root, a, &machine{}, &clock, &verdicts), &clock, int(NodeToolsWithin/Every)-half)
if len(verdicts) != 1 {
t.Fatalf("the bound was not carried across the host restarting: %+v", verdicts)
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+133 -44
View File
@@ -27,15 +27,8 @@ setup() {
echo "$@" >> "$MESH_HOST_STATE_DIR/host.starts"
exit "${STUB_HOST_EXIT:-1}"
STUB
cat > "$MESH_HOST_LIBEXEC/rollback" <<'STUB'
#!/bin/sh
echo rolled-back >> "$MESH_HOST_STATE_DIR/rollback.calls"
[ -n "${STUB_ROLLBACK_FAILS:-}" ] && exit 1
echo "$(cat "$MESH_HOST_STATE_DIR/known-good" 2>/dev/null)" > "$MESH_HOST_STATE_DIR/rollback-attempted"
exit 0
STUB
chmod +x "$MESH_HOST_BIN" "$MESH_HOST_LIBEXEC/rollback"
unset STUB_ROLLBACK_FAILS || true
chmod +x "$MESH_HOST_BIN"
export MESH_HOST_TRIAL_BOUND=600 MESH_HOST_TRIAL_TICK=1 MESH_HOST_STOP_GRACE=1
}
# `|| true` on every launcher call above: a launcher that exits non-zero is something to
@@ -47,7 +40,7 @@ check() { if [ "$3" = "$4" ]; then PASS=$((PASS+1)); printf ' ok %s\n' "$1"
count() { cat "$MESH_HOST_STATE_DIR/start-attempts" 2>/dev/null || echo MISSING; }
started() { [ -f "$MESH_HOST_STATE_DIR/host.starts" ] && echo yes || echo no; }
rolled() { [ -f "$MESH_HOST_STATE_DIR/rollback.calls" ] && echo yes || echo no; }
rolled() { [ -s "$MESH_HOST_STATE_DIR/rolled-back" ] && echo yes || echo no; }
# --- the ordinary start -----------------------------------------------------------------------
setup
@@ -62,18 +55,6 @@ i=1; while [ $i -le 3 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "three starts do not trigger a rollback" "the limit is exceeded, not reached" "$(rolled)" "no"
check "counts them all" "" "$(count)" "3"
# --- past the limit ---------------------------------------------------------------------------
setup
echo "1.4.2" > "$MESH_HOST_STATE_DIR/known-good"
i=1; while [ $i -le 4 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "the fourth start rolls back" "three failures is a binary that does not work" "$(rolled)" "yes"
check "and still starts the host" "the rolled-back version has to be run" "$(started)" "yes"
# Below the limit, not exactly zero. The rollback resets it and the rolled-back version then
# fails once here, so 1 is right — the property is that it did NOT inherit a count already at
# the limit, which would halt the new version on its first attempt.
check "resets the counter after rolling back" "the new version deserves its own attempts, or it halts at once" \
"$([ "$(count)" -lt 3 ] && echo below-limit || echo "at-limit($(count))")" "below-limit"
# --- the host clears the counter on success ----------------------------------------------------
setup
i=1; while [ $i -le 2 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
@@ -82,15 +63,6 @@ i=1; while [ $i -le 3 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "a cleared counter prevents a rollback" "a node up for months must not roll back on a healthy boot" \
"$(rolled)" "no"
# --- rolled back once already -------------------------------------------------------------------
setup
echo "1.4.2" > "$MESH_HOST_STATE_DIR/known-good"
echo "1.4.2" > "$MESH_HOST_STATE_DIR/rollback-attempted"
i=1; while [ $i -le 4 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "does not roll back twice" "the previous version failing too means the machine, not the binary" \
"$(rolled)" "no"
check "halts instead" "" "$([ -f "$MESH_HOST_STATE_DIR/halted" ] && echo halted || echo running)" "halted"
# --- halted stays halted --------------------------------------------------------------------------
setup
echo "rolled back and still failing" > "$MESH_HOST_STATE_DIR/halted"
@@ -99,19 +71,6 @@ check "a halted node does not start the host" "nothing further is tried automati
set +e; "$LAUNCH" >/dev/null 2>&1; RC=$?; set -e
check "a halted node exits zero" "a supervisor loop that is slow and visible beats a crash loop" "$RC" "0"
# --- the rollback itself fails ----------------------------------------------------------------------
setup
echo "1.4.2" > "$MESH_HOST_STATE_DIR/known-good"
STUB_ROLLBACK_FAILS=1; export STUB_ROLLBACK_FAILS
i=1; while [ $i -le 4 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "a failed rollback halts" "restarting into the same failure would loop forever" \
"$([ -f "$MESH_HOST_STATE_DIR/halted" ] && echo halted || echo running)" "halted"
# Counted, not "was it ever started": the first three attempts DID start it, correctly, and
# only the fourth must not. An earlier version of this asserted the host was never started and
# failed for that reason rather than for a fault.
check "and does not start it on the halting attempt" "three starts, not four" \
"$(wc -l < "$MESH_HOST_STATE_DIR/host.starts" 2>/dev/null || echo 0)" "3"
# --- a corrupt counter ------------------------------------------------------------------------------
#
# The values here are chosen because they DISCRIMINATE. An earlier version used
@@ -200,6 +159,7 @@ deliver() {
cat > "$MESH_HOST_LIBEXEC/versions/$1/nox-mesh-host" <<STUB
#!/bin/sh
echo "$1" >> "\$MESH_HOST_STATE_DIR/which.ran"
${3:-}
exit "\${STUB_HOST_EXIT:-1}"
STUB
chmod +x "$MESH_HOST_LIBEXEC/versions/$1/nox-mesh-host"
@@ -252,5 +212,134 @@ setup
"$LAUNCH" >/dev/null 2>&1 || true
check "falls back to the host placed by hand" "every first host arrives this way" "$(started)" "yes"
# --- the launcher witnesses the host's successor (novox/hq to-be 45 §8) --------------------------
#
# A delivered host that is not the known-good one runs on trial: it must write itself into known-good
# (which the host does when the mesh has taken a report it made under its own build) within the bound.
# One that crashes, stops for nothing, or never reports goes back to known-good, once, recorded.
ran_count() { grep -xF "$1" "$MESH_HOST_STATE_DIR/which.ran" 2>/dev/null | wc -l | tr -d ' '; }
last_ran() { tail -n 1 "$MESH_HOST_STATE_DIR/which.ran" 2>/dev/null || echo NONE; }
record_of() { cut -f"$2" "$MESH_HOST_STATE_DIR/rolled-back" 2>/dev/null | sed -n "${1}p"; }
records() { grep . "$MESH_HOST_STATE_DIR/rolled-back" 2>/dev/null | wc -l | tr -d ' '; }
# A new host that crashes at once: three tries, then the known-good one, recorded once.
setup
deliver 1.0 "2 hours ago"
deliver 2.0 "1 hour ago"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
i=1; while [ $i -le 4 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "a crashing new host is tried three times" "the limit is the evidence" "$(ran_count 2.0)" "3"
check "then the known-good one runs" "the witness restores the build before" "$(last_ran)" "1.0"
check "the rollback is recorded once" "one line per verdict" "$(records)" "1"
check "naming what failed" "from" "$(record_of 1 1)" "2.0"
check "and what runs instead" "to" "$(record_of 1 2)" "1.0"
check "as a rollback" "outcome" "$(record_of 1 4)" "rolled-back"
check "saying why" "why" "$(record_of 1 5 | grep -c 'failed 3 times')" "1"
check "the counter starts again for the version gone back to" "or it halts at once" \
"$([ "$(count)" -lt 3 ] && echo below-limit || echo "at-limit($(count))")" "below-limit"
# Not retried: the rolled-back version is still the newest delivered, and is never started again.
i=1; while [ $i -le 2 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "a rolled-back version is never started again" "one rollback per version" "$(ran_count 2.0)" "3"
check "and it is not recorded twice" "" "$(records)" "1"
# What it went back to failing too is the machine, not a binary: halted, and said.
"$LAUNCH" >/dev/null 2>&1 || true
check "the version gone back to failing too halts" "rolling back again would flap" \
"$([ -f "$MESH_HOST_STATE_DIR/halted" ] && echo halted || echo running)" "halted"
check "and the halt is recorded" "" "$(record_of 2 4)" "halted"
check "with no rollback to a third version" "" "$(ran_count 2.0)" "3"
# A new host that exits cleanly at once, standing aside for nothing: counted, then rolled back.
setup
deliver 1.0 "2 hours ago"
deliver 2.0 "1 hour ago" "exit 0"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
i=1; while [ $i -le 4 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "a new host that stops for nothing is rolled back" "a clean exit with nothing newer is not standing aside" \
"$(record_of 1 1) -> $(record_of 1 2)" "2.0 -> 1.0"
check "after three tries" "" "$(ran_count 2.0)" "3"
# A new host that runs and never reports: stopped at the bound, rolled back.
setup
export MESH_HOST_TRIAL_BOUND=2
deliver 1.0 "2 hours ago"
deliver 2.0 "1 hour ago" "sleep 30"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
"$LAUNCH" >/dev/null 2>&1 || true
check "a host that never reports is rolled back at the bound" "started and did nothing" \
"$(record_of 1 1) -> $(record_of 1 2)" "2.0 -> 1.0"
check "saying it did not report" "" "$(record_of 1 5 | grep -c 'did not report within 2s')" "1"
check "and the known-good one runs" "" "$(last_ran)" "1.0"
check "the silent host is not left running" "the witness stops it" \
"$(pgrep -f "$MESH_HOST_LIBEXEC/versions/2.0" >/dev/null 2>&1 && echo running || echo stopped)" "stopped"
# A new host that reports in bound is proved: no rollback, and the trial ends.
setup
export MESH_HOST_TRIAL_BOUND=3
deliver 1.0 "2 hours ago"
deliver 2.0 "1 hour ago" "echo 2.0 > \"\$MESH_HOST_STATE_DIR/known-good\"; sleep 4"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
"$LAUNCH" >/dev/null 2>&1 || true
check "a host that reports in bound is not rolled back" "a healthy update undoes nothing" "$(rolled)" "no"
check "it ran past its bound" "the witness let it be once it reported" "$(ran_count 2.0)" "1"
"$LAUNCH" >/dev/null 2>&1 || true
check "and is the one run after" "known-good now" "$(last_ran)" "2.0"
check "and no longer on trial" "the trial file goes" \
"$([ -e "$MESH_HOST_STATE_DIR/trial" ] && echo on-trial || echo proved)" "proved"
# A launcher restarted after the bound passed rolls back without starting the host again.
setup
deliver 1.0 "2 hours ago"
deliver 2.0 "1 hour ago"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
printf '2.0 %s\n' "$(( $(date +%s) - 4000 ))" > "$MESH_HOST_STATE_DIR/trial"
"$LAUNCH" >/dev/null 2>&1 || true
check "a trial past its bound is ended at the next start" "a host that keeps restarting cannot outrun its bound" \
"$(ran_count 2.0) $(record_of 1 1)" "0 2.0"
# A newer host delivered after a rollback runs, on trial; the one rolled back stays refused.
setup
deliver 1.0 "3 hours ago"
deliver 2.0 "2 hours ago"
echo 1.0 > "$MESH_HOST_STATE_DIR/known-good"
printf '2.0\t1.0\t%s\trolled-back\tit failed 3 times in a row\n' "$(date +%s)" > "$MESH_HOST_STATE_DIR/rolled-back"
echo 1.0 > "$MESH_HOST_STATE_DIR/rollback-pinned"
touch -d "1 hour ago" "$MESH_HOST_STATE_DIR/rollback-pinned"
deliver 3.0 "1 minute ago"
"$LAUNCH" >/dev/null 2>&1 || true
check "a newer delivery after a rollback runs" "a rolled-back version blocks only itself" "$(last_ran)" "3.0"
check "and the pin goes" "" "$([ -e "$MESH_HOST_STATE_DIR/rollback-pinned" ] && echo pinned || echo free)" "free"
check "on trial" "" "$(cut -d' ' -f1 "$MESH_HOST_STATE_DIR/trial" 2>/dev/null)" "3.0"
# No known-good delivered: nothing to go back to, never halted, tried again slowly.
setup
deliver 2.0 "1 hour ago"
i=1; while [ $i -le 5 ]; do "$LAUNCH" >/dev/null 2>&1 || true; i=$((i+1)); done
check "with nothing to go back to there is no rollback" "an installation failure, not an upgrade's" "$(rolled)" "no"
check "and no halt" "" "$([ -f "$MESH_HOST_STATE_DIR/halted" ] && echo halted || echo running)" "running"
# A delivered launcher runs at the host's next clean exit, not at the next boot.
setup
unset MESH_HOST_RUN_ONCE
cp "$LAUNCH" "$WORK/launch"
cat > "$MESH_HOST_BIN" <<STUB
#!/bin/sh
echo start >> "\$MESH_HOST_STATE_DIR/host.starts"
if [ "\$(wc -l < "\$MESH_HOST_STATE_DIR/host.starts")" -eq 1 ]; then
# The mesh delivers a new launcher, and this host stands aside.
sed '2i echo renewed >> "\$MESH_HOST_STATE_DIR/launcher.renewed"' "$WORK/launch" > "$WORK/launch.new"
chmod +x "$WORK/launch.new"; mv "$WORK/launch.new" "$WORK/launch"
exit 0
fi
sleep 30
STUB
chmod +x "$MESH_HOST_BIN"
"$WORK/launch" >/dev/null 2>&1 &
LP=$!
sleep 1
check "a replaced launcher runs itself at the next clean exit" "or a launcher fix waits for a reboot" \
"$(cat "$MESH_HOST_STATE_DIR/launcher.renewed" 2>/dev/null || echo NOT-RENEWED)" "renewed"
kill -TERM "$LP" 2>/dev/null; sleep 1; pkill -f "$MESH_HOST_BIN" 2>/dev/null || true
printf '\nlaunch: %d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
+199 -39
View File
@@ -6,10 +6,22 @@
# up, when to roll back: all of it is policy, and policy in a unit file can only be read and
# hoped for.
#
# **It is the witness of the host's own successor** (novox/hq to-be 45 §8, ADR 0227 rule 8). A
# delivered host that is not the known-good one runs on trial: it must report its declaration
# under its own build — which it marks by writing itself into known-good — within a bound. One
# that crashes repeatedly, exits without standing aside for anything, or does not report in
# bound is stopped, recorded in `rolled-back` with why, and the known-good one is run. The host
# says every record on its reports, so the mesh raises it as a condition. One rollback per
# version: a version in `rolled-back` is never started again; a newer delivery is.
#
# It does NOT exec the host. Exec would replace this process, and then only the init could
# restart anything — which is the arrangement this exists to remove. The cost of staying is
# signal handling, below.
#
# Everything a rollback does is one of: read a file, look at a directory, write a file. It shares
# no code with the host and calls none of it: a binary that cannot start cannot be its own
# recovery.
#
# POSIX sh. `set -e` is deliberately absent: this script's whole job is to inspect exit codes,
# and -e would make it exit on the first one it is meant to handle.
set -u
@@ -23,16 +35,46 @@ VERSIONS="$LIBEXEC/versions"
BINARY="nox-mesh-host"
LIMIT="${MESH_HOST_START_LIMIT:-3}"
BACKOFF="${MESH_HOST_BACKOFF:-5}"
# How long a host on trial has to report, in seconds: to-be 45 §8's ten minutes from the apply.
BOUND="${MESH_HOST_TRIAL_BOUND:-600}"
TICK="${MESH_HOST_TRIAL_TICK:-5}"
GRACE="${MESH_HOST_STOP_GRACE:-20}"
ONCE="${MESH_HOST_RUN_ONCE:-}" # tests run one iteration; nothing else sets this
ATTEMPTS="$STATE_DIR/start-attempts"
HALTED="$STATE_DIR/halted"
PINNED="$STATE_DIR/rollback-pinned"
KNOWN_GOOD="$STATE_DIR/known-good"
# One line per verdict: from, to, when (seconds since the epoch), outcome, why — tab-separated, read
# by the host (internal/upgrade) and said on its reports.
ROLLED="$STATE_DIR/rolled-back"
TRIAL="$STATE_DIR/trial"
EXPIRED="$STATE_DIR/trial-expired"
say() { echo "nox-mesh-host-launch: $*" >&2; }
# Which host to run: the version a rollback pinned, or the most recently delivered one, or the one
# placed by hand when nothing has been delivered (novox/hq ADR 0141).
now() { date +%s; }
known_good() { tr -d '[:space:]' < "$KNOWN_GOOD" 2>/dev/null || true; }
delivered() { [ -n "$1" ] && [ -x "$VERSIONS/$1/$BINARY" ]; }
rolled_back() { [ -s "$ROLLED" ] && cut -f1 "$ROLLED" 2>/dev/null | grep -qxF "$1"; }
# The version a host path is: the directory it was delivered in, or nothing for the host placed by hand.
version_of() {
case "$1" in
"$VERSIONS"/*/"$BINARY") v="${1#"$VERSIONS"/}"; echo "${v%/"$BINARY"}" ;;
*) echo "" ;;
esac
}
record() { # from to outcome why
printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$(now)" "$3" "$4" >> "$ROLLED"
}
# Which host to run: the newest delivered one that was never rolled back — or, while a rollback's pin
# stands, the version it pinned — or the one placed by hand when nothing has been delivered.
#
# **Asked every time round the loop, not once.** Standing aside for a successor is a clean exit, and
# the next turn has to run what is on disk NOW — resolving this once would restart the same binary
@@ -41,27 +83,76 @@ say() { echo "nox-mesh-host-launch: $*" >&2; }
# Newest by when it arrived, never by how its name sorts: a version string is whatever the source was
# described as, and those do not sort — "1.10" orders before "1.9". Ordering by name would start an
# older host and call it an upgrade.
#
# **A pin stands until something newer arrives.** A version delivered after the pin was written, and
# never rolled back, is a new build the mesh asks for: the pin goes and it runs, on trial.
pick_host() {
if [ -s "$PINNED" ]; then
pinned="$(tr -d '[:space:]' < "$PINNED" 2>/dev/null || true)"
if [ -n "$pinned" ] && [ -x "$VERSIONS/$pinned/$BINARY" ]; then
echo "$VERSIONS/$pinned/$BINARY"
return 0
fi
say "the pinned version '$pinned' is not delivered; ignoring the pin"
fi
newest=
# A directory with no executable in it is not a version: an interrupted delivery leaves one, and
# running "the newest" would then mean running nothing.
for candidate in $(ls -1t "$VERSIONS" 2>/dev/null || true); do
if [ -x "$VERSIONS/$candidate/$BINARY" ]; then
echo "$VERSIONS/$candidate/$BINARY"
return 0
fi
delivered "$candidate" || continue
rolled_back "$candidate" && continue
newest="$candidate"
break
done
if [ -s "$PINNED" ]; then
pinned="$(tr -d '[:space:]' < "$PINNED" 2>/dev/null || true)"
if [ -n "$newest" ] && [ "$newest" != "$pinned" ] && [ "$VERSIONS/$newest" -nt "$PINNED" ]; then
say "host $newest arrived after the rollback to $pinned; running it"
rm -f "$PINNED"
elif delivered "$pinned"; then
echo "$VERSIONS/$pinned/$BINARY"
return 0
else
say "the pinned version '$pinned' is not delivered; ignoring the pin"
fi
fi
if [ -n "$newest" ]; then
echo "$VERSIONS/$newest/$BINARY"
return 0
fi
echo "$FALLBACK"
}
# Go back from `from` to the known-good version, once, and say so. False when there is nowhere to go.
roll_back() { # from why
kg="$(known_good)"
if [ -z "$1" ] || [ "$1" = "$kg" ] || ! delivered "$kg"; then
return 1
fi
record "$1" "$kg" rolled-back "$2"
# The pin is what stops the launcher starting a version newer than known-good that is not the one
# rolled back; the record is what stops it starting this one again.
printf '%s\n' "$kg" > "$PINNED"
rm -f "$TRIAL"
say "rolled back from host $1 to $kg: $2"
return 0
}
# Watch a host on trial: done when it writes itself into known-good, stopped when the bound passes.
# A subshell beside the host, so the launcher's own wait is untouched.
trial_watch() { # pid version deadline
while kill -0 "$1" 2>/dev/null; do
[ "$(known_good)" = "$2" ] && return 0
if [ "$(now)" -ge "$3" ]; then
printf '%s\n' "$2" > "$EXPIRED"
say "host $2 did not report within ${BOUND}s of starting; stopping it"
kill -TERM "$1" 2>/dev/null
waited=0
while kill -0 "$1" 2>/dev/null && [ "$waited" -lt "$GRACE" ]; do
sleep 1
waited=$((waited + 1))
done
kill -KILL "$1" 2>/dev/null
return 0
fi
sleep "$TICK"
done
}
child=
watcher=
stopping=
# The machine is shutting down. Pass it on and wait for the host to finish — a supervisor that
@@ -77,6 +168,9 @@ on_term() {
trap on_term TERM INT
mkdir -p "$STATE_DIR"
# What this launcher is, so a delivered successor of it is run at the next clean exit rather than at
# the next boot.
SELF="$(cksum < "$0" 2>/dev/null || true)"
while :; do
if [ -n "$stopping" ]; then
@@ -89,8 +183,8 @@ while :; do
exit 0
fi
# Consecutive failed starts, not starts. Cleared by the host itself when it completes a
# reconcile, which is the only evidence either this or known-good has.
# Consecutive failed starts, not starts. Cleared by the host itself when it reports, which is
# the only evidence either this or known-good has.
#
# Read the FIRST FIELD, then insist it is a plain integer.
#
@@ -105,49 +199,102 @@ while :; do
'' | *[!0-9]*) count=0 ;;
esac
HOST="$(pick_host)"
version="$(version_of "$HOST")"
if [ "$count" -ge "$LIMIT" ]; then
if [ -e "$STATE_DIR/rollback-attempted" ]; then
say "the host failed $count times after a rollback. the previous version does not"
say "start either, so this is the machine and not the binary."
if roll_back "$version" "it failed $count times in a row"; then
# Fresh count for the version now run: it deserves its own attempts, and without this
# it inherits a count already over the limit and halts at once. The variable too, not
# only the file: resetting one and not the other made the next failure count from the
# OLD value — so the rolled-back version got one attempt instead of three.
count=0
printf '%s\n' "$count" > "$ATTEMPTS"
continue
fi
kg="$(known_good)"
if [ -n "$kg" ] && { [ "$version" = "$kg" ] || [ -z "$version" ]; } && [ -s "$ROLLED" ]; then
# Already gone back, and what it went back to fails as well: this is the machine and
# not a binary. Rolling back again would flap between two versions for ever.
say "the host failed $count times after a rollback. the version it went back to does"
say "not start either, so this is the machine and not the binary."
record "${version:-placed-by-hand}" "" halted "the version rolled back to failed $count times as well"
printf 'rolled back and still failing\n' > "$HALTED"
exit 0
fi
say "the host failed $count times. rolling back."
if "$LIBEXEC/rollback"; then
# Fresh count for the version just installed: it deserves its own attempts, and
# without this it inherits a count already over the limit and halts at once.
#
# The variable too, not only the file. Resetting one and not the other made the
# next failure count from the OLD value — so the rolled-back version got one
# attempt instead of three.
count=0
printf '%s\n' "$count" > "$ATTEMPTS"
else
say "rollback failed. halting rather than restarting into the same failure."
printf 'rollback failed\n' > "$HALTED"
exit 0
fi
# Nothing to go back to: no host has ever reported here, or the one that did was placed by
# hand and is not delivered. An installation failure rather than an upgrade's — said, and
# tried again slowly, never guessed at.
say "the host failed $count times and there is no delivered known-good version to go back to."
count=0
printf '%s\n' "$count" > "$ATTEMPTS"
fi
HOST="$(pick_host)"
if [ ! -x "$HOST" ]; then
say "no host to run: nothing delivered under $VERSIONS and $FALLBACK is not executable."
printf 'no host binary\n' > "$HALTED"
exit 0
fi
say "running $HOST"
# **On trial**: a delivered version that is not the known-good one, while a known-good one is
# delivered to go back to. The trial starts when this version was first started, and survives
# this launcher restarting.
trial=
deadline=
kg="$(known_good)"
if [ -n "$version" ] && [ "$version" != "$kg" ] && delivered "$kg"; then
trial=yes
started=
if [ -s "$TRIAL" ]; then
read -r on since _ < "$TRIAL" 2>/dev/null || on=
[ "${on:-}" = "$version" ] && started="${since:-}"
fi
case "$started" in
'' | *[!0-9]*) started="$(now)"; printf '%s %s\n' "$version" "$started" > "$TRIAL" ;;
esac
deadline=$((started + BOUND))
if [ "$(now)" -ge "$deadline" ]; then
roll_back "$version" "it did not report within ${BOUND}s of starting" && continue
fi
else
rm -f "$TRIAL"
fi
rm -f "$EXPIRED"
say "running $HOST${trial:+ (on trial until it reports)}"
"$HOST" run &
child=$!
watcher=
if [ -n "$trial" ]; then
trial_watch "$child" "$version" "$deadline" &
watcher=$!
fi
status=0
wait "$child" || status=$?
if [ -n "$stopping" ]; then
# The signal interrupted the wait, not the host: it was told, and is finishing what it was
# doing. Waited for, so the service manager does not kill it half way through an apply.
wait "$child" 2>/dev/null
fi
child=
if [ -n "$watcher" ]; then
kill "$watcher" 2>/dev/null
wait "$watcher" 2>/dev/null
watcher=
fi
if [ -n "$stopping" ]; then
exit 0
fi
if [ -n "$trial" ] && [ -s "$EXPIRED" ] && [ "$(cat "$EXPIRED" 2>/dev/null)" = "$version" ]; then
rm -f "$EXPIRED"
roll_back "$version" "it did not report within ${BOUND}s of starting"
count=0
printf '%s\n' "$count" > "$ATTEMPTS"
continue
fi
# A signal the host did not survive, and we are not shutting down: treat it as a crash.
case "$status" in
0)
@@ -158,8 +305,21 @@ while :; do
# incremented here rather than before the start: counting attempts meant a host
# that upgraded itself three times rolled itself back, having worked perfectly
# every time.
say "the host exited cleanly; starting it again"
continue
#
# **Unless it stood aside for nothing.** A host on trial that exits cleanly while the
# same host is still the one to run has not stood aside for a successor: it has
# stopped, and a host that stops at once would otherwise loop here for ever unseen.
if [ -n "$trial" ] && [ "$(pick_host)" = "$HOST" ] && [ "$(known_good)" != "$version" ]; then
say "host $version exited cleanly on trial with nothing newer to stand aside for"
else
# A delivered successor of this launcher runs from here on, not from the next boot.
if [ -n "$SELF" ] && [ "$(cksum < "$0" 2>/dev/null || true)" != "$SELF" ]; then
say "the launcher was replaced; running the new one"
exec "$0"
fi
say "the host exited cleanly; starting it again"
continue
fi
;;
esac
+5
View File
@@ -1,6 +1,11 @@
#!/bin/sh
# Put the host back on the last version that worked.
#
# **Superseded by the launcher itself** (novox/hq to-be 45 §8): a launcher from then on rolls back on
# its own — per version, recorded in `rolled-back` and said on the host's reports — and does not call
# this. Kept, unchanged, for the launchers before it still running on a machine until it restarts
# them; the host restarts its service once it sees its launcher was replaced.
#
# novox/hq ADR 0005 and ADR 0141. This runs when nox-mesh-host will not start, so it shares no code
# with it and calls none of it: a binary that cannot start cannot be its own recovery. POSIX sh, no
# bashisms, nothing that has to be installed.