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/23 01:52:07 UTC

(superset-kubernetes-operator) branch main updated: fix(api,controller,docs): validation, createdb SQL, migrate retry (#74)

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 0f51c6c  fix(api,controller,docs): validation, createdb SQL, migrate retry (#74)
0f51c6c is described below

commit 0f51c6cd42d9367c59f7261e980a552034060e9d
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Fri May 22 18:52:01 2026 -0700

    fix(api,controller,docs): validation, createdb SQL, migrate retry (#74)
    
    * fix(api,controller,docs): validation, createdb SQL, migrate retry
    
    * fixes
    
    * codegen
    
    * fix tests
    
    * fix websocket in e2e
    
    * docs
---
 api/v1alpha1/shared_types.go                       |   3 +
 api/v1alpha1/superset_types.go                     |  11 ++-
 .../crds/superset.apache.org_supersets.yaml        |  35 +++++++
 .../crd/bases/superset.apache.org_supersets.yaml   |  35 +++++++
 docs/reference/api-reference.md                    |   8 +-
 docs/user-guide/configuration.md                   |   9 +-
 docs/user-guide/migration.md                       |   7 +-
 docs/user-guide/networking-and-monitoring.md       |  14 +--
 internal/controller/clone_test.go                  |  59 ++++++++++++
 internal/controller/deployment_builder.go          |  29 +++++-
 internal/controller/deployment_builder_test.go     | 107 +++++++++++++++++++++
 internal/controller/lifecycle_clone.go             |  29 +++++-
 internal/controller/lifecycle_create_db.go         |  19 ++--
 internal/controller/lifecycle_create_db_test.go    |  53 +++++++++-
 internal/controller/lifecycle_migrate.go           |  29 ++++--
 internal/controller/superset_controller_test.go    |  70 ++++++++++++++
 test/e2e/e2e_test.go                               |   5 +-
 17 files changed, 482 insertions(+), 40 deletions(-)

diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go
index a2eddf7..f9c6d81 100644
--- a/api/v1alpha1/shared_types.go
+++ b/api/v1alpha1/shared_types.go
@@ -613,6 +613,7 @@ type ComponentSpec struct {
 // --- Component service and scaling types ---
 
 // ComponentServiceSpec defines the Service configuration for a component.
+// +kubebuilder:validation:XValidation:rule="!has(self.nodePort) || self.type == 'NodePort' || self.type == 'LoadBalancer'",message="nodePort is only valid when type is NodePort or LoadBalancer"
 type ComponentServiceSpec struct {
 	// Service type (ClusterIP, NodePort, LoadBalancer).
 	// +optional
@@ -624,6 +625,8 @@ type ComponentServiceSpec struct {
 	Port *int32 `json:"port,omitempty"`
 	// Fixed NodePort number when type=NodePort (30000-32767). If omitted, Kubernetes auto-assigns.
 	// +optional
+	// +kubebuilder:validation:Minimum=30000
+	// +kubebuilder:validation:Maximum=32767
 	NodePort *int32 `json:"nodePort,omitempty"`
 	// Service annotations (e.g., for cloud load balancer configuration).
 	// +optional
diff --git a/api/v1alpha1/superset_types.go b/api/v1alpha1/superset_types.go
index 00ccd3c..48b89bf 100644
--- a/api/v1alpha1/superset_types.go
+++ b/api/v1alpha1/superset_types.go
@@ -123,10 +123,13 @@ type SupersetSpec struct {
 	// Web server (gunicorn) component. Presence enables it; absence disables.
 	// +optional
 	WebServer *WebServerComponentSpec `json:"webServer,omitempty"`
-	// Celery async task worker component. Requires Valkey for broker/backend.
+	// Celery async task worker component. Uses spec.valkey as broker/backend when set;
+	// otherwise the broker must be configured manually via spec.config.
 	// +optional
 	CeleryWorker *CeleryWorkerComponentSpec `json:"celeryWorker,omitempty"`
-	// Celery periodic task scheduler (singleton, always 1 replica). Requires Valkey.
+	// Celery periodic task scheduler (singleton, always 1 replica). Uses spec.valkey
+	// as broker/backend when set; otherwise the broker must be configured manually
+	// via spec.config.
 	// +optional
 	CeleryBeat *CeleryBeatComponentSpec `json:"celeryBeat,omitempty"`
 	// Celery Flower monitoring UI component.
@@ -239,6 +242,9 @@ type CeleryFlowerComponentSpec struct {
 }
 
 // WebsocketServerComponentSpec defines the websocket server component on the parent CRD.
+// The websocket server is a Node.js app — the default Superset image does not contain
+// websocket_server.js, so an image override is required.
+// +kubebuilder:validation:XValidation:rule="has(self.image) && has(self.image.repository) && size(self.image.repository) > 0",message="websocketServer.image.repository is required: the default Superset image does not include websocket_server.js"
 type WebsocketServerComponentSpec struct {
 	ScalableComponentSpec `json:",inline"`
 	ComponentSpec         `json:",inline"`
@@ -687,6 +693,7 @@ type NetworkPolicySpec struct {
 // --- ServiceAccount ---
 
 // ServiceAccountSpec defines ServiceAccount configuration.
+// +kubebuilder:validation:XValidation:rule="!has(self.create) || self.create == true || (has(self.name) && size(self.name) > 0)",message="serviceAccount.name is required when serviceAccount.create is false (otherwise pods would silently use the default ServiceAccount of the namespace)"
 type ServiceAccountSpec struct {
 	// When true (default), the operator creates a ServiceAccount. When false, it references an existing one.
 	// +optional
diff --git a/charts/superset-operator/crds/superset.apache.org_supersets.yaml b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
index 46700e4..1b9d924 100644
--- a/charts/superset-operator/crds/superset.apache.org_supersets.yaml
+++ b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
@@ -7903,6 +7903,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -7915,6 +7917,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                 type: object
               celeryWorker:
                 properties:
@@ -26935,6 +26941,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -26947,6 +26955,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -30985,6 +30997,12 @@ spec:
                   name:
                     type: string
                 type: object
+                x-kubernetes-validations:
+                - message: serviceAccount.name is required when serviceAccount.create
+                    is false (otherwise pods would silently use the default ServiceAccount
+                    of the namespace)
+                  rule: '!has(self.create) || self.create == true || (has(self.name)
+                    && size(self.name) > 0)'
               sqlaEngineOptions:
                 properties:
                   maxOverflow:
@@ -35154,6 +35172,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -35166,6 +35186,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -39121,6 +39145,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -39133,7 +39159,16 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                 type: object
+                x-kubernetes-validations:
+                - message: 'websocketServer.image.repository is required: the default
+                    Superset image does not include websocket_server.js'
+                  rule: has(self.image) && has(self.image.repository) && size(self.image.repository)
+                    > 0
             required:
             - image
             type: object
diff --git a/config/crd/bases/superset.apache.org_supersets.yaml b/config/crd/bases/superset.apache.org_supersets.yaml
index 46700e4..1b9d924 100644
--- a/config/crd/bases/superset.apache.org_supersets.yaml
+++ b/config/crd/bases/superset.apache.org_supersets.yaml
@@ -7903,6 +7903,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -7915,6 +7917,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                 type: object
               celeryWorker:
                 properties:
@@ -26935,6 +26941,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -26947,6 +26955,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -30985,6 +30997,12 @@ spec:
                   name:
                     type: string
                 type: object
+                x-kubernetes-validations:
+                - message: serviceAccount.name is required when serviceAccount.create
+                    is false (otherwise pods would silently use the default ServiceAccount
+                    of the namespace)
+                  rule: '!has(self.create) || self.create == true || (has(self.name)
+                    && size(self.name) > 0)'
               sqlaEngineOptions:
                 properties:
                   maxOverflow:
@@ -35154,6 +35172,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -35166,6 +35186,10 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                   sqlaEngineOptions:
                     properties:
                       maxOverflow:
@@ -39121,6 +39145,8 @@ spec:
                         type: object
                       nodePort:
                         format: int32
+                        maximum: 32767
+                        minimum: 30000
                         type: integer
                       port:
                         format: int32
@@ -39133,7 +39159,16 @@ spec:
                         - LoadBalancer
                         type: string
                     type: object
+                    x-kubernetes-validations:
+                    - message: nodePort is only valid when type is NodePort or LoadBalancer
+                      rule: '!has(self.nodePort) || self.type == ''NodePort'' || self.type
+                        == ''LoadBalancer'''
                 type: object
+                x-kubernetes-validations:
+                - message: 'websocketServer.image.repository is required: the default
+                    Superset image does not include websocket_server.js'
+                  rule: has(self.image) && has(self.image.repository) && size(self.image.repository)
+                    > 0
             required:
             - image
             type: object
diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md
index 4dafcb7..15387f2 100644
--- a/docs/reference/api-reference.md
+++ b/docs/reference/api-reference.md
@@ -313,7 +313,7 @@ _Appears in:_
 | --- | --- | --- | --- |
 | `type` _[ServiceType](https://pkg.go.dev/k8s.io/api/core/v1#ServiceType)_ | Service type (ClusterIP, NodePort, LoadBalancer). | ClusterIP | Enum: [ClusterIP NodePort LoadBalancer] <br />Optional: \{\} <br /> |
 | `port` _integer_ | Service port exposed to clients. Defaults to the component's standard port (8088 for web server, 5555 for Flower). |  | Optional: \{\} <br /> |
-| `nodePort` _integer_ | Fixed NodePort number when type=NodePort (30000-32767). If omitted, Kubernetes auto-assigns. |  | Optional: \{\} <br /> |
+| `nodePort` _integer_ | Fixed NodePort number when type=NodePort (30000-32767). If omitted, Kubernetes auto-assigns. |  | Maximum: 32767 <br />Minimum: 30000 <br />Optional: \{\} <br /> |
 | `annotations` _object (keys:string, values:string)_ | Service annotations (e.g., for cloud load balancer configuration). |  | Optional: \{\} <br /> |
 | `labels` _object (keys:string, values:string)_ | Service labels; merged with operator-managed labels. |  | Optional: \{\} <br /> |
 | `gatewayPath` _string_ | URL path prefix for this component's HTTPRoute rule.<br />Only used when spec.networking.gateway is set.<br />Defaults: /ws (websocket), /mcp (MCP server), /flower (Celery Flower). |  | Pattern: `^/[a-zA-Z0-9/_.-]+$` <br />Optional: \{\} <br /> |
@@ -1088,8 +1088,8 @@ _Appears in:_
 | `celery` _[CelerySpec](#celeryspec)_ | Top-level Celery app configuration rendered into CELERY_CONFIG. Per-component<br />worker/beat process tuning lives on celeryWorker / celeryBeat. |  | Optional: \{\} <br /> |
 | `sqlaEngineOptions` _[SQLAlchemyEngineOptionsSpec](#sqlalchemyengineoptionsspec)_ | SQLAlchemy engine options for connection pooling. Inherited by all Python<br />components; per-component sqlaEngineOptions overrides this entirely.<br />When unset, the operator computes balanced defaults per component. |  | Optional: \{\} <br /> |
 | `webServer` _[WebServerComponentSpec](#webservercomponentspec)_ | Web server (gunicorn) component. Presence enables it; absence disables. |  | Optional: \{\} <br /> |
-| `celeryWorker` _[CeleryWorkerComponentSpec](#celeryworkercomponentspec)_ | Celery async task worker component. Requires Valkey for broker/backend. |  | Optional: \{\} <br /> |
-| `celeryBeat` _[CeleryBeatComponentSpec](#celerybeatcomponentspec)_ | Celery periodic task scheduler (singleton, always 1 replica). Requires Valkey. |  | Optional: \{\} <br /> |
+| `celeryWorker` _[CeleryWorkerComponentSpec](#celeryworkercomponentspec)_ | Celery async task worker component. Uses spec.valkey as broker/backend when set;<br />otherwise the broker must be configured manually via spec.config. |  | Optional: \{\} <br /> |
+| `celeryBeat` _[CeleryBeatComponentSpec](#celerybeatcomponentspec)_ | Celery periodic task scheduler (singleton, always 1 replica). Uses spec.valkey<br />as broker/backend when set; otherwise the broker must be configured manually<br />via spec.config. |  | Optional: \{\} <br /> |
 | `celeryFlower` _[CeleryFlowerComponentSpec](#celeryflowercomponentspec)_ | Celery Flower monitoring UI component. |  | Optional: \{\} <br /> |
 | `websocketServer` _[WebsocketServerComponentSpec](#websocketservercomponentspec)_ | WebSocket server for real-time updates (Node.js, no Python config). |  | Optional: \{\} <br /> |
 | `mcpServer` _[McpServerComponentSpec](#mcpservercomponentspec)_ | FastMCP server component for AI tooling integration. |  | Optional: \{\} <br /> |
@@ -1307,6 +1307,8 @@ _Appears in:_
 
 
 WebsocketServerComponentSpec defines the websocket server component on the parent CRD.
+The websocket server is a Node.js app — the default Superset image does not contain
+websocket_server.js, so an image override is required.
 
 
 
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index c7cb09c..ef5dd64 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -261,9 +261,10 @@ The operator sets certain env vars automatically based on the CR spec. These are
 | `SUPERSET_OPERATOR__VALKEY_HOST`, `SUPERSET_OPERATOR__VALKEY_PORT` | Operator-internal | Operator (from `valkey`) | Valkey connection fields |
 | `SUPERSET_OPERATOR__VALKEY_PASS` | Operator-internal | Operator (from `valkey.password` or `valkey.passwordFrom`) | Valkey password |
 | `SUPERSET_OPERATOR__FORCE_RELOAD` | Operator-internal | Operator (from `spec.forceReload`) | Triggers rolling restart |
-| `PYTHONPATH` | Standard | Operator | Python module search path |
 | `SUPERSET_WEBSERVER_PORT` | Standard | Rendered in config | Web server port (8088) |
 
+The operator does **not** set `PYTHONPATH` — it relies on the upstream Superset image's default (which already includes `/app/pythonpath`, where the operator mounts the rendered `superset_config.py`). Custom images must preserve this entry on `PYTHONPATH` for the rendered config to be picked up.
+
 **Tier descriptions:**
 
 - **Operator-internal transport vars** (`SUPERSET_OPERATOR__` prefix) are used by the operator to pass values into the rendered `superset_config.py`. They are not recognized by Superset directly — the operator renders them as Python assignments (e.g., `SECRET_KEY = os.environ['SUPERSET_OPERATOR__SECRET_KEY']`).
@@ -555,7 +556,9 @@ dashboards via WebSocket connections.
 !!! warning "Requires a dedicated image"
     The websocket server is a separate Node.js application and **does not run
     from the default Superset image**. You must provide an image that contains
-    `websocket_server.js`. A community-maintained image is available at
+    `websocket_server.js` — the CRD enforces this with a CEL rule that rejects
+    `websocketServer` set without an `image.repository` override. A
+    community-maintained image is available at
     [`oneacrefund/superset-websocket`](https://hub.docker.com/r/oneacrefund/superset-websocket)
     (experimental, not officially supported by Apache Superset).
 
@@ -872,7 +875,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) |
+| `ports` | Container ports (replaces operator defaults when set; the first resolved port is used as the Service `targetPort`, the ingress port for the operator-managed NetworkPolicy, and as the target port for any default probes the user did not override) |
 | `securityContext` | Container-level security context |
 | `command` | Container entrypoint (no inheritance) |
 | `args` | Container arguments (no inheritance) |
diff --git a/docs/user-guide/migration.md b/docs/user-guide/migration.md
index b788dec..04d10cc 100644
--- a/docs/user-guide/migration.md
+++ b/docs/user-guide/migration.md
@@ -73,7 +73,10 @@ The main differences are deliberate:
      celeryWorker: {}
      celeryBeat: {}
      celeryFlower: {}
-     websocketServer: {}
+     websocketServer:
+       image:
+         repository: oneacrefund/superset-websocket   # or your own image
+         tag: latest
    ```
 
 6. Decide how the first operator reconciliation should handle lifecycle tasks.
@@ -148,7 +151,7 @@ The main differences are deliberate:
 | `supersetCeleryBeat.enabled` | `spec.celeryBeat: {}` | Celery Beat is always a singleton. |
 | `supersetCeleryFlower.enabled` | `spec.celeryFlower: {}` | Flower gets its own Deployment and Service. |
 | `supersetCeleryFlower.service.*` | `spec.celeryFlower.service.*` | Supports service type, port, nodePort, labels, and annotations. |
-| `supersetWebsockets.enabled` | `spec.websocketServer: {}` | Use a websocket image such as `oneacrefund/superset-websocket`, or your own image. |
+| `supersetWebsockets.enabled` | `spec.websocketServer.image.{repository,tag}` | An image override is required (CEL-validated): the default Superset image does not include `websocket_server.js`. Use a community image such as `oneacrefund/superset-websocket` or your own. |
 | `supersetWebsockets.config` | Env vars or mounted config file | The operator does not render websocket `config.json`. Mount one with `podTemplate.volumes`, or configure the websocket server through env vars. |
 | `init.enabled` | `spec.lifecycle.disabled` or task-level `disabled` | Lifecycle is enabled by default. Set `lifecycle.disabled: true` to skip all tasks. |
 | `init.command`, `init.initscript` | `spec.lifecycle.migrate.command`, `spec.lifecycle.init.command` | The operator splits database migration and application initialization into separate tasks. |
diff --git a/docs/user-guide/networking-and-monitoring.md b/docs/user-guide/networking-and-monitoring.md
index ccded0d..10388b3 100644
--- a/docs/user-guide/networking-and-monitoring.md
+++ b/docs/user-guide/networking-and-monitoring.md
@@ -227,21 +227,21 @@ spec:
 
 Creates per-component NetworkPolicies that:
 
-- Allow ingress from other components of the same Superset instance (matched by `app.kubernetes.io/name: superset` + `superset.apache.org/parent` labels — multiple Superset instances in the same namespace are isolated from each other)
-- Allow ingress on the service port from any source for externally-facing components (web server, Celery Flower, websocket server, MCP server) — this is necessary because ingress controllers and load balancers typically reside outside the namespace and cannot be matched with a pod selector
+- Allow ingress from other pods of the same Superset instance on **any port** (matched by `app.kubernetes.io/name: superset` + `superset.apache.org/parent` labels — multiple Superset instances in the same namespace are isolated from each other). The same-instance rule is intentionally port-unrestricted so internal traffic between components (sidecar metrics scraping, the websocket server fanning out to web pods, etc.) is not silently blocked.
+- Allow ingress on the service port from any source for externally-facing components (web server, Celery Flower, websocket server, MCP server) — this is necessary because ingress controllers and load balancers typically reside outside the namespace and cannot be matched with a pod selector.
 - Allow all egress (for database/cache access)
 - Support custom `extraIngress` and `extraEgress` rules
 
 **Per-component rules:**
 
-| Component | Ingress from Superset pods | Ingress from external | Egress |
+| Component | Ingress from same-instance Superset pods | Ingress from external | Egress |
 |---|---|---|---|
-| WebServer | port 8088 | port 8088 | all |
+| WebServer | any port | port 8088 | all |
 | CeleryWorker | any port | — | all |
 | CeleryBeat | any port | — | all |
-| CeleryFlower | port 5555 | port 5555 | all |
-| WebsocketServer | port 8080 | port 8080 | all |
-| McpServer | port 8088 | port 8088 | all |
+| CeleryFlower | any port | port 5555 | all |
+| WebsocketServer | any port | port 8080 | all |
+| McpServer | any port | port 8088 | all |
 
 If you need to restrict external ingress to specific sources, disable the built-in
 network policy and create your own NetworkPolicy resources with the desired `from`
diff --git a/internal/controller/clone_test.go b/internal/controller/clone_test.go
index 227f6b1..79ad4ae 100644
--- a/internal/controller/clone_test.go
+++ b/internal/controller/clone_test.go
@@ -19,6 +19,7 @@ limitations under the License.
 package controller
 
 import (
+	"fmt"
 	"strings"
 	"testing"
 	"time"
@@ -146,6 +147,64 @@ func TestBuildMySQLCloneScript_ExcludeTables(t *testing.T) {
 	}
 }
 
+func TestBuildMySQLCloneScript_ExcludeTableData(t *testing.T) {
+	mysqlType := "MySQL"
+	clone := &supersetv1alpha1.CloneTaskSpec{
+		Source: supersetv1alpha1.CloneSourceSpec{
+			Type:     &mysqlType,
+			Host:     "mysql-prod.svc",
+			Database: "superset_prod",
+			Username: "reader",
+		},
+		ExcludeTables:    []string{"tab_state"},
+		ExcludeTableData: []string{"logs", "query"},
+	}
+
+	script := buildMySQLCloneScript(clone)
+
+	// Schema-only pass for ExcludeTableData tables.
+	if !strings.Contains(script, `--no-data`) {
+		t.Errorf("expected --no-data pass for ExcludeTableData, got: %s", script)
+	}
+	if strings.Contains(script, "--skip-triggers") {
+		t.Errorf("schema-only pass must preserve triggers (mirrors Postgres --exclude-table-data which keeps schema objects), got: %s", script)
+	}
+	if !strings.Contains(script, `"$SUPERSET_OPERATOR__CLONE_SRC_DB" "logs" "query"`) {
+		t.Errorf("expected schema-only dump to list logs and query tables, got: %s", script)
+	}
+
+	// Data pass should --ignore-table both ExcludeTables and ExcludeTableData.
+	for _, table := range []string{"tab_state", "logs", "query"} {
+		needle := `--ignore-table="$SUPERSET_OPERATOR__CLONE_SRC_DB".` + fmt.Sprintf("%q", table)
+		if !strings.Contains(script, needle) {
+			t.Errorf("expected --ignore-table for %q in data pass, got: %s", table, script)
+		}
+	}
+
+	// Combined output piped to mysql.
+	if !strings.Contains(script, `) | mysql `) {
+		t.Errorf("expected grouped passes piped into mysql, got: %s", script)
+	}
+}
+
+func TestBuildMySQLCloneScript_NoExcludeTableDataKeepsSinglePass(t *testing.T) {
+	mysqlType := "MySQL"
+	clone := &supersetv1alpha1.CloneTaskSpec{
+		Source: supersetv1alpha1.CloneSourceSpec{
+			Type:     &mysqlType,
+			Host:     "mysql-prod.svc",
+			Database: "superset_prod",
+			Username: "reader",
+		},
+	}
+
+	script := buildMySQLCloneScript(clone)
+
+	if strings.Contains(script, "--no-data") {
+		t.Errorf("did not expect --no-data pass when ExcludeTableData is empty, got: %s", script)
+	}
+}
+
 func TestBuildCloneScript_PostCloneSQL(t *testing.T) {
 	t.Run("postgres", func(t *testing.T) {
 		clone := &supersetv1alpha1.CloneTaskSpec{
diff --git a/internal/controller/deployment_builder.go b/internal/controller/deployment_builder.go
index 7c13a89..a147f9d 100644
--- a/internal/controller/deployment_builder.go
+++ b/internal/controller/deployment_builder.go
@@ -101,22 +101,35 @@ func buildDeploymentSpec(
 
 	// Use spec ports if provided, otherwise fall back to component defaults.
 	ports := cfg.DefaultPorts
-	if len(ct.Ports) > 0 {
+	portsOverridden := len(ct.Ports) > 0
+	if portsOverridden {
 		ports = ct.Ports
 	}
 
 	// Resolve probes: user-provided takes precedence over component defaults.
+	// When the user overrides container ports, retarget any default probes to
+	// the first resolved port; otherwise the Service targetPort tracks the new
+	// port while probes remain on the hard-coded default port.
 	livenessProbe := ct.LivenessProbe
 	if livenessProbe == nil {
 		livenessProbe = cfg.DefaultLivenessProbe
+		if portsOverridden && livenessProbe != nil {
+			livenessProbe = retargetProbe(livenessProbe, ports[0].ContainerPort)
+		}
 	}
 	readinessProbe := ct.ReadinessProbe
 	if readinessProbe == nil {
 		readinessProbe = cfg.DefaultReadinessProbe
+		if portsOverridden && readinessProbe != nil {
+			readinessProbe = retargetProbe(readinessProbe, ports[0].ContainerPort)
+		}
 	}
 	startupProbe := ct.StartupProbe
 	if startupProbe == nil {
 		startupProbe = cfg.DefaultStartupProbe
+		if portsOverridden && startupProbe != nil {
+			startupProbe = retargetProbe(startupProbe, ports[0].ContainerPort)
+		}
 	}
 
 	// Build the main container from ContainerTemplate.
@@ -233,6 +246,20 @@ func resolveContainerPort(spec *supersetv1alpha1.FlatComponentSpec, defaultPort
 	return defaultPort
 }
 
+// retargetProbe returns a copy of probe with HTTPGet/TCPSocket port replaced.
+// Used to keep default probes aligned with user-overridden container ports so
+// the Service targetPort and the probe target stay in sync.
+func retargetProbe(probe *corev1.Probe, port int32) *corev1.Probe {
+	out := probe.DeepCopy()
+	switch {
+	case out.HTTPGet != nil:
+		out.HTTPGet.Port = intstr.FromInt32(port)
+	case out.TCPSocket != nil:
+		out.TCPSocket.Port = intstr.FromInt32(port)
+	}
+	return out
+}
+
 // buildServiceSpec constructs a ServiceSpec from the component's ComponentServiceSpec,
 // selector labels, container port, and a default service port.
 func buildServiceSpec(
diff --git a/internal/controller/deployment_builder_test.go b/internal/controller/deployment_builder_test.go
index a2817a2..8f7f755 100644
--- a/internal/controller/deployment_builder_test.go
+++ b/internal/controller/deployment_builder_test.go
@@ -352,3 +352,110 @@ func TestPreserveServiceAllocatedFields(t *testing.T) {
 		t.Errorf("expected IPFamilyPolicy to be preserved, got %v", desired.IPFamilyPolicy)
 	}
 }
+
+func TestBuildDeploymentSpec_RetargetsDefaultProbesToOverriddenPort(t *testing.T) {
+	spec := &supersetv1alpha1.FlatComponentSpec{
+		Image: supersetv1alpha1.ImageSpec{
+			Repository: "apache/superset",
+			Tag:        "latest",
+			PullPolicy: corev1.PullIfNotPresent,
+		},
+		PodTemplate: &supersetv1alpha1.PodTemplate{
+			Container: &supersetv1alpha1.ContainerTemplate{
+				Ports: []corev1.ContainerPort{
+					{Name: common.PortNameHTTP, ContainerPort: 9999, Protocol: corev1.ProtocolTCP},
+				},
+			},
+		},
+	}
+	labels := map[string]string{"app": "test"}
+	cfg := DeploymentConfig{
+		ContainerName:  common.Container,
+		DefaultCommand: []string{"/usr/bin/run-server.sh"},
+		DefaultPorts: []corev1.ContainerPort{
+			{Name: common.PortNameHTTP, ContainerPort: common.PortWebServer, Protocol: corev1.ProtocolTCP},
+		},
+		DefaultLivenessProbe:  httpProbe("/health", common.PortWebServer, 15),
+		DefaultReadinessProbe: httpProbe("/health", common.PortWebServer, 5),
+		DefaultStartupProbe:   tcpProbe(common.PortWebServer, 15),
+	}
+
+	result := buildDeploymentSpec(spec, cfg, nil, labels)
+	container := result.Template.Spec.Containers[0]
+
+	if got := container.LivenessProbe.HTTPGet.Port; got != intstr.FromInt32(9999) {
+		t.Errorf("liveness probe port: got %v, want 9999", got)
+	}
+	if got := container.ReadinessProbe.HTTPGet.Port; got != intstr.FromInt32(9999) {
+		t.Errorf("readiness probe port: got %v, want 9999", got)
+	}
+	if got := container.StartupProbe.TCPSocket.Port; got != intstr.FromInt32(9999) {
+		t.Errorf("startup probe port: got %v, want 9999", got)
+	}
+
+	// Defaults should not be mutated by retargeting.
+	if cfg.DefaultLivenessProbe.HTTPGet.Port != intstr.FromInt32(common.PortWebServer) {
+		t.Errorf("default liveness probe was mutated: got %v", cfg.DefaultLivenessProbe.HTTPGet.Port)
+	}
+}
+
+func TestBuildDeploymentSpec_KeepsDefaultProbesWhenPortsNotOverridden(t *testing.T) {
+	spec := &supersetv1alpha1.FlatComponentSpec{
+		Image: supersetv1alpha1.ImageSpec{
+			Repository: "apache/superset",
+			Tag:        "latest",
+			PullPolicy: corev1.PullIfNotPresent,
+		},
+	}
+	labels := map[string]string{"app": "test"}
+	cfg := DeploymentConfig{
+		ContainerName:  common.Container,
+		DefaultCommand: []string{"/usr/bin/run-server.sh"},
+		DefaultPorts: []corev1.ContainerPort{
+			{Name: common.PortNameHTTP, ContainerPort: common.PortWebServer, Protocol: corev1.ProtocolTCP},
+		},
+		DefaultLivenessProbe: httpProbe("/health", common.PortWebServer, 15),
+	}
+
+	result := buildDeploymentSpec(spec, cfg, nil, labels)
+	container := result.Template.Spec.Containers[0]
+
+	if got := container.LivenessProbe.HTTPGet.Port; got != intstr.FromInt32(common.PortWebServer) {
+		t.Errorf("liveness probe port: got %v, want %d", got, common.PortWebServer)
+	}
+}
+
+func TestBuildDeploymentSpec_UserProbeWinsOverRetargeting(t *testing.T) {
+	userProbe := httpProbe("/custom", 7777, 1)
+	spec := &supersetv1alpha1.FlatComponentSpec{
+		Image: supersetv1alpha1.ImageSpec{
+			Repository: "apache/superset",
+			Tag:        "latest",
+			PullPolicy: corev1.PullIfNotPresent,
+		},
+		PodTemplate: &supersetv1alpha1.PodTemplate{
+			Container: &supersetv1alpha1.ContainerTemplate{
+				Ports: []corev1.ContainerPort{
+					{Name: common.PortNameHTTP, ContainerPort: 9999, Protocol: corev1.ProtocolTCP},
+				},
+				LivenessProbe: userProbe,
+			},
+		},
+	}
+	labels := map[string]string{"app": "test"}
+	cfg := DeploymentConfig{
+		ContainerName:        common.Container,
+		DefaultCommand:       []string{"/usr/bin/run-server.sh"},
+		DefaultLivenessProbe: httpProbe("/health", common.PortWebServer, 15),
+	}
+
+	result := buildDeploymentSpec(spec, cfg, nil, labels)
+	container := result.Template.Spec.Containers[0]
+
+	if got := container.LivenessProbe.HTTPGet.Port; got != intstr.FromInt32(7777) {
+		t.Errorf("user liveness probe port should be preserved: got %v, want 7777", got)
+	}
+	if container.LivenessProbe.HTTPGet.Path != "/custom" {
+		t.Errorf("user liveness probe path should be preserved: got %q", container.LivenessProbe.HTTPGet.Path)
+	}
+}
diff --git a/internal/controller/lifecycle_clone.go b/internal/controller/lifecycle_clone.go
index a0c33ca..7208e16 100644
--- a/internal/controller/lifecycle_clone.go
+++ b/internal/controller/lifecycle_clone.go
@@ -100,13 +100,36 @@ func buildMySQLCloneScript(clone *supersetv1alpha1.CloneTaskSpec) string {
 	var b strings.Builder
 	b.WriteString(`set -e
 mysql -h "$SUPERSET_OPERATOR__DB_HOST" -P "$SUPERSET_OPERATOR__DB_PORT" -u "$SUPERSET_OPERATOR__DB_USER" -p"$SUPERSET_OPERATOR__DB_PASS" -e "DROP DATABASE IF EXISTS $SUPERSET_OPERATOR__DB_NAME; CREATE DATABASE $SUPERSET_OPERATOR__DB_NAME;"
-mysqldump -h "$SUPERSET_OPERATOR__CLONE_SRC_HOST" -P "$SUPERSET_OPERATOR__CLONE_SRC_PORT" -u "$SUPERSET_OPERATOR__CLONE_SRC_USER" -p"$SUPERSET_OPERATOR__CLONE_SRC_PASS" --single-transaction --routines --triggers`)
+`)
+
+	// mysqldump has no per-table --no-data flag, so emit two passes joined into
+	// one stream: a schema-only pass for ExcludeTableData tables (preserves
+	// CREATE TABLE plus per-table triggers, mirroring Postgres
+	// --exclude-table-data which keeps everything except row data), then a
+	// data pass that ignores both ExcludeTables and ExcludeTableData. The
+	// combined stdout is piped to the target mysql client. The schema pass is
+	// skipped when ExcludeTableData is empty so the existing single-pass
+	// behaviour is preserved.
+	mysqldumpHead := `mysqldump -h "$SUPERSET_OPERATOR__CLONE_SRC_HOST" -P "$SUPERSET_OPERATOR__CLONE_SRC_PORT" -u "$SUPERSET_OPERATOR__CLONE_SRC_USER" -p"$SUPERSET_OPERATOR__CLONE_SRC_PASS"`
+
+	b.WriteString("(")
+	if len(clone.ExcludeTableData) > 0 {
+		fmt.Fprintf(&b, " %s --single-transaction --no-data", mysqldumpHead)
+		b.WriteString(` "$SUPERSET_OPERATOR__CLONE_SRC_DB"`)
+		for _, t := range clone.ExcludeTableData {
+			fmt.Fprintf(&b, ` %q`, t)
+		}
+		b.WriteString(" ; ")
+	}
 
+	fmt.Fprintf(&b, "%s --single-transaction --routines --triggers", mysqldumpHead)
 	for _, t := range clone.ExcludeTables {
 		fmt.Fprintf(&b, ` --ignore-table="$SUPERSET_OPERATOR__CLONE_SRC_DB".%q`, t)
 	}
-
-	b.WriteString(` "$SUPERSET_OPERATOR__CLONE_SRC_DB" | mysql -h "$SUPERSET_OPERATOR__DB_HOST" -P "$SUPERSET_OPERATOR__DB_PORT" -u "$SUPERSET_OPERATOR__DB_USER" -p"$SUPERSET_OPERATOR__DB_PASS" "$SUPERSET_OPERATOR__DB_NAME"`)
+	for _, t := range clone.ExcludeTableData {
+		fmt.Fprintf(&b, ` --ignore-table="$SUPERSET_OPERATOR__CLONE_SRC_DB".%q`, t)
+	}
+	b.WriteString(` "$SUPERSET_OPERATOR__CLONE_SRC_DB" ) | mysql -h "$SUPERSET_OPERATOR__DB_HOST" -P "$SUPERSET_OPERATOR__DB_PORT" -u "$SUPERSET_OPERATOR__DB_USER" -p"$SUPERSET_OPERATOR__DB_PASS" "$SUPERSET_OPERATOR__DB_NAME"`)
 
 	for _, sql := range clone.PostCloneSQL {
 		fmt.Fprintf(&b, "\nmysql -h \"$SUPERSET_OPERATOR__DB_HOST\" -P \"$SUPERSET_OPERATOR__DB_PORT\" -u \"$SUPERSET_OPERATOR__DB_USER\" -p\"$SUPERSET_OPERATOR__DB_PASS\" \"$SUPERSET_OPERATOR__DB_NAME\" -e %q", sql)
diff --git a/internal/controller/lifecycle_create_db.go b/internal/controller/lifecycle_create_db.go
index de68ee6..9cd7f76 100644
--- a/internal/controller/lifecycle_create_db.go
+++ b/internal/controller/lifecycle_create_db.go
@@ -30,22 +30,25 @@ import (
 const createDatabaseContainerName = "create-database"
 
 // Postgres: query pg_database to check existence, then `createdb` if absent.
-// We avoid building a `CREATE DATABASE "..."` SQL string because the metastore
-// database name may contain quotes or other identifier-hostile characters; the
-// existence check uses psql's :'var' substitution (proper SQL-literal quoting),
-// and `createdb -- "$NAME"` passes the name as a CLI arg (handled by libpq).
-// `--` prevents the name being interpreted as a flag if it starts with `-`.
+// The DB name is SQL-escaped client-side by doubling single quotes (the only
+// metacharacter inside a SQL string literal). We avoid psql's :'name'
+// interpolation because psql does not process client-side features like
+// variable substitution when the command is passed via -c — the literal
+// :'name' would reach the server and fail with a syntax error. `createdb --
+// "$NAME"` then passes the name as a CLI arg (handled by libpq), and `--`
+// prevents it being interpreted as a flag if it starts with `-`.
 // Password uses ${VAR:-} to support passwordless connections (trust/peer auth,
 // IAM-issued credentials), matching the rendered config's os.environ.get fallback.
 const createDatabasePostgresScript = `set -eu
+ESC_NAME=$(printf '%s' "$SUPERSET_OPERATOR__DB_NAME" | sed "s/'/''/g")
 EXISTS=$(PGPASSWORD="${SUPERSET_OPERATOR__DB_PASS:-}" psql \
   -h "$SUPERSET_OPERATOR__DB_HOST" \
   -p "$SUPERSET_OPERATOR__DB_PORT" \
   -U "$SUPERSET_OPERATOR__DB_USER" \
-  -d postgres -tAc \
+  -d postgres \
   -v ON_ERROR_STOP=1 \
-  -v "name=$SUPERSET_OPERATOR__DB_NAME" \
-  "SELECT 1 FROM pg_database WHERE datname = :'name'")
+  -tA -c \
+  "SELECT 1 FROM pg_database WHERE datname = '$ESC_NAME'")
 if [ "$EXISTS" = "1" ]; then
   echo "Database $SUPERSET_OPERATOR__DB_NAME already exists"
 else
diff --git a/internal/controller/lifecycle_create_db_test.go b/internal/controller/lifecycle_create_db_test.go
index e2f32db..4d9b225 100644
--- a/internal/controller/lifecycle_create_db_test.go
+++ b/internal/controller/lifecycle_create_db_test.go
@@ -77,13 +77,21 @@ func TestBuildCreateDatabaseInitContainer_Postgres(t *testing.T) {
 	for _, want := range []string{
 		"createdb",
 		"pg_database",
-		":'name'",
+		`sed "s/'/''/g"`,
+		`datname = '$ESC_NAME'`,
+		"-tA -c",
 		`-- "$SUPERSET_OPERATOR__DB_NAME"`,
 	} {
 		if !strings.Contains(script, want) {
 			t.Errorf("script missing %q\n--- script ---\n%s", want, script)
 		}
 	}
+	if strings.Contains(script, ":'name'") {
+		t.Errorf("script must not use psql :'name' interpolation; psql does not process client-side features when invoked with -c\n--- script ---\n%s", script)
+	}
+	if strings.Index(script, "-v ON_ERROR_STOP=1") > strings.Index(script, "-tA -c") {
+		t.Errorf("psql -v options must appear before -c so they are parsed as options\n--- script ---\n%s", script)
+	}
 	envMap := envSliceToMap(ctr.Env)
 	if envMap[common.EnvDBHost] != "pg.svc" {
 		t.Errorf("expected DB_HOST=pg.svc, got %q", envMap[common.EnvDBHost])
@@ -562,6 +570,49 @@ func TestMigrateInputs_StructuredTargetIgnoredWhenCreateDatabaseFalse(t *testing
 	}
 }
 
+func TestMigrateInputs_InitContainerScriptParticipatesInChecksum(t *testing.T) {
+	// The create-database init container's script body is rendered by the
+	// operator binary, not the user's spec. Including its content in the
+	// migrate inputs is what lets a previously failed migrate retry after the
+	// operator is upgraded with a fix to the script — otherwise the migrate
+	// checksum is stable across operator upgrades and Block A in
+	// reconcileLifecycleTask would keep returning terminal even though the
+	// rendered Job would now succeed.
+	r := &SupersetReconciler{}
+	mkSuperset := func(dbType *string) *supersetv1alpha1.Superset {
+		s := &supersetv1alpha1.Superset{}
+		s.Spec.Image = supersetv1alpha1.ImageSpec{Repository: "superset", Tag: "1.0"}
+		s.Spec.Metastore = &supersetv1alpha1.MetastoreSpec{
+			Host:           common.Ptr("pg.svc"),
+			Database:       common.Ptr("superset"),
+			Username:       common.Ptr("superset"),
+			CreateDatabase: common.Ptr(true),
+			Type:           dbType,
+		}
+		return s
+	}
+	pgInputs := r.migrateInputs(mkSuperset(nil))
+	mysqlInputs := r.migrateInputs(mkSuperset(common.Ptr("MySQL")))
+
+	pgStruct, ok := pgInputs.(struct {
+		Image               string
+		Trigger             string
+		CreateDatabase      bool
+		Target              any
+		InitContainerScript string
+	})
+	if !ok {
+		t.Fatalf("migrateInputs returned unexpected type: %T", pgInputs)
+	}
+	if pgStruct.InitContainerScript != createDatabasePostgresScript {
+		t.Errorf("postgres init script not embedded in migrate inputs:\n--- got ---\n%s", pgStruct.InitContainerScript)
+	}
+
+	if pgInputs == mysqlInputs {
+		t.Error("expected migrateInputs to differ between Postgres and MySQL init scripts")
+	}
+}
+
 func podHasContainer(containers []corev1.Container, name string) bool {
 	for _, c := range containers {
 		if c.Name == name {
diff --git a/internal/controller/lifecycle_migrate.go b/internal/controller/lifecycle_migrate.go
index 2a89d3e..d273dfe 100644
--- a/internal/controller/lifecycle_migrate.go
+++ b/internal/controller/lifecycle_migrate.go
@@ -29,7 +29,11 @@ import (
 // container that reads the structured metastore target; changes to that
 // target (host/port/database/username/type) must re-run migrate so the init
 // container actually executes against the new server. The flag itself is also
-// included so toggling it re-runs migrate.
+// included so toggling it re-runs migrate. The init container's rendered
+// script body is included too: the script is operator-rendered (not user
+// spec) and changes when the operator binary is upgraded with a fix or
+// hardening, so a checksum bump is what lets a fixed operator retry after a
+// previously failed run.
 func (r *SupersetReconciler) migrateInputs(superset *supersetv1alpha1.Superset) any {
 	currentImage := resolveLifecycleImage(&superset.Spec.Image, lifecycleImageOverride(superset))
 	trigger := ""
@@ -44,6 +48,7 @@ func (r *SupersetReconciler) migrateInputs(superset *supersetv1alpha1.Superset)
 		Database string
 		Username string
 	}
+	var initContainerScript string
 	if superset.Spec.Metastore != nil && superset.Spec.Metastore.CreateDatabase != nil && *superset.Spec.Metastore.CreateDatabase {
 		createDatabase = true
 		m := superset.Spec.Metastore
@@ -55,17 +60,23 @@ func (r *SupersetReconciler) migrateInputs(superset *supersetv1alpha1.Superset)
 		}
 		target.Database = derefOrDefault(m.Database, "")
 		target.Username = derefOrDefault(m.Username, "")
+		initContainerScript = createDatabasePostgresScript
+		if target.Type == dbTypeMySQL {
+			initContainerScript = createDatabaseMySQLScript
+		}
 	}
 	return struct {
-		Image          string
-		Trigger        string
-		CreateDatabase bool
-		Target         any
+		Image               string
+		Trigger             string
+		CreateDatabase      bool
+		Target              any
+		InitContainerScript string
 	}{
-		Image:          currentImage,
-		Trigger:        trigger,
-		CreateDatabase: createDatabase,
-		Target:         target,
+		Image:               currentImage,
+		Trigger:             trigger,
+		CreateDatabase:      createDatabase,
+		Target:              target,
+		InitContainerScript: initContainerScript,
 	}
 }
 
diff --git a/internal/controller/superset_controller_test.go b/internal/controller/superset_controller_test.go
index 69795e7..5030883 100644
--- a/internal/controller/superset_controller_test.go
+++ b/internal/controller/superset_controller_test.go
@@ -236,6 +236,76 @@ var _ = Describe("Integration", Ordered, func() {
 			Expect(err).To(HaveOccurred())
 			Expect(err.Error()).To(ContainSubstring("mutually exclusive"))
 		})
+
+		It("should reject serviceAccount.create=false without name", func() {
+			cr := newSuperset("sa-no-name", ns)
+			cr.Spec.WebServer = &supersetv1alpha1.WebServerComponentSpec{}
+			cr.Spec.ServiceAccount = &supersetv1alpha1.ServiceAccountSpec{
+				Create: boolPtr(false),
+			}
+			err := k8sClient.Create(ctx, cr)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("serviceAccount.name is required"))
+		})
+
+		It("should accept serviceAccount.create=false with name", func() {
+			cr := newSuperset("sa-with-name", ns)
+			cr.Spec.WebServer = &supersetv1alpha1.WebServerComponentSpec{}
+			cr.Spec.ServiceAccount = &supersetv1alpha1.ServiceAccountSpec{
+				Create: boolPtr(false),
+				Name:   "preexisting-sa",
+			}
+			Expect(k8sClient.Create(ctx, cr)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, cr)).To(Succeed())
+		})
+
+		It("should reject service.nodePort below the valid range", func() {
+			cr := newSuperset("np-low", ns)
+			cr.Spec.WebServer = &supersetv1alpha1.WebServerComponentSpec{
+				ScalableComponentSpec: supersetv1alpha1.ScalableComponentSpec{},
+				Service: &supersetv1alpha1.ComponentServiceSpec{
+					Type:     corev1.ServiceTypeNodePort,
+					NodePort: int32Ptr(25000),
+				},
+			}
+			err := k8sClient.Create(ctx, cr)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("nodePort"))
+		})
+
+		It("should reject service.nodePort with type=ClusterIP", func() {
+			cr := newSuperset("np-clusterip", ns)
+			cr.Spec.WebServer = &supersetv1alpha1.WebServerComponentSpec{
+				Service: &supersetv1alpha1.ComponentServiceSpec{
+					Type:     corev1.ServiceTypeClusterIP,
+					NodePort: int32Ptr(30500),
+				},
+			}
+			err := k8sClient.Create(ctx, cr)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("nodePort"))
+		})
+
+		It("should reject websocketServer without an image override", func() {
+			cr := newSuperset("ws-no-image", ns)
+			cr.Spec.WebsocketServer = &supersetv1alpha1.WebsocketServerComponentSpec{}
+			err := k8sClient.Create(ctx, cr)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("websocketServer.image.repository"))
+		})
+
+		It("should accept websocketServer with an image repository override", func() {
+			cr := newSuperset("ws-with-image", ns)
+			cr.Spec.WebsocketServer = &supersetv1alpha1.WebsocketServerComponentSpec{
+				ComponentSpec: supersetv1alpha1.ComponentSpec{
+					Image: &supersetv1alpha1.ImageOverrideSpec{
+						Repository: strPtr("example.com/superset-websocket"),
+					},
+				},
+			}
+			Expect(k8sClient.Create(ctx, cr)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, cr)).To(Succeed())
+		})
 	})
 
 	// --- Reconciliation Lifecycle ---
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index baa6865..c582784 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -386,7 +386,10 @@ spec:
       CELERY_ANNOTATIONS = {}
   celeryBeat: {}
   celeryFlower: {}
-  websocketServer: {}
+  websocketServer:
+    image:
+      repository: oneacrefund/superset-websocket
+      tag: latest
   mcpServer: {}
   lifecycle:
     disabled: true