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)