Files
mesh-host/internal/witness/kept.go
T
jochen 3a117c2d2b
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Keep a replaced build reachable, and delete it only once no process runs from it (hq issue 289)
The witness moved a running controller's build into a 0700 directory and deleted it
on proof, while the old process could still be serving. Its directories are now
0711, and a build without a reader is retired and swept once /proc shows nothing
runs from it.
2026-10-07 02:45:08 +02:00

376 lines
14 KiB
Go

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 retired once it is: the previous build has
// exactly one reader — a restoration — and none once the build after it has been seen healthy. Retired,
// not deleted: a build is deleted only once no process runs from it (draining.go, issue 289).
// 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 := keep(root, s.Process); 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. Retired,
// since it still runs until the restart (issue 289).
if err := retire(root, name, at, now); err != nil {
return Placing{}, err
}
default:
if err := retire(root, name, previousDir(root, name), now); err != nil {
return Placing{}, err
}
// Moved while its process still runs, until the restart stops it: reachable at its new path
// by the user it runs as (issue 289).
if err := keep(root, name); 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 retired — it has no reader now,
// and is deleted once no process runs from it — 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 := retire(root, name, previousDir(root, name), time.Now()); 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 := retire(root, name, failed, now); 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)
}
_ = retire(root, name, failed, now)
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
}