Files
mesh-host/cmd/mesh-host/witnessing.go
T
jochen e74ee82a76
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Renew the engine placed by hand to the proved build, and take a hand-over from the controller's terminal (hq issue 356)
The file on PATH was the engine a person copied at installation, and every delivered engine ran from the versions directory: the operator typed hand-over and a build from before it answered "unexpected argument". A proved engine now copies itself over that file when the bytes differ (never a link). And the hand-over of a directory used as found (issue 339) gets its channel: the controller's terminal asks this node's engine on mesh.node.<node>.ask.hand-over; the engine judges every value — the ask's size and shape, who asked, the path as it states it, a directory and no link, through no link — and records it or refuses and records nothing. Every text the engine addresses to the operator names the nox line (ADR 0272); the engine's own subcommands are install-time and rescue, and its usage says so.
2026-10-09 18:34:10 +02:00

167 lines
6.2 KiB
Go

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, ", ")))
}
renewFallback(p.version, p.say)
renewLauncher(p.say)
})
}
// renewFallback makes the engine placed by hand — the launcher's fallback, and the `nox-mesh-host` on this
// machine's PATH — a copy of this proved build when it is not one (novox/hq issue 356). On the same
// evidence known-good is written on, and said when it happens or cannot: a stale command that answers
// for a build that is not running would otherwise stay until somebody typed it.
func renewFallback(version string, say func(string)) {
self, err := os.Executable()
if err != nil {
say("the engine placed by hand could not be renewed: this build does not know its own path: " + err.Error())
return
}
fallback := upgrade.FallbackPath()
renewed, err := upgrade.RenewFallback(self, fallback)
switch {
case err != nil:
say(fmt.Sprintf("the engine placed by hand at %s is not this build, and could not be renewed: %v", fallback, err))
case renewed:
say(fmt.Sprintf("renewed the engine placed by hand at %s to this build, %s: the command on this machine is the "+
"engine that runs it", fallback, version))
}
}
// 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)
}