This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch main
in repository 
https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 5925dd7  feat(lifecycle): add secret key rotation task (#51)
5925dd7 is described below

commit 5925dd7131d3e54cd79468a752d823f1ff4ff248
Author: Ville Brofeldt <[email protected]>
AuthorDate: Tue May 12 19:40:26 2026 -0700

    feat(lifecycle): add secret key rotation task (#51)
---
 AGENTS.md                                          |  11 +-
 api/v1alpha1/superset_types.go                     |  33 ++-
 api/v1alpha1/supersetlifecycletask_types.go        |   2 +-
 api/v1alpha1/zz_generated.deepcopy.go              |  36 +++
 ...superset.apache.org_supersetlifecycletasks.yaml |   1 +
 .../crds/superset.apache.org_supersets.yaml        |  84 +++++++
 ...superset.apache.org_supersetlifecycletasks.yaml |   1 +
 .../crd/bases/superset.apache.org_supersets.yaml   |  84 +++++++
 docs/architecture/internals.md                     |  12 +-
 docs/architecture/overview.md                      |   2 +-
 docs/index.md                                      |   2 +-
 docs/reference/api-reference.md                    |  33 ++-
 docs/user-guide/lifecycle.md                       |  85 ++++++-
 internal/common/names.go                           |  18 +-
 internal/config/renderer.go                        |   8 +
 internal/config/renderer_test.go                   |  28 +++
 internal/controller/clone_test.go                  | 258 ++++++++++++++++++++-
 internal/controller/config_builder.go              |  15 ++
 internal/controller/drain.go                       |   3 +-
 internal/controller/lifecycle.go                   | 108 +++++++--
 20 files changed, 770 insertions(+), 54 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 621b369..6117e95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@ The operator uses a **two-tier CRD architecture** where the 
parent `Superset` re
 ### CRD Hierarchy
 
 - **Superset** (parent) — top-level CR with shared spec (top-level + 
per-component), environment, secretKey/secretKeyFrom, metastore (with 
uriFrom/passwordFrom), valkey (cache/broker/results), config, LifecycleSpec, 
NetworkingSpec, MonitoringSpec
-- **SupersetLifecycleTask** — lifecycle task runner: bare Pods + ConfigMap. 
Exclusively created and managed by the parent controller (not user-created). 
Three sequential tasks: "clone" (database snapshot from external source), 
"migrate" (`superset db upgrade`), and "init" (`superset init`). Each task can 
be independently disabled via `disabled: true`. Clone supports `cronSchedule` 
for periodic re-execution. Named `{parentName}-clone`, `{parentName}-migrate`, 
and `{parentName}-init`.
+- **SupersetLifecycleTask** — lifecycle task runner: bare Pods + ConfigMap. 
Exclusively created and managed by the parent controller (not user-created). 
Four sequential tasks: "clone" (database snapshot from external source), 
"migrate" (`superset db upgrade`), "rotate" (`superset re-encrypt-secrets` for 
secret key rotation), and "init" (`superset init`). Each task can be 
independently disabled via `disabled: true`. Clone supports `cronSchedule` for 
periodic re-execution. Named `{parentNa [...]
 - **SupersetWebServer** — gunicorn web server Deployment + Service + ConfigMap
 - **SupersetCeleryWorker** — async task worker Deployment + ConfigMap
 - **SupersetCeleryBeat** — periodic task scheduler Deployment + ConfigMap 
(singleton, always 1 replica)
@@ -47,7 +47,7 @@ The operator uses a **two-tier CRD architecture** where the 
parent `Superset` re
 
 **Key principles:**
 - **Parent resolves, children execute.** All layering logic lives in the 
parent controller. Child CRs are fully flattened — no inheritance to trace.
-- **Presence = enabled.** No `enabled: true/false`. If `celeryWorker: {}` is 
set, workers deploy. Lifecycle tasks (migrate, init) run by default; disable 
individual tasks via `disabled: true`. Clone runs when `spec.lifecycle.clone` 
is set.
+- **Presence = enabled.** No `enabled: true/false`. If `celeryWorker: {}` is 
set, workers deploy. Lifecycle tasks (migrate, init) run by default; disable 
individual tasks via `disabled: true`. Clone runs when `spec.lifecycle.clone` 
is set. Rotate runs when `spec.lifecycle.rotate` is set.
 - **Secrets never touch ConfigMaps.** In prod mode, CRD CEL validation rejects 
inline `secretKey`, `metastore.uri`, `metastore.password`, and 
`valkey.password`. Use `secretKeyFrom`, `metastore.uriFrom`, 
`metastore.passwordFrom`, or `valkey.passwordFrom` to reference Kubernetes 
Secrets (operator injects `valueFrom.secretKeyRef` env vars). In dev mode, 
inline secrets are allowed.
 - **Per-component config rendering.** All Python components get `SECRET_KEY` 
rendered from `SUPERSET_OPERATOR__SECRET_KEY`. Web gets port config. Structured 
metastore renders an f-string URI from `SUPERSET_OPERATOR__DB_*` env vars. When 
`spec.valkey` is set, operator renders all cache configs (`CACHE_CONFIG`, 
`DATA_CACHE_CONFIG`, etc.), `CeleryConfig`, and `RESULTS_BACKEND` from 
`SUPERSET_OPERATOR__VALKEY_*` env vars. Websocket gets nothing (Node.js).
 
@@ -123,7 +123,7 @@ The operator uses a **two-tier CRD architecture** where the 
parent `Superset` re
 - **HPA**: When `autoscaling` is set, Deployment replicas is nil (HPA 
manages). Supports custom metrics via `autoscalingv2.MetricSpec`. Top-level 
`autoscaling`/`podDisruptionBudget` provide defaults inherited by all scalable 
components; per-component values override (not merge). CeleryBeat and lifecycle 
tasks are excluded (singleton/bare pods).
 - **Beat singleton**: CeleryBeat always forces replicas=1 regardless of spec.
 - **Gateway API**: Uses `sigs.k8s.io/gateway-api` types. Graceful handling of 
missing CRDs via `meta.IsNoMatchError`.
-- **Lifecycle tasks**: `spec.lifecycle` on the parent CRD (type 
`LifecycleSpec`) defines up to three sequential tasks: "clone" (database 
snapshot from external source), "migrate" (`superset db upgrade`), and "init" 
(`superset init`). Each produces a `SupersetLifecycleTask` child CR named 
`{parentName}-clone`, `{parentName}-migrate`, and `{parentName}-init`. The 
parent controller is the sole orchestrator: it creates task CRs 
(Get+Create/Delete pattern, never CreateOrUpdate), sequences the [...]
+- **Lifecycle tasks**: `spec.lifecycle` on the parent CRD (type 
`LifecycleSpec`) defines up to four sequential tasks: "clone" (database 
snapshot from external source), "migrate" (`superset db upgrade`), "rotate" 
(`superset re-encrypt-secrets` for secret key rotation), and "init" (`superset 
init`). Each produces a `SupersetLifecycleTask` child CR named 
`{parentName}-clone`, `{parentName}-migrate`, `{parentName}-rotate`, and 
`{parentName}-init`. The parent controller is the sole orchestrat [...]
 - **CRD validation**: All validation uses CEL (`x-kubernetes-validations`) on 
CRD types — no admission webhooks. Rules cover: environment mode restrictions, 
secret mutual exclusivity, metastore/valkey validation, networking constraints, 
monitoring constraints. Defaults (repository, pullPolicy, environment) use 
kubebuilder default markers.
 - **Metrics**: Operator exposes controller-runtime default metrics (reconcile 
counts, durations, leader election) on HTTPS :8443 with Kubernetes auth/authz. 
No custom metrics — controller-runtime defaults are sufficient. Superset 
instance monitoring via optional `spec.monitoring.serviceMonitor` (creates a 
Prometheus ServiceMonitor targeting the web-server component using unstructured 
objects; gracefully skips if CRD is absent).
 - **Config mount path**: `/app/pythonpath` for superset_config.py.
@@ -135,6 +135,7 @@ The operator uses a **two-tier CRD architecture** where the 
parent `Superset` re
 |---|---|---|---|
 | `lifecycle` (clone) | `SupersetLifecycleTask` | `clone` | `superset` |
 | `lifecycle` (migrate) | `SupersetLifecycleTask` | `migrate` | `superset` |
+| `lifecycle` (rotate) | `SupersetLifecycleTask` | `rotate` | `superset` |
 | `lifecycle` (init) | `SupersetLifecycleTask` | `init` | `superset` |
 | `webServer` | `SupersetWebServer` | `web-server` | `superset` |
 | `celeryWorker` | `SupersetCeleryWorker` | `celery-worker` | `superset` |
@@ -143,7 +144,7 @@ The operator uses a **two-tier CRD architecture** where the 
parent `Superset` re
 | `websocketServer` | `SupersetWebsocketServer` | `websocket-server` | 
`superset` |
 | `mcpServer` | `SupersetMcpServer` | `mcp-server` | `superset` |
 
-**Two-level naming:** Child CRs always use the parent name (differentiated by 
Kind), except lifecycle tasks which use `{parentName}-{taskName}` (e.g., 
`{parentName}-clone`, `{parentName}-migrate`, `{parentName}-init`). 
Sub-resources (Deployments, Services, ConfigMaps) are named 
`{parentName}-{componentType}`. Each child controller computes sub-resource 
names locally from its CR name and known component type. Example: parent 
`my-superset` → child CR `SupersetWebServer/my-superset` → Deplo [...]
+**Two-level naming:** Child CRs always use the parent name (differentiated by 
Kind), except lifecycle tasks which use `{parentName}-{taskName}` (e.g., 
`{parentName}-clone`, `{parentName}-migrate`, `{parentName}-rotate`, 
`{parentName}-init`). Sub-resources (Deployments, Services, ConfigMaps) are 
named `{parentName}-{componentType}`. Each child controller computes 
sub-resource names locally from its CR name and known component type. Example: 
parent `my-superset` → child CR `SupersetWebServ [...]
 
 All components use the reserved container name `superset` for the main 
container. Since each component runs in its own Pod, names never collide. This 
allows `kubectl exec -it <pod> -c superset` without needing to know the 
component type.
 
@@ -160,7 +161,7 @@ All CRD names (parent and child) are validated via CEL to 
be valid DNS labels (l
 ## Documentation Style
 
 - **README** is a landing page: project description, philosophy, quick start, 
link to docs. Keep it welcoming and free of jargon — don't reference specific 
knobs, internal config names, or implementation details that might intimidate 
newcomers.
-- **docs/index.md** is the primary feature overview for the docs site. Keep 
feature descriptions high-level and outcome-focused. Implementation details 
belong in the user guide or architecture docs.
+- **docs/index.md** is the primary feature overview for the docs site. Keep 
feature descriptions high-level and outcome-focused. Implementation details 
belong in the user guide or architecture docs. Prefer consolidated bullets that 
group related capabilities (e.g., "Lifecycle automation — cloning, migrations, 
key rotation, and init" rather than a separate bullet per feature).
 - **docs/user-guide/configuration.md** is the full configuration reference. 
Here it's appropriate to name specific fields, presets, env vars, and show 
concrete YAML examples.
 - **docs/architecture/overview.md** explains design decisions and internal 
structure for contributors and advanced users.
 - General principles: be concise and objective, avoid overselling or verbose 
language, reserve code blocks for real code (not ASCII art), minimize 
duplication between README and docs (README links to docs for details).
diff --git a/api/v1alpha1/superset_types.go b/api/v1alpha1/superset_types.go
index 5ecc656..8a592be 100644
--- a/api/v1alpha1/superset_types.go
+++ b/api/v1alpha1/superset_types.go
@@ -39,6 +39,9 @@ import (
 // +kubebuilder:validation:XValidation:rule="(has(self.environment) && 
(self.environment == 'Development' || self.environment == 'Staging')) || 
!has(self.lifecycle) || !has(self.lifecycle.clone)",message="lifecycle.clone is 
only allowed when environment is Development or Staging; cloning performs a 
destructive DROP DATABASE on the target metastore"
 // +kubebuilder:validation:XValidation:rule="(has(self.environment) && 
self.environment == 'Development') || !has(self.lifecycle) || 
!has(self.lifecycle.clone) || !has(self.lifecycle.clone.source) || 
!has(self.lifecycle.clone.source.password)",message="lifecycle.clone.source.password
 is only allowed when environment is Development; use 
lifecycle.clone.source.passwordFrom in Staging"
 // +kubebuilder:validation:XValidation:rule="!has(self.lifecycle) || 
!has(self.lifecycle.clone) || (has(self.metastore) && 
has(self.metastore.host))",message="lifecycle.clone requires structured 
metastore configuration (host must be set)"
+// +kubebuilder:validation:XValidation:rule="(has(self.environment) && 
self.environment == 'Development') || 
!has(self.previousSecretKey)",message="previousSecretKey is only allowed when 
environment is Development; use previousSecretKeyFrom in Production"
+// +kubebuilder:validation:XValidation:rule="!has(self.previousSecretKey) || 
!has(self.previousSecretKeyFrom)",message="previousSecretKey and 
previousSecretKeyFrom are mutually exclusive"
+// +kubebuilder:validation:XValidation:rule="!has(self.lifecycle) || 
!has(self.lifecycle.rotate) || has(self.previousSecretKey) || 
has(self.previousSecretKeyFrom)",message="lifecycle.rotate requires 
previousSecretKey (dev) or previousSecretKeyFrom to be set"
 type SupersetSpec struct {
        // Image configuration inherited by all components.
        Image ImageSpec `json:"image"`
@@ -78,6 +81,17 @@ type SupersetSpec struct {
        // +optional
        SecretKeyFrom *corev1.SecretKeySelector `json:"secretKeyFrom,omitempty"`
 
+       // Plain text previous secret key for key rotation. Only allowed in dev 
mode.
+       // When set, rendered as PREVIOUS_SECRET_KEY in superset_config.py for 
all
+       // Python components, enabling fallback decryption during key 
transitions.
+       // +optional
+       PreviousSecretKey *string `json:"previousSecretKey,omitempty"`
+
+       // Reference to a Secret key containing the previous secret key for 
rotation.
+       // Mutually exclusive with previousSecretKey.
+       // +optional
+       PreviousSecretKeyFrom *corev1.SecretKeySelector 
`json:"previousSecretKeyFrom,omitempty"`
+
        // Metastore database connection configuration.
        // +optional
        Metastore *MetastoreSpec `json:"metastore,omitempty"`
@@ -344,6 +358,11 @@ type LifecycleSpec struct {
        // +optional
        Migrate *MigrateTaskSpec `json:"migrate,omitempty"`
 
+       // Secret key rotation task configuration. Runs after migrate and 
before init.
+       // Presence enables the task; absence disables it.
+       // +optional
+       Rotate *RotateTaskSpec `json:"rotate,omitempty"`
+
        // Application initialization task configuration.
        // +optional
        Init *InitTaskSpec `json:"init,omitempty"`
@@ -355,6 +374,14 @@ type MigrateTaskSpec struct {
        BaseTaskSpec `json:",inline"`
 }
 
+// RotateTaskSpec defines the secret key rotation task.
+// Runs superset re-encrypt-secrets between migrate and init when the
+// secret key is rotated. Requires previousSecretKey or previousSecretKeyFrom
+// to be set on the parent spec.
+type RotateTaskSpec struct {
+       BaseTaskSpec `json:",inline"`
+}
+
 // InitTaskSpec defines the application initialization task.
 // Triggers on config changes and upstream task re-execution.
 type InitTaskSpec struct {
@@ -672,7 +699,7 @@ type SupersetStatus struct {
 
 // LifecycleStatus tracks the current lifecycle task execution state.
 type LifecycleStatus struct {
-       // Phase of the lifecycle: Idle, Cloning, Migrating, Initializing, 
Complete, Blocked, AwaitingApproval.
+       // Phase of the lifecycle: Idle, Cloning, Migrating, Rotating, 
Initializing, Complete, Blocked, AwaitingApproval.
        // +optional
        Phase string `json:"phase,omitempty"`
        // MaintenanceActive indicates the maintenance page is currently 
serving traffic
@@ -689,6 +716,9 @@ type LifecycleStatus struct {
        // Migrate task status summary.
        // +optional
        Migrate *TaskRefStatus `json:"migrate,omitempty"`
+       // Rotate task status summary.
+       // +optional
+       Rotate *TaskRefStatus `json:"rotate,omitempty"`
        // Init task status summary.
        // +optional
        Init *TaskRefStatus `json:"init,omitempty"`
@@ -779,6 +809,7 @@ type ComponentRefStatus struct {
 // +kubebuilder:validation:XValidation:rule="!has(self.spec.websocketServer) 
|| size(self.metadata.name) <= 46",message="metadata.name must be at most 46 
characters when websocketServer is enabled (sub-resource suffix 
'-websocket-server' is 17 chars)"
 // +kubebuilder:validation:XValidation:rule="!has(self.spec.mcpServer) || 
size(self.metadata.name) <= 52",message="metadata.name must be at most 52 
characters when mcpServer is enabled (sub-resource suffix '-mcp-server' is 11 
chars)"
 // +kubebuilder:validation:XValidation:rule="!has(self.spec.lifecycle) || 
!has(self.spec.lifecycle.maintenancePage) || size(self.metadata.name) <= 
46",message="metadata.name must be at most 46 characters when 
lifecycle.maintenancePage is enabled (sub-resource suffix '-maintenance-page' 
is 17 chars)"
+// +kubebuilder:validation:XValidation:rule="!has(self.spec.lifecycle) || 
!has(self.spec.lifecycle.rotate) || size(self.metadata.name) <= 
49",message="metadata.name must be at most 49 characters when lifecycle.rotate 
is enabled (task name '{parent}-rotate' + ConfigMap suffix '-config' must fit 
within 63 chars)"
 // +kubebuilder:validation:XValidation:rule="(has(self.spec.lifecycle) && 
has(self.spec.lifecycle.disabled) && self.spec.lifecycle.disabled == true) || 
size(self.metadata.name) <= 48",message="metadata.name must be at most 48 
characters when lifecycle is enabled (task name '{parent}-migrate' + ConfigMap 
suffix '-config' must fit within 63 chars)"
 
 // Superset is the top-level resource representing a complete Superset 
deployment.
diff --git a/api/v1alpha1/supersetlifecycletask_types.go 
b/api/v1alpha1/supersetlifecycletask_types.go
index 83c586c..01c4725 100644
--- a/api/v1alpha1/supersetlifecycletask_types.go
+++ b/api/v1alpha1/supersetlifecycletask_types.go
@@ -27,7 +27,7 @@ type SupersetLifecycleTaskSpec struct {
        FlatComponentSpec `json:",inline"`
 
        // Type identifies the task purpose. Future task types will require 
schema additions.
-       // +kubebuilder:validation:Enum=Clone;Migrate;Init
+       // +kubebuilder:validation:Enum=Clone;Migrate;Rotate;Init
        Type string `json:"type"`
 
        // Command to execute in the task pod.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go 
b/api/v1alpha1/zz_generated.deepcopy.go
index 02c88dd..ee86b78 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -1005,6 +1005,11 @@ func (in *LifecycleSpec) DeepCopyInto(out 
*LifecycleSpec) {
                *out = new(MigrateTaskSpec)
                (*in).DeepCopyInto(*out)
        }
+       if in.Rotate != nil {
+               in, out := &in.Rotate, &out.Rotate
+               *out = new(RotateTaskSpec)
+               (*in).DeepCopyInto(*out)
+       }
        if in.Init != nil {
                in, out := &in.Init, &out.Init
                *out = new(InitTaskSpec)
@@ -1042,6 +1047,11 @@ func (in *LifecycleStatus) DeepCopyInto(out 
*LifecycleStatus) {
                *out = new(TaskRefStatus)
                (*in).DeepCopyInto(*out)
        }
+       if in.Rotate != nil {
+               in, out := &in.Rotate, &out.Rotate
+               *out = new(TaskRefStatus)
+               (*in).DeepCopyInto(*out)
+       }
        if in.Init != nil {
                in, out := &in.Init, &out.Init
                *out = new(TaskRefStatus)
@@ -1474,6 +1484,22 @@ func (in *PodTemplate) DeepCopy() *PodTemplate {
        return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
+func (in *RotateTaskSpec) DeepCopyInto(out *RotateTaskSpec) {
+       *out = *in
+       in.BaseTaskSpec.DeepCopyInto(&out.BaseTaskSpec)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new RotateTaskSpec.
+func (in *RotateTaskSpec) DeepCopy() *RotateTaskSpec {
+       if in == nil {
+               return nil
+       }
+       out := new(RotateTaskSpec)
+       in.DeepCopyInto(out)
+       return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
 func (in *SQLAlchemyEngineOptionsSpec) DeepCopyInto(out 
*SQLAlchemyEngineOptionsSpec) {
        *out = *in
@@ -2241,6 +2267,16 @@ func (in *SupersetSpec) DeepCopyInto(out *SupersetSpec) {
                *out = new(v1.SecretKeySelector)
                (*in).DeepCopyInto(*out)
        }
+       if in.PreviousSecretKey != nil {
+               in, out := &in.PreviousSecretKey, &out.PreviousSecretKey
+               *out = new(string)
+               **out = **in
+       }
+       if in.PreviousSecretKeyFrom != nil {
+               in, out := &in.PreviousSecretKeyFrom, &out.PreviousSecretKeyFrom
+               *out = new(v1.SecretKeySelector)
+               (*in).DeepCopyInto(*out)
+       }
        if in.Metastore != nil {
                in, out := &in.Metastore, &out.Metastore
                *out = new(MetastoreSpec)
diff --git 
a/charts/superset-operator/crds/superset.apache.org_supersetlifecycletasks.yaml 
b/charts/superset-operator/crds/superset.apache.org_supersetlifecycletasks.yaml
index ef6e2e7..a9fd3fe 100644
--- 
a/charts/superset-operator/crds/superset.apache.org_supersetlifecycletasks.yaml
+++ 
b/charts/superset-operator/crds/superset.apache.org_supersetlifecycletasks.yaml
@@ -3995,6 +3995,7 @@ spec:
                 enum:
                 - Clone
                 - Migrate
+                - Rotate
                 - Init
                 type: string
             required:
diff --git a/charts/superset-operator/crds/superset.apache.org_supersets.yaml 
b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
index c269393..2d5ed8f 100644
--- a/charts/superset-operator/crds/superset.apache.org_supersets.yaml
+++ b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
@@ -22933,6 +22933,27 @@ spec:
                           type: object
                         type: array
                     type: object
+                  rotate:
+                    properties:
+                      command:
+                        items:
+                          type: string
+                        type: array
+                        x-kubernetes-list-type: atomic
+                      disabled:
+                        type: boolean
+                      maxRetries:
+                        default: 3
+                        format: int32
+                        minimum: 1
+                        type: integer
+                      requiresDrain:
+                        type: boolean
+                      timeout:
+                        type: string
+                      trigger:
+                        type: string
+                    type: object
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -30903,6 +30924,21 @@ spec:
                       type: object
                     type: array
                 type: object
+              previousSecretKey:
+                type: string
+              previousSecretKeyFrom:
+                properties:
+                  key:
+                    type: string
+                  name:
+                    default: ""
+                    type: string
+                  optional:
+                    type: boolean
+                required:
+                - key
+                type: object
+                x-kubernetes-map-type: atomic
               replicas:
                 format: int32
                 type: integer
@@ -39124,6 +39160,16 @@ spec:
                 (host must be set)
               rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || 
(has(self.metastore)
                 && has(self.metastore.host))'
+            - message: previousSecretKey is only allowed when environment is 
Development;
+                use previousSecretKeyFrom in Production
+              rule: (has(self.environment) && self.environment == 
'Development') ||
+                !has(self.previousSecretKey)
+            - message: previousSecretKey and previousSecretKeyFrom are 
mutually exclusive
+              rule: '!has(self.previousSecretKey) || 
!has(self.previousSecretKeyFrom)'
+            - message: lifecycle.rotate requires previousSecretKey (dev) or 
previousSecretKeyFrom
+                to be set
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.rotate) || 
has(self.previousSecretKey)
+                || has(self.previousSecretKeyFrom)'
           status:
             properties:
               components:
@@ -39350,6 +39396,39 @@ spec:
                     type: object
                   phase:
                     type: string
+                  rotate:
+                    properties:
+                      attempts:
+                        format: int32
+                        type: integer
+                      completedAt:
+                        format: date-time
+                        type: string
+                      duration:
+                        type: string
+                      image:
+                        type: string
+                      lastScheduledAt:
+                        format: date-time
+                        type: string
+                      message:
+                        type: string
+                      nextScheduleAt:
+                        format: date-time
+                        type: string
+                      podName:
+                        type: string
+                      startedAt:
+                        format: date-time
+                        type: string
+                      state:
+                        enum:
+                        - Pending
+                        - Running
+                        - Complete
+                        - Failed
+                        type: string
+                    type: object
                   upgrade:
                     properties:
                       direction:
@@ -39415,6 +39494,11 @@ spec:
             is enabled (sub-resource suffix '-maintenance-page' is 17 chars)
           rule: '!has(self.spec.lifecycle) || 
!has(self.spec.lifecycle.maintenancePage)
             || size(self.metadata.name) <= 46'
+        - message: metadata.name must be at most 49 characters when 
lifecycle.rotate
+            is enabled (task name '{parent}-rotate' + ConfigMap suffix 
'-config' must
+            fit within 63 chars)
+          rule: '!has(self.spec.lifecycle) || !has(self.spec.lifecycle.rotate) 
||
+            size(self.metadata.name) <= 49'
         - message: metadata.name must be at most 48 characters when lifecycle 
is enabled
             (task name '{parent}-migrate' + ConfigMap suffix '-config' must 
fit within
             63 chars)
diff --git a/config/crd/bases/superset.apache.org_supersetlifecycletasks.yaml 
b/config/crd/bases/superset.apache.org_supersetlifecycletasks.yaml
index ef6e2e7..a9fd3fe 100644
--- a/config/crd/bases/superset.apache.org_supersetlifecycletasks.yaml
+++ b/config/crd/bases/superset.apache.org_supersetlifecycletasks.yaml
@@ -3995,6 +3995,7 @@ spec:
                 enum:
                 - Clone
                 - Migrate
+                - Rotate
                 - Init
                 type: string
             required:
diff --git a/config/crd/bases/superset.apache.org_supersets.yaml 
b/config/crd/bases/superset.apache.org_supersets.yaml
index c269393..2d5ed8f 100644
--- a/config/crd/bases/superset.apache.org_supersets.yaml
+++ b/config/crd/bases/superset.apache.org_supersets.yaml
@@ -22933,6 +22933,27 @@ spec:
                           type: object
                         type: array
                     type: object
+                  rotate:
+                    properties:
+                      command:
+                        items:
+                          type: string
+                        type: array
+                        x-kubernetes-list-type: atomic
+                      disabled:
+                        type: boolean
+                      maxRetries:
+                        default: 3
+                        format: int32
+                        minimum: 1
+                        type: integer
+                      requiresDrain:
+                        type: boolean
+                      timeout:
+                        type: string
+                      trigger:
+                        type: string
+                    type: object
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -30903,6 +30924,21 @@ spec:
                       type: object
                     type: array
                 type: object
+              previousSecretKey:
+                type: string
+              previousSecretKeyFrom:
+                properties:
+                  key:
+                    type: string
+                  name:
+                    default: ""
+                    type: string
+                  optional:
+                    type: boolean
+                required:
+                - key
+                type: object
+                x-kubernetes-map-type: atomic
               replicas:
                 format: int32
                 type: integer
@@ -39124,6 +39160,16 @@ spec:
                 (host must be set)
               rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || 
(has(self.metastore)
                 && has(self.metastore.host))'
+            - message: previousSecretKey is only allowed when environment is 
Development;
+                use previousSecretKeyFrom in Production
+              rule: (has(self.environment) && self.environment == 
'Development') ||
+                !has(self.previousSecretKey)
+            - message: previousSecretKey and previousSecretKeyFrom are 
mutually exclusive
+              rule: '!has(self.previousSecretKey) || 
!has(self.previousSecretKeyFrom)'
+            - message: lifecycle.rotate requires previousSecretKey (dev) or 
previousSecretKeyFrom
+                to be set
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.rotate) || 
has(self.previousSecretKey)
+                || has(self.previousSecretKeyFrom)'
           status:
             properties:
               components:
@@ -39350,6 +39396,39 @@ spec:
                     type: object
                   phase:
                     type: string
+                  rotate:
+                    properties:
+                      attempts:
+                        format: int32
+                        type: integer
+                      completedAt:
+                        format: date-time
+                        type: string
+                      duration:
+                        type: string
+                      image:
+                        type: string
+                      lastScheduledAt:
+                        format: date-time
+                        type: string
+                      message:
+                        type: string
+                      nextScheduleAt:
+                        format: date-time
+                        type: string
+                      podName:
+                        type: string
+                      startedAt:
+                        format: date-time
+                        type: string
+                      state:
+                        enum:
+                        - Pending
+                        - Running
+                        - Complete
+                        - Failed
+                        type: string
+                    type: object
                   upgrade:
                     properties:
                       direction:
@@ -39415,6 +39494,11 @@ spec:
             is enabled (sub-resource suffix '-maintenance-page' is 17 chars)
           rule: '!has(self.spec.lifecycle) || 
!has(self.spec.lifecycle.maintenancePage)
             || size(self.metadata.name) <= 46'
+        - message: metadata.name must be at most 49 characters when 
lifecycle.rotate
+            is enabled (task name '{parent}-rotate' + ConfigMap suffix 
'-config' must
+            fit within 63 chars)
+          rule: '!has(self.spec.lifecycle) || !has(self.spec.lifecycle.rotate) 
||
+            size(self.metadata.name) <= 49'
         - message: metadata.name must be at most 48 characters when lifecycle 
is enabled
             (task name '{parent}-migrate' + ConfigMap suffix '-config' must 
fit within
             63 chars)
diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md
index 2a67b5a..133d3a4 100644
--- a/docs/architecture/internals.md
+++ b/docs/architecture/internals.md
@@ -59,18 +59,18 @@ parent CR name. Owned by the parent CR and 
garbage-collected on parent deletion.
 ### Phase 3: Lifecycle Tasks
 
 The parent controller creates `SupersetLifecycleTask` child CRs:
-`{parentName}-clone`, `{parentName}-migrate`, and `{parentName}-init`. The 
parent
+`{parentName}-clone`, `{parentName}-migrate`, `{parentName}-rotate`, and 
`{parentName}-init`. The parent
 uses a Get+Create/Delete pattern (never CreateOrUpdate) to avoid races with the
 task controller's status writes. When a task needs to re-run (checksum 
mismatch),
 the parent deletes the old CR and creates a fresh one on the next reconcile.
 
-Tasks run sequentially: clone → migrate → init. Each task can be independently
+Tasks run sequentially: clone → migrate → rotate → init. Each task can be 
independently
 disabled via `disabled: true`. Clone also supports periodic re-execution via
 `cronSchedule`. Checksums cascade downstream: a re-clone forces re-migrate,
-which forces re-init.
+which forces re-rotate, which forces re-init.
 
-When a task requires drain (`requiresDrain: true`, the default for clone and
-migrate), the operator deletes all component child CRs before running that 
task.
+When a task requires drain (`requiresDrain: true`, the default for clone,
+migrate, and rotate), the operator deletes all component child CRs before 
running that task.
 The parent verifies all component pods have terminated (not just Deployments
 deleted) before proceeding to task execution. This ensures no application pods
 access the metastore during schema changes. If `maintenancePage` is configured,
@@ -326,7 +326,7 @@ During lifecycle drain, the parent:
    labels, instantly routing traffic to maintenance pods.
 3. Drains all component child CRs (GC cascades to Deployments and Pods, but the
    Service is unaffected because it belongs to the parent).
-4. Runs lifecycle tasks (clone → migrate → init).
+4. Runs lifecycle tasks (clone → migrate → rotate → init).
 5. After tasks complete and the web-server child CR is recreated, waits for the
    web-server Deployment to become ready.
 6. Switches the Service selector back to the web-server pod labels.
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index b5dedd8..7c014b2 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -97,7 +97,7 @@ Components fall into two categories:
 
 | CRD Kind | Parent field | Suffix | Creates |
 |---|---|---|---|
-| `SupersetLifecycleTask` | `lifecycle` | `-clone`, `-migrate`, `-init` | 
Pods, ConfigMap |
+| `SupersetLifecycleTask` | `lifecycle` | `-clone`, `-migrate`, `-rotate`, 
`-init` | Pods, ConfigMap |
 | `SupersetCeleryBeat` | `celeryBeat` | `-celery-beat` | Deployment, ConfigMap 
|
 
 **Presence = enabled**: Setting `celeryWorker: {}` deploys workers with
diff --git a/docs/index.md b/docs/index.md
index 72862a7..1aa651e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -33,7 +33,7 @@ The operator manages the full Superset lifecycle: database 
migrations, configura
 - **Full control** — every default is overridable, from high-level presets 
down to individual container fields, with a raw Python escape hatch for 
anything not covered
 - **Component toggle** — enable CeleryWorker, CeleryBeat, CeleryFlower, 
WebsocketServer, or McpServer by setting their spec; omit to disable
 - **Zero-downtime upgrades** — maintenance page serves users during database 
migrations; the operator drains components gracefully, runs lifecycle tasks, 
and restores traffic only after the new version is healthy
-- **Database cloning** — snapshot a production database into staging or QA 
environments on demand or on a cron schedule, with automatic migration and init 
afterward
+- **Lifecycle automation** — database cloning, schema migrations, secret key 
rotation, and application init run as sequenced tasks with automatic change 
detection and checksum-based re-execution
 - **Networking** — Gateway API (HTTPRoute) and Ingress support with 
per-component routing
 - **Production hardening** — HPA with custom metrics, PodDisruptionBudgets, 
NetworkPolicies, Prometheus ServiceMonitor
 
diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md
index 16182da..ee34af4 100644
--- a/docs/reference/api-reference.md
+++ b/docs/reference/api-reference.md
@@ -84,6 +84,7 @@ _Appears in:_
 - [CloneTaskSpec](#clonetaskspec)
 - [InitTaskSpec](#inittaskspec)
 - [MigrateTaskSpec](#migratetaskspec)
+- [RotateTaskSpec](#rotatetaskspec)
 - [SchedulableBaseTaskSpec](#schedulablebasetaskspec)
 
 | Field | Description | Default | Validation |
@@ -654,6 +655,7 @@ _Appears in:_
 | `maintenancePage` _[MaintenancePageSpec](#maintenancepagespec)_ | 
MaintenancePage configures a lightweight maintenance page served during<br 
/>lifecycle drain and task execution. Presence enables the feature.<br />In 
managed mode (no image override), an nginx:alpine container serves<br />a 
default or custom HTML page. In custom mode (image set), the user's<br />image 
handles serving, and content fields are passed as env vars. |  | Optional: \{\} 
<br /> |
 | `clone` _[CloneTaskSpec](#clonetaskspec)_ | Clone configures database 
cloning from an external source before running<br />migrations. The clone 
target is always spec.metastore. Only allowed in dev mode. |  | Optional: \{\} 
<br /> |
 | `migrate` _[MigrateTaskSpec](#migratetaskspec)_ | Database migration task 
configuration. |  | Optional: \{\} <br /> |
+| `rotate` _[RotateTaskSpec](#rotatetaskspec)_ | Secret key rotation task 
configuration. Runs after migrate and before init.<br />Presence enables the 
task; absence disables it. |  | Optional: \{\} <br /> |
 | `init` _[InitTaskSpec](#inittaskspec)_ | Application initialization task 
configuration. |  | Optional: \{\} <br /> |
 
 
@@ -670,11 +672,12 @@ _Appears in:_
 
 | Field | Description | Default | Validation |
 | --- | --- | --- | --- |
-| `phase` _string_ | Phase of the lifecycle: Idle, Cloning, Migrating, 
Initializing, Complete, Blocked, AwaitingApproval. |  | Optional: \{\} <br /> |
+| `phase` _string_ | Phase of the lifecycle: Idle, Cloning, Migrating, 
Rotating, Initializing, Complete, Blocked, AwaitingApproval. |  | Optional: 
\{\} <br /> |
 | `maintenanceActive` _boolean_ | MaintenanceActive indicates the maintenance 
page is currently serving traffic<br />via the web-server Service. |  | 
Optional: \{\} <br /> |
 | `lastCompletedChecksums` _object (keys:string, values:string)_ | 
LastCompletedChecksums maps task type to its ConfigChecksum at last<br 
/>successful completion. Used to detect input drift when task CRs are absent. | 
 | Optional: \{\} <br /> |
 | `clone` _[TaskRefStatus](#taskrefstatus)_ | Clone task status summary. |  | 
Optional: \{\} <br /> |
 | `migrate` _[TaskRefStatus](#taskrefstatus)_ | Migrate task status summary. | 
 | Optional: \{\} <br /> |
+| `rotate` _[TaskRefStatus](#taskrefstatus)_ | Rotate task status summary. |  
| Optional: \{\} <br /> |
 | `init` _[TaskRefStatus](#taskrefstatus)_ | Init task status summary. |  | 
Optional: \{\} <br /> |
 | `upgrade` _[UpgradeContext](#upgradecontext)_ | Upgrade context (populated 
during active upgrade). |  | Optional: \{\} <br /> |
 
@@ -929,6 +932,30 @@ _Appears in:_
 | `container` _[ContainerTemplate](#containertemplate)_ | Main container 
configuration. |  | Optional: \{\} <br /> |
 
 
+#### RotateTaskSpec
+
+
+
+RotateTaskSpec defines the secret key rotation task.
+Runs superset re-encrypt-secrets between migrate and init when the
+secret key is rotated. Requires previousSecretKey or previousSecretKeyFrom
+to be set on the parent spec.
+
+
+
+_Appears in:_
+- [LifecycleSpec](#lifecyclespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `command` _string array_ | Command override for the task pod. |  | Optional: 
\{\} <br /> |
+| `trigger` _string_ | Trigger is an opaque string. Changing its value forces 
a re-run of this<br />task and all downstream tasks. Use a timestamp, UUID, or 
CI build ID. |  | Optional: \{\} <br /> |
+| `requiresDrain` _boolean_ | RequiresDrain controls whether components must 
be scaled to zero before<br />this task runs. When true, the operator deletes 
all component child CRs<br />before executing the task pod, preventing database 
connection conflicts.<br />Defaults vary per task type: true for clone and 
migrate, false for init. |  | Optional: \{\} <br /> |
+| `timeout` 
_[Duration](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration)_ 
| Maximum timeout per attempt. |  | Optional: \{\} <br /> |
+| `maxRetries` _integer_ | Maximum number of retries before permanent failure. 
| 3 | Minimum: 1 <br />Optional: \{\} <br /> |
+| `disabled` _boolean_ | Disabled skips this task entirely when true. |  | 
Optional: \{\} <br /> |
+
+
 #### SQLAlchemyEngineOptionsSpec
 
 
@@ -1288,7 +1315,7 @@ _Appears in:_
 | `serviceAccountName` _string_ | ServiceAccountName to set on the pod. |  | 
Optional: \{\} <br /> |
 | `autoscaling` _[AutoscalingSpec](#autoscalingspec)_ | Autoscaling 
configuration. |  | Optional: \{\} <br /> |
 | `podDisruptionBudget` _[PDBSpec](#pdbspec)_ | PodDisruptionBudget 
configuration. |  | Optional: \{\} <br /> |
-| `type` _string_ | Type identifies the task purpose. Future task types will 
require schema additions. |  | Enum: [Clone Migrate Init] <br /> |
+| `type` _string_ | Type identifies the task purpose. Future task types will 
require schema additions. |  | Enum: [Clone Migrate Rotate Init] <br /> |
 | `command` _string array_ | Command to execute in the task pod. |  |  |
 | `configChecksum` _string_ | Config checksum for detecting changes that 
require re-run. |  | Optional: \{\} <br /> |
 | `timeout` 
_[Duration](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration)_ 
| Maximum timeout per task pod attempt. |  | Optional: \{\} <br /> |
@@ -1406,6 +1433,8 @@ _Appears in:_
 | `environment` _string_ | Environment mode: "Development", "Staging", or 
"Production". Controls validation strictness.<br />In Production mode, CRD 
validation rejects plain text secrets and disallows cloning.<br />In Staging 
mode, secrets are enforced (like Production) but cloning is allowed.<br />In 
Development mode, plain text secrets, cloning, admin user, and load examples 
are all permitted. | Production | Enum: [Development Staging Production] <br 
/>Optional: \{\} <br /> |
 | `secretKey` _string_ | Plain text secret key for session signing. Only 
allowed in dev mode.<br />In prod, use secretKeyFrom to reference a Kubernetes 
Secret. |  | Optional: \{\} <br /> |
 | `secretKeyFrom` 
_[SecretKeySelector](https://pkg.go.dev/k8s.io/api/core/v1#SecretKeySelector)_ 
| Reference to a Secret key containing the secret key for session signing.<br 
/>Mutually exclusive with secretKey. |  | Optional: \{\} <br /> |
+| `previousSecretKey` _string_ | Plain text previous secret key for key 
rotation. Only allowed in dev mode.<br />When set, rendered as 
PREVIOUS_SECRET_KEY in superset_config.py for all<br />Python components, 
enabling fallback decryption during key transitions. |  | Optional: \{\} <br /> 
|
+| `previousSecretKeyFrom` 
_[SecretKeySelector](https://pkg.go.dev/k8s.io/api/core/v1#SecretKeySelector)_ 
| Reference to a Secret key containing the previous secret key for rotation.<br 
/>Mutually exclusive with previousSecretKey. |  | Optional: \{\} <br /> |
 | `metastore` _[MetastoreSpec](#metastorespec)_ | Metastore database 
connection configuration. |  | Optional: \{\} <br /> |
 | `valkey` _[ValkeySpec](#valkeyspec)_ | Valkey cache, broker, and results 
backend configuration. |  | Optional: \{\} <br /> |
 | `config` _string_ | Raw Python appended after operator-generated 
superset_config.py. |  | Optional: \{\} <br /> |
diff --git a/docs/user-guide/lifecycle.md b/docs/user-guide/lifecycle.md
index 9cd6e31..702ee6d 100644
--- a/docs/user-guide/lifecycle.md
+++ b/docs/user-guide/lifecycle.md
@@ -25,11 +25,12 @@ troubleshooting.
 
 ## Overview
 
-The `spec.lifecycle` section controls up to three sequential tasks:
+The `spec.lifecycle` section controls up to four sequential tasks:
 
 1. **clone** — database snapshot from an external source (staging workflows)
 2. **migrate** — `superset db upgrade` (database schema migration)
-3. **init** — `superset init` (application initialization: roles, permissions)
+3. **rotate** — `superset re-encrypt-secrets` (secret key rotation)
+4. **init** — `superset init` (application initialization: roles, permissions)
 
 Tasks run as bare Pods (`restartPolicy: Never`) managed by dedicated
 `SupersetLifecycleTask` child CRs. The parent Superset controller orchestrates
@@ -41,7 +42,7 @@ explicitly with `spec.lifecycle.disabled: true`.
 
 **Key behaviors:**
 
-- Clone must complete before migrate starts; migrate must complete before init 
starts
+- Clone must complete before migrate starts; migrate before rotate; rotate 
before init
 - Components are not created or updated until all enabled tasks complete
 - When config or image changes require a re-run, the parent deletes the old 
task CR and creates a fresh one
 
@@ -53,6 +54,7 @@ Each task has hardcoded trigger inputs — what it watches for 
changes:
 |------|---------|-----------------|
 | Clone | `trigger` field, `cronSchedule` tick, source config, excludes | 
Trigger value changes, schedule tick boundary crossed, or source DB config 
changes |
 | Migrate | Image (resolved lifecycle image) | Image tag or repository changes 
|
+| Rotate | `trigger` field, `secretKeyFrom` ref, `previousSecretKeyFrom` ref | 
Secret key references change or trigger value changes |
 | Init | Config checksum (rendered Python config) | Any config-affecting field 
changes |
 
 All tasks also re-run when an upstream task re-executes (automatic 
propagation).
@@ -178,6 +180,7 @@ and recreates components after the pipeline completes.
 |------|------------------------|-----------|
 | Clone | `true` | DROP DATABASE fails with active connections |
 | Migrate | `true` | Schema changes risk deadlocks and version/schema 
inconsistencies |
+| Rotate | `true` | After re-encryption, stored secrets use the new key — 
components with the old key would fail to decrypt |
 | Init | `false` | Role/permission operations are safe with components running 
|
 
 Override per-task when needed:
@@ -282,8 +285,11 @@ flowchart TD
     C -->|Automatic| E
     C -->|Supervised| D[Await approval]
     D --> E{Any task requires drain?}
-    E -->|Yes| F[Drain: delete component CRs, wait for pod termination]
+    E -->|Yes| MP{Maintenance page configured?}
     E -->|No| G
+    MP -->|Yes| MP1[Deploy maintenance page, switch Service selector]
+    MP -->|No| F
+    MP1 --> F[Drain: delete component CRs, wait for pod termination]
     F --> G[Clone task]
     G -->|checksum match| H[Skip]
     G -->|checksum mismatch| G1[Execute clone pod]
@@ -292,7 +298,11 @@ flowchart TD
     I -->|checksum match| J[Skip]
     I -->|checksum mismatch| I1[Execute migrate pod]
     I1 --> J
-    J --> K[Init task]
+    J --> R[Rotate task]
+    R -->|checksum match| S[Skip]
+    R -->|checksum mismatch| R1[Execute rotate pod]
+    R1 --> S
+    S --> K[Init task]
     K -->|checksum match| L[Skip]
     K -->|checksum mismatch| K1[Execute init pod]
     K1 --> L
@@ -412,6 +422,63 @@ spec:
       command: ["/bin/sh", "-c", "superset db upgrade"]
 ```
 
+## Secret Key Rotation
+
+The rotate task runs `superset re-encrypt-secrets` to re-encrypt stored secrets
+when the application secret key is rotated. It runs after migrate and before 
init.
+
+To enable secret key rotation, set `previousSecretKey` (dev mode) or
+`previousSecretKeyFrom` (staging/production) on the parent spec, and add
+`lifecycle.rotate: {}`:
+
+```yaml
+apiVersion: superset.apache.org/v1alpha1
+kind: Superset
+metadata:
+  name: my-superset
+spec:
+  secretKeyFrom:
+    name: superset-secret-v2
+    key: secret-key
+  previousSecretKeyFrom:
+    name: superset-secret-v1
+    key: secret-key
+  lifecycle:
+    rotate: {}
+```
+
+The operator injects both `SECRET_KEY` and `PREVIOUS_SECRET_KEY` into all 
Python
+components. The rotate task pod uses both to decrypt stored secrets with the 
old
+key and re-encrypt with the new one. After rotation completes, components 
restart
+with the new key and can use `PREVIOUS_SECRET_KEY` for fallback decryption 
during
+the transition.
+
+The command is idempotent: re-running it skips already-converted values. If no
+`PREVIOUS_SECRET_KEY` is set, it exits cleanly. If decryption fails for any 
entry,
+the entire transaction rolls back.
+
+### Rotation Triggers
+
+The task re-runs when:
+
+- The `secretKeyFrom` or `previousSecretKeyFrom` references change (different
+  Secret name or key)
+- The `trigger` field changes (use this for in-place Secret content updates 
where
+  the reference stays the same)
+
+### Drain
+
+By default, the rotate task requires drain (`requiresDrain: true`). After
+re-encryption commits, stored secrets are encrypted with the new key — 
components
+still running with the old key would fail to decrypt them. Override with
+`requiresDrain: false` only if you understand the implications.
+
+### Cleanup
+
+After confirming rotation succeeded, remove `previousSecretKeyFrom` and the
+`lifecycle.rotate` section. The previous secret key is no longer needed once 
all
+components have restarted with the new key.
+
 ## Clone (Development and Staging Mode Only)
 
 The clone task creates a database snapshot from an external source into the 
CR's
@@ -558,7 +625,9 @@ clone.checksum   = hash(parentUID, "Clone", command, 
trigger, source, excludes)
                          ↓ (stored in clone CR status on completion)
 migrate.checksum = hash(clone.status.checksum, "Migrate", command, trigger, 
image)
                          ↓ (stored in migrate CR status on completion)
-init.checksum    = hash(migrate.status.checksum, "Init", command, trigger, 
configChecksum)
+rotate.checksum  = hash(migrate.status.checksum, "Rotate", command, trigger, 
secretKeyFrom, previousSecretKeyFrom)
+                         ↓ (stored in rotate CR status on completion)
+init.checksum    = hash(rotate.status.checksum, "Init", command, trigger, 
configChecksum)
 ```
 
 **The universal rule:** a task executes when its computed checksum differs from
@@ -567,7 +636,7 @@ the checksum stored on its completed task CR. If they 
match, the task skips.
 **Upstream propagation is automatic:** when clone re-runs (e.g., trigger
 changed), its status checksum changes. That new value flows into migrate's
 checksum computation, making it differ from its stored value — so migrate
-re-runs too. The chain continues transitively to init.
+re-runs too. The chain continues transitively through rotate to init.
 
 **Isolation by design:** each task watches only its own relevant inputs.
 Image changes affect only migrate (and downstream via propagation). Config
@@ -619,7 +688,7 @@ Lifecycle task progress is tracked per-task in the parent 
status:
 ```yaml
 status:
   lifecycle:
-    phase: Complete        # Idle | Cloning | Draining | Migrating | 
Initializing | Complete | Blocked | AwaitingApproval
+    phase: Complete        # Idle | Cloning | Draining | Migrating | Rotating 
| Initializing | Complete | Blocked | AwaitingApproval
     clone:
       state: Complete      # Pending | Running | Complete | Failed (only 
present when clone is enabled)
       podName: superset-staging-clone-k8x2m
diff --git a/internal/common/names.go b/internal/common/names.go
index 2d51642..ed21065 100644
--- a/internal/common/names.go
+++ b/internal/common/names.go
@@ -42,6 +42,7 @@ const (
        SuffixMaintenancePage = "-maintenance"      // matches 
ComponentMaintenancePage
        SuffixInit            = "-init"             // matches ComponentInit
        SuffixClone           = "-clone"
+       SuffixRotate          = "-rotate"
        SuffixConfig          = "-config"
        SuffixNetworkPolicy   = "-netpol"
 )
@@ -95,14 +96,15 @@ const (
 // Env var names for operator-managed environment variables.
 const (
        // Operator-internal transport vars (used by rendered 
superset_config.py).
-       EnvSecretKey   = "SUPERSET_OPERATOR__SECRET_KEY"
-       EnvDatabaseURI = "SUPERSET_OPERATOR__DB_URI"
-       EnvDBHost      = "SUPERSET_OPERATOR__DB_HOST"
-       EnvDBPort      = "SUPERSET_OPERATOR__DB_PORT"
-       EnvDBName      = "SUPERSET_OPERATOR__DB_NAME"
-       EnvDBUser      = "SUPERSET_OPERATOR__DB_USER"
-       EnvDBPass      = "SUPERSET_OPERATOR__DB_PASS"
-       EnvForceReload = "SUPERSET_OPERATOR__FORCE_RELOAD"
+       EnvSecretKey         = "SUPERSET_OPERATOR__SECRET_KEY"
+       EnvPreviousSecretKey = "SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY"
+       EnvDatabaseURI       = "SUPERSET_OPERATOR__DB_URI"
+       EnvDBHost            = "SUPERSET_OPERATOR__DB_HOST"
+       EnvDBPort            = "SUPERSET_OPERATOR__DB_PORT"
+       EnvDBName            = "SUPERSET_OPERATOR__DB_NAME"
+       EnvDBUser            = "SUPERSET_OPERATOR__DB_USER"
+       EnvDBPass            = "SUPERSET_OPERATOR__DB_PASS"
+       EnvForceReload       = "SUPERSET_OPERATOR__FORCE_RELOAD"
 
        // Valkey operator-internal transport vars.
        EnvValkeyHost = "SUPERSET_OPERATOR__VALKEY_HOST"
diff --git a/internal/config/renderer.go b/internal/config/renderer.go
index dcd076f..a6a3edc 100644
--- a/internal/config/renderer.go
+++ b/internal/config/renderer.go
@@ -107,6 +107,9 @@ type ConfigInput struct {
        // Engine options for SQLALCHEMY_ENGINE_OPTIONS. Nil = do not render.
        EngineOptions *EngineOptionsInput
 
+       // Whether to render PREVIOUS_SECRET_KEY from env var.
+       HasPreviousSecretKey bool
+
        // Top-level raw Python from spec.config.
        Config string
 
@@ -142,6 +145,11 @@ func RenderConfig(componentType ComponentType, input 
*ConfigInput) string {
        // SECRET_KEY from operator-internal env var.
        fmt.Fprintf(&b, "SECRET_KEY = os.environ['%s']\n", common.EnvSecretKey)
 
+       // PREVIOUS_SECRET_KEY for key rotation (optional, read with get()).
+       if input.HasPreviousSecretKey {
+               fmt.Fprintf(&b, "PREVIOUS_SECRET_KEY = os.environ.get('%s')\n", 
common.EnvPreviousSecretKey)
+       }
+
        // Passthrough metastore: read full URI from operator-internal env var.
        if input.MetastoreMode == MetastorePassthrough {
                fmt.Fprintf(&b, "SQLALCHEMY_DATABASE_URI = os.environ['%s']\n", 
common.EnvDatabaseURI)
diff --git a/internal/config/renderer_test.go b/internal/config/renderer_test.go
index ad2511c..1216056 100644
--- a/internal/config/renderer_test.go
+++ b/internal/config/renderer_test.go
@@ -517,3 +517,31 @@ func TestRenderConfig_EngineOptionsSectionOrder(t 
*testing.T) {
                t.Errorf("SQLALCHEMY_ENGINE_OPTIONS should appear before Valkey 
config")
        }
 }
+
+func TestRenderConfig_PreviousSecretKey(t *testing.T) {
+       t.Run("rendered when HasPreviousSecretKey is true", func(t *testing.T) {
+               input := &ConfigInput{
+                       MetastoreMode:        MetastoreNone,
+                       HasPreviousSecretKey: true,
+               }
+               result := RenderConfig(ComponentInit, input)
+               assertContains(t, result, "PREVIOUS_SECRET_KEY = 
os.environ.get('SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY')")
+       })
+
+       t.Run("not rendered when HasPreviousSecretKey is false", func(t 
*testing.T) {
+               input := &ConfigInput{
+                       MetastoreMode: MetastoreNone,
+               }
+               result := RenderConfig(ComponentInit, input)
+               assertNotContains(t, result, "PREVIOUS_SECRET_KEY")
+       })
+
+       t.Run("rendered for web server component", func(t *testing.T) {
+               input := &ConfigInput{
+                       MetastoreMode:        MetastoreNone,
+                       HasPreviousSecretKey: true,
+               }
+               result := RenderConfig(ComponentWebServer, input)
+               assertContains(t, result, "PREVIOUS_SECRET_KEY = 
os.environ.get('SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY')")
+       })
+}
diff --git a/internal/controller/clone_test.go 
b/internal/controller/clone_test.go
index 4057680..d2c49b1 100644
--- a/internal/controller/clone_test.go
+++ b/internal/controller/clone_test.go
@@ -479,6 +479,12 @@ func TestIsTaskEnabled(t *testing.T) {
                        taskType: taskTypeInit,
                        expected: true,
                },
+               {
+                       name:     "nil lifecycle: rotate disabled",
+                       spec:     nil,
+                       taskType: taskTypeRotate,
+                       expected: false,
+               },
                {
                        name: "clone present and not disabled",
                        spec: &supersetv1alpha1.LifecycleSpec{
@@ -516,6 +522,28 @@ func TestIsTaskEnabled(t *testing.T) {
                        taskType: taskTypeInit,
                        expected: false,
                },
+               {
+                       name: "rotate present and not disabled",
+                       spec: &supersetv1alpha1.LifecycleSpec{
+                               Rotate: &supersetv1alpha1.RotateTaskSpec{},
+                       },
+                       taskType: taskTypeRotate,
+                       expected: true,
+               },
+               {
+                       name: "rotate explicitly disabled",
+                       spec: &supersetv1alpha1.LifecycleSpec{
+                               Rotate: 
&supersetv1alpha1.RotateTaskSpec{BaseTaskSpec: 
supersetv1alpha1.BaseTaskSpec{Disabled: common.Ptr(true)}},
+                       },
+                       taskType: taskTypeRotate,
+                       expected: false,
+               },
+               {
+                       name:     "rotate nil with lifecycle set",
+                       spec:     &supersetv1alpha1.LifecycleSpec{},
+                       taskType: taskTypeRotate,
+                       expected: false,
+               },
        }
 
        for _, tt := range tests {
@@ -590,6 +618,7 @@ func TestTaskRequiresDrain_Defaults(t *testing.T) {
        }{
                {taskTypeClone, true},
                {taskTypeMigrate, true},
+               {taskTypeRotate, true},
                {taskTypeInit, false},
        }
 
@@ -1115,13 +1144,13 @@ func 
TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
        }
 
        t.Run("returns true when nothing changed", func(t *testing.T) {
-               if !r.allTasksStillComplete(superset, false, true, true, 
configChecksum) {
+               if !r.allTasksStillComplete(superset, false, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=true when 
checksums match")
                }
        })
 
        t.Run("returns false when config changes", func(t *testing.T) {
-               if r.allTasksStillComplete(superset, false, true, true, 
"config-changed") {
+               if r.allTasksStillComplete(superset, false, true, false, true, 
"config-changed") {
                        t.Error("expected allTasksStillComplete=false when 
config checksum changed")
                }
        })
@@ -1129,7 +1158,7 @@ func 
TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
        t.Run("returns false when image changes", func(t *testing.T) {
                modified := superset.DeepCopy()
                modified.Spec.Image.Tag = "5.0.0"
-               if r.allTasksStillComplete(modified, false, true, true, 
configChecksum) {
+               if r.allTasksStillComplete(modified, false, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=false when 
image changed")
                }
        })
@@ -1137,7 +1166,7 @@ func 
TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
        t.Run("returns false with no stored checksums", func(t *testing.T) {
                modified := superset.DeepCopy()
                modified.Status.Lifecycle.LastCompletedChecksums = nil
-               if r.allTasksStillComplete(modified, false, true, true, 
configChecksum) {
+               if r.allTasksStillComplete(modified, false, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=false with nil 
checksums")
                }
        })
@@ -1147,7 +1176,7 @@ func 
TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
                modified.Spec.Lifecycle.Migrate = 
&supersetv1alpha1.MigrateTaskSpec{
                        BaseTaskSpec: supersetv1alpha1.BaseTaskSpec{Trigger: 
common.Ptr("force-v1")},
                }
-               if r.allTasksStillComplete(modified, false, true, true, 
configChecksum) {
+               if r.allTasksStillComplete(modified, false, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=false when 
trigger changed")
                }
        })
@@ -1192,7 +1221,7 @@ func TestAllTasksStillComplete_WithCloneSchedule(t 
*testing.T) {
        }
 
        t.Run("stable within cron window", func(t *testing.T) {
-               if !r.allTasksStillComplete(superset, true, true, true, 
configChecksum) {
+               if !r.allTasksStillComplete(superset, true, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=true within 
same cron window")
                }
        })
@@ -1200,8 +1229,223 @@ func TestAllTasksStillComplete_WithCloneSchedule(t 
*testing.T) {
        t.Run("returns false when cron tick crosses boundary", func(t 
*testing.T) {
                nextHour := time.Date(2026, 5, 11, 15, 1, 0, 0, time.UTC)
                r2 := &SupersetReconciler{Now: func() time.Time { return 
nextHour }}
-               if r2.allTasksStillComplete(superset, true, true, true, 
configChecksum) {
+               if r2.allTasksStillComplete(superset, true, true, false, true, 
configChecksum) {
                        t.Error("expected allTasksStillComplete=false after 
cron boundary crossing")
                }
        })
 }
+
+func TestCollectSecretEnvVars_PreviousSecretKey(t *testing.T) {
+       t.Run("dev mode plaintext", func(t *testing.T) {
+               spec := &supersetv1alpha1.SupersetSpec{
+                       Environment:       common.Ptr("Development"),
+                       SecretKey:         common.Ptr("new-key"),
+                       PreviousSecretKey: common.Ptr("old-key"),
+               }
+               envs := collectSecretEnvVars(spec)
+               found := false
+               for _, e := range envs {
+                       if e.Name == common.EnvPreviousSecretKey {
+                               found = true
+                               if e.Value != "old-key" {
+                                       t.Errorf("expected plaintext value 
'old-key', got %q", e.Value)
+                               }
+                       }
+               }
+               if !found {
+                       t.Error("expected 
SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY env var")
+               }
+       })
+
+       t.Run("prod mode secretKeyRef", func(t *testing.T) {
+               ref := &corev1.SecretKeySelector{
+                       LocalObjectReference: corev1.LocalObjectReference{Name: 
"prev-secret"},
+                       Key:                  "key",
+               }
+               spec := &supersetv1alpha1.SupersetSpec{
+                       SecretKeyFrom:         
&corev1.SecretKeySelector{LocalObjectReference: 
corev1.LocalObjectReference{Name: "s"}, Key: "k"},
+                       PreviousSecretKeyFrom: ref,
+               }
+               envs := collectSecretEnvVars(spec)
+               found := false
+               for _, e := range envs {
+                       if e.Name == common.EnvPreviousSecretKey {
+                               found = true
+                               if e.ValueFrom == nil || 
e.ValueFrom.SecretKeyRef == nil {
+                                       t.Fatal("expected secretKeyRef")
+                               }
+                               if e.ValueFrom.SecretKeyRef.Name != 
"prev-secret" {
+                                       t.Errorf("expected secret name 
'prev-secret', got %q", e.ValueFrom.SecretKeyRef.Name)
+                               }
+                       }
+               }
+               if !found {
+                       t.Error("expected 
SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY env var")
+               }
+       })
+
+       t.Run("not present when not configured", func(t *testing.T) {
+               spec := &supersetv1alpha1.SupersetSpec{
+                       SecretKeyFrom: 
&corev1.SecretKeySelector{LocalObjectReference: 
corev1.LocalObjectReference{Name: "s"}, Key: "k"},
+               }
+               envs := collectSecretEnvVars(spec)
+               for _, e := range envs {
+                       if e.Name == common.EnvPreviousSecretKey {
+                               t.Error("should not have 
SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY when not configured")
+                       }
+               }
+       })
+}
+
+func TestDefaultRotateCommand(t *testing.T) {
+       t.Run("default command", func(t *testing.T) {
+               superset := &supersetv1alpha1.Superset{}
+               cmd := defaultRotateCommand(superset)
+               if len(cmd) != 3 || cmd[2] != "superset re-encrypt-secrets" {
+                       t.Errorf("unexpected default command: %v", cmd)
+               }
+       })
+
+       t.Run("custom command", func(t *testing.T) {
+               superset := &supersetv1alpha1.Superset{}
+               superset.Spec.Lifecycle = &supersetv1alpha1.LifecycleSpec{
+                       Rotate: &supersetv1alpha1.RotateTaskSpec{
+                               BaseTaskSpec: supersetv1alpha1.BaseTaskSpec{
+                                       Command: []string{"/bin/sh", "-c", 
"custom-rotate"},
+                               },
+                       },
+               }
+               cmd := defaultRotateCommand(superset)
+               if len(cmd) != 3 || cmd[2] != "custom-rotate" {
+                       t.Errorf("expected custom command, got: %v", cmd)
+               }
+       })
+}
+
+func TestRotateInputs(t *testing.T) {
+       r := &SupersetReconciler{}
+
+       secretRef := &corev1.SecretKeySelector{
+               LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v1"},
+               Key:                  "key",
+       }
+       prevRef := &corev1.SecretKeySelector{
+               LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v0"},
+               Key:                  "key",
+       }
+
+       superset := &supersetv1alpha1.Superset{}
+       superset.Spec.SecretKeyFrom = secretRef
+       superset.Spec.PreviousSecretKeyFrom = prevRef
+       superset.Spec.Lifecycle = &supersetv1alpha1.LifecycleSpec{
+               Rotate: &supersetv1alpha1.RotateTaskSpec{},
+       }
+
+       cmd := defaultRotateCommand(superset)
+       base := r.computeStepChecksum("seed", taskTypeRotate, cmd, 
r.rotateInputs(superset))
+
+       t.Run("changes when previousSecretKeyFrom changes", func(t *testing.T) {
+               modified := superset.DeepCopy()
+               modified.Spec.PreviousSecretKeyFrom = &corev1.SecretKeySelector{
+                       LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v0-changed"},
+                       Key:                  "key",
+               }
+               check := r.computeStepChecksum("seed", taskTypeRotate, cmd, 
r.rotateInputs(modified))
+               if check == base {
+                       t.Error("expected checksum to change when 
previousSecretKeyFrom changes")
+               }
+       })
+
+       t.Run("changes when secretKeyFrom changes", func(t *testing.T) {
+               modified := superset.DeepCopy()
+               modified.Spec.SecretKeyFrom = &corev1.SecretKeySelector{
+                       LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v2"},
+                       Key:                  "key",
+               }
+               check := r.computeStepChecksum("seed", taskTypeRotate, cmd, 
r.rotateInputs(modified))
+               if check == base {
+                       t.Error("expected checksum to change when secretKeyFrom 
changes")
+               }
+       })
+
+       t.Run("changes when trigger changes", func(t *testing.T) {
+               modified := superset.DeepCopy()
+               modified.Spec.Lifecycle.Rotate.Trigger = common.Ptr("force-v1")
+               check := r.computeStepChecksum("seed", taskTypeRotate, cmd, 
r.rotateInputs(modified))
+               if check == base {
+                       t.Error("expected checksum to change when trigger 
changes")
+               }
+       })
+
+       t.Run("stable when nothing changes", func(t *testing.T) {
+               check := r.computeStepChecksum("seed", taskTypeRotate, cmd, 
r.rotateInputs(superset))
+               if check != base {
+                       t.Error("expected checksum to be stable when nothing 
changes")
+               }
+       })
+}
+
+func TestAllTasksStillComplete_WithRotate(t *testing.T) {
+       r := &SupersetReconciler{}
+
+       secretRef := &corev1.SecretKeySelector{
+               LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v1"},
+               Key:                  "key",
+       }
+       prevRef := &corev1.SecretKeySelector{
+               LocalObjectReference: corev1.LocalObjectReference{Name: 
"secret-v0"},
+               Key:                  "key",
+       }
+
+       superset := &supersetv1alpha1.Superset{}
+       superset.UID = "test-uid"
+       superset.Spec.Image = supersetv1alpha1.ImageSpec{Tag: "4.1.4"}
+       superset.Spec.SecretKeyFrom = secretRef
+       superset.Spec.PreviousSecretKeyFrom = prevRef
+       superset.Spec.Lifecycle = &supersetv1alpha1.LifecycleSpec{
+               Rotate: &supersetv1alpha1.RotateTaskSpec{},
+       }
+
+       configChecksum := "config-abc"
+
+       incomingChecksum := string(superset.UID)
+       migrateCmd := defaultMigrateCommand(superset)
+       migrateChecksum := r.computeStepChecksum(incomingChecksum, 
taskTypeMigrate, migrateCmd, r.migrateInputs(superset))
+       rotateCmd := defaultRotateCommand(superset)
+       rotateChecksum := r.computeStepChecksum(migrateChecksum, 
taskTypeRotate, rotateCmd, r.rotateInputs(superset))
+       initCmd := defaultInitCommand(superset)
+       initChecksum := r.computeStepChecksum(rotateChecksum, taskTypeInit, 
initCmd, r.initInputs(superset, configChecksum))
+
+       superset.Status.Lifecycle = &supersetv1alpha1.LifecycleStatus{
+               LastCompletedChecksums: map[string]string{
+                       taskTypeMigrate: migrateChecksum,
+                       taskTypeRotate:  rotateChecksum,
+                       taskTypeInit:    initChecksum,
+               },
+       }
+
+       t.Run("returns true when nothing changed", func(t *testing.T) {
+               if !r.allTasksStillComplete(superset, false, true, true, true, 
configChecksum) {
+                       t.Error("expected allTasksStillComplete=true when 
checksums match")
+               }
+       })
+
+       t.Run("returns false when previousSecretKeyFrom changes", func(t 
*testing.T) {
+               modified := superset.DeepCopy()
+               modified.Spec.PreviousSecretKeyFrom = &corev1.SecretKeySelector{
+                       LocalObjectReference: corev1.LocalObjectReference{Name: 
"rotated"},
+                       Key:                  "key",
+               }
+               if r.allTasksStillComplete(modified, false, true, true, true, 
configChecksum) {
+                       t.Error("expected allTasksStillComplete=false when 
previousSecretKeyFrom changes")
+               }
+       })
+
+       t.Run("rotate cascades to init", func(t *testing.T) {
+               modified := superset.DeepCopy()
+               modified.Spec.Lifecycle.Rotate.Trigger = common.Ptr("force")
+               if r.allTasksStillComplete(modified, false, true, true, true, 
configChecksum) {
+                       t.Error("expected allTasksStillComplete=false when 
rotate trigger changes (cascades to init)")
+               }
+       })
+}
diff --git a/internal/controller/config_builder.go 
b/internal/controller/config_builder.go
index e8459e1..88d0422 100644
--- a/internal/controller/config_builder.go
+++ b/internal/controller/config_builder.go
@@ -131,6 +131,8 @@ func buildConfigInput(spec *supersetv1alpha1.SupersetSpec) 
*supersetconfig.Confi
                input.Config = *spec.Config
        }
 
+       input.HasPreviousSecretKey = spec.PreviousSecretKey != nil || 
spec.PreviousSecretKeyFrom != nil
+
        return input
 }
 
@@ -244,6 +246,19 @@ func collectSecretEnvVars(spec 
*supersetv1alpha1.SupersetSpec) []corev1.EnvVar {
                })
        }
 
+       // SUPERSET_OPERATOR__PREVIOUS_SECRET_KEY — for key rotation.
+       if isDev && spec.PreviousSecretKey != nil {
+               envs = append(envs, corev1.EnvVar{
+                       Name:  naming.EnvPreviousSecretKey,
+                       Value: *spec.PreviousSecretKey,
+               })
+       } else if spec.PreviousSecretKeyFrom != nil {
+               envs = append(envs, corev1.EnvVar{
+                       Name:      naming.EnvPreviousSecretKey,
+                       ValueFrom: &corev1.EnvVarSource{SecretKeyRef: 
spec.PreviousSecretKeyFrom},
+               })
+       }
+
        // Metastore env vars.
        if spec.Metastore != nil {
                if spec.Metastore.URI != nil {
diff --git a/internal/controller/drain.go b/internal/controller/drain.go
index 9af6ce2..3068171 100644
--- a/internal/controller/drain.go
+++ b/internal/controller/drain.go
@@ -56,10 +56,11 @@ func (r *SupersetReconciler) deleteTaskCR(ctx 
context.Context, name, namespace s
 func (r *SupersetReconciler) drainIfNeeded(
        ctx context.Context,
        superset *supersetv1alpha1.Superset,
-       cloneEnabled, migrateEnabled, initEnabled bool,
+       cloneEnabled, migrateEnabled, rotateEnabled, initEnabled bool,
 ) (time.Duration, bool, error) {
        needsDrain := (cloneEnabled && r.taskRequiresDrain(superset, 
taskTypeClone)) ||
                (migrateEnabled && r.taskRequiresDrain(superset, 
taskTypeMigrate)) ||
+               (rotateEnabled && r.taskRequiresDrain(superset, 
taskTypeRotate)) ||
                (initEnabled && r.taskRequiresDrain(superset, taskTypeInit))
        if !needsDrain {
                return 0, true, nil
diff --git a/internal/controller/lifecycle.go b/internal/controller/lifecycle.go
index fd76dd0..cc037a5 100644
--- a/internal/controller/lifecycle.go
+++ b/internal/controller/lifecycle.go
@@ -42,10 +42,12 @@ const (
        taskTypeMigrate = "Migrate"
        taskTypeInit    = "Init"
        taskTypeClone   = "Clone"
+       taskTypeRotate  = "Rotate"
 
        suffixMigrate = "-migrate"
        suffixInit    = "-init"
        suffixClone   = "-clone"
+       suffixRotate  = "-rotate"
 
        upgradeModeAutomatic  = "Automatic"
        upgradeModeSupervsied = "Supervised"
@@ -54,6 +56,7 @@ const (
        lifecyclePhaseCloning          = "Cloning"
        lifecyclePhaseDraining         = "Draining"
        lifecyclePhaseMigrating        = "Migrating"
+       lifecyclePhaseRotating         = "Rotating"
        lifecyclePhaseInitializing     = "Initializing"
        lifecyclePhaseComplete         = "Complete"
        lifecyclePhaseBlocked          = "Blocked"
@@ -125,14 +128,15 @@ func (r *SupersetReconciler) reconcileLifecycle(
        // Determine which tasks are enabled and prune orphans for disabled 
ones.
        cloneEnabled := r.isTaskEnabled(superset, taskTypeClone)
        migrateEnabled := r.isTaskEnabled(superset, taskTypeMigrate)
+       rotateEnabled := r.isTaskEnabled(superset, taskTypeRotate)
        initEnabled := r.isTaskEnabled(superset, taskTypeInit)
 
-       if err := r.pruneDisabledTasks(ctx, superset, cloneEnabled, 
migrateEnabled, initEnabled); err != nil {
+       if err := r.pruneDisabledTasks(ctx, superset, cloneEnabled, 
migrateEnabled, rotateEnabled, initEnabled); err != nil {
                return 0, false, err
        }
 
        // If no tasks are enabled, lifecycle is complete.
-       if !cloneEnabled && !migrateEnabled && !initEnabled {
+       if !cloneEnabled && !migrateEnabled && !rotateEnabled && !initEnabled {
                superset.Status.Lifecycle.Phase = lifecyclePhaseComplete
                setCondition(&superset.Status.Conditions, 
supersetv1alpha1.ConditionTypeInitComplete,
                        metav1.ConditionTrue, "LifecycleComplete", "Lifecycle 
tasks completed successfully", superset.Generation)
@@ -142,7 +146,7 @@ func (r *SupersetReconciler) reconcileLifecycle(
        // Fast path: if all enabled tasks already completed with matching 
checksums,
        // skip drain and pipeline entirely. This prevents unnecessary component
        // deletion on reconciles triggered by child CR creation.
-       if r.allTasksStillComplete(superset, cloneEnabled, migrateEnabled, 
initEnabled, configChecksum) {
+       if r.allTasksStillComplete(superset, cloneEnabled, migrateEnabled, 
rotateEnabled, initEnabled, configChecksum) {
                superset.Status.Lifecycle.Phase = lifecyclePhaseComplete
                setCondition(&superset.Status.Conditions, 
supersetv1alpha1.ConditionTypeInitComplete,
                        metav1.ConditionTrue, "LifecycleComplete", "Lifecycle 
tasks completed successfully", superset.Generation)
@@ -152,6 +156,7 @@ func (r *SupersetReconciler) reconcileLifecycle(
        // Spin up the maintenance page before drain (if configured).
        needsDrain := (cloneEnabled && r.taskRequiresDrain(superset, 
taskTypeClone)) ||
                (migrateEnabled && r.taskRequiresDrain(superset, 
taskTypeMigrate)) ||
+               (rotateEnabled && r.taskRequiresDrain(superset, 
taskTypeRotate)) ||
                (initEnabled && r.taskRequiresDrain(superset, taskTypeInit))
        if isMaintenancePageEnabled(superset) && needsDrain {
                ready, err := r.reconcileMaintenancePageUp(ctx, superset)
@@ -169,14 +174,14 @@ func (r *SupersetReconciler) reconcileLifecycle(
        }
 
        // Drain components if any enabled task requires it.
-       if requeueAfter, drained, err := r.drainIfNeeded(ctx, superset, 
cloneEnabled, migrateEnabled, initEnabled); err != nil {
+       if requeueAfter, drained, err := r.drainIfNeeded(ctx, superset, 
cloneEnabled, migrateEnabled, rotateEnabled, initEnabled); err != nil {
                return 0, false, err
        } else if !drained {
                return requeueAfter, false, nil
        }
 
-       // Orchestrate lifecycle pipeline: clone → migrate → init.
-       if requeueAfter, complete, err := r.runLifecyclePipeline(ctx, superset, 
cloneEnabled, migrateEnabled, initEnabled, imageChanged, configChecksum, 
topLevel, saName); err != nil {
+       // Orchestrate lifecycle pipeline: clone → migrate → rotate → init.
+       if requeueAfter, complete, err := r.runLifecyclePipeline(ctx, superset, 
cloneEnabled, migrateEnabled, rotateEnabled, initEnabled, imageChanged, 
configChecksum, topLevel, saName); err != nil {
                return 0, false, err
        } else if !complete {
                return requeueAfter, false, nil
@@ -220,7 +225,7 @@ func (r *SupersetReconciler) finalizeLifecycle(
 func (r *SupersetReconciler) runLifecyclePipeline(
        ctx context.Context,
        superset *supersetv1alpha1.Superset,
-       cloneEnabled, migrateEnabled, initEnabled, imageChanged bool,
+       cloneEnabled, migrateEnabled, rotateEnabled, initEnabled, imageChanged 
bool,
        configChecksum string,
        topLevel *resolution.SharedInput,
        saName string,
@@ -262,6 +267,21 @@ func (r *SupersetReconciler) runLifecyclePipeline(
                incomingChecksum = r.getTaskStatusChecksum(ctx, superset, 
suffixMigrate)
        }
 
+       if rotateEnabled {
+               superset.Status.Lifecycle.Phase = lifecyclePhaseRotating
+
+               rotateCmd := defaultRotateCommand(superset)
+               taskChecksum := r.computeStepChecksum(incomingChecksum, 
taskTypeRotate, rotateCmd, r.rotateInputs(superset))
+               requeueAfter, complete, err := r.reconcileLifecycleTask(ctx, 
superset, taskTypeRotate, suffixRotate, rotateCmd, taskChecksum, 
configChecksum, topLevel, saName)
+               if err != nil {
+                       return 0, false, fmt.Errorf("reconciling rotate task: 
%w", err)
+               }
+               if !complete {
+                       return requeueAfter, false, nil
+               }
+               incomingChecksum = r.getTaskStatusChecksum(ctx, superset, 
suffixRotate)
+       }
+
        if initEnabled {
                superset.Status.Lifecycle.Phase = lifecyclePhaseInitializing
                if superset.Status.Phase != phaseUpgrading {
@@ -458,6 +478,8 @@ func (r *SupersetReconciler) reconcileLifecycleTask(
                superset.Status.Lifecycle.Clone = taskRef
        case taskTypeMigrate:
                superset.Status.Lifecycle.Migrate = taskRef
+       case taskTypeRotate:
+               superset.Status.Lifecycle.Rotate = taskRef
        case taskTypeInit:
                superset.Status.Lifecycle.Init = taskRef
        }
@@ -615,6 +637,8 @@ func suffixForTaskType(taskType string) string {
                return suffixClone
        case taskTypeMigrate:
                return suffixMigrate
+       case taskTypeRotate:
+               return suffixRotate
        case taskTypeInit:
                return suffixInit
        default:
@@ -640,13 +664,12 @@ func (r *SupersetReconciler) taskPodRetention(superset 
*supersetv1alpha1.Superse
        return superset.Spec.Lifecycle.PodRetention
 }
 
-// isTaskEnabled returns true if the task is part of the lifecycle pipeline 
(strategy != Never).
 // isTaskEnabled returns true if the task is part of the lifecycle pipeline.
 // A task is enabled when its spec exists (presence = enabled) and Disabled != 
true.
-// Clone requires spec.lifecycle.clone to be set; migrate/init are enabled by 
default.
+// Clone and rotate require their spec to be set; migrate/init are enabled by 
default.
 func (r *SupersetReconciler) isTaskEnabled(superset 
*supersetv1alpha1.Superset, taskType string) bool {
        if superset.Spec.Lifecycle == nil {
-               return taskType != taskTypeClone
+               return taskType != taskTypeClone && taskType != taskTypeRotate
        }
        switch taskType {
        case taskTypeClone:
@@ -654,6 +677,11 @@ func (r *SupersetReconciler) isTaskEnabled(superset 
*supersetv1alpha1.Superset,
                        return false
                }
                return superset.Spec.Lifecycle.Clone.Disabled == nil || 
!*superset.Spec.Lifecycle.Clone.Disabled
+       case taskTypeRotate:
+               if superset.Spec.Lifecycle.Rotate == nil {
+                       return false
+               }
+               return superset.Spec.Lifecycle.Rotate.Disabled == nil || 
!*superset.Spec.Lifecycle.Rotate.Disabled
        case taskTypeMigrate:
                if superset.Spec.Lifecycle.Migrate != nil {
                        return superset.Spec.Lifecycle.Migrate.Disabled == nil 
|| !*superset.Spec.Lifecycle.Migrate.Disabled
@@ -669,7 +697,7 @@ func (r *SupersetReconciler) isTaskEnabled(superset 
*supersetv1alpha1.Superset,
 }
 
 // pruneDisabledTasks deletes task CRs for disabled tasks.
-func (r *SupersetReconciler) pruneDisabledTasks(ctx context.Context, superset 
*supersetv1alpha1.Superset, cloneEnabled, migrateEnabled, initEnabled bool) 
error {
+func (r *SupersetReconciler) pruneDisabledTasks(ctx context.Context, superset 
*supersetv1alpha1.Superset, cloneEnabled, migrateEnabled, rotateEnabled, 
initEnabled bool) error {
        if !cloneEnabled {
                if err := r.deleteTaskCR(ctx, superset.Name+suffixClone, 
superset.Namespace); err != nil {
                        return fmt.Errorf("deleting clone task CR: %w", err)
@@ -680,6 +708,11 @@ func (r *SupersetReconciler) pruneDisabledTasks(ctx 
context.Context, superset *s
                        return fmt.Errorf("deleting migrate task CR: %w", err)
                }
        }
+       if !rotateEnabled {
+               if err := r.deleteTaskCR(ctx, superset.Name+suffixRotate, 
superset.Namespace); err != nil {
+                       return fmt.Errorf("deleting rotate task CR: %w", err)
+               }
+       }
        if !initEnabled {
                if err := r.deleteTaskCR(ctx, superset.Name+suffixInit, 
superset.Namespace); err != nil {
                        return fmt.Errorf("deleting init task CR: %w", err)
@@ -788,12 +821,40 @@ func (r *SupersetReconciler) initInputs(superset 
*supersetv1alpha1.Superset, con
        }
 }
 
+// rotateInputs returns the rotate-specific inputs: secret key references and 
trigger.
+func (r *SupersetReconciler) rotateInputs(superset *supersetv1alpha1.Superset) 
any {
+       trigger := ""
+       if superset.Spec.Lifecycle != nil && superset.Spec.Lifecycle.Rotate != 
nil {
+               trigger = 
derefOrDefault(superset.Spec.Lifecycle.Rotate.Trigger, "")
+       }
+       return struct {
+               Trigger               string
+               SecretKey             string
+               SecretKeyFrom         *corev1.SecretKeySelector
+               PreviousSecretKey     string
+               PreviousSecretKeyFrom *corev1.SecretKeySelector
+       }{
+               Trigger:               trigger,
+               SecretKey:             derefOrDefault(superset.Spec.SecretKey, 
""),
+               SecretKeyFrom:         superset.Spec.SecretKeyFrom,
+               PreviousSecretKey:     
derefOrDefault(superset.Spec.PreviousSecretKey, ""),
+               PreviousSecretKeyFrom: superset.Spec.PreviousSecretKeyFrom,
+       }
+}
+
+func defaultRotateCommand(superset *supersetv1alpha1.Superset) []string {
+       if superset.Spec.Lifecycle != nil && superset.Spec.Lifecycle.Rotate != 
nil && len(superset.Spec.Lifecycle.Rotate.Command) > 0 {
+               return superset.Spec.Lifecycle.Rotate.Command
+       }
+       return []string{"/bin/sh", "-c", "superset re-encrypt-secrets"}
+}
+
 // allTasksStillComplete checks whether all enabled tasks have already 
completed
 // with checksums matching the current inputs. Used as a fast path to avoid
 // unnecessary draining on reconciles where nothing has changed.
 func (r *SupersetReconciler) allTasksStillComplete(
        superset *supersetv1alpha1.Superset,
-       cloneEnabled, migrateEnabled, initEnabled bool,
+       cloneEnabled, migrateEnabled, rotateEnabled, initEnabled bool,
        configChecksum string,
 ) bool {
        if superset.Status.Lifecycle == nil || 
superset.Status.Lifecycle.LastCompletedChecksums == nil {
@@ -820,6 +881,15 @@ func (r *SupersetReconciler) allTasksStillComplete(
                incomingChecksum = checksums[taskTypeMigrate]
        }
 
+       if rotateEnabled {
+               rotateCmd := defaultRotateCommand(superset)
+               taskChecksum := r.computeStepChecksum(incomingChecksum, 
taskTypeRotate, rotateCmd, r.rotateInputs(superset))
+               if checksums[taskTypeRotate] != taskChecksum {
+                       return false
+               }
+               incomingChecksum = checksums[taskTypeRotate]
+       }
+
        if initEnabled {
                initCmd := defaultInitCommand(superset)
                taskChecksum := r.computeStepChecksum(incomingChecksum, 
taskTypeInit, initCmd, r.initInputs(superset, configChecksum))
@@ -844,6 +914,10 @@ func (r *SupersetReconciler) taskMaxRetries(superset 
*supersetv1alpha1.Superset,
                if superset.Spec.Lifecycle.Migrate != nil {
                        return superset.Spec.Lifecycle.Migrate.MaxRetries
                }
+       case taskTypeRotate:
+               if superset.Spec.Lifecycle.Rotate != nil {
+                       return superset.Spec.Lifecycle.Rotate.MaxRetries
+               }
        case taskTypeInit:
                if superset.Spec.Lifecycle.Init != nil {
                        return superset.Spec.Lifecycle.Init.MaxRetries
@@ -872,6 +946,10 @@ func (r *SupersetReconciler) taskTimeout(superset 
*supersetv1alpha1.Superset, ta
                if superset.Spec.Lifecycle.Migrate != nil {
                        return superset.Spec.Lifecycle.Migrate.Timeout
                }
+       case taskTypeRotate:
+               if superset.Spec.Lifecycle.Rotate != nil {
+                       return superset.Spec.Lifecycle.Rotate.Timeout
+               }
        case taskTypeInit:
                if superset.Spec.Lifecycle.Init != nil {
                        return superset.Spec.Lifecycle.Init.Timeout
@@ -1095,6 +1173,10 @@ func (r *SupersetReconciler) taskRequiresDrain(superset 
*supersetv1alpha1.Supers
                        if superset.Spec.Lifecycle.Migrate != nil {
                                spec = 
&superset.Spec.Lifecycle.Migrate.BaseTaskSpec
                        }
+               case taskTypeRotate:
+                       if superset.Spec.Lifecycle.Rotate != nil {
+                               spec = 
&superset.Spec.Lifecycle.Rotate.BaseTaskSpec
+                       }
                case taskTypeInit:
                        if superset.Spec.Lifecycle.Init != nil {
                                spec = 
&superset.Spec.Lifecycle.Init.BaseTaskSpec
@@ -1106,7 +1188,7 @@ func (r *SupersetReconciler) taskRequiresDrain(superset 
*supersetv1alpha1.Supers
        }
        // Defaults per task type.
        switch taskType {
-       case taskTypeClone, taskTypeMigrate:
+       case taskTypeClone, taskTypeMigrate, taskTypeRotate:
                return true
        default:
                return false

Reply via email to