Skip to content

SnapshotSchedule

A SnapshotSchedule is the cron that fires Snapshot objects from a SnapshotPolicy. It decides when a backup runs, separately from what runs (the policy) and from one run (the Snapshot).

For the short type-and-default table see the field reference. For task guidance see Backups & schedules.

Each firing creates Snapshot objects in the schedule’s own namespace. Suspending or deleting a schedule does not affect runs that are in flight or already finished.

Set exactly one of policyRef or policySelector. Both the admission webhook and an apiserver CEL rule enforce that.

The single SnapshotPolicy recipe this schedule invokes, resolved in the schedule’s own namespace. It is mutually exclusive with policySelector.

The fan-out form: a label selector over SnapshotPolicy objects in the schedule’s namespace. Every matching policy gets its own Snapshot on each firing, so “back up everything tagged tier=critical nightly” is one object. It is mutually exclusive with policyRef, and mirrors the pvcSelector pattern used elsewhere.

The firing cadence. Its sub-fields follow.

The cron expression, with Jenkins-style H substitution. H picks a deterministic per-object slot inside the field’s range, so identical schedules do not all stampede the same minute.

A deterministic offset added to each firing, written as a Go-style duration such as 30m. It is derived from the schedule’s UID and slot, so it stays the same across restarts rather than being random.

Leave it out and the schedule inherits the scheduleDefaults.jitter of its target policy’s repository. That is resolved when the slot is computed, exactly as timezone is, following policyRef or each policySelector match.

Unlike timezone there is no built-in fallback, so a value missing at both levels means no spread. The resolved window is recorded in status.nextSchedule.jitter. A change at either level re-triggers the schedule through a referent watch and recomputes the pinned slot in the new window.

If matched policies’ repositories disagree on the window, the schedule resolves to no jitter and logs the candidate windows, recommending an explicit schedule.jitter.

The IANA timezone the cron is evaluated in, such as America/Los_Angeles. When you set it, it wins outright.

Leave it out and the schedule inherits the scheduleDefaults.timezone of its target policy’s repository, resolved when the slot is computed, following policyRef or each policySelector match. If there is none, it uses UTC.

The resolved zone is recorded in status.nextSchedule.timezone. A change to the repository default re-triggers the schedule through a referent watch and recomputes the pinned slot.

If a policySelector schedule’s matched policies’ repositories disagree on the zone, it falls back to UTC and raises a TimezoneDefaultAmbiguous condition recommending an explicit schedule.timezone.

Whether to fire immediately when the schedule is created. It defaults to false, which is the GitOps-friendly choice: applying a manifest does not trigger an unexpected backup. This default is written into the stored object and shows up in kubectl explain.

With true, skip future firings. Runs in flight and runs already finished are untouched.

What to do when a slot fires while an earlier run of the same policy is still in flight:

  • Forbid, the default, holds the new run rather than letting runs pile up.
  • Allow starts the new run alongside the in-flight one.
  • Replace cancels this schedule’s own in-flight runs and starts the new one in their place.

Forbid and Replace also wait for Running runs this schedule did not start, as long as they back up one of the slot’s target policies: a manual kubectl kopiur snapshot now, another schedule’s run, or a hand-written Snapshot. The match is on the Snapshot’s spec.policyRef, not its labels. Such a run is never cancelled, not even by Replace. The slot waits for it, then fires. While it waits, the schedule records HeldByOtherRun=True (reason OtherRunActive) naming the blocking Snapshot, and emits one OtherRunActive Normal event. It clears when that run leaves Running. A run of that kind that is only Pending (stuck or not yet started) never holds the slot, so a stuck manual run cannot silence the schedule. Allow ignores other runs, as it always has. To cap how many runs hit a repository at once across manual and scheduled runs, use the repository’s spec.concurrency.maxConcurrentJobs.

This default is also written into the stored object and shows up in kubectl explain.

When a slot comes due and this schedule still has unfinished children, meaning children at phase Pending or Running or not yet stamped, the controller does this for each victim, in this order.

