textmachine/platform/internal/runner/marker.go

153 lines
6.6 KiB
Go

package runner
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
)
// Marker is what systemd saw when the unit ended, recorded by ExecStopPost.
//
// It exists because the platform is deliberately not watching: a run outlives a deploy, so the
// process that started it is usually not the process that learns it is over. A D-Bus signal answers
// only the case where the platform happened to be up at that instant — which is the case the design
// says will often be false, since the control plane is restarted several times a week ON PURPOSE.
// A file in the platform's state directory answers both.
type Marker struct {
Unit string `json:"unit"`
// Result is systemd's $SERVICE_RESULT: success · exit-code · signal · oom-kill · timeout · … .
// It is the discriminator the exit code cannot give: "the engine exited 1" and "the kernel took
// it away at the memory limit" are the same 1 to a caller reading only the code.
Result string `json:"result"`
// Code is $EXIT_CODE — "exited" or "killed" — and Status is $EXIT_STATUS, which is the process's
// exit status for the first and a signal NAME for the second. Kept as text because that is what
// systemd hands over, and inventing a numeric union here would lose the signal names.
Code string `json:"code"`
Status string `json:"status"`
At time.Time `json:"at"`
}
// Exited reports the engine's own exit status, which is the only thing the ratified exit contract
// (0 clean · 2 flagged · 3 bank stop · 1 anything else) is defined over. ok is false when the
// process did not exit on its own — killed, timed out, OOM — because then there is no exit code to
// map and the run has to be judged by Result instead.
func (m Marker) Exited() (code int, ok bool) {
if m.Code != "exited" {
return 0, false
}
var n int
if _, err := fmt.Sscanf(m.Status, "%d", &n); err != nil {
return 0, false
}
return n, true
}
// WriteMarker records the end of a unit. Called by the marker command that runs as ExecStopPost.
//
// The write is atomic (temp file plus rename in the same directory), because the reader is the
// reconciler and it polls: a half-written marker read as "this run ended" would settle money
// against a result nobody produced.
func WriteMarker(path string, m Marker) error {
if m.At.IsZero() {
m.At = time.Now().UTC()
}
body, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("runner: encode marker: %w", err)
}
body = append(body, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("runner: marker directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".marker-*")
if err != nil {
return fmt.Errorf("runner: marker temp file: %w", err)
}
defer func() { _ = os.Remove(tmp.Name()) }() // a no-op once the rename succeeded
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return fmt.Errorf("runner: write marker: %w", err)
}
// Durability before visibility: the marker outlives a machine that loses power, and the reader
// is allowed to conclude "the run is over" from its mere presence.
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("runner: sync marker: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("runner: close marker: %w", err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("runner: publish marker: %w", err)
}
return nil
}
// ErrNoMarker is a unit that has not ended, or ended without being able to say so (a reboot takes
// the whole manager, and with it any ExecStopPost that had not run yet).
var ErrNoMarker = errors.New("runner: no exit marker")
// ErrBadMarker is a marker that EXISTS and does not parse.
//
// It is told apart from an I/O error deliberately, and the distinction is the same one the journal's
// reader makes: a moment in which the file could not be read heals on the next sweep, whereas a file
// whose CONTENT is not ours never does. WriteMarker is atomic — temp file, fsync, rename — so no
// crash of this platform can produce a half-written one; what can is something outside it, an
// operator editing the file or a volume that came back damaged. Retrying such a marker wedged the
// reconciliation of that run for good, on every pass (register row PD-164).
var ErrBadMarker = errors.New("runner: the exit marker cannot be read")
// UnitVanishedResult is the exit_result of an attempt whose unit went away without writing a marker
// at all — a reboot takes the manager and every ExecStopPost with it. Like the two values around it,
// it is deliberately one systemd cannot produce.
const UnitVanishedResult = "unit-vanished"
// UnreadableMarkerResult is the exit_result of an attempt whose marker could not be parsed.
// Deliberately a value systemd cannot produce, like StopRequestedResult: an operator reading the
// column has to be able to tell what the machine said from what this platform concluded.
const UnreadableMarkerResult = "marker-unreadable"
// AttemptsExhaustedResult is the exit_result of the attempt after which this platform STOPPED
// restarting the run: it had as many attempts as the deployment allows (runs.Config.MaxAttempts).
//
// Deliberately a value systemd cannot produce, like the two above, and for a sharper reason than
// theirs: those two are what this platform CONCLUDED about a unit it could not read, while this one
// is its own POLICY — nothing about the machine changed at the moment it is written. An operator
// reading the column has to be able to tell "the run kept dying" from "we stopped trying".
//
// ⚠ It replaces UnitVanishedResult on that last attempt and loses nothing by it: that value is this
// platform's own word too, and the more informative one wins. Where the attempt DID leave a machine
// verdict the caller keeps it — the code and status travel on, only the result word is ours (see
// reconcile.restart).
const AttemptsExhaustedResult = "attempts-exhausted"
// ReadMarker reads the end of a unit.
func ReadMarker(path string) (Marker, error) {
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return Marker{}, ErrNoMarker
}
if err != nil {
return Marker{}, fmt.Errorf("runner: read marker: %w", err)
}
var m Marker
if err := json.Unmarshal(b, &m); err != nil {
return Marker{}, fmt.Errorf("%w: decode %s: %w", ErrBadMarker, path, err)
}
return m, nil
}
// MarkerFromEnv builds a marker from the variables systemd sets for ExecStopPost=.
func MarkerFromEnv(unit string, look func(string) string) Marker {
return Marker{
Unit: unit,
Result: look("SERVICE_RESULT"),
Code: look("EXIT_CODE"),
Status: look("EXIT_STATUS"),
At: time.Now().UTC(),
}
}