Infrastructure migration in progress: some features are temporarily unavailable.
jomcgi.dev

sextant

sextant

Generates type-safe Go state machines for Kubernetes operators from a YAML spec. Invalid state transitions become compile errors, and transition signatures force idempotency keys to be obtained before state changes.

The OCI Model Cache operator runs on a sextant-generated state machine — its spec is modelcache.sextant.yaml and the generated package is drift-checked in CI, so the committed code can never silently diverge from the spec.

flowchart LR
    spec["modelcache.sextant.yaml<br/>(declarative spec)"]
    gen["sextant generate"]
    out["8 generated .go files<br/>states · transitions · calculator<br/>SSA · metrics · OTel"]
    op["operator reconciler<br/>(handwritten)"]
    drift["CI drift test<br/>regenerate + byte-compare"]

    spec --> gen --> out --> op
    drift -. "fails on any diff,<br/>either direction" .-> out
    spec -.-> drift

Why

Operator reconcilers degrade into phase-string soup: if status.Phase == "Resolving" scattered across a thousand-line Reconcile, transitions that forget to set fields, retries that duplicate external API calls. Sextant moves those invariants into the type system:

state := calculator.Calculate(resource) // reconstructs typed state from Status

switch s := state.(type) {
case statemachine.ModelCachePending:
    // s.Resolved(...) and s.CacheHit(...) exist.
    // s.SyncComplete(...) does not — that transition isn't legal from
    // Pending, so calling it is a compile error, not a 3am incident.
case statemachine.ModelCacheResolving:
    ...
}

Three ideas carry the design:

  1. Sealed interfaces. Every state implements a private interface method, so the state set is closed — a switch over states is exhaustive, and only generated transitions can produce new states.

  2. Idempotency by construction. Transitions that record external actions take the external system's ID as a parameter:

    // 1. Call external API   → get jobName
    // 2. s.JobCreated(jobName) → get new state
    // 3. Persist via SSAPatch  → if this fails, next reconcile retries safely
    func (s ModelCacheResolving) JobCreated(syncjobname string) ModelCacheSyncing

    You cannot transition first and call the API second — the signature demands the evidence up front.

  3. Level-triggered calculation. State is recomputed from Status every reconcile by a pure calculator (no network, no side effects), so the machine recovers from crashes, conflicts, and stale caches the same way Kubernetes itself does: by looking at what is, not what happened.

Usage

# Validate a spec (add --xstate to emit XState JSON for visualization)
sextant validate myresource.sextant.yaml

# Generate
sextant generate myresource.sextant.yaml \
  -o ./internal/statemachine --package statemachine \
  --module github.com/you/operator --api github.com/you/operator/api/v1alpha1

In this repo the generated package carries a go:generate directive, so regeneration is:

go generate ./projects/operators/oci-model-cache/internal/statemachine

The spec

apiVersion: sextant.jomcgi.dev/v1alpha1
kind: StateMachine

metadata:
  name: ModelCache
  group: oci-model-cache.jomcgi.dev
  version: v1alpha1

fieldGroups:          # reusable field bundles embedded into states
  resolveResult:
    resolvedRef: string
    digest: string?   # "?" = optional at group level; see `requires`
    ...

states:
  - name: Resolving
    requeue: 10s
    fieldGroups: [resolveResult]
  - name: Ready
    requeue: 6h
    fieldGroups: [resolveResult]
    requires: [digest]   # optional in the group, mandatory in THIS state
  - name: Failed
    error: true
    requeue: 5m
    fieldGroups: [errorInfo]

transitions:
  - from: Resolving
    to: Syncing
    action: JobCreated
    params:
      - syncJobName: string   # idempotency key, forced into the signature
  - from: Failed
    to: Pending
    action: Retry
    guard: retryable          # guarded transitions return *State (nil = refused)

guards:
  retryable:
    condition: "!s.Permanent"

The full ModelCache machine renders like this — and this exact diagram is also emitted by the generator into the generated Go (embedDiagram: true), so the docs in the code can't go stale either:

stateDiagram-v2
    [*] --> Pending
    Pending --> Resolving: Resolved
    Pending --> Ready: CacheHit
    Resolving --> Syncing: JobCreated
    Syncing --> Ready: SyncComplete
    Pending --> Failed: MarkFailed
    Resolving --> Failed: MarkFailed
    Syncing --> Failed: MarkFailed
    Failed --> Pending: Retry [retryable]
    Ready --> Pending: Resync

Semantics worth knowing:

  • Field-group carry-forward — when source and target states share a field group and the transition's params don't populate it, the generated code copies it from the source state (ResolveResult: s.ResolveResult), so data survives intermediate hops without hand-written plumbing.
  • Retry policy — with an errorHandling block you get retry-count exponential backoff with jitter. Without one, retryability is purely guard-driven and RetryBackoff() follows the state's requeue interval.
  • Deletion — states marked deletion: true generate a graceful-degradation deletion path. With none, deletion passes through to normal calculation and Kubernetes GC handles removal.
  • An Unknown error state is always generated: unrecognized or corrupted Status reconstructs to Unknown (with Reset() for recovery) instead of misbehaving as a guessed state.

Generated output

One spec produces eight files (per machine M, in m_*.go): phase constants, state types + sealed interface, the level-triggered calculator, transition methods, a visitor for exhaustive handling, OTel observability hooks, Prometheus metrics, and Server-Side Apply status helpers (SSAPatch clears managedFields — the API server rejects SSA patches that include them).

Output is deterministic (sorted fields, stable ordering), which is what makes the CI drift test possible: it regenerates from the spec and fails on any byte difference, in either direction. Generated code never gets hand-fixed — the spec or the templates do.

Layout

cmd/sextant/      # CLI (generate, validate, --xstate export)
pkg/schema/       # YAML DSL parsing + validation
pkg/codegen/      # text/template-based generator (templates/*.tmpl)
pkg/xstate/       # XState JSON interchange for diagram tooling