Files
mesh-host/internal/upgrade/fallback.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

138 lines
4.5 KiB
Go

package upgrade
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// The engine placed by hand, kept current (novox/hq issue 356).
//
// The first engine on a machine is copied there by a person (novox/hq ADR 0141): the launcher runs it
// while nothing is delivered, and it is the one `nox-mesh-host` on any PATH. Every engine after it is
// delivered under VersionsDirName and run from there, so the file on PATH fell behind: on 2026-10-09 the
// operator typed its `hand-over` and was answered "unexpected argument" by a build months older than the
// one running the machine. A command that answers for a build that is not running is the lie this
// repository exists to refuse.
//
// So a proved engine (Proved) writes itself over the one placed by hand when the two differ: a plain
// copy, renamed into place — never a link, which the mesh creates none of (novox/hq ADR 0012), and never
// a write through one. The launcher's fallback is then the newest proved build, which is also the best
// one to fall back on.
// DefaultFallback is where the engine placed by hand lives: the launcher's fallback, and the command.
const DefaultFallback = "/usr/bin/nox-mesh-host"
// FallbackVar is the environment the launcher reads the fallback's path from; read here too, so the two
// never name different files.
const FallbackVar = "MESH_HOST_BIN"
// FallbackPath is the engine placed by hand: FallbackVar, or DefaultFallback.
func FallbackPath() string {
if p := os.Getenv(FallbackVar); p != "" {
return p
}
return DefaultFallback
}
// RenewFallback makes the file at fallback a copy of the executable at self, when it is not one already.
//
// Nothing is written when the two are the same file (an engine running from the fallback itself, while
// nothing is delivered) or hold the same bytes. A fallback that is a link is refused: the mesh writes
// through no link, and a link there would be somebody else's arrangement. Reports whether it was renewed.
func RenewFallback(self, fallback string) (bool, error) {
if self == "" || fallback == "" {
return false, errors.New("the running engine or the fallback has no path")
}
mine, err := os.Stat(self)
if err != nil {
return false, fmt.Errorf("cannot read the running engine at %s: %w", self, err)
}
if !mine.Mode().IsRegular() {
return false, fmt.Errorf("the running engine at %s is not a regular file", self)
}
placed, err := os.Lstat(fallback)
switch {
case errors.Is(err, os.ErrNotExist):
// Nothing placed by hand: the launcher would have nothing to fall back on. Placed now.
case err != nil:
return false, fmt.Errorf("cannot read the engine placed by hand at %s: %w", fallback, err)
case placed.Mode()&os.ModeSymlink != 0:
return false, fmt.Errorf("%s is a link, and the mesh writes through none (novox/hq ADR 0012): "+
"it is left as it is", fallback)
case !placed.Mode().IsRegular():
return false, fmt.Errorf("%s is not a regular file: it is left as it is", fallback)
case os.SameFile(mine, placed):
return false, nil
default:
same, err := sameBytes(self, fallback)
if err != nil {
return false, err
}
if same {
return false, nil
}
}
src, err := os.Open(self)
if err != nil {
return false, err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(fallback), "."+filepath.Base(fallback)+"-*")
if err != nil {
return false, fmt.Errorf("cannot write beside %s: %w", fallback, err)
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Close(); err != nil {
return false, err
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return false, err
}
// Renamed, never written in place: the kernel refuses to truncate an executable that runs, and a
// rename leaves whatever runs from the old file untouched.
if err := os.Rename(tmp.Name(), fallback); err != nil {
return false, err
}
return true, nil
}
// sameBytes says whether two files hold the same bytes, by digest.
func sameBytes(a, b string) (bool, error) {
da, err := digestOf(a)
if err != nil {
return false, err
}
db, err := digestOf(b)
if err != nil {
return false, err
}
return bytes.Equal(da, db), nil
}
func digestOf(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return h.Sum(nil), nil
}