Files
mesh-host/internal/witness/draining.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

154 lines
5.2 KiB
Go

package witness
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// **A build is never deleted while a process runs from it** (novox/hq issue 289).
//
// The order of a witnessed update is: the running build moved aside as the previous one, the new one
// unpacked in its place, the unit restarted — the supervisor stops the old process, which drains, then
// starts the new — and the build before it retired once the new one is proved. Between the move and the
// stop the old process still serves, and a process may start another from its own image (the
// controller runs every verb as a command of its own binary). So, while that can happen:
//
// - the old build stays **reachable by the user it runs as**: the directories the engine keeps beside
// a process are 0711 — enterable by name, listable by root alone — never 0700, so a build moved
// aside is still at a path its process may open;
// - a build that has no reader any more is **retired**, not deleted: renamed under
// `.witness/<name>/retired/`, which leaves every process running from it untouched, and deleted
// only when no process on the machine runs from it. Which processes run from it is asked of the
// kernel — every process's executable — so the one it ran as is found by its PID, and so are the
// commands it started, wherever its unit is in stopping.
//
// The controller does its half too: it runs its verbs from its own running image rather than the path
// it started from, and refuses as a handover what it cannot run (mesh-controller, issue 289).
// reachable is the mode of every directory the engine keeps beside a process: enterable by the
// process's own user, listable by nobody but root.
const reachable = 0o711
func retiredDir(root, name string) string { return filepath.Join(Dir(root, name), "retired") }
// keep makes the directories the engine keeps about a process — made 0700 before issue 289 — reachable.
func keep(root, name string) error {
for _, dir := range []string{filepath.Join(root, ".witness"), Dir(root, name)} {
if err := os.MkdirAll(dir, reachable); err != nil {
return err
}
if err := os.Chmod(dir, reachable); err != nil {
return err
}
}
return nil
}
// retire takes a build that has no reader any more out of the way: renamed under retired/, and deleted
// at once if nothing runs from it, else at a later sweep. A path that is not there is nothing to do.
func retire(root, name, dir string, now time.Time) error {
if _, err := os.Lstat(dir); os.IsNotExist(err) {
return nil
}
if err := keep(root, name); err != nil {
return err
}
into := retiredDir(root, name)
if err := os.MkdirAll(into, reachable); err != nil {
return err
}
if err := os.Chmod(into, reachable); err != nil {
return err
}
at := filepath.Join(into, fmt.Sprintf("%s-%d", filepath.Base(dir), now.UnixNano()))
if err := os.Rename(dir, at); err != nil {
return fmt.Errorf("cannot retire %s's build at %s: %w", name, dir, err)
}
_, err := Sweep(root, name)
return err
}
// Sweep deletes every retired build of a process that no process runs from any more, and says which
// are kept, with the processes still running from each.
func Sweep(root, name string) ([]string, error) {
into := retiredDir(root, name)
entries, err := os.ReadDir(into)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var draining []string
for _, e := range entries {
dir := filepath.Join(into, e.Name())
if pids := RunningFrom(dir); len(pids) > 0 {
draining = append(draining, fmt.Sprintf("%s (still run by PID %s)", dir, joinPIDs(pids)))
continue
}
if err := os.RemoveAll(dir); err != nil {
return draining, err
}
}
return draining, nil
}
// RunningFrom is every process whose executable is a file under dir — renamed there, or deleted from
// there since it started. A variable so a test can name the machine's processes.
var RunningFrom = func(dir string) []int {
return runningFromProc("/proc", dir)
}
func runningFromProc(proc, dir string) []int {
entries, err := os.ReadDir(proc)
if err != nil {
return nil
}
dir = filepath.Clean(dir) + string(filepath.Separator)
var pids []int
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
exe, err := os.Readlink(filepath.Join(proc, e.Name(), "exe"))
if err != nil {
// Gone between the listing and the read, a kernel thread, or another user's process this
// engine may not read — it runs as root, so in practice the first two.
continue
}
if strings.HasPrefix(strings.TrimSuffix(exe, " (deleted)"), dir) {
pids = append(pids, pid)
}
}
sort.Ints(pids)
return pids
}
func joinPIDs(pids []int) string {
words := make([]string, len(pids))
for i, p := range pids {
words[i] = strconv.Itoa(p)
}
return strings.Join(words, ", ")
}
// SweepAll is Sweep for every process the engine keeps anything about: what the witness does on every
// look, so a build retired while its process drained goes once the process has.
func SweepAll(root string) []string {
var draining []string
for _, s := range all(root) {
kept, err := Sweep(root, s.Process)
if err != nil {
draining = append(draining, fmt.Sprintf("%s: %v", s.Process, err))
}
draining = append(draining, kept...)
}
return draining
}