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