The KICK operator, formally
This page gives a precise, self-contained model of what the KICK controller does. It is written for readers who want to reason about correctness rather than read Go. Every definition corresponds to a concrete piece of the controller, and the invariants at the end are the properties the implementation is designed to preserve.
1. Objects and state
Let a point in time be . The observable cluster state at time is a tuple
whose components are:
- — the set of workloads. Each has a kind and a namespace .
- — the set of sources, i.e. objects of kind or . Each has a creation time , a resource version , and a last-write time , where are the server-side field-management entries of and is the time the API server recorded for the last write of manager . By construction , and for a source never written since its creation.
- — the key/value payload of a source (
data+binaryData/stringData), together with itstypeand immutability flag. - — the Pod template of a workload, in particular its annotation map .
- — for a Deployment, the set of its ReplicaSets; the current one is .
The controller never mutates ; its only write to cluster state is a single annotation on , defined in §8.
2. Dependency extraction
A workload consumes a source when its Pod template references it through an
environment variable, an envFrom, a volume, or a projected volume source, in
any container or init container. Image-pull secrets are not consumed data and
are excluded. This is captured by a pure function
is deterministic and depends only on ; it is insensitive to reference multiplicity, so a source referenced twice contributes once. Its inverse image gives the consumers of a source,
restricted to .
3. Fingerprints and relevant change
To distinguish a change that matters from one that does not, each source is reduced to a content fingerprint by a collision-resistant hash :
Keys are sorted so the fingerprint is canonical, and only the payload, type, and
immutability enter it. Object metadata — labels, annotations, resourceVersion,
managed fields — is deliberately excluded. Hence
A relevant change to a source is exactly a change of ; a change of with fixed is metadata-only.
4. The observation store
The controller maintains a durable partial map from a source identity to a record,
where is the time of the last relevant change and the last relevant fingerprint. When the controller observes a source at wall-clock time , the store transition classifies the event and updates the record:
The recorded change time is the crux of baseline correctness:
Why baseline uses and not or . A first observation never witnessed the change that produced the content it sees, so it must date that content from evidence. Using the wall-clock observation instant would make freshness depend on a race between “KICK first saw the Secret” and “the ReplicaSet was created”, producing spurious restarts whenever a workload is adopted. Using is unsound in the other direction: if was written after its creation but before KICK first observed it — KICK was installed, restarting, or its cache had not synced — then even though the content is newer than the rollout, and since every later observation matches that baseline the change is dismissed as fresh and never reconsidered.
is the tightest evidence the API server records for “when did this content come to be”: it is the latest write the server itself attributes to the object. The baseline therefore takes unmodified. The residual ambiguity — the API server stores that instant with second granularity — is handled by carrying change times at sub-second precision (§5), not by widening the baseline: an artificially advanced baseline dates content later than it provably is and causes spurious restarts when KICK adopts an existing cluster.
Only and enqueue work for the consumers ; and are inert.
5. Timestamp precision
Kubernetes records object timestamps — creationTimestamp, managedFields
entry times, Deployment condition times — with whole-second granularity.
Every quantity that enters is therefore second-granular, while a
change time observed by the controller is not. Change times must be
carried at sub-second precision at every hop: the durable observation record
stores as RFC 3339 with nanosecond precision, and the KickRequest
status field latestObservedDependencyChange is a metav1.MicroTime rather
than a metav1.Time.
The failure mode is exact. Truncation to whole seconds is the map . Let a relevant change occur at with , so the change is genuinely newer than the rollout it must supersede. Truncation gives , and because the staleness test of §6 is strict,
so the workload is wrongly declared fresh and the change is lost. Any change falling in the same second as the rollout it supersedes is affected. Hence no component on the path from the observation store to the freshness comparison — record serialisation, status write, status read, coalescing — may truncate.
6. Rollout state and the freshness relation
For a workload the rollout inspector returns a start time and a completeness flag,
The start time is the latest moment at which the currently-running Pod template
is provably in place. Writing for the workload’s status
conditions and for the lastUpdateTime of a condition , or
its lastTransitionTime when the former is unset:
with . For a Deployment the current ReplicaSet’s creation time is advanced to the latest condition update, which is effectively the moment the rollout became available and complete. This is deliberate: must be the latest instant that is provably true, because a source change may only be dismissed when the running Pods provably already carry it. A DaemonSet falls back to its latest condition transition when one is present; upstream rarely populates DaemonSet conditions, so in practice the creation time is used.
For StatefulSets and DaemonSets no comparable completion timestamp exists, so there is only a lower bound on when the Pods began running. A source created after the workload object but before its Pods started is consequently counted as newer and produces exactly one adoption restart. Manifest ordering makes this rare — Helm and Argo CD apply Secrets and ConfigMaps before the workloads that consume them — and one extra restart is the safe side of the ambiguity.
Completeness is kind-aware — this is what lets non-Deployment workloads be evaluated at all:
Given the dependency scope selected by the policy (§7), the latest relevant change seen for is
The workload is stale exactly when its rollout is complete yet older than the latest relevant dependency change:
If the workload is in progress and no freshness decision is taken (the request waits). The comparison is strict, so equal timestamps count as fresh.
7. Policy scope and the gate
A KickPolicy selects which workloads it manages and which of their dependency
changes may trigger a restart, via two label selectors:
The gate decides whether a permitted-in-principle restart may run now:
with blocking reasons . It is evaluated in two stages. First, KICK-native schedule windows, if any, are applied: a set of allow/deny cron windows induces the predicate
and yields . Second, the GitOps provider is consulted. With (the default) KICK self-gates and the stage returns . Otherwise the owner-resolution relation must yield exactly one owner whose application is reconciled/synced:
8. The restart action
Restarting is the single side effect KICK performs on a workload. It stamps the Pod template with the standard annotation, which forces the workload controller to roll a new revision:
By the definition of in §6, immediately after we have , so the workload is no longer stale. KICK writes no other state: no content hashes, no environment variables, no owner annotations. The action is the only writer of in this model.
9. The KickRequest transition system
Discovery and coalescing (§10) produce at most one KickRequest per target
workload. A request is a state machine over phases
with terminal set . Each request carries a rollout marker (the start time of the rollout it is currently driving). One reconcile step applied to a non-terminal request evaluates, in order, the gate, the freshness relation, and the executor:
The executor issues the physical restart only on the transition into when the request has no in-flight rollout (); while it merely watches the rollout to completion. A terminal request is inert under except for retention: it is deleted after a TTL.
10. Coalescing and reopening
For a target workload , the coalescer maintains a single request keyed by . On an incoming relevant change with time :
Resetting on reopen is essential: it is what makes the next step run the executor’s start-rollout path (issuing a fresh ) instead of adopting the already-completed previous rollout.
11. Invariants
We collect the properties the operator maintains. Let a run be an infinite fair sequence of reconcile steps under a scheduler that eventually delivers every enqueued event.
(I1) No spurious restart at baseline. If a source was last written no later than its consumer’s rollout, , and no relevant change occurs, then and hence ; by §9 the request settles in and never fires. (Ensured by , §4.) Conversely, if was written after the rollout started, , the workload is genuinely stale and is restarted exactly once: KICK adopting it late does not make it fresh.
(I2) Eventual restart on relevant change. If at time a relevant change gives , and from some time on and the workload’s rollout is complete, then in every run eventually fires and afterwards . (Ensured by reopen resetting , §10.)
(I3) Kind-agnostic freshness. is well-defined and satisfiable for every , because completeness is defined per kind and does not require a ReplicaSet. (Ensured by the kind-aware , §6.)
(I4) At most one active request per target. At every reconcile boundary, . Duplicate and repeated references to the same source therefore cause at most one concurrent restart. (Ensured by the keyed coalescer, §10.)
(I5) Gate safety. fires only from the transition, which is reachable only when . Equivalently,
(I6) Idempotence / no rollout amplification. After at time with , we have , so and no further restart is issued until a new relevant change advances . A single change thus yields exactly one new rollout.
(I7) Non-injection.
The only workload write is , setting the standard
annotation. KICK injects no dependency hashes, no
environment, and no owner state into managed workloads, and never treats
imagePullSecrets as dependencies (§2). Secret values never appear in
status, events, or logs.
(I8) Source-driven evaluation.
A workload is evaluated only when KICK observes a or
event for one of its in-scope sources; there is no
workload-driven evaluation. Consequently a workload created after every source
in has already been observed carries no KickRequest at all. This is
sound: the Pods of such a workload started from the current content of every
source, so and would
hold if the request existed. The absence of a request is the correct no-op, not
a missed restart.
12. Reading the pipeline as one relation
Composing the pieces, the end-to-end effect of a relevant change to a source on a managed, in-scope consumer is
Everything else the controller does — coalescing, phase transitions, retention — exists to make this implication hold exactly once per change, only when permitted, and uniformly across workload kinds.
See also
- Freshness — the same idea in prose.
- GitOps gates — provider and window behaviour.
- KickPolicy reference and KickRequest reference.