First it deletes the run’s mover Job, which stops the pod deterministically instead of racing ownership garbage collection. Then it annotates the Snapshot object with kopiur.home-operations.com/pruned-by: replaced-run and deletes it. Only then does it mint the new slot’s Snapshot, in the same reconcile. One Normal event, ReplacedActiveRun, lists every run that was cancelled.

The replaced-run stamp marks the deletion as an operator prune, which exempts it from the repository’s mass-deletion breaker. Without the stamp, every Replace firing would look like an external mass deletion, and a busy schedule would trip its own breaker.

Three situations make Replace decline to replace anything. In each it waits rather than firing.

  • A child at an unrecognized phase. If a run sits at a status.phase this operator build does not know, which almost always means a newer operator wrote it, the schedule refuses to delete what it cannot classify. It raises ScheduleRunnable=False with reason BlockedOnUnreadableRun, exactly as Forbid does. Finish the operator upgrade, or delete that Snapshot if the run is genuinely over.
  • A child parked behind the repository’s concurrency cap. A run holding RepositorySlotAvailable=False is queued, not running. Cancelling it would free no capacity and the replacement would immediately queue in its place, so Replace behaves like Forbid until the pool drains. The schedule records ReplacementHeld=True with reason WaitingForRepositorySlot, and emits one WaitingForRepositorySlot Normal event when it enters the hold, not one per retry. Unlike ScheduleRunnable=False this is not a structural gate: it clears on its own and needs no action from you.
  • A Running run of the same policy that this schedule did not start. Replace never cancels a run it does not own. It holds the whole slot, cancelling nothing, its own previous run included, until that run finishes, and records HeldByOtherRun=True as described under schedule.concurrencyPolicy.

If a slot is missed by more than this many seconds, for example because the operator was down, skip it instead of firing late. Omit the field for no deadline. 0, meaning fire only exactly on time, is legitimate.

How many failed Snapshot objects from this schedule to keep. The default is 10, and 0 keeps none.

Each reconcile prunes the oldest failures beyond the limit, keeping the newest by completion time. That bounds failure history, including backups held back by a preflight check.

There is deliberately no successfulJobsHistoryLimit. Retention of successful snapshots is GFS-driven through the SnapshotPolicy’s retention block, not a flat count.

Field Meaning
observedGeneration The metadata.generation this status reflects, for staleness detection.
lastSchedule The most recent firing, with cron and jitter already applied, and the Snapshot it produced.
nextSchedule The next firing slot the controller computed, plus the cron, timezone and jitter it was computed with, so a change to any of them can invalidate and recompute the pinned slot.
lastSuccessfulSchedule The most recent firing whose Snapshot succeeded.
consecutiveFailures How many runs failed back to back; resets on success. Drives alerting.
conditions Standard Kubernetes conditions carrying schedule health.

Each schedule slot is recorded as an at, the RFC 3339 instant it fired or is due to fire, plus an optional snapshotRef naming the Snapshot that slot produced.

All three slots share one schema, so cron, timezone and jitter appear on each. The controller only ever writes them on nextSchedule, because they describe a pin it may still have to invalidate rather than a record of a slot that already fired.

If the schedule’s effective timezone or jitter later changes, through a schedule.timezone or schedule.jitter edit or through an inherited repository scheduleDefaults change, the controller notices the mismatch and recomputes the pinned slot in the new zone or window. A recorded value that is absent counts as unchanged, so a pin written by an older operator is never churned on upgrade.

The same goes for a schedule.cron edit: the pinned slot is recomputed from the new expression on the next reconcile, so the old expression never fires one last time. Re-spacing the expression is not a change. A pin with no recorded cron (written by an older operator, or on a cluster whose installed CRD predates the field and drops it) is left alone and picks up the new expression at its next firing.

Any spec edit, including suspend, also brings status.observedGeneration and the Ready condition up to date straight away, so Ready reads Suspended as soon as you pause a schedule.