StageSet

apiVersion: stages.metio.wtf/v1
kind: StageSet
A StageSet is a namespaced Kubernetes
resource
describing an ordered set of stages. Only spec.stages is required; everything else
refines scheduling, security, gating, versioning, and rollback. Every field below is
shown in YAML at least once.
The smallest valid StageSet:
apiVersion: stages.metio.wtf/v1
kind: StageSet
metadata:
name: my-app
namespace: default
spec:
stages:
- name: app
sourceRef:
name: my-app
Scheduling
spec:
interval: 5m # optional: reconcile cadence (default: --default-interval)
retryInterval: 1m # cadence after a failed run (default: interval)
driftDetectionInterval: 2m # faster drift correction than interval (optional)
timeout: 15m # default per-stage timeout (optional, default 15m)
onTimeout: Hold # Hold (default) or Rollback, when a stage's verify times out
suspend: false # pause reconciliation without deleting (default false)
interval(optional) — steady-state reconcile cadence; each reconcile re-resolves sources, re-asserts desired state (correcting drift), and prunes. When omitted, the controller’s--default-intervalis used (the chart’scontroller.defaultInterval, default10m), so most StageSets can leave it out.retryInterval— retry cadence after a failure; falls back tointerval.driftDetectionInterval— a shorter cadence dedicated to healing out-of-band drift when you need it tighter thaninterval.timeout— how long any one stage may take before it fails. Defaults to15m. Three levels override it, most specific first:stages[].readyChecks.timeout, thenstages[].timeout, then this. A non-positive value at any level falls through to the next rather than expiring at once. It is the binding constraint on the whole verify phase — the kstatus wait, the explicit checks, and the CEL expressions after them, with nothing downstream able to wait past it — and it applies to a first install just as much as to a re-roll, so it has to fit the slowest thing an application ever does: coming up against an empty database, running its migrations and seed data before it serves. Raise it for a stage whose workload declares a long startup grace of its own (astartupProbewith a highfailureThreshold), so the two numbers agree.onTimeout— what a verify timeout means forrollbackOnFailure:Hold(default) orRollback. Override per stage withstages[].onTimeout. The verify wait fails fast when an object reaches a terminal failure, so the clock only ever runs out on objects that were still making progress — a workload that needed longer, not one that broke. UnderHoldthe run halts at that stage and reportsStageProgressing— a reason of its own, notStageFailed, so a portal or a fleet gate can tell a release that is still coming up from one that broke. The objects stay applied, the next reconcile re-verifies them, and the wait returns as soon as the workload reports ready, so a slow rollout converges with nobody intervening. The re-check is paced byretryInterval(falling back tointerval), not by workqueue backoff. ChooseRollbackwhen a stage that overruns its budget is genuinely a release you want undone; restoring the previous manifests over a half-finished migration is why that is not the default.suspend— short-circuits toReady=False / Suspended, leaving applied state running. Usestagesetctl reconcile --forceto run once while suspended. See theSuspendedrunbook .
Ordering between StageSets
dependsOn gates this StageSet on others being Ready at their observed generation
with no new revision held back — cross-release ordering. (Ordering within a
StageSet is the order of stages.) A dependency may also carry a minVersion: the
dependent then waits not just for the dependency to be Ready, but for it to be
deployed at or above that version — so an app that needs its database migrated
to 5.2 first waits for exactly that, not merely for the database to be up.
spec:
dependsOn:
- name: platform
namespace: platform-system
- name: db
minVersion: "5.2.0" # wait until the database is deployed at ≥ 5.2.0
The dependent holds under the DependencyNotReady reason until every dependency is
Ready and at its minVersion; a minVersion that is not a semver is rejected at
admission.
Security and targeting
spec:
serviceAccountName: payments-deployer # impersonated for every cluster operation
kubeConfig:
secretRef:
name: prod-eu-kubeconfig # apply to a remote cluster
decryption:
provider: sops # decrypt SOPS files in stage sources
secretRef:
name: sops-age # holds an age key under *.agekey
serviceAccountName— the ServiceAccount the controller applies as (via a minted TokenRequest token on the local cluster); the StageSet can do exactly what its RBAC allows. A stage can override it with its ownserviceAccountNameto target a different tenant. See multi-cluster and tenancy .kubeConfig— apply to a remote cluster. Set exactly one of:secretRef— a Secret holding a self-contained kubeconfig (with its own embedded credentials).configMapRef— a ConfigMap selecting cloud-provider workload-identity auth (aws,azure,gcp, orgeneric); the cluster bearer token is minted by the cloud’s IAM/STS. See multi-cluster and tenancy .
decryption— decrypt SOPS-encrypted files (age) in every stage’s source before they are built.providerissops;secretRefnames the key Secret, read underserviceAccountName. See secrets encryption .
Versioning and migrations
Versioning is off unless spec.version is set. Set exactly one of
value / fromObject / fromArtifact:
spec:
version:
# A StageSet has ONE version that all its stages converge on. fromObject reads
# it from a rendered object — by default the app.kubernetes.io/version label,
# so it travels in the manifests (works for every source kind, including
# JaaS). The recommended default.
fromObject:
# stage: app # optional; which stage's output to read the version from — defaults to the first stage (the leading stage carries the new version first)
kind: Deployment
name: web
# apiVersion: apps/v1 # optional; narrows an ambiguous Kind+Name
# fieldPath: "{.data.version}" # optional JSONPath; defaults to the version label
# value: "2.1.0" # …or pin it inline
# fromArtifact: { stage: app, path: VERSION } # …or read a VERSION file (Git/OCI/Bucket)
migrations:
- name: backfill-ledger-2-0 # idempotency-ledger / Events name
from: ">=1.0.0, <2.0.0" # optional: a semver CONSTRAINT on the current version
to: "2.0.0" # required: the EXACT boundary this migration crosses
stage: app # runs before this stage's pre-actions
actions: # the same Action shape used by stages (see below)
- name: backfill
job:
sourceRef:
name: ledger-backfill-job
to is an exact version (like version.value). from is a semver
constraint, not an exact version — it accepts ranges such as
>=1.0.0, <2.0.0, 1.x, or ^1.2, matched against the currently deployed
version. The migration fires only when the deployed version satisfies from
and the run crosses up to to. See
versioned migrations
.
Rollback
spec:
rollbackOnFailure: true # restore last-good revisions on a failed run
onRollback: # best-effort cleanup after the restore
- name: disable-maintenance-mode
job:
sourceRef:
name: moodle-maintenance-off
Needs a rollback store configured; see rollback .
onRollback— StageSet-level actions run best-effort after a rollback restores the previous manifests (bothrollbackOnFailureand a promotion gate’sonFailure: Rollbackrevert). They run against the restored state underserviceAccountName, are not gated by the per-revision action ledger (so they fire on every rollback), and take the same operation types as stage actions . Names must be unique within the list. Use it for cleanup that only makes sense once the old version is back — e.g. lifting an application maintenance mode a failed upgrade enabled. See post-rollback cleanup .
Update windows
Gate when new revisions roll out. Each window is Allow or Deny, recurring
(cron) or absolute (from/to). windowScope controls how strict a closed window
is.
spec:
windowScope: Updates # Updates (default): hold rollouts, keep correcting
# drift. All: a hard freeze — no applies at all.
updateWindows:
- type: Deny # Deny always wins over Allow
schedule: "0 9 * * MON-FRI" # 5-field cron: window start
duration: 8h
timeZone: Europe/Berlin # IANA tz (default UTC)
- type: Deny # an absolute one-off freeze
from: 2026-12-24T00:00:00Z
to: 2026-12-27T00:00:00Z
A recurring window uses schedule + duration; an absolute window uses
from + to. See update windows
.
Stages
stages (required, min 1) is the ordered list. A stage with every field set:
spec:
stages:
- name: app # required; DNS-label, unique in the StageSet
sourceRef:
name: my-app # required
kind: ExternalArtifact # default; also GitRepository/OCIRepository/Bucket
# directly, or a producer (e.g. JsonnetSnippet)
apiVersion: source.toolkit.fluxcd.io/v1 # required for a producer kind
namespace: other-ns # default: the StageSet's namespace
path: ./overlays/prod # path inside the artifact (default ./)
serviceAccountName: payments-eu-deployer # per-stage identity (default: spec.serviceAccountName)
prune: true # GC objects that leave the stage (default true)
timeout: 3m # per-stage timeout (default: spec.timeout)
onTimeout: Hold # per-stage timeout policy (default: spec.onTimeout)
force: false # sugar for conflictPolicy.default: Recreate
applyHelmHookResources: true # apply helm.sh/hook objects as ordinary ones
patches: [] # Kustomize patches applied after build
conflictPolicy: {} # see below
postBuild: {} # see below
actions: {} # see below
readyChecks: {} # see below
sourceRef.kind defaults to ExternalArtifact, so the common case is just
sourceRef: { name: … }. A sourceRef resolves to a Flux
artifact in one of three ways: an ExternalArtifact
(RFC-0012
, the default), a classic
Flux source — GitRepository, OCIRepository, or Bucket — consumed directly,
or any other kind treated as a producer and resolved to its ExternalArtifact via
the back-pointer index. See
stages and sources
and
producer-aware sources
.
sourceRef.kind is intentionally open: it defaults to ExternalArtifact but
accepts any producer kind, because a producer reference is resolved through the
ExternalArtifact’s RFC-0012 spec.sourceRef back-pointer rather than matched
against a fixed set. This is a deliberate divergence from a source consumer that
restricts its source kinds to a closed enum — here the resolution is
producer-aware, so a new producer kind works without a schema change.
serviceAccountName
The ServiceAccount this stage’s cluster operations run as — apply, prune,
readiness verification, and actions
— overriding the StageSet-level
spec.serviceAccountName. Empty inherits the StageSet default. Each stage’s
writes are bounded by its own ServiceAccount’s RBAC, so a single StageSet can
roll a change through environments that live under different identities:
spec:
serviceAccountName: staging-deployer # default for stages that omit it
stages:
- name: staging
sourceRef: { name: my-app } # runs as staging-deployer
- name: production
serviceAccountName: prod-deployer # runs as prod-deployer instead
sourceRef: { name: my-app }
The same identity that applies a stage also prunes and tears it down, so a
per-stage ServiceAccount with create rights can garbage-collect its own objects.
On the local cluster the identity is assumed by minting a short-lived TokenRequest
token; a remote kubeConfig
instead impersonates the
ServiceAccount against that cluster’s credentials. SOPS decryption keys are read
under the StageSet-level identity, since spec.decryption is a StageSet field.
See multi-cluster and tenancy
.
patches
kustomize strategic-merge or JSON6902 patches, applied after the build:
patches:
- patch: |
- op: replace
path: /spec/replicas
value: 6
target:
kind: Deployment
name: web
postBuild
Variable substitution after build and patching:
postBuild:
substitute:
cluster_name: prod-eu # inline key/value
substituteFrom:
- kind: ConfigMap # required: ConfigMap or Secret
name: cluster-vars
- kind: Secret
name: cluster-secrets
optional: true # tolerate a missing source
conflictPolicy
Per-resource answers to apply conflicts (immutable fields, ownership):
conflictPolicy:
default: Fail # Fail (default) | Recreate | KeepExisting
rules:
- target: # partial selector; unset fields match all
apiVersion: batch/v1
kind: Job
action: Recreate
- target:
kind: PersistentVolumeClaim
name: scratch
action: Recreate
allowDataLoss: true # required to Recreate a PVC/PV
See conflict policies .
readyChecks
Gate when the stage counts as complete:
readyChecks:
timeout: 20m # most specific timeout level; see spec.timeout
disableWait: false # true = apply without waiting for readiness
checks: # explicit objects, evaluated with kstatus
- apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
name: ledgers.example
exprs: # custom health via CEL expressions (healthCheckExprs shape)
- apiVersion: db.example/v1
kind: Database
current: "status.phase == 'Running'"
inProgress: "status.phase in ['Pending','Provisioning']"
failed: "status.phase == 'Failed'"
Health expressions use CEL . See ready checks .
timeout— bounds this stage’s whole verify phase: the kstatus wait over the applied objects andchecks, plus theexprsevaluated after it. It is the most specific level of the timeout ladder , ahead ofstages[].timeoutandspec.timeout. Reach for it when one stage’s readiness is what needs the patience — a workload that migrates before it serves — and for the coarser levels when every stage of a run needs the same bound.
Actions
stages[].actions (and migrations[].actions) carry typed steps. Each Action
has a name, optional timeout/retries, an optional scope (pre/post only),
and exactly one operation block.
scope selects how often a pre/post action runs: Revision (default) once per
pinned artifact revision, Version once per resolved spec.version episode — so
revision churn at a fixed version stops re-running upgrade choreography — or
Lifetime once ever, for a durable bootstrap. Version requires spec.version;
Lifetime records its completion in a StageLedger. See
action scope
.
actions:
pre: # before apply; failure aborts the stage with nothing applied
- name: db-migrate
timeout: 10m
retries: 2
job:
sourceRef: { name: my-app-migrations }
path: ./jobs
post: # after verify; the stage is Ready only if these pass
- name: smoke-test
http:
url: https://my-app.internal/healthz
method: GET # default POST
expectedStatus: [200] # default: any 2xx
headersFrom:
- name: gate-token
key: token
onFailure: # best-effort on any failure from apply onward
- name: page-oncall
http:
url: https://alerts.internal/stageset-failed
The six operation types — one per Action:
# patch — patch an existing object
- name: enable-traffic
patch:
target: { apiVersion: v1, kind: Service, name: web }
type: merge # merge (default) | json6902
patch: '{ "spec": { "selector": { "release": "green" } } }'
# http — call an endpoint (hosts gated by --allowed-action-hosts)
- name: approve
http:
url: https://gate.internal/approve
bodyFrom: { name: approve-secret, key: body }
# wait — block for a duration or until a CEL expr holds
- name: settle
wait:
duration: 30s
- name: until-available
wait:
target: { apiVersion: apps/v1, kind: Deployment, name: web }
expr: "status.availableReplicas >= 3"
timeout: 5m
# job — render and await Jobs from an artifact
- name: migrate
job:
sourceRef: { name: my-app-migrations }
path: ./jobs
# delete — remove an existing object (missing = success)
- name: drop-legacy
delete:
target: { apiVersion: batch/v1, kind: Job, name: legacy-migration }
# apply — transient, rollout-scoped manifests (NOT inventory-tracked, never pruned)
- name: canary
apply:
sourceRef: { name: my-app-canary }
path: ./
wait: true # block until applied objects report Ready
See actions .
status
status is controller-owned and read-only. A representative snapshot:
status:
observedGeneration: 7
conditions:
- type: Ready
status: "True"
reason: Succeeded
message: All 2 stages applied
lastHandledReconcileAt: "2026-06-15T09:21:04Z"
lastAttemptedRevisions: { payments/payments-app: sha256:1a2b }
lastAppliedRevisions: { payments/payments-app: sha256:1a2b }
version: "2.1.0"
pendingMigrations: []
executedMigrations: []
stages:
- name: infrastructure
phase: Ready # Pending|Applying|Pruning|Verifying|Ready|Failed
appliedRevision: sha256:9f3c
entriesCount: 12
shards: 1
message: ""
executedActions: []
ledgerRevision: sha256:9f3c
lastAppliedSnapshot:
- stage: infrastructure
url: http://source-controller.../infra.tar.gz
digest: sha256:9f3c
pendingUpdate: # set only when a window holds a rollout
revisions: { payments/payments-app: sha256:cafe }
nextWindowOpens: "2026-06-16T08:00:00Z"
lastHandledUpdateOverride: "2026-06-15T09:30:00Z"
The Ready condition’s reason is one of the wire-stable values documented in the
runbooks
. The happy-state reason is Succeeded, matching the
apply-style success reason kustomize-controller writes, so existing Flux tooling
and alert routing recognize it unchanged.