119 lines
4.3 KiB
Go
119 lines
4.3 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")
|
|
|
|
// 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("runner: decode marker %s: %w", 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(),
|
|
}
|
|
}
|