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/22 22:11:47 UTC

(superset-kubernetes-operator) branch main updated: fix(api,controller,docs): tighten lifecycle, CEL, and labeling (#72)

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 3821c03  fix(api,controller,docs): tighten lifecycle, CEL, and labeling (#72)
3821c03 is described below

commit 3821c031583dd45a539edef0098886bfced89bfa
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Fri May 22 15:11:41 2026 -0700

    fix(api,controller,docs): tighten lifecycle, CEL, and labeling (#72)
---
 api/v1alpha1/shared_types.go                       |  24 +++
 api/v1alpha1/superset_types.go                     |  16 +-
 api/v1alpha1/zz_generated.deepcopy.go              |  24 ++-
 .../crds/superset.apache.org_supersets.yaml        |  16 +-
 .../crd/bases/superset.apache.org_supersets.yaml   |  16 +-
 docs/architecture/internals.md                     |  13 +-
 docs/reference/api-reference.md                    |  30 ++-
 docs/user-guide/configuration.md                   |   1 +
 internal/controller/clone_test.go                  |  69 ++++++-
 internal/controller/component_descriptors.go       |   4 +-
 internal/controller/component_reconciler.go        |   1 +
 internal/controller/drain_test.go                  |   6 +-
 internal/controller/lifecycle.go                   |   5 +-
 internal/controller/lifecycle_clone.go             |  39 +++-
 internal/controller/lifecycle_init.go              |  50 +++--
 internal/controller/lifecycle_init_test.go         | 220 +++++++++++++++++++++
 internal/controller/lifecycle_taskdescriptor.go    |   4 +-
 internal/controller/lifecycle_taskspec.go          |  34 ++--
 internal/controller/maintenance.go                 |   8 +-
 internal/controller/maintenance_test.go            |  46 +++++
 .../controller/reconcile_parent_resources_test.go  |  57 +++++-
 internal/controller/superset_controller.go         |   5 +-
 internal/controller/test_helpers_test.go           |   4 +-
 23 files changed, 591 insertions(+), 101 deletions(-)

diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go
index 9e0fe38..72384d1 100644
--- a/api/v1alpha1/shared_types.go
+++ b/api/v1alpha1/shared_types.go
@@ -70,6 +70,30 @@ type ImageOverrideSpec struct {
 	Repository *string `json:"repository,omitempty"`
 }
 
+// ContainerImageSpec defines a generic container image. Unlike ImageSpec, it
+// has no Superset-specific repository default — the operator selects a
+// context-appropriate default at reconcile time when fields are omitted (e.g.,
+// `nginx:alpine` for the maintenance page, `postgres:17-alpine` /
+// `mysql:8-alpine` for the clone Job). Use this type for non-Superset images.
+type ContainerImageSpec struct {
+	// Container image repository.
+	// +optional
+	Repository string `json:"repository,omitempty"`
+
+	// Image tag.
+	// +optional
+	Tag string `json:"tag,omitempty"`
+
+	// Image pull policy (IfNotPresent, Always, Never).
+	// +optional
+	// +kubebuilder:default=IfNotPresent
+	PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"`
+
+	// References to Secrets for pulling images from private registries.
+	// +optional
+	PullSecrets []corev1.LocalObjectReference `json:"pullSecrets,omitempty"`
+}
+
 // --- Metastore types ---
 
 // MetastoreSpec defines the database connection for Superset's metastore.
diff --git a/api/v1alpha1/superset_types.go b/api/v1alpha1/superset_types.go
index ab7244e..00ccd3c 100644
--- a/api/v1alpha1/superset_types.go
+++ b/api/v1alpha1/superset_types.go
@@ -36,12 +36,12 @@ import (
 // +kubebuilder:validation:XValidation:rule="!has(self.networking) || !has(self.networking.ingress) || has(self.webServer)",message="spec.networking.ingress requires spec.webServer to be set (all Ingress rules target the web server service)"
 // +kubebuilder:validation:XValidation:rule="!has(self.networking) || !has(self.networking.gateway) || has(self.webServer) || has(self.websocketServer) || has(self.mcpServer) || has(self.celeryFlower)",message="spec.networking.gateway requires at least one component with a routable service (webServer, websocketServer, mcpServer, or celeryFlower)"
 // +kubebuilder:validation:XValidation:rule="!has(self.monitoring) || !has(self.monitoring.serviceMonitor) || has(self.webServer)",message="spec.monitoring.serviceMonitor requires spec.webServer to be set (scrapes the web server service)"
-// +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' || self.environment == 'Staging')) || !has(self.lifecycle) || !has(self.lifecycle.clone) || (has(self.lifecycle.clone.disabled) && self.lifecycle.clone.disabled)",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.lifecycle) || !has(self.lifecycle.clone) || (has(self.lifecycle.clone.disabled) && self.lifecycle.clone.disabled) || (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"
+// +kubebuilder:validation:XValidation:rule="!has(self.lifecycle) || !has(self.lifecycle.rotate) || (has(self.lifecycle.rotate.disabled) && self.lifecycle.rotate.disabled) || 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"`
@@ -488,9 +488,10 @@ type MaintenancePageSpec struct {
 	// for serving HTTP traffic on the web-server port (default 8088). The port
 	// must match the web-server Service's target port since the maintenance page
 	// takes over that Service during lifecycle tasks.
-	// When unset, defaults to nginx:alpine (managed mode).
+	// When unset, defaults to nginx:alpine (managed mode). Partial specs (e.g.,
+	// only `tag` set) inherit the nginx default for the omitted fields.
 	// +optional
-	Image *ImageSpec `json:"image,omitempty"`
+	Image *ContainerImageSpec `json:"image,omitempty"`
 
 	// Number of maintenance page pod replicas.
 	// +optional
@@ -536,9 +537,10 @@ type CloneTaskSpec struct {
 	PostCloneSQL []string `json:"postCloneSQL,omitempty"`
 
 	// Image for the clone Job. Defaults to postgres:17-alpine (PostgreSQL)
-	// or mysql:8-alpine (MySQL) based on source.type.
+	// or mysql:8-alpine (MySQL) based on source.type. Partial specs (e.g.,
+	// only `tag` set) inherit the type-appropriate default for omitted fields.
 	// +optional
-	Image *ImageSpec `json:"image,omitempty"`
+	Image *ContainerImageSpec `json:"image,omitempty"`
 
 	// Pod and container template for the clone task Job.
 	// +optional
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 3e77f44..56890bf 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -378,7 +378,7 @@ func (in *CloneTaskSpec) DeepCopyInto(out *CloneTaskSpec) {
 	}
 	if in.Image != nil {
 		in, out := &in.Image, &out.Image
-		*out = new(ImageSpec)
+		*out = new(ContainerImageSpec)
 		(*in).DeepCopyInto(*out)
 	}
 	if in.PodTemplate != nil {
@@ -547,6 +547,26 @@ func (in *ComponentStatusMap) DeepCopy() *ComponentStatusMap {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ContainerImageSpec) DeepCopyInto(out *ContainerImageSpec) {
+	*out = *in
+	if in.PullSecrets != nil {
+		in, out := &in.PullSecrets, &out.PullSecrets
+		*out = make([]v1.LocalObjectReference, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerImageSpec.
+func (in *ContainerImageSpec) DeepCopy() *ContainerImageSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ContainerImageSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ContainerTemplate) DeepCopyInto(out *ContainerTemplate) {
 	*out = *in
@@ -1112,7 +1132,7 @@ func (in *MaintenancePageSpec) DeepCopyInto(out *MaintenancePageSpec) {
 	}
 	if in.Image != nil {
 		in, out := &in.Image, &out.Image
-		*out = new(ImageSpec)
+		*out = new(ContainerImageSpec)
 		(*in).DeepCopyInto(*out)
 	}
 	if in.Replicas != nil {
diff --git a/charts/superset-operator/crds/superset.apache.org_supersets.yaml b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
index 5c3f316..03b5701 100644
--- a/charts/superset-operator/crds/superset.apache.org_supersets.yaml
+++ b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
@@ -12020,13 +12020,9 @@ spec:
                               x-kubernetes-map-type: atomic
                             type: array
                           repository:
-                            default: apachesuperset.docker.scarf.sh/apache/superset
                             type: string
                           tag:
-                            minLength: 1
                             type: string
-                        required:
-                        - tag
                         type: object
                       maxRetries:
                         default: 3
@@ -15767,13 +15763,9 @@ spec:
                               x-kubernetes-map-type: atomic
                             type: array
                           repository:
-                            default: apachesuperset.docker.scarf.sh/apache/superset
                             type: string
                           tag:
-                            minLength: 1
                             type: string
-                        required:
-                        - tag
                         type: object
                       message:
                         type: string
@@ -39182,6 +39174,7 @@ spec:
                 metastore
               rule: (has(self.environment) && (self.environment == 'Development' ||
                 self.environment == 'Staging')) || !has(self.lifecycle) || !has(self.lifecycle.clone)
+                || (has(self.lifecycle.clone.disabled) && self.lifecycle.clone.disabled)
             - message: lifecycle.clone.source.password is only allowed when environment
                 is Development; use lifecycle.clone.source.passwordFrom in Staging
               rule: (has(self.environment) && self.environment == 'Development') ||
@@ -39189,8 +39182,8 @@ spec:
                 || !has(self.lifecycle.clone.source.password)
             - message: lifecycle.clone requires structured metastore configuration
                 (host must be set)
-              rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || (has(self.metastore)
-                && has(self.metastore.host))'
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || (has(self.lifecycle.clone.disabled)
+                && self.lifecycle.clone.disabled) || (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') ||
@@ -39199,7 +39192,8 @@ spec:
               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)
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.rotate) || (has(self.lifecycle.rotate.disabled)
+                && self.lifecycle.rotate.disabled) || has(self.previousSecretKey)
                 || has(self.previousSecretKeyFrom)'
           status:
             properties:
diff --git a/config/crd/bases/superset.apache.org_supersets.yaml b/config/crd/bases/superset.apache.org_supersets.yaml
index 5c3f316..03b5701 100644
--- a/config/crd/bases/superset.apache.org_supersets.yaml
+++ b/config/crd/bases/superset.apache.org_supersets.yaml
@@ -12020,13 +12020,9 @@ spec:
                               x-kubernetes-map-type: atomic
                             type: array
                           repository:
-                            default: apachesuperset.docker.scarf.sh/apache/superset
                             type: string
                           tag:
-                            minLength: 1
                             type: string
-                        required:
-                        - tag
                         type: object
                       maxRetries:
                         default: 3
@@ -15767,13 +15763,9 @@ spec:
                               x-kubernetes-map-type: atomic
                             type: array
                           repository:
-                            default: apachesuperset.docker.scarf.sh/apache/superset
                             type: string
                           tag:
-                            minLength: 1
                             type: string
-                        required:
-                        - tag
                         type: object
                       message:
                         type: string
@@ -39182,6 +39174,7 @@ spec:
                 metastore
               rule: (has(self.environment) && (self.environment == 'Development' ||
                 self.environment == 'Staging')) || !has(self.lifecycle) || !has(self.lifecycle.clone)
+                || (has(self.lifecycle.clone.disabled) && self.lifecycle.clone.disabled)
             - message: lifecycle.clone.source.password is only allowed when environment
                 is Development; use lifecycle.clone.source.passwordFrom in Staging
               rule: (has(self.environment) && self.environment == 'Development') ||
@@ -39189,8 +39182,8 @@ spec:
                 || !has(self.lifecycle.clone.source.password)
             - message: lifecycle.clone requires structured metastore configuration
                 (host must be set)
-              rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || (has(self.metastore)
-                && has(self.metastore.host))'
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.clone) || (has(self.lifecycle.clone.disabled)
+                && self.lifecycle.clone.disabled) || (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') ||
@@ -39199,7 +39192,8 @@ spec:
               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)
+              rule: '!has(self.lifecycle) || !has(self.lifecycle.rotate) || (has(self.lifecycle.rotate.disabled)
+                && self.lifecycle.rotate.disabled) || has(self.previousSecretKey)
                 || has(self.previousSecretKeyFrom)'
           status:
             properties:
diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md
index d121851..9d573d5 100644
--- a/docs/architecture/internals.md
+++ b/docs/architecture/internals.md
@@ -262,10 +262,15 @@ parent `Superset` name, for selector matching.
 
 ### Orphan Cleanup
 
-When a component is disabled, the operator uses label-based discovery to find
-and delete parent-owned resources for that component. On each reconcile, it
-lists matching Deployments, Services, ConfigMaps, HPAs, and PDBs by parent and
-component labels, then deletes resources that are no longer desired.
+When a component is removed from the parent spec, the parent reconciler invokes
+the component descriptor's cleanup path, which deletes the component's
+parent-owned resources (Deployment, Service, ConfigMap, HPA, PDB) by their
+deterministic `{parent}-{component}` names. The parent CR's owner references
+also cascade-delete the same resources if the CR itself is deleted.
+
+Operator-managed labels remain available on parent-owned resources for human
+discovery — e.g. `kubectl get deploy,svc,cm,hpa,pdb -l
+app.kubernetes.io/instance=<parent>` lists every parent-owned workload.
 
 ---
 
diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md
index bd13415..62da60a 100644
--- a/docs/reference/api-reference.md
+++ b/docs/reference/api-reference.md
@@ -247,7 +247,7 @@ _Appears in:_
 | `excludeTables` _string array_ | Tables to exclude entirely from the dump (schema and data). |  | Optional: \{\} <br /> |
 | `excludeTableData` _string array_ | Tables where schema is dumped but data is not. Useful for large tables<br />needed by migrations but not for testing (e.g., "logs", "query"). |  | Optional: \{\} <br /> |
 | `postCloneSQL` _string array_ | SQL statements to execute against the target database after cloning.<br />Useful for sanitizing cloned data (e.g., disabling alerts, deleting<br />OAuth tokens, masking PII). |  | Optional: \{\} <br /> |
-| `image` _[ImageSpec](#imagespec)_ | Image for the clone Job. Defaults to postgres:17-alpine (PostgreSQL)<br />or mysql:8-alpine (MySQL) based on source.type. |  | Optional: \{\} <br /> |
+| `image` _[ContainerImageSpec](#containerimagespec)_ | Image for the clone Job. Defaults to postgres:17-alpine (PostgreSQL)<br />or mysql:8-alpine (MySQL) based on source.type. Partial specs (e.g.,<br />only `tag` set) inherit the type-appropriate default for omitted fields. |  | Optional: \{\} <br /> |
 | `podTemplate` _[PodTemplate](#podtemplate)_ | Pod and container template for the clone task Job. |  | Optional: \{\} <br /> |
 | `podRetention` _[PodRetentionSpec](#podretentionspec)_ | Retention policy for completed clone Jobs and their Pods. |  | Optional: \{\} <br /> |
 
@@ -362,6 +362,30 @@ _Appears in:_
 | `mcpServer` _[ComponentRefStatus](#componentrefstatus)_ |  |  | Optional: \{\} <br /> |
 
 
+#### ContainerImageSpec
+
+
+
+ContainerImageSpec defines a generic container image. Unlike ImageSpec, it
+has no Superset-specific repository default — the operator selects a
+context-appropriate default at reconcile time when fields are omitted (e.g.,
+`nginx:alpine` for the maintenance page, `postgres:17-alpine` /
+`mysql:8-alpine` for the clone Job). Use this type for non-Superset images.
+
+
+
+_Appears in:_
+- [CloneTaskSpec](#clonetaskspec)
+- [MaintenancePageSpec](#maintenancepagespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `repository` _string_ | Container image repository. |  | Optional: \{\} <br /> |
+| `tag` _string_ | Image tag. |  | Optional: \{\} <br /> |
+| `pullPolicy` _[PullPolicy](https://pkg.go.dev/k8s.io/api/core/v1#PullPolicy)_ | Image pull policy (IfNotPresent, Always, Never). | IfNotPresent | Optional: \{\} <br /> |
+| `pullSecrets` _[LocalObjectReference](https://pkg.go.dev/k8s.io/api/core/v1#LocalObjectReference) array_ | References to Secrets for pulling images from private registries. |  | Optional: \{\} <br /> |
+
+
 #### ContainerTemplate
 
 
@@ -499,8 +523,6 @@ ImageSpec defines the container image configuration.
 
 
 _Appears in:_
-- [CloneTaskSpec](#clonetaskspec)
-- [MaintenancePageSpec](#maintenancepagespec)
 - [SupersetSpec](#supersetspec)
 
 | Field | Description | Default | Validation |
@@ -663,7 +685,7 @@ _Appears in:_
 | `title` _string_ | Title displayed on the maintenance page heading (managed mode).<br />In custom mode, passed as env var SUPERSET_OPERATOR__MAINTENANCE_TITLE. |  | Optional: \{\} <br /> |
 | `message` _string_ | Message displayed below the title (managed mode).<br />In custom mode, passed as env var SUPERSET_OPERATOR__MAINTENANCE_MESSAGE. |  | Optional: \{\} <br /> |
 | `body` _string_ | Full HTML page content. When set in managed mode, title and message are<br />ignored and this value is served as the complete page.<br />In custom mode, passed as env var SUPERSET_OPERATOR__MAINTENANCE_BODY. |  | Optional: \{\} <br /> |
-| `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 /> |
+| `image` _[ContainerImageSpec](#containerimagespec)_ | 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). Partial specs (e.g., [...]
 | `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. |  | Optional: \{\} <br /> |
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 586162b..8eec05d 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -847,6 +847,7 @@ top-level for shared entries and component-level for component-specific ones.
 | `env` | Environment variables (merged by name) |
 | `envFrom` | ConfigMap/Secret env sources (appended) |
 | `volumeMounts` | Volume mounts (merged by name) |
+| `ports` | Container ports (replaces operator defaults when set; the first resolved port is used as the Service `targetPort` and as the ingress port for the operator-managed NetworkPolicy) |
 | `securityContext` | Container-level security context |
 | `command` | Container entrypoint (no inheritance) |
 | `args` | Container arguments (no inheritance) |
diff --git a/internal/controller/clone_test.go b/internal/controller/clone_test.go
index 57257a2..227f6b1 100644
--- a/internal/controller/clone_test.go
+++ b/internal/controller/clone_test.go
@@ -439,7 +439,7 @@ func TestResolveCloneImage_CustomOverride(t *testing.T) {
 			Database: "superset_prod",
 			Username: "reader",
 		},
-		Image: &supersetv1alpha1.ImageSpec{
+		Image: &supersetv1alpha1.ContainerImageSpec{
 			Repository: "my-registry/custom-tools",
 			Tag:        "v2",
 		},
@@ -452,6 +452,61 @@ func TestResolveCloneImage_CustomOverride(t *testing.T) {
 	}
 }
 
+// TestResolveCloneImage_PartialOverride asserts that an image spec with only a
+// tag set inherits the type-appropriate database tooling repository, not the
+// Superset image repository — which is the bug ContainerImageSpec was
+// introduced to prevent.
+func TestResolveCloneImage_PartialOverride(t *testing.T) {
+	tests := []struct {
+		name         string
+		srcType      string
+		image        *supersetv1alpha1.ContainerImageSpec
+		expectedRepo string
+		expectedTag  string
+	}{
+		{
+			name:         "tag-only override on PostgreSQL inherits postgres repo",
+			srcType:      dbTypePostgresql,
+			image:        &supersetv1alpha1.ContainerImageSpec{Tag: "16-alpine"},
+			expectedRepo: "postgres",
+			expectedTag:  "16-alpine",
+		},
+		{
+			name:         "tag-only override on MySQL inherits mysql repo",
+			srcType:      dbTypeMySQL,
+			image:        &supersetv1alpha1.ContainerImageSpec{Tag: "9-alpine"},
+			expectedRepo: "mysql",
+			expectedTag:  "9-alpine",
+		},
+		{
+			name:         "repository-only override on PostgreSQL inherits default tag",
+			srcType:      dbTypePostgresql,
+			image:        &supersetv1alpha1.ContainerImageSpec{Repository: "my-registry/postgres"},
+			expectedRepo: "my-registry/postgres",
+			expectedTag:  "17-alpine",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srcType := tt.srcType
+			clone := &supersetv1alpha1.CloneTaskSpec{
+				Source: supersetv1alpha1.CloneSourceSpec{
+					Host:     "src.svc",
+					Database: "src",
+					Username: "reader",
+					Type:     &srcType,
+				},
+				Image: tt.image,
+			}
+			img := resolveCloneImage(clone)
+			if img.Repository != tt.expectedRepo || img.Tag != tt.expectedTag {
+				t.Errorf("expected %s:%s, got %s:%s", tt.expectedRepo, tt.expectedTag, img.Repository, img.Tag)
+			}
+		})
+	}
+}
+
 func TestIsTaskEnabled(t *testing.T) {
 	r := &SupersetReconciler{}
 
@@ -1163,7 +1218,7 @@ func TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
 	migrateCmd := defaultMigrateCommand(superset)
 	migrateChecksum := r.computeStepChecksum(incomingChecksum, taskTypeMigrate, migrateCmd, r.migrateInputs(superset))
 	initCmd := defaultInitCommand(superset)
-	initChecksum := r.computeStepChecksum(migrateChecksum, taskTypeInit, initCmd, r.initInputs(superset, configChecksum))
+	initChecksum := r.computeStepChecksum(migrateChecksum, taskTypeInit, initCmd, r.initInputs(superset))
 
 	superset.Status.Lifecycle = &supersetv1alpha1.LifecycleStatus{
 		LastCompletedChecksums: map[string]string{
@@ -1179,8 +1234,10 @@ func TestAllTasksStillComplete_SkipsDrainWhenNothingChanged(t *testing.T) {
 	})
 
 	t.Run("returns false when config changes", func(t *testing.T) {
-		if r.allTasksStillComplete(superset, "config-changed") {
-			t.Error("expected allTasksStillComplete=false when config checksum changed")
+		modified := superset.DeepCopy()
+		modified.Spec.FeatureFlags = map[string]bool{"ALERT_REPORTS": true}
+		if r.allTasksStillComplete(modified, configChecksum) {
+			t.Error("expected allTasksStillComplete=false when featureFlags change the rendered config")
 		}
 	})
 
@@ -1239,7 +1296,7 @@ func TestAllTasksStillComplete_WithCloneSchedule(t *testing.T) {
 	migrateCmd := defaultMigrateCommand(superset)
 	migrateChecksum := r.computeStepChecksum(cloneChecksum, taskTypeMigrate, migrateCmd, r.migrateInputs(superset))
 	initCmd := defaultInitCommand(superset)
-	initChecksum := r.computeStepChecksum(migrateChecksum, taskTypeInit, initCmd, r.initInputs(superset, configChecksum))
+	initChecksum := r.computeStepChecksum(migrateChecksum, taskTypeInit, initCmd, r.initInputs(superset))
 
 	superset.Status.Lifecycle = &supersetv1alpha1.LifecycleStatus{
 		LastCompletedChecksums: map[string]string{
@@ -1443,7 +1500,7 @@ func TestAllTasksStillComplete_WithRotate(t *testing.T) {
 	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))
+	initChecksum := r.computeStepChecksum(rotateChecksum, taskTypeInit, initCmd, r.initInputs(superset))
 
 	superset.Status.Lifecycle = &supersetv1alpha1.LifecycleStatus{
 		LastCompletedChecksums: map[string]string{
diff --git a/internal/controller/component_descriptors.go b/internal/controller/component_descriptors.go
index e5c7934..044709b 100644
--- a/internal/controller/component_descriptors.go
+++ b/internal/controller/component_descriptors.go
@@ -200,7 +200,7 @@ func (r *SupersetReconciler) reconcileComponent(
 		operatorInjected = buildOperatorInjected(renderedConfig, resourceBaseName, superset.Spec.ForceReload, secretEnvVars)
 
 		// Create/update the component ConfigMap.
-		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, renderedConfig, resourceBaseName); err != nil {
+		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, renderedConfig, resourceBaseName, componentLabels(string(desc.componentType), superset.Name)); err != nil {
 			return fmt.Errorf("reconciling ConfigMap for %s: %w", desc.componentType, err)
 		}
 
@@ -286,7 +286,7 @@ func (r *SupersetReconciler) deleteComponentResources(ctx context.Context, super
 	}
 
 	if desc.hasPythonConfig {
-		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, "", resourceBaseName); err != nil {
+		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, "", resourceBaseName, nil); err != nil {
 			return fmt.Errorf("deleting ConfigMap for disabled %s: %w", desc.componentType, err)
 		}
 	}
diff --git a/internal/controller/component_reconciler.go b/internal/controller/component_reconciler.go
index 0f937ec..43879db 100644
--- a/internal/controller/component_reconciler.go
+++ b/internal/controller/component_reconciler.go
@@ -117,6 +117,7 @@ func reconcileComponentDeployment(
 		if err := controllerutil.SetControllerReference(owner, deploy, scheme); err != nil {
 			return err
 		}
+		deploy.Labels = mergeLabels(deploy.Labels, labels)
 		deploy.Spec = buildDeploymentSpec(spec, cfg, checksumAnnotations, labels)
 		return nil
 	})
diff --git a/internal/controller/drain_test.go b/internal/controller/drain_test.go
index 478adc2..4235996 100644
--- a/internal/controller/drain_test.go
+++ b/internal/controller/drain_test.go
@@ -337,7 +337,7 @@ func TestDrainIfNeededSkipsWhenOnlyNonDrainInitWillRun(t *testing.T) {
 		},
 	}
 	superset.Status.Lifecycle = &supersetv1alpha1.LifecycleStatus{
-		LastCompletedChecksums: completedLifecycleChecksums(r, superset, "old-config"),
+		LastCompletedChecksums: completedLifecycleChecksums(r, superset),
 	}
 	pod := &corev1.Pod{
 		ObjectMeta: metav1.ObjectMeta{
@@ -369,7 +369,7 @@ func TestDrainIfNeededSkipsWhenOnlyNonDrainInitWillRun(t *testing.T) {
 	assertNoEvents(t, recorder)
 }
 
-func completedLifecycleChecksums(r *SupersetReconciler, superset *supersetv1alpha1.Superset, configChecksum string) map[string]string {
+func completedLifecycleChecksums(r *SupersetReconciler, superset *supersetv1alpha1.Superset) map[string]string {
 	checksums := make(map[string]string)
 	incomingChecksum := string(superset.UID)
 
@@ -382,7 +382,7 @@ func completedLifecycleChecksums(r *SupersetReconciler, superset *supersetv1alph
 	incomingChecksum = checksums[taskTypeMigrate]
 
 	initCmd := defaultInitCommand(superset)
-	checksums[taskTypeInit] = r.computeStepChecksum(incomingChecksum, taskTypeInit, initCmd, r.initInputs(superset, configChecksum))
+	checksums[taskTypeInit] = r.computeStepChecksum(incomingChecksum, taskTypeInit, initCmd, r.initInputs(superset))
 
 	return checksums
 }
diff --git a/internal/controller/lifecycle.go b/internal/controller/lifecycle.go
index 6faab49..41ae580 100644
--- a/internal/controller/lifecycle.go
+++ b/internal/controller/lifecycle.go
@@ -59,6 +59,7 @@ 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"
 )
 
@@ -498,7 +499,7 @@ func (r *SupersetReconciler) reconcileLifecycleTask(
 
 	// Create the ConfigMap before the task Pod (only for tasks that need Python config).
 	if renderedConfig != "" {
-		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, renderedConfig, taskName); err != nil {
+		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, renderedConfig, taskName, componentLabels(string(naming.ComponentInit), taskName)); err != nil {
 			return lifecycleResult{}, fmt.Errorf("reconciling ConfigMap for lifecycle task %s: %w", taskName, err)
 		}
 	}
@@ -581,7 +582,7 @@ func (r *SupersetReconciler) deleteLifecycleTaskResources(ctx context.Context, s
 		return err
 	}
 	if taskType != taskTypeClone {
-		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, "", taskName); err != nil {
+		if err := reconcileParentOwnedConfigMap(ctx, r.Client, r.Scheme, superset, "", taskName, nil); err != nil {
 			return err
 		}
 	}
diff --git a/internal/controller/lifecycle_clone.go b/internal/controller/lifecycle_clone.go
index 495c568..a0c33ca 100644
--- a/internal/controller/lifecycle_clone.go
+++ b/internal/controller/lifecycle_clone.go
@@ -169,21 +169,44 @@ func collectCloneEnvVars(superset *supersetv1alpha1.Superset) []corev1.EnvVar {
 	return envs
 }
 
-// resolveCloneImage determines the image for the clone pod.
+// resolveCloneImage determines the image for the clone pod. Defaults are
+// type-aware: postgres:17-alpine for PostgreSQL sources, mysql:8-alpine for
+// MySQL. Partial user specs inherit the default repository or tag for omitted
+// fields rather than the Superset image (which would be incorrect for a
+// database tooling container).
 func resolveCloneImage(clone *supersetv1alpha1.CloneTaskSpec) supersetv1alpha1.ImageSpec {
-	if clone.Image != nil {
-		return *clone.Image
-	}
 	srcType := dbTypePostgresql
 	if clone.Source.Type != nil {
 		srcType = *clone.Source.Type
 	}
+	defaultRef := naming.CloneImagePostgres
 	if srcType == dbTypeMySQL {
-		repo, tag := splitImageRef(naming.CloneImageMySQL)
-		return supersetv1alpha1.ImageSpec{Repository: repo, Tag: tag}
+		defaultRef = naming.CloneImageMySQL
+	}
+	defRepo, defTag := splitImageRef(defaultRef)
+	return resolveContainerImage(clone.Image, defRepo, defTag)
+}
+
+// resolveContainerImage merges a user-provided ContainerImageSpec with
+// context-specific defaults. Fields the user set win; omitted fields fall back
+// to the supplied defaults.
+func resolveContainerImage(spec *supersetv1alpha1.ContainerImageSpec, defaultRepo, defaultTag string) supersetv1alpha1.ImageSpec {
+	if spec == nil {
+		return supersetv1alpha1.ImageSpec{Repository: defaultRepo, Tag: defaultTag}
+	}
+	img := supersetv1alpha1.ImageSpec{
+		Repository:  spec.Repository,
+		Tag:         spec.Tag,
+		PullPolicy:  spec.PullPolicy,
+		PullSecrets: spec.PullSecrets,
+	}
+	if img.Repository == "" {
+		img.Repository = defaultRepo
+	}
+	if img.Tag == "" {
+		img.Tag = defaultTag
 	}
-	repo, tag := splitImageRef(naming.CloneImagePostgres)
-	return supersetv1alpha1.ImageSpec{Repository: repo, Tag: tag}
+	return img
 }
 
 func splitImageRef(ref string) (string, string) {
diff --git a/internal/controller/lifecycle_init.go b/internal/controller/lifecycle_init.go
index febb4df..d6f12c9 100644
--- a/internal/controller/lifecycle_init.go
+++ b/internal/controller/lifecycle_init.go
@@ -19,30 +19,56 @@ limitations under the License.
 package controller
 
 import (
+	corev1 "k8s.io/api/core/v1"
+
 	supersetv1alpha1 "github.com/apache/superset-kubernetes-operator/api/v1alpha1"
 )
 
-// initInputs returns the init-specific inputs that contribute to its step
-// checksum. Init is the config-sensitive task: rendered superset_config.py
-// changes propagate via configChecksum so feature/config changes re-run init
-// without re-running the upstream migrate/rotate steps.
-func (r *SupersetReconciler) initInputs(superset *supersetv1alpha1.Superset, configChecksum string) any {
-	currentImage := resolveLifecycleImage(&superset.Spec.Image, lifecycleImageOverride(superset))
+// initInputs returns the inputs that contribute to init's task checksum.
+//
+// Init re-runs whenever the lifecycle Job's mounted artifacts change:
+//   - Image: a different binary may produce different init results
+//   - ConfigHash: rendered superset_config.py — covers featureFlags, celery,
+//     lifecycle.config, lifecycle sqlaEngineOptions, valkey cache layout, etc.
+//   - EnvHash: spec-derived env vars injected into the Job pod — covers
+//     secret/metastore/valkey refs and lifecycle admin user credentials.
+//     These are env-only because the rendered Python references env var
+//     *names*, not values; without this hash, changing spec.metastore.host
+//     would silently leave init pointed at the wrong database.
+//   - Trigger: the manual `lifecycle.init.trigger` opaque string.
+//
+// Hashing the rendered config and resolved env vars directly — rather than a
+// hand-curated list of spec fields — keeps the checksum honest as new
+// config-rendering or env-injection fields are added.
+func (r *SupersetReconciler) initInputs(superset *supersetv1alpha1.Superset) any {
 	trigger := ""
 	if superset.Spec.Lifecycle != nil && superset.Spec.Lifecycle.Init != nil {
 		trigger = derefOrDefault(superset.Spec.Lifecycle.Init.Trigger, "")
 	}
 	return struct {
-		Image          string
-		ConfigChecksum string
-		Trigger        string
+		Image      string
+		ConfigHash string
+		EnvHash    string
+		Trigger    string
 	}{
-		Image:          currentImage,
-		ConfigChecksum: configChecksum,
-		Trigger:        trigger,
+		Image:      resolveLifecycleImage(&superset.Spec.Image, lifecycleImageOverride(superset)),
+		ConfigHash: computeChecksum(renderLifecycleTaskConfig(superset)),
+		EnvHash:    computeChecksum(initTaskEnv(superset)),
+		Trigger:    trigger,
 	}
 }
 
+// initTaskEnv returns the spec-derived env vars injected into the init task
+// pod. The slice mirrors what buildStandardTaskFlatSpec assembles via
+// buildOperatorInjected for taskType == taskTypeInit. forceReload is
+// intentionally excluded — it is a per-component rollout knob, not a lifecycle
+// re-run signal, and the prior configChecksum-based behavior also did not
+// re-run init on forceReload changes.
+func initTaskEnv(superset *supersetv1alpha1.Superset) []corev1.EnvVar {
+	env := collectSecretEnvVars(&superset.Spec, superset.Name)
+	return append(env, collectLifecycleInitEnvVars(superset.Spec.Lifecycle)...)
+}
+
 // defaultInitCommand returns the user override, or the standard
 // `superset init` flow (with admin-user / load-examples appended in dev mode
 // via buildInitCommand).
diff --git a/internal/controller/lifecycle_init_test.go b/internal/controller/lifecycle_init_test.go
new file mode 100644
index 0000000..67936b7
--- /dev/null
+++ b/internal/controller/lifecycle_init_test.go
@@ -0,0 +1,220 @@
+/*
+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"
+
+	corev1 "k8s.io/api/core/v1"
+
+	supersetv1alpha1 "github.com/apache/superset-kubernetes-operator/api/v1alpha1"
+	"github.com/apache/superset-kubernetes-operator/internal/common"
+)
+
+// TestInitInputs_ChangesWithRenderedConfig asserts that init's checksum input
+// reflects every config-rendering field the lifecycle Job actually consumes.
+// The previous hand-picked configChecksum struct silently skipped init re-runs
+// when fields like featureFlags or celery changed; hashing the rendered
+// superset_config.py forecloses that class of bug.
+func TestInitInputs_ChangesWithRenderedConfig(t *testing.T) {
+	r := &SupersetReconciler{}
+
+	base := func() *supersetv1alpha1.Superset {
+		s := &supersetv1alpha1.Superset{}
+		s.Name = "test"
+		s.Spec.Image = supersetv1alpha1.ImageSpec{Tag: "4.1.4"}
+		s.Spec.SecretKeyFrom = secretKeyRef("secret", "key")
+		s.Spec.Metastore = &supersetv1alpha1.MetastoreSpec{
+			Host:         common.Ptr("db.svc"),
+			Database:     common.Ptr("superset"),
+			Username:     common.Ptr("superset"),
+			PasswordFrom: secretKeyRef("db-secret", "password"),
+		}
+		// Valkey is required for Celery rendering — without it, the renderer
+		// emits no CELERY_CONFIG and celery imports never reach the config.
+		s.Spec.Valkey = &supersetv1alpha1.ValkeySpec{Host: "valkey.svc"}
+		s.Spec.Lifecycle = &supersetv1alpha1.LifecycleSpec{
+			Init: &supersetv1alpha1.InitTaskSpec{},
+		}
+		return s
+	}
+
+	baseline := r.initInputs(base())
+
+	tests := []struct {
+		name   string
+		mutate func(*supersetv1alpha1.Superset)
+	}{
+		// Config-rendering changes — covered by ConfigHash.
+		{
+			name: "featureFlags",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.FeatureFlags = map[string]bool{"ALERT_REPORTS": true}
+			},
+		},
+		{
+			name: "celery.imports",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Celery = &supersetv1alpha1.CelerySpec{
+					Imports: []string{"superset.tasks.cache"},
+				}
+			},
+		},
+		{
+			name: "lifecycle.config",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Lifecycle.Config = common.Ptr("EXTRA_LIFECYCLE = True")
+			},
+		},
+		{
+			name: "lifecycle.sqlaEngineOptions.preset=disabled",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Lifecycle.SQLAlchemyEngineOptions = &supersetv1alpha1.SQLAlchemyEngineOptionsSpec{
+					Preset: common.Ptr("disabled"),
+				}
+			},
+		},
+		{
+			name: "valkey.cache.database",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				v := int32(10)
+				s.Spec.Valkey.Cache = &supersetv1alpha1.ValkeyCacheSpec{Database: &v}
+			},
+		},
+
+		// Env-only changes — covered by EnvHash. The rendered Python references
+		// these via env var names (e.g. os.environ['SUPERSET_OPERATOR__DB_HOST']),
+		// so the rendered string is identical when the value changes; only the
+		// resolved env var slice differs.
+		{
+			name: "metastore.host",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Metastore.Host = common.Ptr("db-2.svc")
+			},
+		},
+		{
+			name: "metastore.database",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Metastore.Database = common.Ptr("superset_v2")
+			},
+		},
+		{
+			name: "metastore.passwordFrom (different ref)",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Metastore.PasswordFrom = secretKeyRef("rotated-db-secret", "password")
+			},
+		},
+		{
+			name: "secretKeyFrom (different ref)",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.SecretKeyFrom = secretKeyRef("rotated-secret", "key")
+			},
+		},
+		{
+			name: "previousSecretKeyFrom",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.PreviousSecretKeyFrom = secretKeyRef("prev-secret", "key")
+			},
+		},
+		{
+			name: "valkey.host",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Valkey.Host = "valkey-2.svc"
+			},
+		},
+		{
+			name: "lifecycle.init.adminUser",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.Lifecycle.Init.AdminUser = &supersetv1alpha1.AdminUserSpec{
+					Username: common.Ptr("admin"),
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			modified := base()
+			tt.mutate(modified)
+			got := r.initInputs(modified)
+			if computeChecksum(got) == computeChecksum(baseline) {
+				t.Errorf("expected init checksum input to change when %s mutated, but it stayed the same", tt.name)
+			}
+		})
+	}
+}
+
+// TestInitInputs_StableForNonConfigFields asserts that mutating fields that do
+// not influence the rendered superset_config.py or resolved env vars (e.g.,
+// per-Pod resources, replica counts, forceReload) does not change init's
+// checksum input — re-running init is expensive (admin user creation, role
+// updates, examples loading) and should only happen when config or env
+// actually changed.
+func TestInitInputs_StableForNonConfigFields(t *testing.T) {
+	r := &SupersetReconciler{}
+
+	base := func() *supersetv1alpha1.Superset {
+		s := &supersetv1alpha1.Superset{}
+		s.Name = "test"
+		s.Spec.Image = supersetv1alpha1.ImageSpec{Tag: "4.1.4"}
+		s.Spec.SecretKeyFrom = secretKeyRef("secret", "key")
+		s.Spec.Lifecycle = &supersetv1alpha1.LifecycleSpec{
+			Init: &supersetv1alpha1.InitTaskSpec{},
+		}
+		return s
+	}
+
+	baseline := r.initInputs(base())
+
+	tests := []struct {
+		name   string
+		mutate func(*supersetv1alpha1.Superset)
+	}{
+		{
+			name: "spec.replicas",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				three := int32(3)
+				s.Spec.Replicas = &three
+			},
+		},
+		{
+			name: "spec.forceReload",
+			mutate: func(s *supersetv1alpha1.Superset) {
+				s.Spec.ForceReload = "2026-05-22T15:00:00Z"
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			modified := base()
+			tt.mutate(modified)
+			if computeChecksum(r.initInputs(modified)) != computeChecksum(baseline) {
+				t.Errorf("expected init checksum input to be stable when only %s changed", tt.name)
+			}
+		})
+	}
+}
+
+func secretKeyRef(name, key string) *corev1.SecretKeySelector {
+	return &corev1.SecretKeySelector{
+		LocalObjectReference: corev1.LocalObjectReference{Name: name},
+		Key:                  key,
+	}
+}
diff --git a/internal/controller/lifecycle_taskdescriptor.go b/internal/controller/lifecycle_taskdescriptor.go
index ecfcbdf..b59eb71 100644
--- a/internal/controller/lifecycle_taskdescriptor.go
+++ b/internal/controller/lifecycle_taskdescriptor.go
@@ -151,8 +151,8 @@ var lifecycleTaskDescriptors = []*lifecycleTaskDescriptor{
 		BuildCommand: func(_ *SupersetReconciler, s *supersetv1alpha1.Superset) []string {
 			return defaultInitCommand(s)
 		},
-		BuildInputs: func(r *SupersetReconciler, s *supersetv1alpha1.Superset, configChecksum string) any {
-			return r.initInputs(s, configChecksum)
+		BuildInputs: func(r *SupersetReconciler, s *supersetv1alpha1.Superset, _ string) any {
+			return r.initInputs(s)
 		},
 		IsEnabled: func(s *supersetv1alpha1.Superset) bool {
 			if s.Spec.Lifecycle == nil {
diff --git a/internal/controller/lifecycle_taskspec.go b/internal/controller/lifecycle_taskspec.go
index 5934238..a2e0782 100644
--- a/internal/controller/lifecycle_taskspec.go
+++ b/internal/controller/lifecycle_taskspec.go
@@ -86,21 +86,8 @@ func (r *SupersetReconciler) buildStandardTaskFlatSpec(
 	instanceName := superset.Name + suffixForTaskType(taskType)
 	resourceBaseName := instanceName
 
-	compConfigInput := buildConfigInput(&superset.Spec)
-	if superset.Spec.Lifecycle != nil && superset.Spec.Lifecycle.Config != nil {
-		compConfigInput.ComponentConfig = *superset.Spec.Lifecycle.Config
-	}
-
-	var lifecycleSQLASpec *supersetv1alpha1.SQLAlchemyEngineOptionsSpec
-	if superset.Spec.Lifecycle != nil {
-		lifecycleSQLASpec = superset.Spec.Lifecycle.SQLAlchemyEngineOptions
-	}
-	compConfigInput.EngineOptions = supersetconfig.ComputeEngineOptions(
-		naming.ComponentInit, superset.Spec.SQLAlchemyEngineOptions, lifecycleSQLASpec, 0, 0,
-	)
-
+	renderedConfig := renderLifecycleTaskConfig(superset)
 	comp := convertTaskComponent(superset.Spec.Lifecycle, command)
-	renderedConfig := supersetconfig.RenderConfig(supersetconfig.ComponentInit, compConfigInput)
 
 	secretEnvVars := collectSecretEnvVars(&superset.Spec, superset.Name)
 	var initEnvVars []corev1.EnvVar
@@ -124,6 +111,25 @@ func (r *SupersetReconciler) buildStandardTaskFlatSpec(
 	return flatSpec, renderedConfig
 }
 
+// renderLifecycleTaskConfig renders the superset_config.py mounted on
+// migrate/rotate/init task Pods. Sharing this with the init checksum input
+// ensures the checksum reflects exactly what gets mounted — adding a new
+// config-rendering field can never silently skip an init re-run.
+func renderLifecycleTaskConfig(superset *supersetv1alpha1.Superset) string {
+	compConfigInput := buildConfigInput(&superset.Spec)
+	if superset.Spec.Lifecycle != nil && superset.Spec.Lifecycle.Config != nil {
+		compConfigInput.ComponentConfig = *superset.Spec.Lifecycle.Config
+	}
+	var lifecycleSQLASpec *supersetv1alpha1.SQLAlchemyEngineOptionsSpec
+	if superset.Spec.Lifecycle != nil {
+		lifecycleSQLASpec = superset.Spec.Lifecycle.SQLAlchemyEngineOptions
+	}
+	compConfigInput.EngineOptions = supersetconfig.ComputeEngineOptions(
+		naming.ComponentInit, superset.Spec.SQLAlchemyEngineOptions, lifecycleSQLASpec, 0, 0,
+	)
+	return supersetconfig.RenderConfig(supersetconfig.ComponentInit, compConfigInput)
+}
+
 func suffixForTaskType(taskType string) string {
 	if desc := lifecycleTaskDescriptorByType(taskType); desc != nil {
 		return desc.Suffix
diff --git a/internal/controller/maintenance.go b/internal/controller/maintenance.go
index 7553aa1..ae49065 100644
--- a/internal/controller/maintenance.go
+++ b/internal/controller/maintenance.go
@@ -397,13 +397,7 @@ func buildMaintenanceFlatSpec(parentName string, spec *supersetv1alpha1.Maintena
 }
 
 func resolveMaintenanceImage(spec *supersetv1alpha1.MaintenancePageSpec) supersetv1alpha1.ImageSpec {
-	if spec.Image != nil {
-		return *spec.Image
-	}
-	return supersetv1alpha1.ImageSpec{
-		Repository: maintenanceDefaultImage,
-		Tag:        maintenanceDefaultTag,
-	}
+	return resolveContainerImage(spec.Image, maintenanceDefaultImage, maintenanceDefaultTag)
 }
 
 func computeMaintenanceChecksum(spec *supersetv1alpha1.MaintenancePageSpec) string {
diff --git a/internal/controller/maintenance_test.go b/internal/controller/maintenance_test.go
index 0d2ddcd..08001c1 100644
--- a/internal/controller/maintenance_test.go
+++ b/internal/controller/maintenance_test.go
@@ -279,3 +279,49 @@ func TestBuildMaintenanceFlatSpec_DoesNotMutateInputSpec(t *testing.T) {
 		t.Fatalf("input spec PodTemplate.Container.Env mutated: got %d env vars, want 1", got)
 	}
 }
+
+// TestResolveMaintenanceImage_PartialOverride asserts that a maintenance image
+// spec with only `tag` set inherits the nginx repository — not the Superset
+// image — which is the bug ContainerImageSpec was introduced to prevent.
+func TestResolveMaintenanceImage_PartialOverride(t *testing.T) {
+	tests := []struct {
+		name         string
+		image        *supersetv1alpha1.ContainerImageSpec
+		expectedRepo string
+		expectedTag  string
+	}{
+		{
+			name:         "nil image uses managed defaults",
+			image:        nil,
+			expectedRepo: maintenanceDefaultImage,
+			expectedTag:  maintenanceDefaultTag,
+		},
+		{
+			name:         "tag-only override inherits nginx repo",
+			image:        &supersetv1alpha1.ContainerImageSpec{Tag: "1.27"},
+			expectedRepo: maintenanceDefaultImage,
+			expectedTag:  "1.27",
+		},
+		{
+			name:         "repository-only override inherits default tag",
+			image:        &supersetv1alpha1.ContainerImageSpec{Repository: "my-registry/maintenance"},
+			expectedRepo: "my-registry/maintenance",
+			expectedTag:  maintenanceDefaultTag,
+		},
+		{
+			name:         "full override is used as-is",
+			image:        &supersetv1alpha1.ContainerImageSpec{Repository: "my-registry/maintenance", Tag: "v3"},
+			expectedRepo: "my-registry/maintenance",
+			expectedTag:  "v3",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			img := resolveMaintenanceImage(&supersetv1alpha1.MaintenancePageSpec{Image: tt.image})
+			if img.Repository != tt.expectedRepo || img.Tag != tt.expectedTag {
+				t.Errorf("expected %s:%s, got %s:%s", tt.expectedRepo, tt.expectedTag, img.Repository, img.Tag)
+			}
+		})
+	}
+}
diff --git a/internal/controller/reconcile_parent_resources_test.go b/internal/controller/reconcile_parent_resources_test.go
index fe96196..902a61e 100644
--- a/internal/controller/reconcile_parent_resources_test.go
+++ b/internal/controller/reconcile_parent_resources_test.go
@@ -51,7 +51,7 @@ func TestReconcile_CreatesParentOwnedComponentResources(t *testing.T) {
 
 	c := reconcileOnce(t, scheme, superset).Build()
 	r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
-	doReconcile(t, r, "test")
+	doReconcile(t, r)
 
 	for _, name := range []string{
 		"test-web-server",
@@ -97,7 +97,7 @@ func TestReconcile_DisabledComponentDeletesParentOwnedResources(t *testing.T) {
 
 	c := reconcileOnce(t, scheme, superset).WithObjects(workerDeploy, workerConfig).Build()
 	r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
-	doReconcile(t, r, "test")
+	doReconcile(t, r)
 
 	if err := c.Get(context.Background(), types.NamespacedName{Name: "test-celery-worker", Namespace: "default"}, &appsv1.Deployment{}); err == nil {
 		t.Fatal("expected disabled celery worker Deployment to be deleted")
@@ -107,6 +107,57 @@ func TestReconcile_DisabledComponentDeletesParentOwnedResources(t *testing.T) {
 	}
 }
 
+// TestReconcile_ComponentResourcesCarryLabels asserts that every parent-owned
+// component resource (Deployment, ConfigMap, Service, HPA, PDB) carries the
+// operator-managed labels on its ObjectMeta. The internals doc promises label
+// discoverability via `kubectl … -l app.kubernetes.io/instance=<parent>`, so
+// missing labels on any of these would silently break that contract.
+func TestReconcile_ComponentResourcesCarryLabels(t *testing.T) {
+	scheme := testScheme(t)
+
+	spec := minimalSupersetSpec()
+	superset := &supersetv1alpha1.Superset{
+		ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default", UID: "uid-1"},
+		Spec:       spec,
+	}
+
+	c := reconcileOnce(t, scheme, superset).Build()
+	r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+	doReconcile(t, r)
+
+	expected := map[string]string{
+		"app.kubernetes.io/name":      "superset",
+		"app.kubernetes.io/component": "web-server",
+		"app.kubernetes.io/instance":  "test",
+	}
+	assertLabels := func(t *testing.T, kind string, labels map[string]string) {
+		t.Helper()
+		for k, want := range expected {
+			if got := labels[k]; got != want {
+				t.Errorf("%s missing label %s=%s (got %q)", kind, k, want, got)
+			}
+		}
+	}
+
+	deploy := &appsv1.Deployment{}
+	if err := c.Get(context.Background(), types.NamespacedName{Name: "test-web-server", Namespace: "default"}, deploy); err != nil {
+		t.Fatalf("get Deployment: %v", err)
+	}
+	assertLabels(t, "Deployment", deploy.Labels)
+
+	cm := &corev1.ConfigMap{}
+	if err := c.Get(context.Background(), types.NamespacedName{Name: "test-web-server-config", Namespace: "default"}, cm); err != nil {
+		t.Fatalf("get ConfigMap: %v", err)
+	}
+	assertLabels(t, "ConfigMap", cm.Labels)
+
+	svc := &corev1.Service{}
+	if err := c.Get(context.Background(), types.NamespacedName{Name: "test-web-server", Namespace: "default"}, svc); err != nil {
+		t.Fatalf("get Service: %v", err)
+	}
+	assertLabels(t, "Service", svc.Labels)
+}
+
 func TestReconcile_LifecycleCreatesParentOwnedTaskJobAndStatus(t *testing.T) {
 	scheme := testScheme(t)
 
@@ -119,7 +170,7 @@ func TestReconcile_LifecycleCreatesParentOwnedTaskJobAndStatus(t *testing.T) {
 
 	c := reconcileOnce(t, scheme, superset).Build()
 	r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
-	doReconcile(t, r, "test")
+	doReconcile(t, r)
 
 	jobs := &batchv1.JobList{}
 	if err := c.List(context.Background(), jobs,
diff --git a/internal/controller/superset_controller.go b/internal/controller/superset_controller.go
index 8df988f..e49906e 100644
--- a/internal/controller/superset_controller.go
+++ b/internal/controller/superset_controller.go
@@ -409,7 +409,8 @@ func (r *SupersetReconciler) SetupWithManager(mgr ctrl.Manager) error {
 
 // reconcileParentOwnedConfigMap creates or updates a ConfigMap owned by the
 // parent Superset CR. The ConfigMap contains superset_config.py and is mounted
-// by component pods via a conventional name.
+// by component pods via a conventional name. Pass labels matching the workload
+// pods so the ConfigMap is discoverable via the same label selector.
 func reconcileParentOwnedConfigMap(
 	ctx context.Context,
 	c client.Client,
@@ -417,6 +418,7 @@ func reconcileParentOwnedConfigMap(
 	parent *supersetv1alpha1.Superset,
 	config string,
 	resourceBaseName string,
+	labels map[string]string,
 ) error {
 	cmName := naming.ConfigMapName(resourceBaseName)
 
@@ -438,6 +440,7 @@ func reconcileParentOwnedConfigMap(
 		if err := controllerutil.SetControllerReference(parent, cm, scheme); err != nil {
 			return err
 		}
+		cm.Labels = mergeLabels(cm.Labels, labels)
 		cm.Data = map[string]string{
 			"superset_config.py": config,
 		}
diff --git a/internal/controller/test_helpers_test.go b/internal/controller/test_helpers_test.go
index 362a48b..cb76eef 100644
--- a/internal/controller/test_helpers_test.go
+++ b/internal/controller/test_helpers_test.go
@@ -94,10 +94,10 @@ func reconcileOnce(t *testing.T, scheme *runtime.Scheme, superset *supersetv1alp
 		WithStatusSubresource(&supersetv1alpha1.Superset{})
 }
 
-func doReconcile(t *testing.T, r *SupersetReconciler, name string) {
+func doReconcile(t *testing.T, r *SupersetReconciler) {
 	t.Helper()
 	_, err := r.Reconcile(context.Background(), reconcile.Request{
-		NamespacedName: types.NamespacedName{Name: name, Namespace: "default"},
+		NamespacedName: types.NamespacedName{Name: "test", Namespace: "default"},
 	})
 	if err != nil {
 		t.Fatalf("reconcile failed: %v", err)