Documentation
¶
Overview ¶
Package servicespec defines the portable fak.service.v1 desired-state and restart-semantics contract (#4749, parent #4748). Service intent was encoded independently in SCM verbs, Scheduled Task scripts, cron concepts, and deployment docs; this leaf is the ONE versioned schema all of them render from: identity, command, cwd, environment references, readiness, restart / backoff, checkpoint/resume, dependencies, and intentional-stop state.
Two axes are kept strictly disjoint:
- DESIRED state ("desired-running" / "desired-stopped") is intent — what the operator or a reconciler wants. It has exactly two values.
- OBSERVED state (starting / ready / degraded / failed / fenced / stopped / unknown) is what a supervisor last saw. No string is shared between the axes, so a reader can never conflate intent with observation.
Recurring jobs (#749) versus always-on services: a KindService with desired=running is a steady-state obligation — ANY exit (even clean) leaves the desire unmet and the supervisor restarts it. A KindJob is a bounded run whose SCHEDULE owns recurrence: a clean exit is completion (never restarted here); only a non-clean exit is retried under the same backoff contract.
The leaf is pure and portable: no SCM, systemd, launchd, or cron calls — platform supervisors (#4750, #4756) render from and report into this contract. Secrets are referenced (EnvRef.SecretRef), never serialized.
Index ¶
Constants ¶
const ( DefaultInitialBackoffMS int64 = 1000 DefaultMaxBackoffMS int64 = 60000 DefaultBackoffFactor int64 = 2 DefaultWindowMS int64 = 600000 DefaultWindowMaxRestarts int = 5 DefaultStableRunResetMS int64 = 300000 )
Restart-policy defaults, filled by Normalize when a spec omits them.
const ( ReasonDesiredStopped = "desired-stopped" ReasonOperatorStop = "operator-stop" ReasonJobComplete = "job-complete" ReasonCircuitOpen = "circuit-open" ReasonBootRecovery = "boot-recovery" ReasonRestart = "restart" )
Decision reasons — the closed vocabulary a supervisor logs with each restart decision.
const ObservedSchemaV1 = "fak.service.observed.v1"
ObservedSchemaV1 names the observed-state schema a supervisor reports.
const SchemaV1 = "fak.service.v1"
SchemaV1 names the versioned desired-state schema this package parses.
Variables ¶
var AllExitClasses = []ExitClass{ExitClean, ExitCrash, ExitWatchdog, ExitBootRecovery, ExitDependencyLoss, ExitOperatorStop}
AllExitClasses enumerates the exit vocabulary (stable order).
var AllPhases = []Phase{PhaseStarting, PhaseReady, PhaseDegraded, PhaseFailed, PhaseFenced, PhaseStopped, PhaseUnknown}
AllPhases enumerates the observed vocabulary (stable order).
Functions ¶
func CanTransition ¶
CanTransition reports whether an observed-state transition is legal. Every phase may decay to unknown (observation loss is always possible); ready is only reachable through starting (or a re-observation from unknown), never directly from stopped or fenced.
Types ¶
type DesiredState ¶
type DesiredState string
DesiredState is the intent axis. Exactly two values: a service is wanted running or wanted stopped. Everything else is observation, not desire. The wire strings carry the "desired-" prefix (the issue's own vocabulary) so no desired value ever shares a string with an observed Phase — a log line or a JSON field can never be misread across the axes.
const ( DesiredRunning DesiredState = "desired-running" DesiredStopped DesiredState = "desired-stopped" )
type EnvRef ¶
type EnvRef struct {
Name string `json:"name"`
Value string `json:"value,omitempty"`
SecretRef string `json:"secret_ref,omitempty"`
}
EnvRef is one environment binding. A ref carries EITHER a literal value OR an opaque secret reference — never both, so a resolved secret can never be serialized next to its reference. The contract never carries secret bytes.
type ExitClass ¶
type ExitClass string
ExitClass classifies why a run ended. Every restart decision starts from one of these; no exit is left unclassified.
const ( ExitClean ExitClass = "clean" // process exited with success ExitCrash ExitClass = "crash" // nonzero exit, signal, or panic ExitWatchdog ExitClass = "watchdog-timeout" // liveness/readiness deadline missed ExitBootRecovery ExitClass = "boot-recovery" // host rebooted; the service did not fail ExitDependencyLoss ExitClass = "dependency-loss" // a depends_on service left ready ExitOperatorStop ExitClass = "operator-stop" // a human or control plane stopped it on purpose )
type ExitRecord ¶
type ExitRecord struct {
Class ExitClass `json:"class"`
Code int `json:"code,omitempty"`
AtUnixMS int64 `json:"at_unix_ms,omitempty"`
RunMS int64 `json:"run_ms,omitempty"`
}
ExitRecord classifies the most recent run's end.
type Identity ¶
type Identity struct {
Node string `json:"node"`
Service string `json:"service"`
Workload string `json:"workload,omitempty"`
}
Identity gives the service, the node it is bound to, and the concrete workload instance stable names. Node and Service are required; Workload defaults to Service (a single-instance service is its own workload).
type Kind ¶
type Kind string
Kind separates always-on services from recurring jobs (#749).
const ( // KindService is an always-on service: desired=running is a steady-state // obligation, so any exit — clean included — is restarted. KindService Kind = "service" // KindJob is a bounded recurring run: the schedule owns recurrence, a // clean exit is completion, only failures are retried. KindJob Kind = "job" )
type Observed ¶
type Observed struct {
Schema string `json:"schema"`
Identity Identity `json:"identity"`
Phase Phase `json:"phase"`
Attempt int `json:"attempt,omitempty"`
LastExit *ExitRecord `json:"last_exit,omitempty"`
}
Observed is the fak.service.observed.v1 report a supervisor emits. It never carries intent — Desired lives only in Spec.
type Phase ¶
type Phase string
Phase is the observed axis — what the supervisor last saw, never what is wanted.
const ( PhaseStarting Phase = "starting" // launched, readiness not yet met PhaseReady Phase = "ready" // readiness met PhaseDegraded Phase = "degraded" // readiness previously met, now impaired PhaseFailed Phase = "failed" // exited or unhealthy, restart pending PhaseFenced Phase = "fenced" // held off: circuit open or operator fence PhaseStopped Phase = "stopped" // intentionally not running PhaseUnknown Phase = "unknown" // observation lost )
type Readiness ¶
type Readiness struct {
Kind string `json:"kind"`
Target string `json:"target,omitempty"`
TimeoutMS int64 `json:"timeout_ms,omitempty"`
}
Readiness declares how a supervisor decides PhaseStarting -> PhaseReady. Kind and Target are opaque to this leaf (platform supervisors interpret them); the contract only fixes their shape and the deadline.
type RestartDecision ¶
type RestartDecision struct {
Restart bool `json:"restart"`
DelayMS int64 `json:"delay_ms"`
CircuitOpen bool `json:"circuit_open"`
NextAttempt int `json:"next_attempt"`
Reason string `json:"reason"`
}
RestartDecision is the portable verdict: restart or not, after what delay, and whether the circuit is now open (PhaseFenced until an operator or a window expiry releases it).
type RestartInput ¶
type RestartInput struct {
Kind Kind
Desired DesiredState
Class ExitClass
// Attempt is the consecutive restart count since the last stable run.
Attempt int
// RunMS is how long the exited run lasted.
RunMS int64
// WindowCount is how many restarts already happened inside the rolling
// restart window.
WindowCount int
}
RestartInput is what a supervisor knows when a run ends.
type RestartPolicy ¶
type RestartPolicy struct {
InitialBackoffMS int64 `json:"initial_backoff_ms"`
MaxBackoffMS int64 `json:"max_backoff_ms"`
BackoffFactor int64 `json:"backoff_factor"`
WindowMS int64 `json:"window_ms"`
WindowMaxRestarts int `json:"window_max_restarts"`
StableRunResetMS int64 `json:"stable_run_reset_ms"`
}
RestartPolicy bounds how a supervisor re-runs an exited workload: bounded exponential backoff (initial * factor^attempt, capped at max), a rolling restart-window cap that opens the circuit, and a stable-run threshold that resets the attempt counter. All durations are integer milliseconds so the wire form is deterministic on every platform.
func (RestartPolicy) Decide ¶
func (p RestartPolicy) Decide(in RestartInput) RestartDecision
Decide applies the v1 restart semantics, in priority order:
- desired=stopped never restarts (intent wins).
- operator-stop never restarts even under desired=running: an intentional stop is recorded, not fought.
- a KindJob clean exit is completion — the schedule owns the next run.
- the rolling restart-window cap opens the circuit (no restart, fenced).
- boot-recovery restarts immediately with a fresh attempt counter: the HOST failed, not the service, so prior backoff does not carry over.
- a run that outlived stable_run_reset_ms resets the attempt counter.
- otherwise restart after bounded exponential backoff: min(initial * factor^attempt, max).
type Spec ¶
type Spec struct {
Schema string `json:"schema"`
Identity Identity `json:"identity"`
Kind Kind `json:"kind"`
Desired DesiredState `json:"desired"`
Command []string `json:"command"`
Dir string `json:"dir,omitempty"`
Env []EnvRef `json:"env,omitempty"`
Readiness *Readiness `json:"readiness,omitempty"`
Restart RestartPolicy `json:"restart"`
CheckpointDir string `json:"checkpoint_dir,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
Spec is the fak.service.v1 desired-state document.
func ParseSpec ¶
ParseSpec strictly decodes, validates, and normalizes one fak.service.v1 document. Unknown fields are refused (a typo'd field must never silently drop intent). The returned spec is normalized: defaults filled, env sorted by name, depends_on sorted and deduplicated.
func (*Spec) CanonicalJSON ¶
CanonicalJSON returns the deterministic wire form of the normalized spec: fixed field order, sorted lists, integer durations. Parsing the canonical form and re-canonicalizing is the identity.