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" // 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(), } }