Skip to content
Open
36 changes: 36 additions & 0 deletions api/v1alpha1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ const (
// ConditionProgressing is True while a rollout is actively in-flight —
// i.e., the target version has not yet been promoted to current.
ConditionProgressing = "Progressing"

// ConditionStalled is True when reconciliation cannot progress and only a spec
// change can resolve it — an invalid spec, or a connection kind this controller
// cannot read. kstatus
// (https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus) reports
// Failed when it is True, so Argo Rollouts and Helm --wait abort instead of
// waiting out their timeout.
//
// kstatus's own convention is that such a condition is absent while things are
// normal, but it only ever tests for True, so this controller writes the
// condition on every path and sets it False when nothing is stalled. That reads
// identically to kstatus and keeps every condition the controller owns visible
// in kubectl describe, consistent with Ready and Progressing.
//
// It is set only for failures decidable from information already in hand.
// Failures that are waiting on another object to exist (a missing Connection or
// credential Secret) and transient infrastructure failures report Reconciling
// instead, because neither can be told apart from a normal few-second gap
// during a deploy. See stalledReasons in the controller package.
ConditionStalled = "Stalled"

// ConditionReconciling is True while the controller is still working toward the
// spec, and False once it has caught up. kstatus reports InProgress when it is
// True, which is the path kstatus intends for custom resources. Without it,
// kstatus has to infer the same answer from Ready=False, a fallback its own
// documentation flags as unreliable.
//
// It is close to the inverse of Progressing but not identical: a transient
// blocking error sets Progressing=False (blocked) and Reconciling=True (still
// retrying), because those two vocabularies disagree about what a retry is.
//
// Reconciling and Stalled must never both be True on the same object.
// kstatus scans status.conditions in array order and returns on the first
// match, so the verdict would depend on insertion order. The controller
// writes both on every path and sets at most one of them to True.
ConditionReconciling = "Reconciling"
)

