You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/05/15 21:57:43 UTC
(superset-kubernetes-operator) branch main updated: fix(lifecycle): post-refactor cleanup (gating, Helm RBAC, docs) (#58)
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 9307db6 fix(lifecycle): post-refactor cleanup (gating, Helm RBAC, docs) (#58)
9307db6 is described below
commit 9307db67dc3821dcf7d57630f2e3f01366b5ce1f
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Fri May 15 14:57:38 2026 -0700
fix(lifecycle): post-refactor cleanup (gating, Helm RBAC, docs) (#58)
* fix(lifecycle): post-refactor cleanup (gating, Helm RBAC, docs)
* fix e2e
---
api/v1alpha1/superset_types.go | 32 +---
.../crds/superset.apache.org_supersets.yaml | 82 ---------
charts/superset-operator/templates/_helpers.tpl | 5 +-
.../crd/bases/superset.apache.org_supersets.yaml | 82 ---------
config/rbac/role.yaml | 1 -
docs/architecture/internals.md | 6 +-
docs/getting-started.md | 2 +
docs/reference/api-reference.md | 17 +-
docs/reference/security.md | 2 +-
docs/user-guide/configuration.md | 2 +-
docs/user-guide/lifecycle.md | 31 ++--
internal/controller/config_test.go | 8 +-
internal/controller/lifecycle.go | 109 ++++++------
internal/controller/lifecycle_job.go | 68 -------
internal/controller/lifecycle_job_test.go | 3 -
internal/controller/lifecycle_settle_test.go | 196 +++++++++++++++++++++
.../controller/reconcile_parent_resources_test.go | 4 +-
internal/controller/schedule.go | 12 +-
internal/controller/schedule_test.go | 121 +++++++++++++
internal/controller/status.go | 18 +-
internal/controller/status_test.go | 45 +++--
internal/controller/superset_controller.go | 9 +-
test/e2e/e2e_test.go | 5 +-
23 files changed, 471 insertions(+), 389 deletions(-)
diff --git a/api/v1alpha1/superset_types.go b/api/v1alpha1/superset_types.go
index 717b887..3f49301 100644
--- a/api/v1alpha1/superset_types.go
+++ b/api/v1alpha1/superset_types.go
@@ -196,9 +196,12 @@ type CeleryWorkerComponentSpec struct {
}
// CeleryBeatComponentSpec defines the celery beat component on the parent CRD.
-// The controller forces replicas=1 regardless of spec.
+// CeleryBeat is a singleton: it always runs with one replica, and the
+// inherited `spec.replicas` value (if any) is ignored. The spec exposes no
+// replicas field, no autoscaling, and no PodDisruptionBudget by design.
type CeleryBeatComponentSpec struct {
- // Deployment-level overrides (strategy, revision history). Always enforces replicas=1.
+ // Deployment-level overrides (strategy, revision history). Replica count
+ // is fixed at 1 by the controller and cannot be overridden.
// +optional
DeploymentTemplate *DeploymentTemplate `json:"deploymentTemplate,omitempty"`
// Pod and container template for Celery beat pods.
@@ -482,8 +485,7 @@ type MaintenancePageSpec struct {
// +optional
DeploymentTemplate *DeploymentTemplate `json:"deploymentTemplate,omitempty"`
- // Pod template for the maintenance page pod (nested within deployment template
- // for user convenience).
+ // Pod template for the maintenance page pod.
// +optional
PodTemplate *PodTemplate `json:"podTemplate,omitempty"`
}
@@ -711,7 +713,7 @@ type SupersetStatus struct {
// LifecycleStatus tracks the current lifecycle task execution state.
type LifecycleStatus struct {
- // Phase of the lifecycle: Idle, Cloning, Draining, Migrating, Rotating, Initializing, Restoring, Complete, Blocked, AwaitingApproval.
+ // Phase of the lifecycle: Cloning, Draining, Migrating, Rotating, Initializing, Restoring, Complete, Blocked, AwaitingApproval.
// +optional
Phase string `json:"phase,omitempty"`
// MaintenanceActive indicates the maintenance page is currently serving traffic
@@ -745,31 +747,15 @@ type TaskRefStatus struct {
// +optional
// +kubebuilder:validation:Enum=Pending;Running;Complete;Failed
State string `json:"state,omitempty"`
- // Reference to the current or most recent task Job.
- // +optional
- Ref string `json:"ref,omitempty"`
// +optional
StartedAt *metav1.Time `json:"startedAt,omitempty"`
// +optional
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
// +optional
- Duration string `json:"duration,omitempty"`
- // +optional
Attempts int32 `json:"attempts,omitempty"`
// Maximum number of attempts before the task is considered permanently failed.
// +optional
MaxRetries int32 `json:"maxRetries,omitempty"`
- // PodName is retained for backward-compatible status shape. New lifecycle
- // executions use JobName and Ref instead.
- // +optional
- PodName string `json:"podName,omitempty"`
- // JobName is the deterministic Kubernetes Job name for the current or most
- // recent task execution.
- // +optional
- JobName string `json:"jobName,omitempty"`
- // Reference to the rendered task ConfigMap.
- // +optional
- ConfigMapRef string `json:"configMapRef,omitempty"`
// +optional
Image string `json:"image,omitempty"`
// +optional
@@ -831,10 +817,6 @@ type ComponentRefStatus struct {
// +optional
// +kubebuilder:validation:Enum=Pending;Progressing;Ready;Unavailable;Drained
Phase string `json:"phase,omitempty"`
- // "2/2" format showing ready vs desired replicas.
- Ready string `json:"ready"`
- // Reference to the primary workload resource for this component.
- Ref string `json:"ref"`
// Resources lists the Kubernetes resources currently expected for this
// component and whether the operator can observe them.
// +optional
diff --git a/charts/superset-operator/crds/superset.apache.org_supersets.yaml b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
index e1dfb3b..d4388a1 100644
--- a/charts/superset-operator/crds/superset.apache.org_supersets.yaml
+++ b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
@@ -39199,13 +39199,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39234,9 +39230,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
celeryFlower:
properties:
@@ -39257,13 +39250,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39292,9 +39281,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
celeryWorker:
properties:
@@ -39315,13 +39301,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39350,9 +39332,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
mcpServer:
properties:
@@ -39373,13 +39352,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39408,9 +39383,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
webServer:
properties:
@@ -39431,13 +39403,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39466,9 +39434,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
websocketServer:
properties:
@@ -39489,13 +39454,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39524,9 +39485,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
type: object
conditions:
@@ -39617,16 +39575,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39641,10 +39593,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39702,16 +39650,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39726,10 +39668,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39793,16 +39731,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39817,10 +39749,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39880,16 +39808,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39904,10 +39826,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
diff --git a/charts/superset-operator/templates/_helpers.tpl b/charts/superset-operator/templates/_helpers.tpl
index fcac54d..4ef86c2 100644
--- a/charts/superset-operator/templates/_helpers.tpl
+++ b/charts/superset-operator/templates/_helpers.tpl
@@ -122,13 +122,16 @@ desired indentation.
verbs: [create, delete, get, list, patch, update, watch]
- apiGroups: [""]
resources: [pods]
- verbs: [create, delete, get, list, watch]
+ verbs: [get, list, watch]
- apiGroups: [apps]
resources: [deployments]
verbs: [create, delete, get, list, patch, update, watch]
- apiGroups: [autoscaling]
resources: [horizontalpodautoscalers]
verbs: [create, delete, get, list, patch, update, watch]
+- apiGroups: [batch]
+ resources: [jobs]
+ verbs: [create, delete, get, list, patch, update, watch]
- apiGroups: [events.k8s.io]
resources: [events]
verbs: [create, patch, update]
diff --git a/config/crd/bases/superset.apache.org_supersets.yaml b/config/crd/bases/superset.apache.org_supersets.yaml
index e1dfb3b..d4388a1 100644
--- a/config/crd/bases/superset.apache.org_supersets.yaml
+++ b/config/crd/bases/superset.apache.org_supersets.yaml
@@ -39199,13 +39199,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39234,9 +39230,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
celeryFlower:
properties:
@@ -39257,13 +39250,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39292,9 +39281,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
celeryWorker:
properties:
@@ -39315,13 +39301,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39350,9 +39332,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
mcpServer:
properties:
@@ -39373,13 +39352,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39408,9 +39383,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
webServer:
properties:
@@ -39431,13 +39403,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39466,9 +39434,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
websocketServer:
properties:
@@ -39489,13 +39454,9 @@ spec:
- Unavailable
- Drained
type: string
- ready:
- type: string
readyReplicas:
format: int32
type: integer
- ref:
- type: string
replicas:
format: int32
type: integer
@@ -39524,9 +39485,6 @@ spec:
updatedReplicas:
format: int32
type: integer
- required:
- - ready
- - ref
type: object
type: object
conditions:
@@ -39617,16 +39575,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39641,10 +39593,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39702,16 +39650,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39726,10 +39668,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39793,16 +39731,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39817,10 +39749,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
@@ -39880,16 +39808,10 @@ spec:
- type
type: object
type: array
- configMapRef:
- type: string
desiredChecksum:
type: string
- duration:
- type: string
image:
type: string
- jobName:
- type: string
lastScheduledAt:
format: date-time
type: string
@@ -39904,10 +39826,6 @@ spec:
nextScheduleAt:
format: date-time
type: string
- podName:
- type: string
- ref:
- type: string
startedAt:
format: date-time
type: string
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 6559161..ba0e083 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -23,7 +23,6 @@ rules:
resources:
- pods
verbs:
- - delete
- get
- list
- watch
diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md
index 76a52c9..def946b 100644
--- a/docs/architecture/internals.md
+++ b/docs/architecture/internals.md
@@ -188,8 +188,10 @@ changed.
| Component | ConfigMap | Workload | Service | HPA | PDB |
|---|---|---|---|---|---|
-| Migrate (task) | superset_config.py | bare Pod | — | — | — |
-| Init (task) | superset_config.py | bare Pod | — | — | — |
+| Clone (task) | — | Job (database tool) | — | — | — |
+| Migrate (task) | superset_config.py | Job | — | — | — |
+| Rotate (task) | superset_config.py | Job | — | — | — |
+| Init (task) | superset_config.py | Job | — | — | — |
| WebServer | superset_config.py | Deployment (gunicorn) | port 8088 | if set | if set |
| CeleryWorker | superset_config.py | Deployment (celery worker) | — | if set | if set |
| CeleryBeat | superset_config.py | Deployment (celery beat) | — | — | — |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a129d2b..6fe6d5b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -51,6 +51,8 @@ metadata:
name: my-superset
spec:
environment: Development
+ image:
+ tag: "6.0.1"
secretKey: thisIsNotSecure_changeInProduction!
metastore:
uri: postgresql+psycopg2://superset:superset@postgres:5432/superset
diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md
index 46ac20e..919dda3 100644
--- a/docs/reference/api-reference.md
+++ b/docs/reference/api-reference.md
@@ -87,7 +87,9 @@ _Appears in:_
CeleryBeatComponentSpec defines the celery beat component on the parent CRD.
-The controller forces replicas=1 regardless of spec.
+CeleryBeat is a singleton: it always runs with one replica, and the
+inherited `spec.replicas` value (if any) is ignored. The spec exposes no
+replicas field, no autoscaling, and no PodDisruptionBudget by design.
@@ -96,7 +98,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `deploymentTemplate` _[DeploymentTemplate](#deploymenttemplate)_ | Deployment-level overrides (strategy, revision history). Always enforces replicas=1. | | Optional: \{\} <br /> |
+| `deploymentTemplate` _[DeploymentTemplate](#deploymenttemplate)_ | Deployment-level overrides (strategy, revision history). Replica count<br />is fixed at 1 by the controller and cannot be overridden. | | Optional: \{\} <br /> |
| `podTemplate` _[PodTemplate](#podtemplate)_ | Pod and container template for Celery beat pods. | | Optional: \{\} <br /> |
| `image` _[ImageOverrideSpec](#imageoverridespec)_ | Image tag and/or repository overrides; inherits from spec.image if unset. | | Optional: \{\} <br /> |
| `config` _string_ | Per-component raw Python appended after top-level config. | | Optional: \{\} <br /> |
@@ -245,8 +247,6 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `phase` _string_ | Phase summarizes the component workload state. | | Enum: [Pending Progressing Ready Unavailable Drained] <br />Optional: \{\} <br /> |
-| `ready` _string_ | "2/2" format showing ready vs desired replicas. | | |
-| `ref` _string_ | Reference to the primary workload resource for this component. | | |
| `resources` _[ComponentResourceStatus](#componentresourcestatus) array_ | Resources lists the Kubernetes resources currently expected for this<br />component and whether the operator can observe them. | | Optional: \{\} <br /> |
| `image` _string_ | Image currently configured on the component's main container. | | Optional: \{\} <br /> |
| `replicas` _integer_ | Desired replica count used for status reporting. | | Optional: \{\} <br /> |
@@ -612,7 +612,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `phase` _string_ | Phase of the lifecycle: Idle, Cloning, Draining, Migrating, Rotating, Initializing, Restoring, Complete, Blocked, AwaitingApproval. | | Optional: \{\} <br /> |
+| `phase` _string_ | Phase of the lifecycle: Cloning, Draining, Migrating, Rotating, Initializing, Restoring, 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 task checksum at last<br />successful completion. Used to detect input drift when task status refs<br />are absent. | | Optional: \{\} <br /> |
| `clone` _[TaskRefStatus](#taskrefstatus)_ | Clone task status summary. | | Optional: \{\} <br /> |
@@ -647,7 +647,7 @@ _Appears in:_
| `image` _[ImageSpec](#imagespec)_ | Image for the maintenance page container. When set, switches to custom<br />mode: no nginx config is injected, and the user's image is responsible<br />for serving HTTP traffic on the web-server port (default 8088). The port<br />must match the web-server Service's target port since the maintenance page<br />takes over that Service during lifecycle tasks.<br />When unset, defaults to nginx:alpine (managed mode). | | Optional: \{\} <br /> |
| `replicas` _integer_ | Number of maintenance page pod replicas. | 1 | Optional: \{\} <br /> |
| `deploymentTemplate` _[DeploymentTemplate](#deploymenttemplate)_ | Deployment-level overrides for the maintenance page (strategy, revision history).<br />For pod-level settings, use PodTemplate. | | Optional: \{\} <br /> |
-| `podTemplate` _[PodTemplate](#podtemplate)_ | Pod template for the maintenance page pod (nested within deployment template<br />for user convenience). | | Optional: \{\} <br /> |
+| `podTemplate` _[PodTemplate](#podtemplate)_ | Pod template for the maintenance page pod. | | Optional: \{\} <br /> |
#### McpServerComponentSpec
@@ -1096,15 +1096,10 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `state` _string_ | | | Enum: [Pending Running Complete Failed] <br />Optional: \{\} <br /> |
-| `ref` _string_ | Reference to the current or most recent task Job. | | Optional: \{\} <br /> |
| `startedAt` _[Time](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Time)_ | | | Optional: \{\} <br /> |
| `completedAt` _[Time](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Time)_ | | | Optional: \{\} <br /> |
-| `duration` _string_ | | | Optional: \{\} <br /> |
| `attempts` _integer_ | | | Optional: \{\} <br /> |
| `maxRetries` _integer_ | Maximum number of attempts before the task is considered permanently failed. | | Optional: \{\} <br /> |
-| `podName` _string_ | PodName is retained for backward-compatible status shape. New lifecycle<br />executions use JobName and Ref instead. | | Optional: \{\} <br /> |
-| `jobName` _string_ | JobName is the deterministic Kubernetes Job name for the current or most<br />recent task execution. | | Optional: \{\} <br /> |
-| `configMapRef` _string_ | Reference to the rendered task ConfigMap. | | Optional: \{\} <br /> |
| `image` _string_ | | | Optional: \{\} <br /> |
| `message` _string_ | | | Optional: \{\} <br /> |
| `nextAttemptAt` _[Time](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Time)_ | NextAttemptAt is the earliest time the operator may retry this task after<br />a failure or timeout. | | Optional: \{\} <br /> |
diff --git a/docs/reference/security.md b/docs/reference/security.md
index 9d9a210..2484ab5 100644
--- a/docs/reference/security.md
+++ b/docs/reference/security.md
@@ -237,7 +237,7 @@ across namespaces. Each permission is justified below:
| `configmaps` | CRUD | Stores generated `superset_config.py` per component |
| `services` | CRUD | Exposes web server, Flower, websocket, MCP server |
| `serviceaccounts` | CRUD | Creates per-instance ServiceAccount for pod identity |
-| `pods` | delete, get, list, watch | Reads Job pods and cleans up legacy lifecycle task Pods |
+| `pods` | get, list, watch | Reads Job pods to verify drain progress and component readiness |
| `jobs` | CRUD | Manages deterministic lifecycle task Jobs |
| `events` | create, patch | Records reconciliation events |
| `deployments` | CRUD | Manages component Deployments |
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index cab2631..e81a0e6 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -370,7 +370,7 @@ Presets control **poolClass**, **poolSize**, and **maxOverflow**:
CeleryBeat and lifecycle tasks always use NullPool regardless of preset (singleton/short-lived components with minimal DB interaction). CeleryFlower uses standard pool sizing (defaults to 1 for performance/aggressive since it has no worker configuration).
-`spec.sqlaEngineOptions` sets the baseline for all Python components. Per-component `sqlaEngineOptions` on `webServer`, `celeryWorker`, `celeryBeat`, `mcpServer`, or `init` replaces the top-level entirely (override semantics, not merge).
+`spec.sqlaEngineOptions` sets the baseline for all Python components. Per-component `sqlaEngineOptions` on `webServer`, `celeryWorker`, `celeryBeat`, `mcpServer`, or `lifecycle` replaces the top-level entirely (override semantics, not merge).
```yaml
spec:
diff --git a/docs/user-guide/lifecycle.md b/docs/user-guide/lifecycle.md
index fb80e57..1cd14c5 100644
--- a/docs/user-guide/lifecycle.md
+++ b/docs/user-guide/lifecycle.md
@@ -44,7 +44,7 @@ explicitly with `spec.lifecycle.disabled: true`.
- 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 Pod and creates a fresh one
+- When config or image changes require a re-run, the parent deletes the old task Job and creates a fresh one
## Task Triggers
@@ -134,7 +134,7 @@ the `trigger` field on a cron schedule. This requires a CronJob resource,
ServiceAccount, RoleBinding, and a kubectl image, but keeps the scheduling
logic outside the operator.
-When disabled, the task's Pods and ConfigMap are deleted, its projected status
+When disabled, the task's Job and ConfigMap are deleted, its projected status
is cleared from the parent, and it does not participate in the pipeline.
Downstream tasks still run but don't receive propagation from the disabled task.
@@ -266,14 +266,13 @@ spec:
repository: my-org/maintenance-server
tag: v2
replicas: 2
- deploymentTemplate:
- podTemplate:
- container:
- command: ["/serve", "--port=8088", "--redirect-all=/"]
- resources:
- requests:
- cpu: 50m
- memory: 64Mi
+ podTemplate:
+ container:
+ command: ["/serve", "--port=8088", "--redirect-all=/"]
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
```
In custom mode, `title`, `message`, and `body` are passed as environment variables
@@ -356,7 +355,7 @@ spec:
```
On failure, the operator retries with exponential backoff (`10s * 2^(attempt-1)`,
-capped at 5m). If a pod exceeds the timeout while Running or Pending, it counts
+capped at 5m). If a Job exceeds the timeout while Running or Pending, it counts
as a failed attempt.
**Task retention policies:**
@@ -703,30 +702,26 @@ Lifecycle task progress is tracked per-task in the parent status:
```yaml
status:
lifecycle:
- phase: Complete # Idle | Cloning | Draining | Migrating | Rotating | Initializing | Restoring | Complete | Blocked | AwaitingApproval
+ phase: Complete # Cloning | Draining | Migrating | Rotating | Initializing | Restoring | Complete | Blocked | AwaitingApproval
clone:
state: Complete # Pending | Running | Complete | Failed (only present when clone is enabled)
- podName: superset-staging-clone-k8x2m
- duration: "4m32s"
attempts: 1
migrate:
state: Complete # Pending | Running | Complete | Failed
- podName: my-superset-migrate-x7k2m
startedAt: "2026-03-16T10:00:00Z"
completedAt: "2026-03-16T10:00:12Z"
- duration: "12s"
attempts: 1
image: apache/superset:latest
init:
state: Complete
- podName: my-superset-init-a9b3k
startedAt: "2026-03-16T10:00:13Z"
completedAt: "2026-03-16T10:00:22Z"
- duration: "9s"
attempts: 1
image: apache/superset:latest
```
+Task Job names are deterministic: `{parentName}-{taskType}` (e.g. `my-superset-migrate`). Inspect the Job directly with `kubectl get job my-superset-migrate`.
+
**Parent phase values related to lifecycle:**
| Phase | Meaning |
diff --git a/internal/controller/config_test.go b/internal/controller/config_test.go
index 2300422..ba6fdd0 100644
--- a/internal/controller/config_test.go
+++ b/internal/controller/config_test.go
@@ -82,10 +82,10 @@ func TestIsComponentReady(t *testing.T) {
status *supersetv1alpha1.ComponentRefStatus
want bool
}{
- {"ready", &supersetv1alpha1.ComponentRefStatus{Phase: "Ready", Ready: "1/1"}, true},
- {"progressing", &supersetv1alpha1.ComponentRefStatus{Phase: "Progressing", Ready: "1/2"}, false},
- {"unavailable", &supersetv1alpha1.ComponentRefStatus{Phase: "Unavailable", Ready: "0/1"}, false},
- {"pending", &supersetv1alpha1.ComponentRefStatus{Phase: "Pending", Ready: "0/1"}, false},
+ {"ready", &supersetv1alpha1.ComponentRefStatus{Phase: "Ready"}, true},
+ {"progressing", &supersetv1alpha1.ComponentRefStatus{Phase: "Progressing"}, false},
+ {"unavailable", &supersetv1alpha1.ComponentRefStatus{Phase: "Unavailable"}, false},
+ {"pending", &supersetv1alpha1.ComponentRefStatus{Phase: "Pending"}, false},
}
for _, tt := range tests {
diff --git a/internal/controller/lifecycle.go b/internal/controller/lifecycle.go
index 128c787..1ed3a0e 100644
--- a/internal/controller/lifecycle.go
+++ b/internal/controller/lifecycle.go
@@ -29,7 +29,6 @@ import (
logf "sigs.k8s.io/controller-runtime/pkg/log"
supersetv1alpha1 "github.com/apache/superset-kubernetes-operator/api/v1alpha1"
- naming "github.com/apache/superset-kubernetes-operator/internal/common"
"github.com/apache/superset-kubernetes-operator/internal/resolution"
)
@@ -45,9 +44,8 @@ const (
suffixRotate = "-rotate"
upgradeModeAutomatic = "Automatic"
- upgradeModeSupervsied = "Supervised"
+ upgradeModeSupervised = "Supervised"
- lifecyclePhaseIdle = "Idle"
lifecyclePhaseCloning = "Cloning"
lifecyclePhaseDraining = "Draining"
lifecyclePhaseMigrating = "Migrating"
@@ -108,7 +106,9 @@ func (r *SupersetReconciler) reconcileLifecycle(
saName string,
) (lifecycleResult, error) {
- // If lifecycle is disabled, prune orphans and mark complete.
+ // If lifecycle is disabled, prune orphans and mark settled. Settling
+ // (advancing LastLifecycleImage) is what stops Supervised mode from
+ // re-gating image changes when no task would actually run.
if isLifecycleDisabled(superset) {
if err := r.deleteLifecycleTaskResources(ctx, superset, taskTypeClone, suffixClone); err != nil {
return lifecycleResult{}, fmt.Errorf("pruning clone task resources: %w", err)
@@ -125,8 +125,8 @@ func (r *SupersetReconciler) reconcileLifecycle(
if err := r.cleanupMaintenanceResources(ctx, superset); err != nil {
return lifecycleResult{}, fmt.Errorf("cleaning up maintenance resources: %w", err)
}
- setCondition(&superset.Status.Conditions, supersetv1alpha1.ConditionTypeLifecycleComplete,
- metav1.ConditionTrue, "LifecycleDisabled", "Lifecycle tasks are disabled", superset.Generation)
+ currentImage := resolveLifecycleImage(&superset.Spec.Image, lifecycleImageOverride(superset))
+ r.settleLifecycle(superset, currentImage, "LifecycleDisabled", "Lifecycle tasks are disabled")
superset.Status.Lifecycle = nil
return lifecycleComplete(), nil
}
@@ -165,11 +165,12 @@ func (r *SupersetReconciler) reconcileLifecycle(
return lifecycleResult{}, err
}
- // If no tasks are enabled, lifecycle is complete.
+ // If no tasks are enabled, the pipeline is already settled. Advancing
+ // LastLifecycleImage here avoids re-gating Supervised image changes when
+ // the user has disabled every task.
if !cloneEnabled && !migrateEnabled && !rotateEnabled && !initEnabled {
superset.Status.Lifecycle.Phase = lifecyclePhaseComplete
- setCondition(&superset.Status.Conditions, supersetv1alpha1.ConditionTypeLifecycleComplete,
- metav1.ConditionTrue, "LifecycleComplete", "Lifecycle tasks completed successfully", superset.Generation)
+ r.settleLifecycle(superset, currentImage, "NoLifecycleTasks", "No lifecycle tasks configured")
return lifecycleComplete(), nil
}
@@ -211,9 +212,7 @@ func (r *SupersetReconciler) reconcileLifecycle(
}
// All tasks complete.
- if err := r.finalizeLifecycle(ctx, superset, currentImage); err != nil {
- return lifecycleResult{}, err
- }
+ r.finalizeLifecycle(superset, currentImage)
return lifecycleComplete(), nil
}
@@ -261,35 +260,53 @@ func lifecycleParentPhase(upgradeInProgress bool) string {
return phaseInitializing
}
-// finalizeLifecycle updates status and clears approval annotations after all
-// lifecycle tasks complete. Maintenance teardown is handled separately in
-// reconcileMaintenanceReturn(), gated on web-server readiness.
-func (r *SupersetReconciler) finalizeLifecycle(
- ctx context.Context,
- superset *supersetv1alpha1.Superset,
- currentImage string,
-) error {
- superset.Status.LastLifecycleImage = currentImage
+// finalizeLifecycle updates status after all lifecycle tasks complete.
+// Maintenance teardown is handled separately in reconcileMaintenanceReturn(),
+// gated on web-server readiness. The upgrade approval annotation is cleared by
+// the parent reconciler after status is persisted, so a status patch failure
+// does not leave the annotation cleared (which would re-gate Supervised
+// upgrades on the next reconcile while LastLifecycleImage was stale).
+func (r *SupersetReconciler) finalizeLifecycle(superset *supersetv1alpha1.Superset, currentImage string) {
if anyComponentEnabled(superset) {
superset.Status.Lifecycle.Phase = lifecyclePhaseRestoring
} else {
superset.Status.Lifecycle.Phase = lifecyclePhaseComplete
}
- superset.Status.Lifecycle.Upgrade = nil
+ r.settleLifecycle(superset, currentImage, "LifecycleComplete", "Lifecycle tasks completed successfully")
+}
- if annotations := superset.GetAnnotations(); annotations != nil {
- if _, ok := annotations[annotationApproveUpgrade]; ok {
- patch := client.MergeFrom(superset.DeepCopy())
- delete(annotations, annotationApproveUpgrade)
- superset.SetAnnotations(annotations)
- if err := r.Patch(ctx, superset, patch); err != nil {
- return fmt.Errorf("clearing approval annotation: %w", err)
- }
- }
+// settleLifecycle records that the lifecycle pipeline has nothing more to do
+// for the current image. Used by all completion paths (lifecycle disabled, no
+// enabled tasks, finalize after a successful run). Advancing
+// LastLifecycleImage is what prevents the upgrade gate from re-triggering on
+// the next reconcile when no task would actually run.
+func (r *SupersetReconciler) settleLifecycle(superset *supersetv1alpha1.Superset, currentImage, reason, message string) {
+ superset.Status.LastLifecycleImage = currentImage
+ if superset.Status.Lifecycle != nil {
+ superset.Status.Lifecycle.Upgrade = nil
}
-
setCondition(&superset.Status.Conditions, supersetv1alpha1.ConditionTypeLifecycleComplete,
- metav1.ConditionTrue, "LifecycleComplete", "Lifecycle tasks completed successfully", superset.Generation)
+ metav1.ConditionTrue, reason, message, superset.Generation)
+}
+
+// clearUpgradeApprovalAnnotation removes the supervised upgrade approval
+// annotation. Called by the parent reconciler after the post-lifecycle status
+// patch succeeds, so a failed status patch never leaves the annotation
+// cleared while LastLifecycleImage is still stale.
+func (r *SupersetReconciler) clearUpgradeApprovalAnnotation(ctx context.Context, superset *supersetv1alpha1.Superset) error {
+ annotations := superset.GetAnnotations()
+ if annotations == nil {
+ return nil
+ }
+ if _, ok := annotations[annotationApproveUpgrade]; !ok {
+ return nil
+ }
+ patch := client.MergeFrom(superset.DeepCopy())
+ delete(annotations, annotationApproveUpgrade)
+ superset.SetAnnotations(annotations)
+ if err := r.Patch(ctx, superset, patch); err != nil {
+ return fmt.Errorf("clearing upgrade approval annotation: %w", err)
+ }
return nil
}
@@ -433,7 +450,7 @@ func (r *SupersetReconciler) checkUpgradeGates(
}
// Supervised mode: check for approval annotation.
- if getUpgradeMode(superset) == upgradeModeSupervsied {
+ if getUpgradeMode(superset) == upgradeModeSupervised {
annotations := superset.GetAnnotations()
if annotations == nil || annotations[annotationApproveUpgrade] != "true" {
log.Info("Upgrade awaiting approval")
@@ -483,10 +500,6 @@ func (r *SupersetReconciler) reconcileLifecycleTask(
taskRef := ensureTaskStatus(superset, taskType)
taskRef.DesiredChecksum = taskChecksum
taskRef.MaxRetries = r.taskMaxRetriesValue(superset, taskType)
- taskRef.ConfigMapRef = ""
- if renderedConfig != "" {
- taskRef.ConfigMapRef = "ConfigMap/" + naming.ConfigMapName(taskName)
- }
r.projectScheduleStatus(superset, taskType, taskRef)
if taskRef.State == taskStateComplete && taskRef.CompletedChecksum == taskChecksum {
@@ -502,7 +515,7 @@ func (r *SupersetReconciler) reconcileLifecycleTask(
return lifecycleTerminal(), nil
}
- if taskRef.State == taskStateComplete || taskRef.State == taskStateFailed || taskRef.CompletedChecksum != "" && taskRef.CompletedChecksum != taskChecksum {
+ if taskRef.State == taskStateComplete || taskRef.State == taskStateFailed || (taskRef.CompletedChecksum != "" && taskRef.CompletedChecksum != taskChecksum) {
log.Info("Task status is stale, resetting to re-run", "task", taskType,
"completedChecksum", taskRef.CompletedChecksum, "expectedChecksum", taskChecksum)
if err := r.deleteTaskJobs(ctx, superset, taskName); err != nil {
@@ -665,24 +678,6 @@ func (r *SupersetReconciler) getTaskStatusChecksum(ctx context.Context, superset
return ""
}
-// computePipelineSeed computes a seed checksum for the lifecycle pipeline.
-// Reserved for future use (custom task hooks may need a shared seed).
-// Currently unused — the pipeline anchor is parentUID directly.
-//
-//nolint:unused
-func (r *SupersetReconciler) computePipelineSeed(superset *supersetv1alpha1.Superset, configChecksum string) string {
- currentImage := resolveLifecycleImage(&superset.Spec.Image, lifecycleImageOverride(superset))
- return computeChecksum(struct {
- ParentUID string
- Image string
- ConfigChecksum string
- }{
- ParentUID: string(superset.UID),
- Image: currentImage,
- ConfigChecksum: configChecksum,
- })
-}
-
// computeStepChecksum computes a task's checksum from the incoming checksum
// (seed or previous task's completed checksum) plus the task's own inputs.
// The incoming checksum carries all upstream state — each task only adds its
diff --git a/internal/controller/lifecycle_job.go b/internal/controller/lifecycle_job.go
index 216e22c..77ed2a0 100644
--- a/internal/controller/lifecycle_job.go
+++ b/internal/controller/lifecycle_job.go
@@ -64,12 +64,8 @@ func ensureTaskStatus(superset *supersetv1alpha1.Superset, taskType string) *sup
func resetTaskStatusForRun(taskRef *supersetv1alpha1.TaskRefStatus, desiredChecksum string, maxRetries int32) {
taskRef.State = taskStatePending
- taskRef.Ref = ""
- taskRef.PodName = ""
- taskRef.JobName = ""
taskRef.StartedAt = nil
taskRef.CompletedAt = nil
- taskRef.Duration = ""
taskRef.Attempts = 0
taskRef.MaxRetries = maxRetries
taskRef.NextAttemptAt = nil
@@ -122,10 +118,6 @@ func (r *SupersetReconciler) reconcileLifecycleTaskJob(
}
if existingJob != nil {
- taskRef.JobName = existingJob.Name
- taskRef.PodName = ""
- taskRef.Ref = "Job/" + existingJob.Name
-
if existingJob.DeletionTimestamp != nil {
return lifecycleWait(), nil
}
@@ -152,9 +144,6 @@ func (r *SupersetReconciler) reconcileLifecycleTaskJob(
if taskRef.StartedAt == nil && existingJob.Status.StartTime != nil {
taskRef.StartedAt = existingJob.Status.StartTime
}
- if taskRef.StartedAt != nil {
- taskRef.Duration = now.Sub(taskRef.StartedAt.Time).Round(time.Second).String()
- }
taskRef.Message = "Completed successfully"
taskRef.CompletedChecksum = taskChecksum
setCondition(&taskRef.Conditions, supersetv1alpha1.ConditionTypeTaskComplete,
@@ -229,12 +218,8 @@ func (r *SupersetReconciler) reconcileLifecycleTaskJob(
now := metav1.Now()
taskRef.State = taskStateRunning
- taskRef.JobName = job.Name
- taskRef.PodName = ""
- taskRef.Ref = "Job/" + job.Name
taskRef.StartedAt = &now
taskRef.CompletedAt = nil
- taskRef.Duration = ""
taskRef.Image = image
taskRef.Message = ""
setCondition(&taskRef.Conditions, supersetv1alpha1.ConditionTypeTaskComplete,
@@ -330,9 +315,6 @@ func (r *SupersetReconciler) reconcileTaskJobImage(
}
taskRef.State = taskStatePending
taskRef.Image = image
- taskRef.Ref = ""
- taskRef.JobName = ""
- taskRef.PodName = ""
taskRef.Message = "Image changed, re-running task"
r.Recorder.Eventf(superset, nil, corev1.EventTypeNormal, "TaskImageChanged", "Lifecycle",
"%s image changed from %s to %s, re-running task", taskType, existingImage, image)
@@ -376,25 +358,6 @@ func (r *SupersetReconciler) deleteTaskJobs(ctx context.Context, superset *super
if err := r.deleteLifecycleJob(ctx, job); err != nil {
return fmt.Errorf("deleting lifecycle task job: %w", err)
}
- if err := r.deleteLegacyTaskPods(ctx, superset, taskName); err != nil {
- return err
- }
- return nil
-}
-
-func (r *SupersetReconciler) deleteLegacyTaskPods(ctx context.Context, superset *supersetv1alpha1.Superset, taskName string) error {
- podList := &corev1.PodList{}
- if err := r.List(ctx, podList,
- client.InNamespace(superset.Namespace),
- client.MatchingLabels{labelInitInstance: taskName},
- ); err != nil {
- return fmt.Errorf("listing legacy lifecycle task pods: %w", err)
- }
- for i := range podList.Items {
- if err := client.IgnoreNotFound(r.Delete(ctx, &podList.Items[i])); err != nil {
- return err
- }
- }
return nil
}
@@ -448,37 +411,6 @@ func (r *SupersetReconciler) cleanupTaskJobsByRetention(ctx context.Context, sup
}
}
}
-
- return r.cleanupLegacyTaskPodsByRetention(ctx, superset, taskName, taskType)
-}
-
-func (r *SupersetReconciler) cleanupLegacyTaskPodsByRetention(ctx context.Context, superset *supersetv1alpha1.Superset, taskName, taskType string) error {
- podList := &corev1.PodList{}
- if err := r.List(ctx, podList,
- client.InNamespace(superset.Namespace),
- client.MatchingLabels{
- labelInitInstance: taskName,
- labelInitTask: strings.ToLower(taskType),
- },
- ); err != nil {
- return fmt.Errorf("listing legacy lifecycle task pods for retention: %w", err)
- }
-
- policy := r.taskRetentionPolicyValue(superset, taskType)
- for i := range podList.Items {
- pod := &podList.Items[i]
- if pod.DeletionTimestamp != nil {
- continue
- }
- if pod.Status.Phase != corev1.PodSucceeded && pod.Status.Phase != corev1.PodFailed {
- continue
- }
- if ShouldDeletePod(policy, pod.Status.Phase) {
- if err := r.Delete(ctx, pod); client.IgnoreNotFound(err) != nil {
- return fmt.Errorf("deleting retained legacy lifecycle task pod %s: %w", pod.Name, err)
- }
- }
- }
return nil
}
diff --git a/internal/controller/lifecycle_job_test.go b/internal/controller/lifecycle_job_test.go
index 4c8233b..c20699a 100644
--- a/internal/controller/lifecycle_job_test.go
+++ b/internal/controller/lifecycle_job_test.go
@@ -68,9 +68,6 @@ func TestReconcileLifecycleTaskJob_CheckpointsCompletionBeforeRetention(t *testi
if taskRef.State != taskStateComplete || taskRef.CompletedChecksum != taskChecksum {
t.Fatalf("expected completed status with checksum, got state=%q checksum=%q", taskRef.State, taskRef.CompletedChecksum)
}
- if taskRef.Ref != "Job/test-migrate" || taskRef.JobName != "test-migrate" {
- t.Fatalf("expected job ref/name, got ref=%q jobName=%q", taskRef.Ref, taskRef.JobName)
- }
if err := c.Get(ctx, types.NamespacedName{Name: job.Name, Namespace: job.Namespace}, &batchv1.Job{}); err != nil {
t.Fatalf("completed job should remain until status persistence cleanup: %v", err)
}
diff --git a/internal/controller/lifecycle_settle_test.go b/internal/controller/lifecycle_settle_test.go
new file mode 100644
index 0000000..23d99f1
--- /dev/null
+++ b/internal/controller/lifecycle_settle_test.go
@@ -0,0 +1,196 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/events"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ supersetv1alpha1 "github.com/apache/superset-kubernetes-operator/api/v1alpha1"
+)
+
+// TestReconcileLifecycle_DisabledAdvancesLastLifecycleImage covers finding #5:
+// when lifecycle is disabled, LastLifecycleImage must advance so subsequent
+// reconciles don't re-trigger the upgrade gate (image change → AwaitingApproval
+// in Supervised mode) for work that will never actually run.
+func TestReconcileLifecycle_DisabledAdvancesLastLifecycleImage(t *testing.T) {
+ scheme := testScheme(t)
+ superset := &supersetv1alpha1.Superset{
+ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default", UID: "uid-1"},
+ Spec: supersetv1alpha1.SupersetSpec{
+ Image: supersetv1alpha1.ImageSpec{Repository: "apache/superset", Tag: "6.0.1"},
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{Disabled: boolPtr(true)},
+ },
+ Status: supersetv1alpha1.SupersetStatus{LastLifecycleImage: "apache/superset:5.0.0"},
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+ r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+ res, err := r.reconcileLifecycle(context.Background(), superset, "checksum", nil, "sa")
+ if err != nil {
+ t.Fatalf("reconcileLifecycle: %v", err)
+ }
+ if !res.Complete {
+ t.Fatalf("expected lifecycle Complete=true, got %#v", res)
+ }
+ if superset.Status.LastLifecycleImage != "apache/superset:6.0.1" {
+ t.Fatalf("expected LastLifecycleImage to advance to 6.0.1, got %q", superset.Status.LastLifecycleImage)
+ }
+}
+
+// TestReconcileLifecycle_NoTasksConfigured covers findings #5 and #8:
+// when every lifecycle task is absent or disabled, LastLifecycleImage must
+// advance and the user-facing message must reflect the actual situation.
+func TestReconcileLifecycle_NoTasksConfigured(t *testing.T) {
+ scheme := testScheme(t)
+ superset := &supersetv1alpha1.Superset{
+ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default", UID: "uid-1"},
+ Spec: supersetv1alpha1.SupersetSpec{
+ Image: supersetv1alpha1.ImageSpec{Repository: "apache/superset", Tag: "6.0.1"},
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{
+ Migrate: &supersetv1alpha1.MigrateTaskSpec{BaseTaskSpec: supersetv1alpha1.BaseTaskSpec{Disabled: boolPtr(true)}},
+ Init: &supersetv1alpha1.InitTaskSpec{BaseTaskSpec: supersetv1alpha1.BaseTaskSpec{Disabled: boolPtr(true)}},
+ },
+ },
+ Status: supersetv1alpha1.SupersetStatus{LastLifecycleImage: "apache/superset:5.0.0"},
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+ r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+ res, err := r.reconcileLifecycle(context.Background(), superset, "checksum", nil, "sa")
+ if err != nil {
+ t.Fatalf("reconcileLifecycle: %v", err)
+ }
+ if !res.Complete {
+ t.Fatalf("expected lifecycle Complete=true, got %#v", res)
+ }
+ if superset.Status.LastLifecycleImage != "apache/superset:6.0.1" {
+ t.Fatalf("expected LastLifecycleImage to advance to 6.0.1, got %q", superset.Status.LastLifecycleImage)
+ }
+ if !hasConditionReason(superset.Status.Conditions, supersetv1alpha1.ConditionTypeLifecycleComplete, "NoLifecycleTasks") {
+ t.Fatalf("expected LifecycleComplete reason NoLifecycleTasks, got %#v", superset.Status.Conditions)
+ }
+ if !hasConditionMessage(superset.Status.Conditions, supersetv1alpha1.ConditionTypeLifecycleComplete, "No lifecycle tasks configured") {
+ t.Fatalf("expected user-facing message %q, got %#v", "No lifecycle tasks configured", superset.Status.Conditions)
+ }
+}
+
+// TestClearUpgradeApprovalAnnotation_RemovesAnnotation covers finding #6:
+// the annotation-clearing helper must actually remove the annotation when
+// invoked. This guards the "happy path" piece of the bug fix.
+func TestClearUpgradeApprovalAnnotation_RemovesAnnotation(t *testing.T) {
+ scheme := testScheme(t)
+ superset := &supersetv1alpha1.Superset{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ UID: "uid-1",
+ Annotations: map[string]string{annotationApproveUpgrade: "true", "other": "preserved"},
+ },
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+ r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+ if err := r.clearUpgradeApprovalAnnotation(context.Background(), superset); err != nil {
+ t.Fatalf("clearUpgradeApprovalAnnotation: %v", err)
+ }
+ if _, ok := superset.GetAnnotations()[annotationApproveUpgrade]; ok {
+ t.Fatalf("expected annotation to be removed in-memory, still present: %#v", superset.GetAnnotations())
+ }
+
+ persisted := &supersetv1alpha1.Superset{}
+ if err := c.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "default"}, persisted); err != nil {
+ t.Fatalf("get persisted Superset: %v", err)
+ }
+ if _, ok := persisted.GetAnnotations()[annotationApproveUpgrade]; ok {
+ t.Fatalf("expected annotation to be removed in API, still present: %#v", persisted.GetAnnotations())
+ }
+ if persisted.GetAnnotations()["other"] != "preserved" {
+ t.Fatalf("expected other annotations to be preserved, got %#v", persisted.GetAnnotations())
+ }
+}
+
+// TestClearUpgradeApprovalAnnotation_NoOpWhenAbsent covers finding #6:
+// the helper must be safe to call when the annotation is absent (the common
+// case once lifecycle has settled). It must not error or mutate annotations.
+func TestClearUpgradeApprovalAnnotation_NoOpWhenAbsent(t *testing.T) {
+ scheme := testScheme(t)
+ superset := &supersetv1alpha1.Superset{
+ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default", UID: "uid-1"},
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+ r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+ if err := r.clearUpgradeApprovalAnnotation(context.Background(), superset); err != nil {
+ t.Fatalf("clearUpgradeApprovalAnnotation on absent annotation should be no-op, got %v", err)
+ }
+}
+
+// TestFinalizeLifecycleDoesNotPatchAnnotation covers finding #6: the bug was
+// that finalizeLifecycle issued a separate annotation Patch *before* status
+// was persisted, so a status patch failure would strand the annotation
+// (re-gating Supervised upgrades while LastLifecycleImage was stale). Annotation
+// clearing has moved to the parent reconciler post-status-persist. This test
+// pins finalizeLifecycle to settling status only.
+func TestFinalizeLifecycleDoesNotPatchAnnotation(t *testing.T) {
+ scheme := testScheme(t)
+ superset := &supersetv1alpha1.Superset{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ UID: "uid-1",
+ Annotations: map[string]string{annotationApproveUpgrade: "true"},
+ },
+ Spec: supersetv1alpha1.SupersetSpec{
+ Image: supersetv1alpha1.ImageSpec{Repository: "apache/superset", Tag: "6.0.1"},
+ },
+ Status: supersetv1alpha1.SupersetStatus{
+ Lifecycle: &supersetv1alpha1.LifecycleStatus{Phase: lifecyclePhaseInitializing},
+ },
+ }
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+ r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+ r.finalizeLifecycle(superset, "apache/superset:6.0.1")
+
+ if superset.Status.LastLifecycleImage != "apache/superset:6.0.1" {
+ t.Fatalf("expected LastLifecycleImage to advance, got %q", superset.Status.LastLifecycleImage)
+ }
+ persisted := &supersetv1alpha1.Superset{}
+ if err := c.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "default"}, persisted); err != nil {
+ t.Fatalf("get persisted: %v", err)
+ }
+ if _, ok := persisted.GetAnnotations()[annotationApproveUpgrade]; !ok {
+ t.Fatalf("finalizeLifecycle must not touch the annotation; the parent reconciler clears it after status persist. Annotation was removed.")
+ }
+}
+
+func hasConditionMessage(conditions []metav1.Condition, conditionType, message string) bool {
+ for _, c := range conditions {
+ if c.Type == conditionType && c.Message == message {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/controller/reconcile_parent_resources_test.go b/internal/controller/reconcile_parent_resources_test.go
index 8bf5d77..fe96196 100644
--- a/internal/controller/reconcile_parent_resources_test.go
+++ b/internal/controller/reconcile_parent_resources_test.go
@@ -147,7 +147,7 @@ func TestReconcile_LifecycleCreatesParentOwnedTaskJobAndStatus(t *testing.T) {
if updated.Status.Lifecycle.Migrate.DesiredChecksum == "" {
t.Fatal("expected migrate desired checksum")
}
- if updated.Status.Lifecycle.Migrate.Ref != "Job/test-migrate" {
- t.Fatalf("expected migrate job ref, got %q", updated.Status.Lifecycle.Migrate.Ref)
+ if jobs.Items[0].Name != "test-migrate" {
+ t.Fatalf("expected deterministic migrate Job name, got %q", jobs.Items[0].Name)
}
}
diff --git a/internal/controller/schedule.go b/internal/controller/schedule.go
index d22d577..3893c2a 100644
--- a/internal/controller/schedule.go
+++ b/internal/controller/schedule.go
@@ -108,13 +108,19 @@ func (r *SupersetReconciler) projectScheduleStatus(superset *supersetv1alpha1.Su
}
// validateSchedules checks all active cron expressions for validity and sets
-// a warning condition + event if any are invalid.
+// a warning condition + event if any are invalid. When no schedules are
+// configured, the ScheduleValid condition is removed entirely so users don't
+// see a misleading "valid" signal for absent schedules.
func (r *SupersetReconciler) validateSchedules(superset *supersetv1alpha1.Superset) {
if superset.Spec.Lifecycle == nil {
+ removeCondition(&superset.Status.Conditions, conditionTypeScheduleValid)
return
}
+ hasSchedule := false
if superset.Spec.Lifecycle.Clone != nil && superset.Spec.Lifecycle.Clone.CronSchedule != nil &&
+ *superset.Spec.Lifecycle.Clone.CronSchedule != "" &&
!isDisabled(superset.Spec.Lifecycle.Clone.Disabled) {
+ hasSchedule = true
expr := *superset.Spec.Lifecycle.Clone.CronSchedule
if err := schedule.Validate(expr); err != nil {
setCondition(&superset.Status.Conditions, conditionTypeScheduleValid,
@@ -124,6 +130,10 @@ func (r *SupersetReconciler) validateSchedules(superset *supersetv1alpha1.Supers
return
}
}
+ if !hasSchedule {
+ removeCondition(&superset.Status.Conditions, conditionTypeScheduleValid)
+ return
+ }
setCondition(&superset.Status.Conditions, conditionTypeScheduleValid,
metav1.ConditionTrue, "SchedulesValid", "All cron schedules are valid", superset.Generation)
}
diff --git a/internal/controller/schedule_test.go b/internal/controller/schedule_test.go
new file mode 100644
index 0000000..af8917f
--- /dev/null
+++ b/internal/controller/schedule_test.go
@@ -0,0 +1,121 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/tools/events"
+
+ supersetv1alpha1 "github.com/apache/superset-kubernetes-operator/api/v1alpha1"
+)
+
+// TestValidateSchedules_NoConditionWhenNoSchedules covers finding #9: the
+// ScheduleValid condition was being set unconditionally on every reconcile,
+// even when no schedule was configured. That polluted status with a
+// "valid" signal for an empty set of schedules.
+func TestValidateSchedules_NoConditionWhenNoSchedules(t *testing.T) {
+ r := &SupersetReconciler{Recorder: events.NewFakeRecorder(10)}
+
+ // Case 1: lifecycle is nil entirely.
+ superset := &supersetv1alpha1.Superset{}
+ r.validateSchedules(superset)
+ if hasScheduleValidCondition(superset.Status.Conditions) {
+ t.Fatalf("expected no ScheduleValid condition when lifecycle is nil, got %#v", superset.Status.Conditions)
+ }
+
+ // Case 2: lifecycle present but no clone (the only scheduled task).
+ superset = &supersetv1alpha1.Superset{
+ Spec: supersetv1alpha1.SupersetSpec{
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{},
+ },
+ }
+ r.validateSchedules(superset)
+ if hasScheduleValidCondition(superset.Status.Conditions) {
+ t.Fatalf("expected no ScheduleValid condition when no clone task, got %#v", superset.Status.Conditions)
+ }
+
+ // Case 3: clone present but no cron schedule set.
+ superset = &supersetv1alpha1.Superset{
+ Spec: supersetv1alpha1.SupersetSpec{
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{
+ Clone: &supersetv1alpha1.CloneTaskSpec{},
+ },
+ },
+ }
+ r.validateSchedules(superset)
+ if hasScheduleValidCondition(superset.Status.Conditions) {
+ t.Fatalf("expected no ScheduleValid condition when clone has no schedule, got %#v", superset.Status.Conditions)
+ }
+}
+
+// TestValidateSchedules_RemovesStaleCondition covers finding #9: when a
+// previously-set schedule is removed, the leftover ScheduleValid condition
+// must be cleared (otherwise users see a stale signal forever).
+func TestValidateSchedules_RemovesStaleCondition(t *testing.T) {
+ r := &SupersetReconciler{Recorder: events.NewFakeRecorder(10)}
+ superset := &supersetv1alpha1.Superset{
+ Spec: supersetv1alpha1.SupersetSpec{
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{},
+ },
+ Status: supersetv1alpha1.SupersetStatus{
+ Conditions: []metav1.Condition{{
+ Type: conditionTypeScheduleValid,
+ Status: metav1.ConditionTrue,
+ Reason: "SchedulesValid",
+ Message: "All cron schedules are valid",
+ }},
+ },
+ }
+ r.validateSchedules(superset)
+ if hasScheduleValidCondition(superset.Status.Conditions) {
+ t.Fatalf("expected stale ScheduleValid condition to be removed when no schedules configured, got %#v", superset.Status.Conditions)
+ }
+}
+
+// TestValidateSchedules_SetsTrueWhenScheduleValid covers the happy path:
+// when at least one valid schedule is configured, the condition appears with
+// reason SchedulesValid.
+func TestValidateSchedules_SetsTrueWhenScheduleValid(t *testing.T) {
+ r := &SupersetReconciler{Recorder: events.NewFakeRecorder(10)}
+ schedule := "0 2 * * *"
+ superset := &supersetv1alpha1.Superset{
+ Spec: supersetv1alpha1.SupersetSpec{
+ Lifecycle: &supersetv1alpha1.LifecycleSpec{
+ Clone: &supersetv1alpha1.CloneTaskSpec{
+ SchedulableBaseTaskSpec: supersetv1alpha1.SchedulableBaseTaskSpec{CronSchedule: &schedule},
+ },
+ },
+ },
+ }
+ r.validateSchedules(superset)
+ if !hasConditionReason(superset.Status.Conditions, conditionTypeScheduleValid, "SchedulesValid") {
+ t.Fatalf("expected ScheduleValid=True with reason SchedulesValid, got %#v", superset.Status.Conditions)
+ }
+}
+
+func hasScheduleValidCondition(conditions []metav1.Condition) bool {
+ for _, c := range conditions {
+ if c.Type == conditionTypeScheduleValid {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/controller/status.go b/internal/controller/status.go
index 7cd88e9..d11f31e 100644
--- a/internal/controller/status.go
+++ b/internal/controller/status.go
@@ -92,6 +92,16 @@ func setCondition(conditions *[]metav1.Condition, conditionType string, status m
})
}
+// removeCondition removes a condition of the given type from the slice, if present.
+func removeCondition(conditions *[]metav1.Condition, conditionType string) {
+ for i, c := range *conditions {
+ if c.Type == conditionType {
+ *conditions = append((*conditions)[:i], (*conditions)[i+1:]...)
+ return
+ }
+ }
+}
+
func (r *SupersetReconciler) updateStatus(ctx context.Context, superset *supersetv1alpha1.Superset, origSuperset *supersetv1alpha1.Superset) error {
superset.Status.ObservedGeneration = superset.Generation
superset.Status.Version = superset.Spec.Image.Tag
@@ -235,7 +245,6 @@ func webServerMaintenanceStatus(status *supersetv1alpha1.ComponentRefStatus) *su
}
copied := status.DeepCopy()
copied.Phase = componentPhaseProgressing
- copied.Ready = fmt.Sprintf("0/%d", copied.Replicas)
copied.ReadyReplicas = 0
copied.AvailableReplicas = 0
copied.Message = "Web-server Service is routing to the maintenance page"
@@ -255,8 +264,6 @@ func drainedComponentStatus(superset *supersetv1alpha1.Superset, desc *component
}
return &supersetv1alpha1.ComponentRefStatus{
Phase: componentPhaseDrained,
- Ready: fmt.Sprintf("0/%d", desired),
- Ref: "Deployment/" + resourceBaseName,
Resources: resources,
Replicas: desired,
Message: "Component reconciliation is paused while lifecycle tasks run",
@@ -265,7 +272,6 @@ func drainedComponentStatus(superset *supersetv1alpha1.Superset, desc *component
func (r *SupersetReconciler) getComponentStatus(ctx context.Context, superset *supersetv1alpha1.Superset, desc *componentDescriptor) *supersetv1alpha1.ComponentRefStatus {
resourceBaseName := desc.resourceBaseName(&superset.Spec, superset.Name)
- ref := "Deployment/" + resourceBaseName
accessor := desc.extract(&superset.Spec)
cfg, _ := componentResourceConfig(desc.componentType)
desired := desiredReplicasForStatus(superset, desc, accessor)
@@ -279,8 +285,6 @@ func (r *SupersetReconciler) getComponentStatus(ctx context.Context, superset *s
resources = append(resources, r.expectedComponentResources(ctx, superset, desc, accessor, cfg, resourceBaseName)...)
return &supersetv1alpha1.ComponentRefStatus{
Phase: "Pending",
- Ready: fmt.Sprintf("0/%d", desired),
- Ref: ref,
Resources: resources,
Replicas: desired,
Message: fmt.Sprintf("Deployment %s not found", resourceBaseName),
@@ -299,8 +303,6 @@ func (r *SupersetReconciler) getComponentStatus(ctx context.Context, superset *s
return &supersetv1alpha1.ComponentRefStatus{
Phase: phase,
- Ready: fmt.Sprintf("%d/%d", deploy.Status.ReadyReplicas, desired),
- Ref: ref,
Resources: resources,
Image: deploymentMainImage(deploy),
Replicas: desired,
diff --git a/internal/controller/status_test.go b/internal/controller/status_test.go
index 3e304a9..cc1d6fe 100644
--- a/internal/controller/status_test.go
+++ b/internal/controller/status_test.go
@@ -122,11 +122,10 @@ func TestGetComponentStatusFromDeployment(t *testing.T) {
name string
replicas int32
readyReplicas int32
- wantReady string
}{
- {"all ready", 3, 3, "3/3"},
- {"partially ready", 3, 1, "1/3"},
- {"not ready", 2, 0, "0/2"},
+ {"all ready", 3, 3},
+ {"partially ready", 3, 1},
+ {"not ready", 2, 0},
}
for _, tt := range tests {
@@ -156,11 +155,12 @@ func TestGetComponentStatusFromDeployment(t *testing.T) {
status := r.getComponentStatus(context.Background(), superset, webServerDescriptor)
- if status.Ready != tt.wantReady {
- t.Errorf("expected Ready=%s, got %s", tt.wantReady, status.Ready)
+ if status.Replicas != tt.replicas || status.ReadyReplicas != tt.readyReplicas {
+ t.Errorf("expected replicas=%d ready=%d, got replicas=%d ready=%d",
+ tt.replicas, tt.readyReplicas, status.Replicas, status.ReadyReplicas)
}
- if status.Ref != "Deployment/test-web-server" {
- t.Errorf("expected deployment ref, got %s", status.Ref)
+ if !hasComponentResource(status.Resources, "Deployment", "test-web-server", "Present") {
+ t.Errorf("expected Deployment/test-web-server resource Present, got %#v", status.Resources)
}
if status.ConfigChecksum != "sha256:test" {
t.Errorf("expected config checksum, got %s", status.ConfigChecksum)
@@ -192,11 +192,11 @@ func TestGetComponentStatusMissingDeployment(t *testing.T) {
r := &SupersetReconciler{Client: c, Scheme: scheme}
status := r.getComponentStatus(context.Background(), superset, webServerDescriptor)
- if status.Ready != "0/1" {
- t.Fatalf("expected missing deployment to report 0/1, got %s", status.Ready)
+ if status.Replicas != 1 || status.ReadyReplicas != 0 {
+ t.Fatalf("expected missing deployment to report replicas=1 ready=0, got replicas=%d ready=%d", status.Replicas, status.ReadyReplicas)
}
- if status.Ref != "Deployment/test-web-server" {
- t.Fatalf("expected missing deployment ref, got %s", status.Ref)
+ if !hasComponentResource(status.Resources, "Deployment", "test-web-server", "Missing") {
+ t.Fatalf("expected Deployment/test-web-server resource Missing, got %#v", status.Resources)
}
deploy := &appsv1.Deployment{}
@@ -219,8 +219,8 @@ func TestDrainedComponentStatusUsesValidResourceStatus(t *testing.T) {
if status.Phase != componentPhaseDrained {
t.Fatalf("expected drained phase, got %q", status.Phase)
}
- if status.Ready != "0/1" {
- t.Fatalf("expected drained ready 0/1, got %q", status.Ready)
+ if status.Replicas != 1 {
+ t.Fatalf("expected drained desired replicas 1, got %d", status.Replicas)
}
if len(status.Resources) != 1 {
t.Fatalf("expected deployment resource status, got %d resources", len(status.Resources))
@@ -255,14 +255,12 @@ func TestUpdateLifecycleComponentStatusCountsOnlyWebServerUnavailableDuringMaint
if superset.Status.Ready != "1/2" {
t.Fatalf("expected aggregate Ready=1/2 during maintenance, got %q", superset.Status.Ready)
}
- if superset.Status.Components.WebServer.Ready != "0/1" {
- t.Fatalf("expected web-server ready 0/1 during maintenance, got %q", superset.Status.Components.WebServer.Ready)
- }
if superset.Status.Components.WebServer.ReadyReplicas != 0 {
t.Fatalf("expected web-server ready replicas to be suppressed during maintenance, got %d", superset.Status.Components.WebServer.ReadyReplicas)
}
- if superset.Status.Components.CeleryWorker.Ready != "1/1" {
- t.Fatalf("expected celery worker ready 1/1 during maintenance, got %q", superset.Status.Components.CeleryWorker.Ready)
+ if superset.Status.Components.CeleryWorker.ReadyReplicas != 1 || superset.Status.Components.CeleryWorker.Replicas != 1 {
+ t.Fatalf("expected celery worker 1/1 ready replicas during maintenance, got %d/%d",
+ superset.Status.Components.CeleryWorker.ReadyReplicas, superset.Status.Components.CeleryWorker.Replicas)
}
if !hasConditionReason(superset.Status.Conditions, supersetv1alpha1.ConditionTypeAvailable, "MaintenanceActive") {
t.Fatalf("expected Available condition reason MaintenanceActive, got %#v", superset.Status.Conditions)
@@ -405,3 +403,12 @@ func hasConditionReason(conditions []metav1.Condition, conditionType, reason str
}
return false
}
+
+func hasComponentResource(resources []supersetv1alpha1.ComponentResourceStatus, kind, name, status string) bool {
+ for _, r := range resources {
+ if r.Kind == kind && r.Name == name && r.Status == status {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/controller/superset_controller.go b/internal/controller/superset_controller.go
index ae62092..8df988f 100644
--- a/internal/controller/superset_controller.go
+++ b/internal/controller/superset_controller.go
@@ -59,7 +59,7 @@ type SupersetReconciler struct {
// +kubebuilder:rbac:groups=superset.apache.org,resources=supersets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch;update
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;delete
+// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
@@ -159,6 +159,13 @@ func (r *SupersetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
if statusErr := patchStatusIfChanged(ctx, r.Client, superset, origSuperset, origSuperset.Status, superset.Status); statusErr != nil {
return ctrl.Result{}, fmt.Errorf("updating status after lifecycle: %w", statusErr)
}
+ // Clear the supervised upgrade approval annotation only after status has
+ // been persisted. Doing this earlier risks stranding the annotation if the
+ // status patch fails — the next reconcile would see imageChanged=true and
+ // re-gate AwaitingApproval despite the user already approving.
+ if err := r.clearUpgradeApprovalAnnotation(ctx, superset); err != nil {
+ return ctrl.Result{}, err
+ }
if err := r.cleanupLifecycleTaskJobsByRetention(ctx, superset); err != nil {
return ctrl.Result{}, fmt.Errorf("cleaning up lifecycle task jobs after lifecycle: %w", err)
}
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index a2a219a..baa6865 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -303,10 +303,11 @@ spec:
By("verifying parent status reports the web-server component")
Eventually(func(g Gomega) {
cmd := exec.Command("kubectl", "get", "superset",
- crName, "-n", namespace, "-o", "jsonpath={.status.components.webServer.ref}")
+ crName, "-n", namespace,
+ "-o", "jsonpath={.status.components.webServer.resources[?(@.kind==\"Deployment\")].name}")
output, err := utils.Run(cmd)
g.Expect(err).NotTo(HaveOccurred())
- g.Expect(output).To(Equal("Deployment/" + crName + "-web-server"))
+ g.Expect(output).To(Equal(crName + "-web-server"))
}, 30*time.Second, time.Second).Should(Succeed())
By("verifying the ConfigMap contains operator-generated content")