Files
mesh-host/internal/upgrade/upgrade.go
jschoubben 430e2a1271 Fix a comment left odd by the renumbering
Two records merged into one, so a reference to both became 'ADR 0005 and ADR
0005'.
2026-08-29 01:47:15 +02:00

160 lines
5.5 KiB
Go

// Package upgrade is how the host survives replacing itself.
//
// novox/hq ADR 0005. Two facts, and neither is the host judging its own health:
//
// - whether the executable this process started from has been replaced on disk, which is how
// it knows to stand aside for a new one;
// - which version last got as far as a completed reconcile, which is what a rollback outside
// this binary reads when this binary will not start.
//
// The second is written for a reader that is not the host. A binary that cannot start cannot be
// its own recovery, so what it leaves behind has to be plain enough for a shell script.
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Files the launcher reads and this binary writes. Next to the store, because they are node
// state of exactly the same kind.
const (
KnownGoodName = "known-good"
AttemptsName = "start-attempts"
)
// Self is the executable this process started from, remembered.
//
// Identity is taken once, at start, and compared later. The obvious alternative — asking
// /proc/self/exe whether it is marked deleted — was tried and is worse in two ways: it is Linux
// procfs behaviour rather than a fact about files, and it catches only *unlink*, so a binary
// swapped by rename onto the same path reads as untouched. Remembering what we started from
// needs no special filesystem and misses neither case.
type Self struct {
path string
info os.FileInfo
}
// Current captures the running executable's identity.
//
// path is what os.Executable() returned; a test passes one it can manipulate, because the
// boundary being tested is the filesystem and a fake would assert that the fake behaves as
// expected (novox/hq ADR 0017).
func Current(path string) (Self, error) {
info, err := os.Stat(path)
if err != nil {
return Self{}, fmt.Errorf(
"cannot stat %s, so this host cannot tell whether it is later replaced: %w", path, err)
}
return Self{path: path, info: info}, nil
}
// Path is where the executable was when this process started.
func (s Self) Path() string { return s.path }
// Replaced reports whether a different file is at that path now, or none.
//
// Never a silent false: a host that cannot read its own image says so rather than assuming it is
// current, which is the shape of every fault this repository catalogues.
func (s Self) Replaced() (bool, error) {
if s.info == nil {
return false, errors.New("this host never captured its own identity, so it cannot tell " +
"whether it has been replaced")
}
now, err := os.Stat(s.path)
if errors.Is(err, os.ErrNotExist) {
// Removed rather than upgraded. Still not what is running, and saying "unchanged"
// would leave the host claiming a version that is no longer installed.
return true, nil
}
if err != nil {
return false, err
}
return !os.SameFile(s.info, now), nil
}
// KnownGoodPath is where the marker lives, given where the store lives.
func KnownGoodPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), KnownGoodName)
}
// AttemptsPath is where the launcher counts starts that have not yet worked.
func AttemptsPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), AttemptsName)
}
// ClearAttempts tells the launcher this start worked.
//
// Written at the same moment as known-good and for the same reason: a completed reconcile is
// the evidence, and it is the only evidence either of them has. Without this the counter only
// ever climbs, so a node that has been up for months rolls itself back on its third ordinary
// restart — a healthy machine undone by its own recovery.
func ClearAttempts(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte("0\n"), 0o644)
}
// RecordKnownGood marks a version as one that started and completed a reconcile.
//
// Written atomically and as one bare line. The reader is a shell script running on a machine
// where the host is failing to start, so the format is the least it can be: no JSON, no
// escaping, nothing that needs a parser to be present and working.
func RecordKnownGood(path, version string) error {
if strings.TrimSpace(version) == "" {
return errors.New("refusing to record an empty version as known-good: a rollback " +
"reading it would install nothing and report success")
}
if strings.ContainsAny(version, "\n\r") {
return fmt.Errorf("refusing to record %q as known-good: it must be one line", version)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".known-good-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := fmt.Fprintln(tmp, version); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// ReadKnownGood returns the recorded version, or "" if there has never been one.
//
// Absence is not an error. A machine whose host has never completed a reconcile has no version
// to go back to, and that is a real state rather than a fault: the node was never working, so
// the failure belongs to the installation and not to an upgrade. A rollback that guessed here
// would become a second fault.
func ReadKnownGood(path string) (string, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}