// Deprecated condition type constants. Maintained for backward compatibility with
Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha1/workerdeployment_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ const (
// when the target version has been successfully registered as the current version.
ReasonRolloutComplete = "RolloutComplete"

// ReasonReconcileSucceeded is set on ConditionStalled=False after any reconcile
// that completed without a blocking error, whatever stage the rollout is at.
// It is what allows a WorkerDeployment that recovers from a blocking error to
// stop reporting Failed to kstatus consumers.
ReasonReconcileSucceeded = "ReconcileSucceeded"

// ReasonWaitingForPollers is set on ConditionProgressing=True when the target
// version's Kubernetes Deployment has been created but the version is not yet
// registered with Temporal (workers have not started polling yet).
Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha1/workerresourcetemplate_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ type WorkerResourceTemplateStatus struct {
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`

// ObservedGeneration is the .metadata.generation the controller last
// reconciled. Compare against .metadata.generation to tell whether the
// controller has caught up with the latest spec change.
//
// This is the only generation field kstatus consults; the per-entry
// observedGeneration carried on each condition is not read by it.
// +optional
// +kubebuilder:validation:Minimum=0
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}

//+kubebuilder:object:root=true
Expand Down
64 changes: 50 additions & 14 deletions docs/cd-rollouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ For migration help, see [migration-to-versioned.md](migration-to-versioned.md).

## Understanding the conditions

The `WorkerDeployment` resource exposes two standard conditions on `status.conditions` that CD tools and scripts can consume.
The `WorkerDeployment` resource exposes four standard conditions on `status.conditions` that CD tools and scripts can consume, in two pairs.

`Ready` and `Progressing` describe the rollout in the controller's own terms. They are the ones to read in a script, a dashboard, or `kubectl describe`, and their `reason` fields carry the detail.

`Stalled` and `Reconciling` say the same thing in the vocabulary [kstatus](https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus) understands, the library behind Helm 4 `--wait` and Flux health assessment. They follow kstatus's "abnormal-true" convention: each is present and `True` only while something unusual is happening, and absent otherwise. You rarely need to read them yourself; they exist so those tools reach the right verdict without a custom health check.

### `Ready`

Expand Down Expand Up @@ -43,6 +47,34 @@ When `Progressing=False` due to an error, the `reason` field identifies what wen

Once the underlying problem is fixed, the next successful reconcile will restore `Progressing` and `Ready` to the correct state.

### `Stalled` and `Reconciling`

`Reconciling=True` means the controller is still working toward the spec. kstatus-based tools report the resource as **in progress** and keep waiting. `Stalled=True` means reconciliation cannot proceed and waiting will not help. The kstatus tools report **failed** and stop. Both are absent once a rollout is complete, and only one is ever set at a time.

`Stalled` is set only for failures that are decidable from information already in hand, where nothing arriving later could change the answer:

| Reason | Condition set | Why |
|---|---|---|
| `InvalidSpec` | `Stalled` | Settled by the spec you just applied |
| `ClusterConnectionUnsupported` | `Stalled` | Settled by the spec plus how the controller was deployed |
| `ConnectionNotFound` | `Reconciling` | Waiting on another object — the `Connection` may not exist *yet* |
| `AuthSecretInvalid` | `Reconciling` | Same, and this reason also covers a credential Secret that is simply absent |
| `TemporalClientCreationFailed` | `Reconciling` | Server unreachable; retried |
| `TemporalStateFetchFailed` | `Reconciling` | Includes rate limiting; retried |
| `PlanGenerationFailed`, `PlanExecutionFailed` | `Reconciling` | Retried with backoff |

The reason a missing `Connection` is not treated as terminal is ordering. Applying a `WorkerDeployment` alongside its `Connection` and credentials in one release gives no guarantee about which lands first, so a missing reference is frequently a normal gap of a few seconds rather than a mistake.

The trade-off is that an incorrect `connectionRef` or a `Connection` that was never created keeps reporting *in progress* until your tool's timeout expires rather than failing immediately. Set timeouts you are willing to wait out, and read the `reason` on `Ready`/`Progressing` (or the resource's Kubernetes Events) to see what is actually blocking.

`WorkerResourceTemplate` follows the same pattern: a template that cannot render, or that the API server rejects outright, sets `Stalled`. A `WorkerResourceTemplate` waiting for its `WorkerDeployment` to appear, or retrying a transient apply failure, sets `Reconciling`.

### `Connection` and `ClusterConnection`

`Connection` and `ClusterConnection` are configuration-only resources. They have no controller of their own and expose no conditions, so tools that assess health from conditions like Helm `--wait`, Flux, and anything else built on [kstatus](https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus) treat them as healthy as soon as they exist. This is intentional as there is no reconcile loop behind them and therefore nothing to wait for. Kubernetes treats `ConfigMap` and `Secret` the same way.

A broken connection is still reported, just on the `WorkerDeployment` that references it rather than on the connection itself (see the `ConnectionNotFound` and `AuthSecretInvalid` reasons above). Gate your rollouts on the `WorkerDeployment` as waiting on a `Connection` tells you only that the object was accepted by the API server, not that the credentials in it work.

## Triggering a rollout

A rollout starts when you change the pod template in your `WorkerDeployment` spec — a changed pod spec produces a new Build ID, which the controller treats as a new version to roll out.
Expand Down Expand Up @@ -97,7 +129,7 @@ Set `--timeout` to exceed the longest expected rollout time — for progressive

### Helm 4

Helm 4 uses [kstatus](https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus) for its `--wait` implementation ([HIP-0022](https://helm.sh/community/hips/hip-0022/)). kstatus understands the standard Kubernetes conditions contract and should block until `Ready=True` on your `WorkerDeployment`:
Helm 4 uses [kstatus](https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus) for its `--wait` implementation ([HIP-0022](https://helm.sh/community/hips/hip-0022/)). kstatus understands the standard Kubernetes conditions contract and should block until `Ready=True` on your `WorkerDeployment`. Because the controller also emits `Stalled` (see above), a rollout blocked by an invalid spec fails the release immediately instead of waiting out the timeout:

```bash
helm upgrade my-worker ./chart --values values.yaml --wait --timeout 10m
Expand All @@ -121,38 +153,42 @@ kubectl wait workerdeployment/my-worker \

ArgoCD does not have a generic fallback that automatically checks `status.conditions` on unknown CRD types. For any resource whose group (`temporal.io`) is not in ArgoCD's built-in health check registry, ArgoCD silently skips that resource when computing application health. A [custom Lua health check](https://argo-cd.readthedocs.io/en/stable/operator-manual/health/) is the standard mechanism for teaching ArgoCD how to assess a CRD's health.

The two standard conditions (`Ready`, `Progressing`) keep the Lua simple — it only needs to read the condition type and status, not any controller-specific status fields. The following script is a starting point; adapt it to your ArgoCD version and any site-specific requirements:
The standard conditions keep the Lua simple — it only needs to read condition types and statuses, not any controller-specific status fields. Reading `Stalled` and `Reconciling` rather than `Progressing` also makes ArgoCD agree with Helm and Flux about what counts as a failure, instead of showing **Degraded** for a `Connection` that is a second away from existing. The following script is a starting point; adapt it to your ArgoCD version and any site-specific requirements:

```yaml
# In your argocd-cm ConfigMap
data:
resource.customizations.health.temporal.io_WorkerDeployment: |
local ready = nil
local progressing = nil
local stalled = nil
local reconciling = nil
if obj.status ~= nil and obj.status.conditions ~= nil then
for _, c in ipairs(obj.status.conditions) do
if c.type == "Ready" then ready = c end
if c.type == "Progressing" then progressing = c end
if c.type == "Stalled" then stalled = c end
if c.type == "Reconciling" then reconciling = c end
end
end
-- Check Stalled first: it is the only condition that means waiting will not help.
if stalled ~= nil and stalled.status == "True" then
return {status = "Degraded", message = stalled.message}
end
if ready ~= nil and ready.status == "True" then
return {status = "Healthy", message = ready.message}
end
if progressing ~= nil then
if progressing.status == "True" then
return {status = "Progressing", message = progressing.message}
else
return {status = "Degraded", message = progressing.message}
end
if reconciling ~= nil and reconciling.status == "True" then
return {status = "Progressing", message = reconciling.message}
end
return {status = "Progressing", message = "Waiting for conditions"}
```

With a health check like this in place:

- ArgoCD shows **Degraded** when reconciliation is stalled (`Stalled=True`) — an invalid spec, or a connection kind this controller cannot read.
- ArgoCD shows **Healthy** once `Ready=True`.
- ArgoCD shows **Progressing** while a rollout is in-flight (`Progressing=True`).
- ArgoCD shows **Degraded** when progress is blocked (`Progressing=False` with an error reason).
- ArgoCD shows **Progressing** while a rollout is in-flight, and also while the controller is retrying a recoverable problem such as a `Connection` that does not exist yet. Read the `reason` on `Ready` to tell those apart.

The same script works for `WorkerResourceTemplate` — register it under `resource.customizations.health.temporal.io_WorkerResourceTemplate` as well, since it emits the same three conditions.

If you use [sync waves](https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/) and workers must be fully rolled out before a dependent service is updated, place the `WorkerDeployment` in an earlier wave.

Expand All @@ -162,7 +198,7 @@ If you use [sync waves](https://argo-cd.readthedocs.io/en/stable/user-guide/sync

### Kustomization

Flux's `Kustomization` controller uses kstatus to assess resource health. Because `WorkerDeployment` emits a standard `Ready` condition, Flux should treat it as healthy when `Ready=True`. Adding an explicit `healthChecks` entry makes the dependency visible and ensures Flux waits on the `WorkerDeployment` before marking the Kustomization as ready:
Flux's `Kustomization` controller uses kstatus to assess resource health. Because `WorkerDeployment` emits the standard `Ready`, `Reconciling`, and `Stalled` conditions, Flux should treat it as healthy when `Ready=True`, keep waiting while `Reconciling=True`, and fail the health check rather than wait out the timeout when `Stalled=True`. Adding an explicit `healthChecks` entry makes the dependency visible and ensures Flux waits on the `WorkerDeployment` before marking the Kustomization as ready:

```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
Expand Down
2 changes: 2 additions & 0 deletions docs/migration-crd-rename.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ message: "Migration complete. Delete this TemporalWorkerDeployment."

For `TemporalConnection`, the same `Deprecated` → `MigratedToConnection` pattern applies (there is no ownership transfer step, so there is no intermediate state).

> **CD health checks:** because these resources never report `Ready=True`, tools that assess health from conditions (Helm `--wait`, Flux, and anything else built on [kstatus](https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus)) treat an unmigrated `TemporalWorkerDeployment` or `TemporalConnection` as not-ready for as long as it exists — completing the migration is what resolves it. A resource already marked for deletion reports `Terminating` instead, so following the migration steps above does not leave a release waiting.

## Deletion protection

After upgrading to v1.7, the controller adds a `temporal.io/migration-guard` finalizer to every `TemporalWorkerDeployment` and `TemporalConnection`. This finalizer prevents the resource from being fully deleted until migration is confirmed:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
k8s.io/apiextensions-apiserver v0.36.0
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
sigs.k8s.io/cli-utils v0.37.2
sigs.k8s.io/controller-runtime v0.24.0
sigs.k8s.io/yaml v1.6.0
)
Expand Down Expand Up @@ -79,7 +80,6 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,8 @@ modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/cli-utils v0.37.2 h1:GOfKw5RV2HDQZDJlru5KkfLO1tbxqMoyn1IYUxqBpNg=
sigs.k8s.io/cli-utils v0.37.2/go.mod h1:V+IZZr4UoGj7gMJXklWBg6t5xbdThFBcpj4MrZuCYco=
sigs.k8s.io/controller-runtime v0.24.0 h1:Ck6N2LdS8Lovy1o25BB4r1xjvLEKUl1s2o9kU+KWDE4=
sigs.k8s.io/controller-runtime v0.24.0/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
minimum: 0
type: integer
versions:
items:
properties:
Expand Down
Loading
Loading