Compare commits

...

13 Commits

Author SHA1 Message Date
Evan
9826ce9100 fix(plugin-chart-echarts): import t from @apache-superset/core/translation
@superset-ui/core no longer exports t, so Radar/utils.ts failed to
type-check and its test threw "t is not a function". Import t the
same way the sibling Radar files (index.ts, controlPanel.tsx) already
do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 08:55:18 -07:00
Evan
c346fba656 fix(plugin-chart-echarts): wrap radar tooltip N/A string for i18n
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:38:35 -07:00
Evan
8794b8a729 style(plugin-chart-echarts): fix prettier formatting in radar test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:12:21 -07:00
Evan
53076ec5e7 fix(plugin-chart-echarts): denormalize missing radar metrics as null, not NaN
The null-preservation fix for #30270 kept a missing metric out of the
denormalization map, so label/tooltip lookups fell through to
Number("null") and rendered "NaN" instead of a gap. Record an explicit
null entry and skip formatting it in the label and tooltip renderers.
Also convert the single-case describe() block to standalone tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:27:07 -07:00
Evan Rusackas
8c959a3008 style(plugin-chart-echarts): prettier-format radar null test (#30270)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:17:55 -07:00
Evan
5881cc8de0 fix(plugin-chart-echarts): preserve null metrics in radar normalization
Radar transformProps normalized each series value with value / max in
normalizeArray. A null (missing) metric coerced to 0 in that division, so
a missing data point was plotted at the center of the radar as a real
zero instead of being left out as a gap (#30270).

Preserve null/non-finite values through normalization so ECharts renders
them as gaps, and exclude nulls when computing the per-series max.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:20:02 -07:00
Claude Code
b94aa33de9 test(plugin-chart-echarts): reproduce radar null values plotted as 0 (#30270)
Radar transformProps normalizes each value with null / max, coercing a
missing (null) metric into 0 so it is plotted at the center instead of
left as a gap. Adds a regression test asserting nulls are preserved.

Closes #30270

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:13:39 -07:00
dependabot[bot]
df209cedbf chore(deps-dev): update sqlalchemy-firebird requirement from <2.2,>=0.7.0 to >=0.8.0,<2.2 (#41950)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 10:51:36 -07:00
Mike Bridge
b641008da6 fix(versioning): preserve complete multi-flush history (#41940)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-11 10:50:48 -07:00
Luis Carbonell
678fcba8b4 feat(helm): Superset 6 structured config schema with deprecation path (#41777) 2026-07-11 10:50:20 -07:00
Greg Neighbors
5c12f216d5 feat(mcp): add delete_chart and delete_dashboard tools (#41472)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:47:27 -07:00
Ujjwal Jain
7a752d1c22 fix(dashboard): restore arrow key navigation in modal inputs opened from dropdown (#37978) 2026-07-11 10:39:28 -07:00
dependabot[bot]
51ce2b11ad chore(deps-dev): update impyla requirement from <0.23,>0.16.2 to >=0.24.0,<0.25 (#41951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 05:18:31 -07:00
42 changed files with 3323 additions and 327 deletions

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.19.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.20.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square)
![Version: 0.20.0](https://img.shields.io/badge/Version-0.20.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -74,10 +74,42 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|-----|------|---------|-------------|
| affinity | object | `{}` | |
| bootstrapScript | string | see `values.yaml` | Install additional packages and do any other bootstrap configuration in this script For production clusters it's recommended to build own image with this step done in CI |
| cache | object | `{"asyncQueries":{"keyPrefix":"qc-","timeout":86400},"cacheDb":null,"cacheUrl":null,"celeryDb":null,"celeryUrl":null,"defaultTimeout":86400,"driver":"","enabled":true,"host":null,"keyPrefix":"superset_","password":null,"port":null,"resultsBackendKeyPrefix":"superset_results","sentinel":null,"ssl":{"ca_certs":null,"certfile":null,"enabled":false,"keyfile":null,"ssl_cert_reqs":"required"},"user":""}` | Redis cache configuration for Superset Redis is optional but recommended for caching and Celery. If redis.enabled (chart dependency) is true, defaults point to the chart's Redis instance. |
| cache.asyncQueries | object | `{"keyPrefix":"qc-","timeout":86400}` | Async queries configuration |
| cache.cacheDb | string | `nil` | Redis database number for cache (default: 1 when unset; legacy redis_cache_db is honored) |
| cache.cacheUrl | string | `nil` | Full Redis cache URL (overrides host/port/user/pass if set) |
| cache.celeryDb | string | `nil` | Redis database number for Celery (default: 0 when unset; legacy redis_celery_db is honored) |
| cache.celeryUrl | string | `nil` | Full Redis Celery URL (overrides host/port/user/pass if set) |
| cache.defaultTimeout | int | `86400` | Default cache timeout in seconds |
| cache.driver | string | `""` | Custom Redis driver (e.g. TLS/managed variants); overrides the redis proto in URLs when set. Ports the legacy supersetNode.connections.redis_driver escape hatch. |
| cache.enabled | bool | `true` | Enable Redis-based features (cache, Celery). Set to false to disable Redis usage entirely. |
| cache.host | string | `nil` | Redis host (default: {{ .Release.Name }}-redis-headless) |
| cache.keyPrefix | string | `"superset_"` | Cache key prefix |
| cache.password | string | `nil` | Redis password |
| cache.port | string | `nil` | Redis port (default: 6379 when unset; legacy supersetNode.connections.redis_port is honored) |
| cache.resultsBackendKeyPrefix | string | `"superset_results"` | Results backend key prefix |
| cache.sentinel | string | `nil` | Redis Sentinel configuration (optional) |
| cache.ssl | object | `{"ca_certs":null,"certfile":null,"enabled":false,"keyfile":null,"ssl_cert_reqs":"required"}` | Redis SSL configuration |
| cache.user | string | `""` | Redis user (optional, for Redis ACL) |
| cluster | object | `{"databaseServiceName":null,"domain":".svc.cluster.local","redisServiceName":null,"websocketServiceName":null}` | Kubernetes cluster configuration Used for constructing service URLs between chart components |
| cluster.databaseServiceName | string | `nil` | Database service name (default: {{ .Release.Name }}-postgresql) Override if using a different service name for the database |
| cluster.domain | string | `".svc.cluster.local"` | Kubernetes cluster domain (default: .svc.cluster.local) Override if using a custom cluster domain |
| cluster.redisServiceName | string | `nil` | Redis service name (default: {{ .Release.Name }}-redis-headless) Override if using a different service name for Redis |
| cluster.websocketServiceName | string | `nil` | WebSocket service name (default: {{ .Release.Name }}-ws) Override if using a different service name for the WebSocket service |
| config | object | `{}` | Superset configuration properties Set any configuration property from superset/config.py here See https://github.com/apache/superset/blob/master/superset/config.py for all available options |
| configFromSecret | string | `"{{ template \"superset.fullname\" . }}-config"` | The name of the secret which we will use to generate a superset_config.py file Note: this secret must have the key superset_config.py in it and can include other files as well |
| configMountPath | string | `"/app/pythonpath"` | |
| configOverrides | object | `{}` | A dictionary of overrides to append at the end of superset_config.py - the name does not matter WARNING: the order is not guaranteed Files can be passed as helm --set-file configOverrides.my-override=my-file.py |
| configOverridesFiles | object | `{}` | Same as above but the values are files |
| database | object | `{"driver":null,"host":null,"name":null,"password":null,"port":null,"ssl":{"enabled":false,"mode":"require"},"uri":null,"user":null}` | Database connection configuration for the Superset metadata database |
| database.driver | string | `nil` | Database driver used when uri is not set (default: postgresql+psycopg2 when unset; legacy supersetNode.connections.db_type is honored when this is unset) |
| database.host | string | `nil` | Database host (default: {{ .Release.Name }}-postgresql) |
| database.name | string | `nil` | Database name (default: superset, resolved via superset.db.name) |
| database.password | string | `nil` | Database password (default: superset, resolved via superset.db.password) ⚠️ CHANGE THIS for production |
| database.port | string | `nil` | Database port (default: 5432 when unset; legacy supersetNode.connections.db_port is honored) |
| database.ssl | object | `{"enabled":false,"mode":"require"}` | Database SSL configuration |
| database.uri | string | `nil` | Full database URI (overrides host/port/user/pass/name if set) Example: "postgresql+psycopg2://user:pass@host:5432/dbname" |
| database.user | string | `nil` | Database user (default: superset, resolved via superset.db.user) |
| envFromSecret | string | `"{{ template \"superset.fullname\" . }}-env"` | The name of the secret which we will use to populate env vars in deployed pods This can be useful for secret keys, etc. |
| envFromSecrets | list | `[]` | This can be a list of templated strings |
| extraConfigMountPath | string | `"/app/configs"` | |
@@ -89,7 +121,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| extraSecrets | object | `{}` | Extra files to be mounted as Secrets on the path specified in `configMountPath` |
| extraVolumeMounts | list | `[]` | |
| extraVolumes | list | `[]` | |
| featureFlags | object | `{}` | Feature flags configuration See https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md |
| fullnameOverride | string | `nil` | Provide a name to override the full names of resources |
| globalPodAnnotations | object | `{}` | Global pod annotations to be added to all pods Use this to set annotations that apply to all Superset components Component-specific podAnnotations will be merged with these global annotations |
| hostAliases | list | `[]` | Custom hostAliases for all superset pods # https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/ |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.repository | string | `"apachesuperset.docker.scarf.sh/apache/superset"` | |
@@ -116,7 +150,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| init.enabled | bool | `true` | |
| init.extraContainers | list | `[]` | Launch additional containers into init job pod |
| init.initContainers | list | a container waiting for postgres | List of initContainers |
| init.initscript | string | a script to create admin user and initialize roles | A Superset init script |
| init.initscript | string | unused; kept for backwards-compatibility only | DEPRECATED: this field is no longer used by the chart. The init script is rendered entirely from the internal `superset.initScript` template (which runs `superset db upgrade`, `superset init`, admin creation, and examples). Any customization placed here is silently ignored. See UPGRADING.md. |
| init.jobAnnotations."helm.sh/hook" | string | `"post-install,post-upgrade"` | |
| init.jobAnnotations."helm.sh/hook-delete-policy" | string | `"before-hook-creation"` | |
| init.loadExamples | bool | `false` | |
@@ -143,6 +177,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| service.type | string | `"ClusterIP"` | |
| serviceAccount.annotations | object | `{}` | |
| serviceAccount.create | bool | `false` | Create custom service account for Superset. If create: true and serviceAccountName is not provided, `superset.fullname` will be used. |
| serviceAccount.name | string | `""` | Service account name to use (if not specified, defaults to release name + chart name) |
| serviceAccountName | string | `nil` | Specify service account name to be used |
| supersetCeleryBeat.affinity | object | `{}` | Affinity to be added to supersetCeleryBeat deployment |
| supersetCeleryBeat.command | list | a `celery beat` command | Command |
@@ -214,20 +249,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| supersetNode.autoscaling.minReplicas | int | `1` | |
| supersetNode.autoscaling.targetCPUUtilizationPercentage | int | `80` | |
| supersetNode.command | list | See `values.yaml` | Startup command |
| supersetNode.connections.db_host | string | `"{{ .Release.Name }}-postgresql"` | |
| supersetNode.connections.db_name | string | `"superset"` | |
| supersetNode.connections.db_pass | string | `"superset"` | |
| supersetNode.connections.db_port | string | `"5432"` | |
| supersetNode.connections.db_type | string | `"postgresql"` | Database type for Superset metadata (Supported types: "postgresql", "mysql") |
| supersetNode.connections.db_user | string | `"superset"` | |
| supersetNode.connections.redis_cache_db | string | `"1"` | |
| supersetNode.connections.redis_celery_db | string | `"0"` | |
| supersetNode.connections.redis_driver | string | `""` | |
| supersetNode.connections.redis_host | string | `"{{ .Release.Name }}-redis-headless"` | Change in case of bringing your own redis and then also set redis.enabled:false |
| supersetNode.connections.redis_port | string | `"6379"` | |
| supersetNode.connections.redis_ssl.enabled | bool | `false` | |
| supersetNode.connections.redis_ssl.ssl_cert_reqs | string | `"CERT_NONE"` | |
| supersetNode.connections.redis_user | string | `""` | |
| supersetNode.connections | object | `{}` | |
| supersetNode.containerSecurityContext | object | `{}` | |
| supersetNode.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetNode deployment |
| supersetNode.deploymentAnnotations | object | `{}` | Annotations to be added to supersetNode deployment |
@@ -334,6 +356,11 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| supersetWorker.deploymentLabels | object | `{}` | Labels to be added to supersetWorker deployment |
| supersetWorker.extraContainers | list | `[]` | Launch additional containers into supersetWorker pod |
| supersetWorker.forceReload | bool | `false` | If true, forces deployment to reload on each upgrade |
| supersetWorker.healthCheck | object | `{"enabled":false,"livenessFile":"/tmp/celery_worker_alive","livenessHeartbeatInterval":10,"readinessFile":"/tmp/celery_worker_ready"}` | Celery worker file-based health check (worker writes readiness/liveness files via signals; point supersetWorker.readinessProbe/livenessProbe at these files to use them) |
| supersetWorker.healthCheck.enabled | bool | `false` | Enable the file-based Celery worker health check |
| supersetWorker.healthCheck.livenessFile | string | `"/tmp/celery_worker_alive"` | Liveness file (touched periodically by a heartbeat thread) |
| supersetWorker.healthCheck.livenessHeartbeatInterval | int | `10` | Seconds between liveness heartbeats |
| supersetWorker.healthCheck.readinessFile | string | `"/tmp/celery_worker_ready"` | Readiness file (created when the worker is ready, removed on shutdown) |
| supersetWorker.initContainers | list | a container waiting for postgres and redis | Init container |
| supersetWorker.lifecycle | object | `{}` | Container lifecycle hooks for the worker pod |
| supersetWorker.livenessProbe.exec.command | list | a `celery inspect ping` command | Liveness probe command |

161
helm/superset/UPGRADING.md Normal file
View File

@@ -0,0 +1,161 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Upgrading the Superset Helm Chart
## Upgrading to chart 0.20.0
Chart 0.20.0 introduces a structured connection schema. The old keys listed below are **DEPRECATED** — they still work (auto-mapped to the new keys at render time) and a deprecation warning is printed in `helm install`/`helm upgrade` NOTES, but they will be removed in a future release. Migrate as soon as possible.
### 1. Connection schema: `supersetNode.connections.*` → `database.*` / `cache.*`
**Before (deprecated — still honored):**
```yaml
supersetNode:
connections:
db_host: "pg-host"
db_port: "5432"
db_user: "superset"
db_pass: "superset"
db_name: "superset"
redis_host: "redis-host"
redis_port: "6379"
redis_cache_db: "1"
redis_celery_db: "0"
redis_driver: "rediss"
```
**After (new structured keys — recommended):**
```yaml
database:
host: "pg-host"
port: 5432
user: "superset"
password: "superset"
name: "superset"
cache:
host: "redis-host"
port: 6379
cacheDb: 1
celeryDb: 0
driver: "rediss" # optional: custom Redis driver / TLS variant
```
Key-by-key mapping summary:
| Old key | New key |
|---|---|
| `supersetNode.connections.db_host` | `database.host` |
| `supersetNode.connections.db_port` | `database.port` |
| `supersetNode.connections.db_user` | `database.user` |
| `supersetNode.connections.db_pass` | `database.password` |
| `supersetNode.connections.db_name` | `database.name` |
| `supersetNode.connections.redis_host` | `cache.host` |
| `supersetNode.connections.redis_port` | `cache.port` |
| `supersetNode.connections.redis_cache_db` | `cache.cacheDb` |
| `supersetNode.connections.redis_celery_db` | `cache.celeryDb` |
| `supersetNode.connections.redis_driver` | `cache.driver` |
### 2. Service account: root `serviceAccountName` → `serviceAccount.name`
**Before (deprecated — still honored):**
```yaml
serviceAccountName: my-sa
```
**After (new key — recommended):**
```yaml
serviceAccount:
name: my-sa
```
### 3. Init script: `init.initscript` is deprecated and replaced by the built-in template
> **This is a behavior change.** The chart no longer uses `init.initscript`. The init script is rendered
> entirely from an internal chart template (`superset.initScript`), which runs the full initialization
> sequence:
>
> 1. `superset db upgrade` — applies all pending database schema migrations
> 2. `superset init` — initializes roles and permissions
> 3. Admin user creation (when `init.createAdmin: true`)
> 4. Example data loading (when `init.loadExamples: true`)
> 5. Datasource import (when `import_datasources.yaml` is present)
>
> A future PR will optionally split the database migration step into a dedicated upgrade Job for
> zero-downtime deployments. Until that PR lands, migrations run as part of the init Job above.
If you customized `init.initscript` in your `values.yaml`, that customization is silently ignored.
Move any customizations to `config` or `configOverrides`:
```yaml
# Move custom Python config here:
config:
MY_SETTING: "value"
configOverrides:
my_custom_override: |
# Python snippet appended to superset_config.py
MY_SETTING = "value"
```
If your use case cannot be covered by `config` or `configOverrides`, please open an issue so the maintainers
can evaluate extending the template.
### 4. New top-level sections: `config.*` and `featureFlags.*`
Two new sections provide direct Superset configuration passthrough without needing raw `configOverrides`.
**`config.*` — direct Superset config properties:**
```yaml
config:
SECRET_KEY: "$(SUPERSET_SECRET_KEY)"
ROW_LIMIT: 50000
WTF_CSRF_ENABLED: true
SQLALCHEMY_POOL_SIZE: 10
```
Each key is injected verbatim into `superset_config.py`. String values are quoted; non-string values
(integers, booleans) are rendered as-is.
**`featureFlags.*` — feature flag overrides:**
```yaml
featureFlags:
ALERT_REPORTS: true
DASHBOARD_RBAC: true
ENABLE_TEMPLATE_PROCESSING: false
```
This is equivalent to setting `FEATURE_FLAGS = {...}` in `superset_config.py`, but is more readable and
schema-validated.
### 5. `values.schema.json` — early validation
The chart now ships a `values.schema.json` that validates the values you provide. Wrong types (e.g., a string
where an integer is expected) or unknown keys in structured sections will cause `helm install`/`helm upgrade`
to fail immediately with a descriptive error instead of producing a broken deployment.
If you get a validation error after upgrading, check that your overridden values match the types documented in
`values.yaml` and the schema.

View File

@@ -35,3 +35,9 @@
echo "Visit http://127.0.0.1:8088 to use your application"
kubectl port-forward service/superset 8088:8088 --namespace {{ .Release.Namespace }}
{{- end }}
{{- $warnings := include "superset.deprecationWarnings" . }}
{{- if trim $warnings }}
⚠️ DEPRECATION WARNINGS
{{ $warnings }}
{{- end }}

View File

@@ -47,11 +47,12 @@ If release name contains chart name it will be used as a full name.
Create the name of the service account to use
*/}}
{{- define "superset.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "superset.fullname" .) .Values.serviceAccountName -}}
{{- else -}}
{{- default "default" .Values.serviceAccountName -}}
{{- end -}}
{{- $name := coalesce .Values.serviceAccount.name .Values.serviceAccountName -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "superset.fullname" .) $name -}}
{{- else -}}
{{- default "default" $name -}}
{{- end -}}
{{- end -}}
{{/*
@@ -105,80 +106,572 @@ app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{- define "superset-config" }}
{{/*
Coalescing resolvers for DB and Redis connection parameters.
Each resolver checks (in order):
1. New top-level database.* / cache.* values
2. Legacy supersetNode.connections.* keys (deprecation path, using safe index to avoid errors on absent maps)
3. cluster.* service name overrides
4. Hard-coded defaults derived from the release name
Call with root context: {{ include "superset.db.host" . }}
*/}}
{{/*
Helper to safely read .Values.supersetNode.connections.<key> without erroring when maps are absent.
*/}}
{{- define "_superset.legacyConn" -}}
{{- $sn := index .Values "supersetNode" | default dict -}}
{{- $conn := index $sn "connections" | default dict -}}
{{- $conn | toJson -}}
{{- end -}}
{{- define "superset.db.host" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- tpl (coalesce .Values.database.host (index $conn "db_host") .Values.cluster.databaseServiceName (printf "%s-postgresql" .Release.Name)) $ -}}
{{- end -}}
{{- define "superset.db.port" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if .Values.database.port -}}
{{- .Values.database.port | toString -}}
{{- else -}}
{{- coalesce (index $conn "db_port") "5432" -}}
{{- end -}}
{{- end -}}
{{- define "superset.db.user" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.database.user (index $conn "db_user") "superset" -}}
{{- end -}}
{{- define "superset.db.password" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.database.password (index $conn "db_pass") "superset" -}}
{{- end -}}
{{- define "superset.db.name" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.database.name (index $conn "db_name") "superset" -}}
{{- end -}}
{{- define "superset.db.driver" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.database.driver (index $conn "db_type") "postgresql+psycopg2" -}}
{{- end -}}
{{- define "superset.redis.host" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- tpl (coalesce .Values.cache.host (index $conn "redis_host") .Values.cluster.redisServiceName (printf "%s-redis-headless" .Release.Name)) $ -}}
{{- end -}}
{{- define "superset.redis.port" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if .Values.cache.port -}}
{{- .Values.cache.port | toString -}}
{{- else -}}
{{- coalesce (index $conn "redis_port") "6379" -}}
{{- end -}}
{{- end -}}
{{- define "superset.redis.user" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.cache.user (index $conn "redis_user") "" -}}
{{- end -}}
{{- define "superset.redis.password" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- coalesce .Values.cache.password (index $conn "redis_password") "" -}}
{{- end -}}
{{- define "superset.redis.cacheDb" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if not (kindIs "invalid" .Values.cache.cacheDb) -}}
{{- .Values.cache.cacheDb | toString -}}
{{- else -}}
{{- coalesce (index $conn "redis_cache_db") "1" -}}
{{- end -}}
{{- end -}}
{{- define "superset.redis.celeryDb" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if not (kindIs "invalid" .Values.cache.celeryDb) -}}
{{- .Values.cache.celeryDb | toString -}}
{{- else -}}
{{- coalesce (index $conn "redis_celery_db") "0" -}}
{{- end -}}
{{- end -}}
{{- define "superset.redis.proto" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if .Values.cache.driver }}{{ .Values.cache.driver }}{{- else if index $conn "redis_driver" }}{{ index $conn "redis_driver" }}{{- else if .Values.cache.ssl.enabled }}rediss{{- else }}redis{{- end -}}
{{- end -}}
{{- define "superset.config" }}
{{- /* SECURITY: Validate admin password is set if admin creation is enabled */}}
{{- if and .Values.init.createAdmin (or (not .Values.init.adminUser.password) (eq .Values.init.adminUser.password "")) }}
{{- fail "SECURITY ERROR: init.createAdmin is true but init.adminUser.password is empty. You must set a secure password using --set init.adminUser.password='your-password' or via external secret." }}
{{- end }}
import os
import json
from urllib.parse import quote
{{- if or .Values.config.cacheConfig .Values.config.dataCacheConfig .Values.config.resultsBackend .Values.config.celeryConfig .Values.cache.enabled }}
from flask_caching.backends.rediscache import RedisCache
{{- end }}
def env(key, default=None):
return os.getenv(key, default)
# Redis Base URL
{{- if .Values.supersetNode.connections.redis_password }}
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
{{- /* Database Configuration - Superset always requires a database */}}
{{- if .Values.database.uri }}
SQLALCHEMY_DATABASE_URI = {{ .Values.database.uri | quote }}
{{- else }}
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
{{- $driver := include "superset.db.driver" . }}
{{- $sslParams := "" }}
{{- if and (hasKey .Values.database "ssl") .Values.database.ssl.enabled }}
{{- $sslMode := .Values.database.ssl.mode | default "require" }}
{{- $sslParams = printf "?sslmode=%s" $sslMode }}
{{- end }}
SQLALCHEMY_DATABASE_URI = f"{{ $driver }}://{quote(env('DB_USER', ''), safe='')}:{quote(env('DB_PASS', ''), safe='')}@{env('DB_HOST')}:{env('DB_PORT')}/{env('DB_NAME')}{{ $sslParams }}"
{{- end }}
{{- if hasKey .Values.config "SQLALCHEMY_TRACK_MODIFICATIONS" }}
SQLALCHEMY_TRACK_MODIFICATIONS = {{ .Values.config.SQLALCHEMY_TRACK_MODIFICATIONS | toString | title }}
{{- else }}
SQLALCHEMY_TRACK_MODIFICATIONS = False
{{- end }}
# Redis URL Params
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
REDIS_URL_PARAMS = f"?ssl_cert_reqs={env('REDIS_SSL_CERT_REQS')}"
{{- /* Redis Configuration - only if Redis cache is configured */}}
{{- if .Values.cache.enabled }}
{{- if .Values.cache.cacheUrl }}
CACHE_REDIS_URL = {{ .Values.cache.cacheUrl | quote }}
{{- else }}
{{- $useSSL := and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
_redis_user = quote(env('REDIS_USER', ''), safe='')
_redis_password = quote(env('REDIS_PASSWORD', ''), safe='')
_redis_auth = f"{_redis_user}:{_redis_password}@" if (_redis_user or _redis_password) else ""
REDIS_BASE_URL = f"{{ include "superset.redis.proto" . }}://{_redis_auth}{env('REDIS_HOST')}:{env('REDIS_PORT')}"
{{- if $useSSL }}
{{- $sslCertReqs := .Values.cache.ssl.ssl_cert_reqs | default "required" }}
REDIS_URL_PARAMS = f"?ssl_cert_reqs={{ $sslCertReqs }}"
{{- else }}
REDIS_URL_PARAMS = ""
{{- end}}
{{- end }}
{{- $cacheDb := include "superset.redis.cacheDb" . }}
CACHE_REDIS_URL = f"{REDIS_BASE_URL}/{{ $cacheDb }}{REDIS_URL_PARAMS}"
{{- end }}
{{- if .Values.cache.celeryUrl }}
CELERY_REDIS_URL = {{ .Values.cache.celeryUrl | quote }}
{{- else if not .Values.cache.cacheUrl }}
{{- $celeryDb := include "superset.redis.celeryDb" . }}
CELERY_REDIS_URL = f"{REDIS_BASE_URL}/{{ $celeryDb }}{REDIS_URL_PARAMS}"
{{- else }}
{{- if or .Values.config.celeryConfig (not .Values.cache.enabled) }}
{{- /* Custom celeryConfig provided or cache disabled - OK */}}
{{- else }}
{{- fail "CONFIGURATION ERROR: cache.cacheUrl is set but cache.celeryUrl is not set. When using cacheUrl, you must also set celeryUrl for Celery to work. Alternatively, set config.celeryConfig to provide a custom Celery configuration." }}
{{- end }}
{{- end }}
{{- end }}
# Build Redis URLs
CACHE_REDIS_URL = f"{REDIS_BASE_URL}/{env('REDIS_DB', 1)}{REDIS_URL_PARAMS}"
CELERY_REDIS_URL = f"{REDIS_BASE_URL}/{env('REDIS_CELERY_DB', 0)}{REDIS_URL_PARAMS}"
MAPBOX_API_KEY = env('MAPBOX_API_KEY', '')
{{- /* Cache Configuration */}}
{{- if .Values.config.cacheConfig }}
CACHE_CONFIG = json.loads({{ .Values.config.cacheConfig | toJson | quote }})
{{- else if .Values.cache.enabled }}
CACHE_CONFIG = {
'CACHE_TYPE': 'RedisCache',
'CACHE_DEFAULT_TIMEOUT': 300,
'CACHE_KEY_PREFIX': 'superset_',
'CACHE_REDIS_URL': CACHE_REDIS_URL,
'CACHE_TYPE': 'RedisCache',
'CACHE_DEFAULT_TIMEOUT': {{ .Values.cache.defaultTimeout | default (.Values.config.cacheDefaultTimeout | default 86400) | int }},
'CACHE_KEY_PREFIX': {{ .Values.cache.keyPrefix | default "superset_" | quote }},
'CACHE_REDIS_URL': CACHE_REDIS_URL,
}
{{- end }}
{{- if .Values.config.dataCacheConfig }}
DATA_CACHE_CONFIG = json.loads({{ .Values.config.dataCacheConfig | toJson | quote }})
{{- else if .Values.config.cacheConfig }}
DATA_CACHE_CONFIG = CACHE_CONFIG
{{- else if .Values.cache.enabled }}
DATA_CACHE_CONFIG = CACHE_CONFIG
{{- end }}
{{- /* SQLLAB_ASYNC_TIME_LIMIT_SEC - Required for async_queries module import (default: 6 hours) */}}
{{- if .Values.config.SQLLAB_ASYNC_TIME_LIMIT_SEC }}
SQLLAB_ASYNC_TIME_LIMIT_SEC = {{ .Values.config.SQLLAB_ASYNC_TIME_LIMIT_SEC | int }}
{{- else }}
from datetime import timedelta
SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
{{- end }}
if os.getenv("SQLALCHEMY_DATABASE_URI"):
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI")
else:
{{- if eq .Values.supersetNode.connections.db_type "postgresql" }}
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{os.getenv('DB_USER')}:{os.getenv('DB_PASS')}@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
{{- else if eq .Values.supersetNode.connections.db_type "mysql" }}
SQLALCHEMY_DATABASE_URI = f"mysql+mysqldb://{os.getenv('DB_USER')}:{os.getenv('DB_PASS')}@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
{{- else }}
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }}
{{- /* Celery Configuration */}}
{{- if .Values.config.celeryConfig }}
{{- if kindIs "string" .Values.config.celeryConfig }}
{{ .Values.config.celeryConfig }}
{{- else }}
class CeleryConfig:
{{- range $key, $value := .Values.config.celeryConfig }}
{{ $key }} = json.loads({{ $value | toJson | quote }})
{{- end }}
{{- if hasKey .Values.config.celeryConfig "imports" }}
CELERY_IMPORTS = CeleryConfig.imports
{{- else }}
CELERY_IMPORTS = ()
{{- end }}
CELERY_CONFIG = CeleryConfig
{{- end }}
{{- else if .Values.cache.enabled }}
from celery.schedules import crontab
from datetime import timedelta
class CeleryConfig:
imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL
result_backend = CELERY_REDIS_URL
imports = (
"superset.sql_lab",
"superset.tasks.scheduler",
"superset.tasks.thumbnails",
"superset.tasks.cache",
)
broker_connection_retry_on_startup = True
worker_prefetch_multiplier = 10
task_acks_late = True
broker_url = CELERY_REDIS_URL
result_backend = CELERY_REDIS_URL
task_annotations = {
"sql_lab.get_sql_results": {
"rate_limit": "100/s",
},
}
beat_schedule = {
"reports.scheduler": {
"task": "reports.scheduler",
"schedule": crontab(minute="*", hour="*"),
"options": {"expires": int(timedelta(weeks=1).total_seconds())},
},
"reports.prune_log": {
"task": "reports.prune_log",
"schedule": crontab(minute=0, hour=0),
},
}
CELERY_IMPORTS = CeleryConfig.imports
CELERY_CONFIG = CeleryConfig
RESULTS_BACKEND = RedisCache(
host=env('REDIS_HOST'),
{{- if .Values.supersetNode.connections.redis_password }}
password=env('REDIS_PASSWORD'),
{{- end }}
port=env('REDIS_PORT'),
key_prefix='superset_results',
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
ssl=True,
ssl_cert_reqs=env('REDIS_SSL_CERT_REQS'),
{{- end }}
)
{{- end }}
{{ if .Values.configOverrides }}
# Overrides
{{- /* Celery Worker Health Check - File-based health probes for Kubernetes */}}
{{- if and .Values.supersetWorker.healthCheck .Values.supersetWorker.healthCheck.enabled }}
# Celery Worker Health Check Configuration
import threading
from celery import bootsteps
from celery.signals import worker_ready, worker_shutdown, worker_init
_readiness_file = {{ .Values.supersetWorker.healthCheck.readinessFile | default "/tmp/celery_worker_ready" | quote }}
_liveness_file = {{ .Values.supersetWorker.healthCheck.livenessFile | default "/tmp/celery_worker_alive" | quote }}
_heartbeat_interval = {{ .Values.supersetWorker.healthCheck.livenessHeartbeatInterval | default 10 | int }}
_liveness_thread = None
_liveness_stop_event = None
@worker_ready.connect
def create_ready_file(sender, **kwargs):
try:
open(_readiness_file, 'w').close()
except Exception as e:
print(f"Warning: Could not create readiness file: {e}")
@worker_shutdown.connect
def remove_ready_file(sender, **kwargs):
global _liveness_thread, _liveness_stop_event
if _liveness_stop_event:
_liveness_stop_event.set()
if _liveness_thread:
_liveness_thread.join(timeout=5)
try:
if os.path.exists(_readiness_file):
os.remove(_readiness_file)
if os.path.exists(_liveness_file):
os.remove(_liveness_file)
except Exception as e:
print(f"Warning: Could not remove health check files: {e}")
@worker_init.connect
def start_liveness_heartbeat(sender, **kwargs):
global _liveness_thread, _liveness_stop_event
_liveness_stop_event = threading.Event()
def update_liveness():
while not _liveness_stop_event.is_set():
try:
with open(_liveness_file, 'w') as f:
f.write(str(os.getpid()))
except Exception as e:
print(f"Warning: Could not update liveness file: {e}")
_liveness_stop_event.wait(_heartbeat_interval)
_liveness_thread = threading.Thread(target=update_liveness, daemon=True)
_liveness_thread.start()
{{- else }}
CELERY_WORKER_HEALTH_CHECK_ENABLED = False
{{- end }}
{{- /* Results Backend */}}
{{- $redisHostForBackend := include "superset.redis.host" . }}
{{- $redisPortForBackend := include "superset.redis.port" . }}
{{- $redisPasswordForBackend := include "superset.redis.password" . }}
{{- if .Values.config.resultsBackend }}
{{- if kindIs "string" .Values.config.resultsBackend }}
RESULTS_BACKEND = {{ .Values.config.resultsBackend }}
{{- else }}
RESULTS_BACKEND = RedisCache(
host={{ $redisHostForBackend | quote }},
{{- if $redisPasswordForBackend }}
password={{ $redisPasswordForBackend | quote }},
{{- end }}
port={{ $redisPortForBackend | int }},
key_prefix={{ .Values.cache.resultsBackendKeyPrefix | default "superset_results" | quote }},
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
ssl=True,
ssl_cert_reqs={{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
{{- end }}
)
{{- end }}
{{- else if .Values.cache.enabled }}
RESULTS_BACKEND = RedisCache(
host={{ $redisHostForBackend | quote }},
{{- if $redisPasswordForBackend }}
password={{ $redisPasswordForBackend | quote }},
{{- end }}
port={{ $redisPortForBackend | int }},
key_prefix={{ .Values.cache.resultsBackendKeyPrefix | default "superset_results" | quote }},
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
ssl=True,
ssl_cert_reqs={{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
{{- end }}
)
{{- end }}
{{- /* Global Async Queries Cache Backend */}}
{{- $redisUserForGaq := include "superset.redis.user" . }}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_CACHE_BACKEND }}
GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = json.loads({{ .Values.config.GLOBAL_ASYNC_QUERIES_CACHE_BACKEND | toJson | quote }})
{{- else if .Values.cache.enabled }}
GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_HOST": {{ $redisHostForBackend | quote }},
"CACHE_REDIS_PORT": {{ $redisPortForBackend | int }},
{{- if $redisUserForGaq }}
"CACHE_REDIS_USER": {{ $redisUserForGaq | quote }},
{{- end }}
{{- if $redisPasswordForBackend }}
"CACHE_REDIS_PASSWORD": {{ $redisPasswordForBackend | quote }},
{{- else }}
"CACHE_REDIS_PASSWORD": "",
{{- end }}
"CACHE_REDIS_DB": {{ .Values.cache.asyncQueries.db | default .Values.cache.cacheDb | default 0 | int }},
"CACHE_KEY_PREFIX": {{ .Values.cache.asyncQueries.keyPrefix | default "qc-" | quote }},
"CACHE_DEFAULT_TIMEOUT": {{ .Values.cache.asyncQueries.timeout | default 86400 | int }},
{{- if and .Values.cache.sentinel .Values.cache.sentinel.enabled }}
{{- if .Values.cache.sentinel.sentinels }}
"CACHE_REDIS_SENTINELS": {{ .Values.cache.sentinel.sentinels | toJson }},
{{- else }}
{{- fail "CONFIGURATION ERROR: cache.sentinel.enabled is true but cache.sentinel.sentinels is not set. You must provide Sentinel host(s) in cache.sentinel.sentinels (e.g., [['sentinel-host', 26379]])." }}
{{- end }}
"CACHE_REDIS_SENTINEL_MASTER": {{ .Values.cache.sentinel.master | default "mymaster" | quote }},
{{- if .Values.cache.sentinel.password }}
"CACHE_REDIS_SENTINEL_PASSWORD": {{ .Values.cache.sentinel.password | quote }},
{{- else }}
"CACHE_REDIS_SENTINEL_PASSWORD": None,
{{- end }}
{{- end }}
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
"CACHE_REDIS_SSL": True,
"CACHE_REDIS_SSL_CERTFILE": {{ if .Values.cache.ssl.certfile }}{{ .Values.cache.ssl.certfile | quote }}{{ else }}None{{ end }},
"CACHE_REDIS_SSL_KEYFILE": {{ if .Values.cache.ssl.keyfile }}{{ .Values.cache.ssl.keyfile | quote }}{{ else }}None{{ end }},
"CACHE_REDIS_SSL_CERT_REQS": {{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
"CACHE_REDIS_SSL_CA_CERTS": {{ if .Values.cache.ssl.ca_certs }}{{ .Values.cache.ssl.ca_certs | quote }}{{ else }}None{{ end }},
{{- else }}
"CACHE_REDIS_SSL": False,
"CACHE_REDIS_SSL_CERTFILE": None,
"CACHE_REDIS_SSL_KEYFILE": None,
"CACHE_REDIS_SSL_CERT_REQS": {{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
"CACHE_REDIS_SSL_CA_CERTS": None,
{{- end }}
}
{{- end }}
{{- /* Global Async Queries Results Backend */}}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND }}
GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND = json.loads({{ .Values.config.GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND | toJson | quote }})
{{- else if .Values.cache.enabled }}
GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND = {
"backend": "redis",
"host": {{ $redisHostForBackend | quote }},
"port": {{ $redisPortForBackend | int }},
"prefix": {{ .Values.cache.asyncQueries.keyPrefix | default "qc-" | quote }},
"db": {{ .Values.cache.asyncQueries.db | default .Values.cache.cacheDb | default 0 | int }},
{{- if $redisPasswordForBackend }}
"password": {{ $redisPasswordForBackend | quote }},
{{- end }}
}
{{- end }}
{{- /* Feature Flags */}}
{{- if .Values.featureFlags }}
FEATURE_FLAGS = {
{{- range $key, $value := .Values.featureFlags }}
{{- if kindIs "bool" $value }}
"{{ $key }}": {{ if $value }}True{{ else }}False{{ end }},
{{- else if kindIs "string" $value }}
"{{ $key }}": {{ $value | quote }},
{{- else if kindIs "float64" $value }}
"{{ $key }}": {{ $value }},
{{- else if kindIs "int" $value }}
"{{ $key }}": {{ $value }},
{{- else if kindIs "invalid" $value }}
"{{ $key }}": None,
{{- else }}
"{{ $key }}": json.loads({{ $value | toJson | quote }}),
{{- end }}
{{- end }}
}
{{- end }}
{{- /* FAB Security API - Required for List Roles view in 6.0.0+ */}}
{{- if not (hasKey .Values.config "FAB_ADD_SECURITY_API") }}
FAB_ADD_SECURITY_API = True
{{- end }}
{{- if not (hasKey .Values.config "FAB_ADD_SECURITY_VIEWS") }}
FAB_ADD_SECURITY_VIEWS = True
{{- end }}
{{- /* Global Async Queries Transport - Auto-configure for websockets if enabled */}}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_TRANSPORT }}
GLOBAL_ASYNC_QUERIES_TRANSPORT = {{ .Values.config.GLOBAL_ASYNC_QUERIES_TRANSPORT | quote }}
{{- else if .Values.supersetWebsockets.enabled }}
GLOBAL_ASYNC_QUERIES_TRANSPORT = "ws"
{{- else }}
GLOBAL_ASYNC_QUERIES_TRANSPORT = "polling"
{{- end }}
{{- /* Global Async Queries WebSocket URL */}}
{{- $wsUrl := "" }}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL }}
{{- $wsUrl = .Values.config.GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL }}
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.websocketUrl }}
{{- $wsUrl = .Values.supersetWebsockets.websocketUrl }}
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
{{- else if .Values.supersetWebsockets.enabled }}
{{- $wsServiceName := .Values.cluster.websocketServiceName }}
{{- if not $wsServiceName }}
{{- $wsServiceName = printf "%s-ws" (include "superset.fullname" .) }}
{{- end }}
{{- $wsPort := .Values.supersetWebsockets.service.port | default 8080 }}
{{- $wsPath := "/ws" }}
{{- $clusterDomain := .Values.cluster.domain | default ".svc.cluster.local" }}
{{- $wsUrl = printf "ws://%s.%s%s:%d%s" $wsServiceName .Release.Namespace $clusterDomain $wsPort $wsPath }}
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
{{- end }}
{{- /* Global Async Queries JWT Secret */}}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_SECRET }}
GLOBAL_ASYNC_QUERIES_JWT_SECRET = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_SECRET | quote }}
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.config.jwtSecret }}
GLOBAL_ASYNC_QUERIES_JWT_SECRET = {{ .Values.supersetWebsockets.config.jwtSecret | quote }}
{{- end }}
{{- /* Global Async Queries JWT Cookie Settings */}}
{{- if hasKey .Values.config "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE" }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE | toString | title }}
{{- else if and .Values.supersetWebsockets.enabled (or (hasPrefix "wss://" $wsUrl) .Values.ingress.tls) }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = True
{{- else if .Values.supersetWebsockets.enabled }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = False
{{- end }}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE | quote }}
{{- else if .Values.supersetWebsockets.enabled }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE = "Lax"
{{- end }}
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME | quote }}
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.config.jwtCookieName }}
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME = {{ .Values.supersetWebsockets.config.jwtCookieName | quote }}
{{- end }}
{{- /* Content Security Policy (CSP) */}}
{{- if and .Values.supersetWebsockets.enabled $wsUrl (not (hasKey .Values.config "TALISMAN_CONFIG")) }}
TALISMAN_CONFIG = {
"content_security_policy": {
"base-uri": ["'self'"],
"default-src": ["'self'"],
"img-src": [
"'self'",
"blob:",
"data:",
"https://apachesuperset.gateway.scarf.sh",
"https://static.scarf.sh/",
"ows.terrestris.de",
"https://cdn.document360.io",
],
"worker-src": ["'self'", "blob:"],
"connect-src": [
"'self'",
{{ $wsUrl | quote }},
"https://api.mapbox.com",
"https://events.mapbox.com",
"https://tile.openstreetmap.org",
"https://tile.osm.ch",
],
"object-src": "'none'",
"style-src": [
"'self'",
"'unsafe-inline'",
],
"script-src": ["'self'", "'strict-dynamic'"],
},
"content_security_policy_nonce_in": ["script-src"],
{{- if or (hasPrefix "wss://" $wsUrl) .Values.ingress.tls }}
"force_https": True,
"session_cookie_secure": True,
{{- else }}
"force_https": False,
"session_cookie_secure": False,
{{- end }}
}
{{- end }}
{{- /* General Configuration - iterate through all config values */}}
{{- range $key, $value := .Values.config }}
{{- if and (ne $key "cacheConfig") (ne $key "dataCacheConfig") (ne $key "celeryConfig") (ne $key "resultsBackend") (ne $key "GLOBAL_ASYNC_QUERIES_CACHE_BACKEND") (ne $key "GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND") (ne $key "GLOBAL_ASYNC_QUERIES_TRANSPORT") (ne $key "GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_SECRET") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME") (ne $key "TALISMAN_CONFIG") (ne $key "SQLLAB_ASYNC_TIME_LIMIT_SEC") (ne $key "SQLALCHEMY_TRACK_MODIFICATIONS") }}
{{- if kindIs "map" $value }}
{{ $key }} = json.loads({{ $value | toJson | quote }})
{{- else if kindIs "slice" $value }}
{{ $key }} = json.loads({{ $value | toJson | quote }})
{{- else if kindIs "bool" $value }}
{{ $key }} = {{ if $value }}True{{ else }}False{{ end }}
{{- else if kindIs "string" $value }}
{{- if or (hasPrefix "f\"" $value) (hasPrefix "F\"" $value) (hasPrefix "r\"" $value) (hasPrefix "R\"" $value) (hasPrefix "b\"" $value) (hasPrefix "B\"" $value) }}
{{ $key }} = {{ $value }}
{{- else }}
{{ $key }} = {{ $value | quote }}
{{- end }}
{{- else if kindIs "invalid" $value }}
{{ $key }} = None
{{- else }}
{{ $key }} = {{ $value | toJson }}
{{- end }}
{{- end }}
{{- end }}
{{- /* Custom Config Overrides */}}
{{- if .Values.configOverrides }}
# Custom Overrides
{{- range $key, $value := .Values.configOverrides }}
# {{ $key }}
{{ tpl $value $ }}
{{- end }}
{{- end }}
{{ if .Values.configOverridesFiles }}
{{- if .Values.configOverridesFiles }}
# Overrides from files
{{- $files := .Files }}
{{- range $key, $value := .Values.configOverridesFiles }}
@@ -187,7 +680,57 @@ RESULTS_BACKEND = RedisCache(
{{- end }}
{{- end }}
{{- end -}}
{{- define "superset.initScript" -}}
#!/bin/sh
set -eu
echo "Upgrading DB schema..."
superset db upgrade
echo "Initializing roles and permissions..."
superset init
echo "Init job: Creating admin user and loading initial data..."
{{- if .Values.init.createAdmin }}
echo "Creating admin user (if not present)..."
if superset fab list-users 2>/dev/null | grep -qF {{ printf "username:%s" .Values.init.adminUser.username | squote }}; then
echo "Admin user already exists, skipping."
else
superset fab create-admin \
--username {{ .Values.init.adminUser.username | squote }} \
--firstname {{ .Values.init.adminUser.firstname | squote }} \
--lastname {{ .Values.init.adminUser.lastname | squote }} \
--email {{ .Values.init.adminUser.email | squote }} \
--password {{ .Values.init.adminUser.password | squote }}
fi
{{- else }}
echo "Skipping admin creation (init.createAdmin=false)"
{{- end }}
{{- if .Values.init.loadExamples }}
echo "Loading examples..."
superset load_examples
{{- else }}
echo "Skipping examples (init.loadExamples=false)"
{{- end }}
if [ -f "{{ .Values.extraConfigMountPath }}/import_datasources.yaml" ]; then
echo "Importing database connections..."
superset import_datasources -p {{ .Values.extraConfigMountPath }}/import_datasources.yaml
fi
echo "Init job complete."
{{- end -}}
{{/*
Deprecation warnings returns a newline-separated list of active deprecation messages,
or empty string when no deprecated keys are set. Rendered in NOTES.txt after install/upgrade.
*/}}
{{- define "superset.deprecationWarnings" -}}
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
{{- if gt (len (keys $conn)) 0 }}
- supersetNode.connections.* is deprecated; use database.* and cache.* (auto-mapped for now). See UPGRADING.md
{{- end }}
{{- if .Values.serviceAccountName }}
- root serviceAccountName is deprecated; use serviceAccount.name (auto-mapped for now). See UPGRADING.md
{{- end }}
{{- end -}}
{{- define "supersetNode.selectorLabels" -}}
app.kubernetes.io/name: {{ include "superset.name" . }}

View File

@@ -37,9 +37,12 @@ spec:
template:
metadata:
annotations:
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
checksum/database: {{ .Values.database | toYaml | sha256sum }}
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
checksum/config: {{ .Values.config | toYaml | sha256sum }}
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
@@ -52,6 +55,9 @@ spec:
{{- if .Values.supersetCeleryBeat.podAnnotations }}
{{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }}
{{- end }}
{{- with .Values.globalPodAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryBeat.podLabels }}

View File

@@ -36,11 +36,14 @@ spec:
template:
metadata:
annotations:
checksum/config: {{ include "superset-config" . | sha256sum }}
checksum/config: {{ include "superset.config" . | sha256sum }}
checksum/secrets: {{ tpl (toJson .Values.extraSecretEnv) . | sha256sum }}
{{- if .Values.supersetCeleryFlower.podAnnotations }}
{{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }}
{{- end }}
{{- with .Values.globalPodAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryFlower.podLabels }}

View File

@@ -43,9 +43,12 @@ spec:
template:
metadata:
annotations:
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
checksum/database: {{ .Values.database | toYaml | sha256sum }}
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
checksum/config: {{ .Values.config | toYaml | sha256sum }}
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
@@ -58,6 +61,9 @@ spec:
{{- if .Values.supersetWorker.podAnnotations }}
{{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }}
{{- end }}
{{- with .Values.globalPodAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "supersetWorker.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWorker.podLabels }}

View File

@@ -44,6 +44,9 @@ spec:
{{- if .Values.supersetWebsockets.podAnnotations }}
{{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }}
{{- end }}
{{- with .Values.globalPodAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "supersetWebsockets.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWebsockets.podLabels }}

View File

@@ -44,10 +44,13 @@ spec:
metadata:
annotations:
# Force reload on config changes
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
checksum/superset_init.sh: {{ tpl .Values.init.initscript . | sha256sum }}
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
checksum/superset_init.sh: {{ include "superset.initScript" . | sha256sum }}
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
checksum/database: {{ .Values.database | toYaml | sha256sum }}
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
checksum/config: {{ .Values.config | toYaml | sha256sum }}
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
@@ -60,6 +63,9 @@ spec:
{{- if .Values.supersetNode.podAnnotations }}
{{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }}
{{- end }}
{{- with .Values.globalPodAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "supersetNode.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetNode.podLabels }}

View File

@@ -26,24 +26,28 @@ metadata:
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }}
REDIS_USER: {{ .Values.supersetNode.connections.redis_user | quote }}
{{- if .Values.supersetNode.connections.redis_password }}
REDIS_PASSWORD: {{ .Values.supersetNode.connections.redis_password | quote }}
DB_HOST: {{ include "superset.db.host" . | quote }}
DB_PORT: {{ include "superset.db.port" . | quote }}
DB_USER: {{ include "superset.db.user" . | quote }}
DB_PASS: {{ include "superset.db.password" . | quote }}
DB_NAME: {{ include "superset.db.name" . | quote }}
{{- if .Values.database.ssl.enabled }}
DB_SSL_MODE: {{ .Values.database.ssl.mode | default "require" | quote }}
{{- end }}
{{- if .Values.cache.enabled }}
REDIS_HOST: {{ include "superset.redis.host" . | quote }}
REDIS_PORT: {{ include "superset.redis.port" . | quote }}
REDIS_USER: {{ include "superset.redis.user" . | quote }}
{{- if include "superset.redis.password" . }}
REDIS_PASSWORD: {{ include "superset.redis.password" . | quote }}
{{- end }}
REDIS_DB: {{ include "superset.redis.cacheDb" . | quote }}
REDIS_CELERY_DB: {{ include "superset.redis.celeryDb" . | quote }}
REDIS_PROTO: {{ include "superset.redis.proto" . | quote }}
{{- if .Values.cache.driver }}
REDIS_DRIVER: {{ .Values.cache.driver | quote }}
{{- end }}
REDIS_PORT: {{ .Values.supersetNode.connections.redis_port | quote }}
REDIS_PROTO: {{ if .Values.supersetNode.connections.redis_ssl.enabled }}"rediss"{{ else }}"redis"{{ end }}
REDIS_DRIVER: {{ .Values.supersetNode.connections.redis_driver | quote }}
REDIS_DB: {{ .Values.supersetNode.connections.redis_cache_db | quote }}
REDIS_CELERY_DB: {{ .Values.supersetNode.connections.redis_celery_db | quote }}
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
REDIS_SSL_CERT_REQS: {{ .Values.supersetNode.connections.redis_ssl.ssl_cert_reqs | default "CERT_NONE" | quote }}
{{- end }}
DB_HOST: {{ tpl .Values.supersetNode.connections.db_host . | quote }}
DB_PORT: {{ .Values.supersetNode.connections.db_port | quote }}
DB_USER: {{ .Values.supersetNode.connections.db_user | quote }}
DB_PASS: {{ .Values.supersetNode.connections.db_pass | quote }}
DB_NAME: {{ .Values.supersetNode.connections.db_name | quote }}
{{- if .Values.extraSecretEnv }}
{{- range $key, $value := .Values.extraSecretEnv }}
{{ $key }}: {{ $value | quote }}

View File

@@ -27,9 +27,9 @@ metadata:
type: Opaque
stringData:
superset_config.py: |
{{- include "superset-config" . | nindent 4 }}
{{- include "superset.config" . | nindent 4 }}
superset_init.sh: |
{{- tpl .Values.init.initscript . | nindent 4 }}
{{- include "superset.initScript" . | nindent 4 }}
superset_bootstrap.sh: |
{{- tpl .Values.bootstrapScript . | nindent 4 }}

View File

@@ -0,0 +1,132 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: config checksums + global pod annotations
templates:
- deployment.yaml
- deployment-worker.yaml
- deployment-beat.yaml
- deployment-flower.yaml
- deployment-ws.yaml
tests:
- it: main deployment has new config checksums, not the old connections checksum
template: deployment.yaml
asserts:
- exists:
path: 'spec.template.metadata.annotations["checksum/database"]'
- exists:
path: 'spec.template.metadata.annotations["checksum/config"]'
- notExists:
path: 'spec.template.metadata.annotations["checksum/connections"]'
- it: main deployment applies globalPodAnnotations
template: deployment.yaml
set:
globalPodAnnotations:
team: data
asserts:
- equal:
path: 'spec.template.metadata.annotations.team'
value: data
- it: globalPodAnnotations coexist with per-component podAnnotations on deployment.yaml
template: deployment.yaml
set:
globalPodAnnotations:
global: "yes"
supersetNode:
podAnnotations:
component: "yes"
asserts:
- equal:
path: 'spec.template.metadata.annotations.global'
value: "yes"
- equal:
path: 'spec.template.metadata.annotations.component'
value: "yes"
- it: worker deployment has config and database checksums
template: deployment-worker.yaml
asserts:
- exists:
path: 'spec.template.metadata.annotations["checksum/database"]'
- exists:
path: 'spec.template.metadata.annotations["checksum/config"]'
- it: worker deployment applies globalPodAnnotations
template: deployment-worker.yaml
set:
globalPodAnnotations:
team: data
asserts:
- equal:
path: 'spec.template.metadata.annotations.team'
value: data
- it: beat deployment has config and database checksums
template: deployment-beat.yaml
set:
supersetCeleryBeat:
enabled: true
asserts:
- exists:
path: 'spec.template.metadata.annotations["checksum/database"]'
- exists:
path: 'spec.template.metadata.annotations["checksum/config"]'
- it: beat deployment applies globalPodAnnotations
template: deployment-beat.yaml
set:
supersetCeleryBeat:
enabled: true
globalPodAnnotations:
team: data
asserts:
- equal:
path: 'spec.template.metadata.annotations.team'
value: data
- it: flower deployment has config checksum
template: deployment-flower.yaml
set:
supersetCeleryFlower:
enabled: true
asserts:
- exists:
path: 'spec.template.metadata.annotations["checksum/config"]'
- it: flower deployment applies globalPodAnnotations
template: deployment-flower.yaml
set:
supersetCeleryFlower:
enabled: true
globalPodAnnotations:
team: data
asserts:
- equal:
path: 'spec.template.metadata.annotations.team'
value: data
- it: websockets deployment has wsconfig checksum
template: deployment-ws.yaml
set:
supersetWebsockets:
enabled: true
asserts:
- exists:
path: 'spec.template.metadata.annotations["checksum/wsconfig"]'
- it: websockets deployment applies globalPodAnnotations
template: deployment-ws.yaml
set:
supersetWebsockets:
enabled: true
globalPodAnnotations:
team: data
asserts:
- equal:
path: 'spec.template.metadata.annotations.team'
value: data

View File

@@ -0,0 +1,94 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: superset_config.py rendering
templates:
- secret-superset-config.yaml
tests:
- it: builds SQLALCHEMY_DATABASE_URI from env with percent-encoded creds
set: {database: {host: pg.svc, port: 5432, user: su, password: pw, name: sset}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = f"postgresql\+psycopg2://\{quote\(env\(.DB_USER'}
- it: honors database.uri override
set: {database.uri: "postgresql://x:y@h:5432/d"}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = "postgresql://x:y@h:5432/d"'}
- it: imports required for env/json handling are present
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'import json'}
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'from urllib\.parse import quote'}
- it: redis creds are percent-encoded and read from env
set: {cache: {enabled: true, password: "p@ss"}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'quote\(env\(.REDIS_PASSWORD'}
- it: config map values render via json.loads
set: {config: {MY_MAP: {enabled: true}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'MY_MAP = json\.loads\('}
- it: config nil values render as None
set: {config: {MY_NIL: null}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'MY_NIL = None'}
- it: supersetWorker healthCheck enabled renders readiness file path
set: {supersetWorker: {healthCheck: {enabled: true}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '/tmp/celery_worker_ready'}
- it: SQLALCHEMY_TRACK_MODIFICATIONS defaults False
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_TRACK_MODIFICATIONS = False'}
- it: renders feature flags
set: {featureFlags: {ALERT_REPORTS: true}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"ALERT_REPORTS": True'}
- it: SQLALCHEMY_TRACK_MODIFICATIONS user-set true renders Python True
set: {config: {SQLALCHEMY_TRACK_MODIFICATIONS: true}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_TRACK_MODIFICATIONS = True'}
- it: celeryConfig without imports emits a safe empty CELERY_IMPORTS (no AttributeError)
set: {config: {celeryConfig: {broker_url: "redis://x"}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'CELERY_IMPORTS = \(\)'}
- notMatchRegex: {path: 'stringData["superset_config.py"]', pattern: 'CELERY_IMPORTS = CeleryConfig\.imports'}
- it: legacy db_type maps to the SQLAlchemy driver (deprecation path)
set: {supersetNode: {connections: {db_type: "postgresql+pg8000"}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = f"postgresql\+pg8000://'}
- it: init script single-quotes the admin username (no shell injection)
set: {init: {createAdmin: true, adminUser: {password: "x"}}}
asserts:
- matchRegex: {path: 'stringData["superset_init.sh"]', pattern: "grep -qF 'username:"}
- notMatchRegex: {path: 'stringData["superset_init.sh"]', pattern: 'create-admin[\s\S]*\|\| true'}
- it: sentinel disabled does not fail render
set: {cache: {enabled: true, sentinel: {enabled: false}}}
asserts:
- hasDocuments:
count: 1
- it: ssl certfile path is quoted
set: {cache: {enabled: true, ssl: {enabled: true, certfile: "/etc/ssl/cert.pem"}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"CACHE_REDIS_SSL_CERTFILE": "/etc/ssl/cert.pem"'}
- it: explicit JWT cookie secure false is respected
set: {supersetWebsockets: {enabled: true}, config: {GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE: false}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = False'}
- it: celeryConfig non-string values render as valid Python via json.loads
set: {config: {celeryConfig: {broker_url: "redis://x", task_acks_late: true}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'task_acks_late = json\.loads\('}
- it: featureFlags non-scalar values render as valid Python via json.loads
set: {featureFlags: {SOME_MAP: {nested: true}}}
asserts:
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"SOME_MAP": json\.loads\('}

View File

@@ -0,0 +1,28 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: deprecation path does not fail render
templates:
- secret-superset-config.yaml
tests:
- it: legacy supersetNode.connections still renders (no fail)
set: {supersetNode.connections.db_host: legacy}
asserts:
- hasDocuments: {count: 1}
- it: legacy root serviceAccountName still renders (no fail)
set: {serviceAccountName: my-sa}
asserts:
- hasDocuments: {count: 1}

View File

@@ -0,0 +1,63 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: superset_init.sh rendering
templates:
- secret-superset-config.yaml
tests:
- it: renders superset db upgrade in init script
asserts:
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'superset db upgrade'
- it: renders superset init in init script
asserts:
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'superset init'
- it: renders db upgrade before admin creation
set:
init:
createAdmin: true
adminUser:
username: admin
firstname: Admin
lastname: User
email: admin@example.com
password: adminpass
asserts:
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'superset db upgrade'
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'superset init'
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'fab create-admin'
- it: admin creation is idempotent and does not swallow real failures
set:
init:
createAdmin: true
adminUser:
password: "x"
asserts:
- matchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'superset fab list-users'
- notMatchRegex:
path: 'stringData["superset_init.sh"]'
pattern: 'create-admin.*\|\| true'

View File

@@ -0,0 +1,71 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: secret-env schema + deprecation mapping
templates:
- secret-env.yaml
tests:
- it: emits DB_* and REDIS_* from new schema
set:
database: {host: pg.svc, port: 5432, user: su, password: pw, name: superset}
cache: {enabled: true, host: rd.svc, port: 6379, cacheDb: 1, celeryDb: 0}
asserts:
- equal: {path: 'stringData.DB_HOST', value: pg.svc}
- equal: {path: 'stringData.DB_PORT', value: "5432"}
- equal: {path: 'stringData.REDIS_HOST', value: rd.svc}
- it: maps ALL legacy supersetNode.connections.* keys (deprecation path, no fail)
set:
cache:
enabled: true
supersetNode:
connections:
db_host: legacy-pg
db_port: "5555"
db_user: legacy-user
db_pass: legacy-pass
db_name: legacy-name
redis_host: legacy-rd
redis_port: "6380"
redis_cache_db: "7"
redis_celery_db: "8"
redis_driver: rediss
asserts:
- equal: {path: 'stringData.DB_HOST', value: legacy-pg}
- equal: {path: 'stringData.DB_PORT', value: "5555"}
- equal: {path: 'stringData.DB_USER', value: legacy-user}
- equal: {path: 'stringData.DB_PASS', value: legacy-pass}
- equal: {path: 'stringData.DB_NAME', value: legacy-name}
- equal: {path: 'stringData.REDIS_HOST', value: legacy-rd}
- equal: {path: 'stringData.REDIS_PORT', value: "6380"}
- equal: {path: 'stringData.REDIS_DB', value: "7"}
- equal: {path: 'stringData.REDIS_CELERY_DB', value: "8"}
- equal: {path: 'stringData.REDIS_PROTO', value: rediss}
- it: new-schema values still win over legacy when both are set
set:
database: {host: new-host, port: 1234}
supersetNode:
connections: {db_host: old-host, db_port: "9999"}
asserts:
- equal: {path: 'stringData.DB_HOST', value: new-host}
- equal: {path: 'stringData.DB_PORT', value: "1234"}
- it: emits REDIS_DRIVER when cache.driver set
set: {cache: {enabled: true, driver: "rediss+managed"}}
asserts:
- equal: {path: 'stringData.REDIS_DRIVER', value: "rediss+managed"}
- it: omits redis vars when cache disabled
set: {cache.enabled: false}
asserts:
- notExists: {path: 'stringData.REDIS_HOST'}

View File

@@ -0,0 +1,28 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: serviceAccountName resolution
templates:
- serviceaccount.yaml
tests:
- it: uses serviceAccount.name when set
set: {serviceAccount: {create: true, name: my-sa}}
asserts:
- equal: {path: 'metadata.name', value: my-sa}
- it: falls back to legacy root serviceAccountName (deprecation path)
set: {serviceAccount: {create: true}, serviceAccountName: legacy-sa}
asserts:
- equal: {path: 'metadata.name', value: legacy-sa}

View File

@@ -0,0 +1,30 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
suite: values defaults
templates:
- secret-env.yaml
tests:
- it: renders with new database defaults
set:
database.host: db.example
cache.enabled: true
asserts:
- hasDocuments:
count: 1
- equal:
path: 'stringData.DB_HOST'
value: db.example

View File

@@ -0,0 +1,140 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": true,
"properties": {
"database": {
"type": "object",
"additionalProperties": true,
"properties": {
"uri": {
"type": ["string", "null"]
},
"driver": {
"type": ["string", "null"]
},
"host": {
"type": ["string", "null"]
},
"port": {
"type": ["integer", "string", "null"]
},
"user": {
"type": ["string", "null"]
},
"password": {
"type": ["string", "null"]
},
"name": {
"type": ["string", "null"]
},
"ssl": {
"type": "object",
"additionalProperties": true,
"properties": {
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string"
}
}
}
}
},
"cache": {
"type": "object",
"additionalProperties": true,
"properties": {
"enabled": {
"type": "boolean"
},
"driver": {
"type": "string"
},
"host": {
"type": ["string", "null"]
},
"port": {
"type": ["integer", "string", "null"]
},
"cacheDb": {
"type": ["integer", "string", "null"]
},
"celeryDb": {
"type": ["integer", "string", "null"]
},
"ssl": {
"type": "object",
"additionalProperties": true,
"properties": {
"enabled": {
"type": "boolean"
}
}
}
}
},
"cluster": {
"type": "object",
"additionalProperties": true,
"properties": {
"domain": {
"type": "string"
},
"databaseServiceName": {
"type": ["string", "null"]
},
"redisServiceName": {
"type": ["string", "null"]
},
"websocketServiceName": {
"type": ["string", "null"]
}
}
},
"config": {
"type": "object",
"additionalProperties": true
},
"featureFlags": {
"type": "object",
"additionalProperties": true
},
"serviceAccount": {
"type": "object",
"additionalProperties": true,
"properties": {
"create": {
"type": "boolean"
},
"name": {
"type": "string"
},
"annotations": {
"type": "object",
"additionalProperties": true
}
}
},
"globalPodAnnotations": {
"type": "object",
"additionalProperties": true
},
"image": {
"type": "object",
"additionalProperties": true,
"properties": {
"repository": {
"type": "string"
},
"tag": {
"type": ["string", "null"]
},
"pullPolicy": {
"type": "string"
}
}
}
}
}

View File

@@ -28,9 +28,30 @@ nameOverride: ~
# -- Provide a name to override the full names of resources
fullnameOverride: ~
# -- Kubernetes cluster configuration
# Used for constructing service URLs between chart components
cluster:
# -- Kubernetes cluster domain (default: .svc.cluster.local)
# Override if using a custom cluster domain
domain: ".svc.cluster.local"
# -- Database service name (default: {{ .Release.Name }}-postgresql)
# Override if using a different service name for the database
databaseServiceName: ~
# -- Redis service name (default: {{ .Release.Name }}-redis-headless)
# Override if using a different service name for Redis
redisServiceName: ~
# -- WebSocket service name (default: {{ .Release.Name }}-ws)
# Override if using a different service name for the WebSocket service
websocketServiceName: ~
# -- Labels to be added to all resources
extraLabels: {}
# -- Global pod annotations to be added to all pods
# Use this to set annotations that apply to all Superset components
# Component-specific podAnnotations will be merged with these global annotations
globalPodAnnotations: {}
# -- User ID directive. This user must have enough permissions to run the bootstrap script
# Running containers as root is not recommended in production. Change this to another UID - e.g. 1000 to be more secure
runAsUser: 0
@@ -46,6 +67,8 @@ serviceAccountName: ~
serviceAccount:
# -- Create custom service account for Superset. If create: true and serviceAccountName is not provided, `superset.fullname` will be used.
create: false
# -- Service account name to use (if not specified, defaults to release name + chart name)
name: ""
annotations: {}
# -- Install additional packages and do any other bootstrap configuration in this script
@@ -244,6 +267,89 @@ hostAliases: []
# - nodns.my.lan
# ip: 18.27.36.45
# -- Database connection configuration for the Superset metadata database
database:
# -- Full database URI (overrides host/port/user/pass/name if set)
# Example: "postgresql+psycopg2://user:pass@host:5432/dbname"
uri: ~
# -- Database driver used when uri is not set (default: postgresql+psycopg2 when unset;
# legacy supersetNode.connections.db_type is honored when this is unset)
driver: ~
# -- Database host (default: {{ .Release.Name }}-postgresql)
host: ~
# -- Database port (default: 5432 when unset; legacy supersetNode.connections.db_port is honored)
port: ~
# -- Database user (default: superset, resolved via superset.db.user)
user: ~
# -- Database password (default: superset, resolved via superset.db.password)
# ⚠️ CHANGE THIS for production
password: ~
# -- Database name (default: superset, resolved via superset.db.name)
name: ~
# -- Database SSL configuration
ssl:
enabled: false
mode: require
# -- Redis cache configuration for Superset
# Redis is optional but recommended for caching and Celery.
# If redis.enabled (chart dependency) is true, defaults point to the chart's Redis instance.
cache:
# -- Enable Redis-based features (cache, Celery). Set to false to disable Redis usage entirely.
enabled: true
# -- Full Redis cache URL (overrides host/port/user/pass if set)
cacheUrl: ~
# -- Full Redis Celery URL (overrides host/port/user/pass if set)
celeryUrl: ~
# -- Redis host (default: {{ .Release.Name }}-redis-headless)
host: ~
# -- Redis port (default: 6379 when unset; legacy supersetNode.connections.redis_port is honored)
port: ~
# -- Redis user (optional, for Redis ACL)
user: ""
# -- Redis password
password: ~
# -- Redis database number for cache (default: 1 when unset; legacy redis_cache_db is honored)
cacheDb: ~
# -- Redis database number for Celery (default: 0 when unset; legacy redis_celery_db is honored)
celeryDb: ~
# -- Cache key prefix
keyPrefix: "superset_"
# -- Default cache timeout in seconds
defaultTimeout: 86400
# -- Results backend key prefix
resultsBackendKeyPrefix: "superset_results"
# -- Async queries configuration
asyncQueries:
keyPrefix: "qc-"
timeout: 86400
# -- Redis Sentinel configuration (optional)
sentinel: ~
# -- Redis SSL configuration
ssl:
enabled: false
ssl_cert_reqs: required
certfile: ~
keyfile: ~
ca_certs: ~
# -- Custom Redis driver (e.g. TLS/managed variants); overrides the redis proto in URLs when set.
# Ports the legacy supersetNode.connections.redis_driver escape hatch.
driver: ""
# -- Superset configuration properties
# Set any configuration property from superset/config.py here
# See https://github.com/apache/superset/blob/master/superset/config.py for all available options
config: {}
# SECRET_KEY: "your-secret-key-here"
# ROW_LIMIT: 50000
# DEBUG: false
# -- Feature flags configuration
# See https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md
featureFlags: {}
# ALERT_REPORTS: true
# DASHBOARD_RBAC: true
# Superset node configuration
supersetNode:
replicas:
@@ -270,28 +376,8 @@ supersetNode:
- "/bin/sh"
- "-c"
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; exec /usr/bin/run-server.sh"
connections:
# -- Change in case of bringing your own redis and then also set redis.enabled:false
redis_host: "{{ .Release.Name }}-redis-headless"
redis_port: "6379"
redis_user: ""
# redis_password: superset
redis_cache_db: "1"
redis_celery_db: "0"
# Or SSL port is usually 6380
# Update following for using Redis with SSL
redis_ssl:
enabled: false
ssl_cert_reqs: CERT_NONE
redis_driver: ""
# You need to change below configuration incase bringing own PostgresSQL instance and also set postgresql.enabled:false
# -- Database type for Superset metadata (Supported types: "postgresql", "mysql")
db_type: "postgresql"
db_host: "{{ .Release.Name }}-postgresql"
db_port: "5432"
db_user: superset
db_pass: superset
db_name: superset
# DEPRECATED: use database.* and cache.* instead (still honored if set). See UPGRADING.md
connections: {}
env: {}
# -- If true, forces deployment to reload on each upgrade
forceReload: false
@@ -508,6 +594,17 @@ supersetWorker:
startupProbe: {}
# -- No startup/readiness probes by default since we don't really care about its startup time (it doesn't serve traffic)
readinessProbe: {}
# -- Celery worker file-based health check (worker writes readiness/liveness files via signals;
# point supersetWorker.readinessProbe/livenessProbe at these files to use them)
healthCheck:
# -- Enable the file-based Celery worker health check
enabled: false
# -- Readiness file (created when the worker is ready, removed on shutdown)
readinessFile: "/tmp/celery_worker_ready"
# -- Liveness file (touched periodically by a heartbeat thread)
livenessFile: "/tmp/celery_worker_alive"
# -- Seconds between liveness heartbeats
livenessHeartbeatInterval: 10
# -- Set priorityClassName for supersetWorker pods
priorityClassName: ~
@@ -870,8 +967,11 @@ init:
requests:
cpu: "250m"
memory: "128Mi"
# -- A Superset init script
# @default -- a script to create admin user and initialize roles
# -- DEPRECATED: this field is no longer used by the chart.
# The init script is rendered entirely from the internal `superset.initScript` template
# (which runs `superset db upgrade`, `superset init`, admin creation, and examples).
# Any customization placed here is silently ignored. See UPGRADING.md.
# @default -- unused; kept for backwards-compatibility only
initscript: |-
#!/bin/sh
set -eu

View File

@@ -161,7 +161,7 @@ fastmcp = [
# heuristic that under-counts JSON-heavy MCP responses.
"tiktoken>=0.13.0,<1.0",
]
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebird = ["sqlalchemy-firebird>=0.8.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
@@ -173,7 +173,7 @@ hive = [
"thrift>=0.23.0, <1.0.0",
"thrift_sasl>=0.4.3, < 1.0.0",
]
impala = ["impyla>0.16.2, <0.23"]
impala = ["impyla>=0.24.0, <0.25"]
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kylin = ["kylinpy>=2.8.4, <2.9"]
mssql = ["pymssql>=2.3.13, <3"]

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent, fireEvent } from '@superset-ui/core/spec';
import { ModalTrigger } from '.';
const mockedProps = {
@@ -74,3 +75,42 @@ test('should render a modal after click', async () => {
await userEvent.click(screen.getByRole('button'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('stops propagation of navigation keys to parent elements', async () => {
const handleParentKeyDown = jest.fn();
render(
<div onKeyDown={handleParentKeyDown}>
<ModalTrigger
triggerNode={<span>Trigger</span>}
modalBody={<input data-test="modal-input" />}
/>
</div>,
);
await userEvent.click(screen.getByText('Trigger'));
const input = await screen.findByTestId('modal-input');
const blockedKeys = [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
];
for (const key of blockedKeys) {
handleParentKeyDown.mockClear();
fireEvent.keyDown(input, { key });
expect(handleParentKeyDown).not.toHaveBeenCalled();
}
const allowedKeys = ['Escape', 'Tab'];
for (const key of allowedKeys) {
handleParentKeyDown.mockClear();
fireEvent.keyDown(input, { key });
expect(handleParentKeyDown).toHaveBeenCalled();
}
});

View File

@@ -27,6 +27,15 @@ import {
import { Button } from '../Button';
import { Modal } from '../Modal';
const MENU_NAVIGATION_KEYS = new Set([
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
]);
export interface ModalTriggerProps {
dialogClassName?: string;
triggerNode: ReactNode;
@@ -140,7 +149,16 @@ export const ModalTrigger = forwardRef(
draggableConfig={draggableConfig}
destroyOnHidden={destroyOnHidden}
>
{modalBody}
<div
style={{ display: 'contents' }}
onKeyDown={e => {
if (MENU_NAVIGATION_KEYS.has(e.key)) {
e.stopPropagation();
}
}}
>
{modalBody}
</div>
</Modal>
</>
);

View File

@@ -62,24 +62,29 @@ export function formatLabel({
params: CallbackDataParams;
labelType: EchartsRadarLabelType;
numberFormatter: NumberFormatter;
getDenormalizedSeriesValue: (seriesName: string, value: string) => number;
getDenormalizedSeriesValue: (
seriesName: string,
value: string,
) => number | null;
metricsWithCustomBounds: Set<string>;
metricLabels: string[];
}): string {
const { name = '', value, dimensionIndex = 0 } = params;
const metricLabel = metricLabels[dimensionIndex];
const formattedValue = numberFormatter(
metricsWithCustomBounds.has(metricLabel)
? (value as number)
: (getDenormalizedSeriesValue(name, String(value)) as number),
);
const rawValue = metricsWithCustomBounds.has(metricLabel)
? (value as number | null)
: getDenormalizedSeriesValue(name, String(value));
// A missing metric is preserved as null so it renders as a gap; skip
// formatting it into a misleading "NaN" label.
const formattedValue = rawValue == null ? '' : numberFormatter(rawValue);
switch (labelType) {
case EchartsRadarLabelType.Value:
return formattedValue;
case EchartsRadarLabelType.KeyValue:
return `${name}: ${formattedValue}`;
return formattedValue ? `${name}: ${formattedValue}` : name;
default:
return name;
}
@@ -134,9 +139,20 @@ export default function transformProps(
const getDenormalizedSeriesValue = (
seriesName: string,
normalizedValue: string,
): number =>
denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ??
Number(normalizedValue);
): number | null => {
const seriesMap = denormalizedSeriesValues?.[seriesName];
// A missing metric is recorded explicitly as `null`, so use
// `hasOwnProperty` rather than `??`/`?.` to distinguish "recorded as
// null" from "never recorded" (which falls back to parsing the raw
// normalized value).
if (
seriesMap &&
Object.prototype.hasOwnProperty.call(seriesMap, normalizedValue)
) {
return seriesMap[normalizedValue];
}
return Number(normalizedValue);
};
const metricLabels = metrics.map(getMetricLabel);
@@ -254,14 +270,31 @@ export default function transformProps(
{},
);
const normalizeArray = (arr: number[], decimals = 10, seriesName: string) =>
const normalizeArray = (
arr: (number | null)[],
decimals = 10,
seriesName: string,
): (number | null)[] =>
arr.map((value, index) => {
const metricLabel = metricLabels[index];
if (metricsWithCustomBounds.has(metricLabel)) {
return value;
}
const max = Math.max(...arr);
// Preserve missing (null/undefined) metric values so they render as a
// gap. Dividing null/undefined by max coerces it to 0, which would plot
// the point at the center of the radar as if it were a real zero.
// Explicitly record the denormalized entry as null (rather than
// leaving it unset) so downstream label/tooltip lookups can tell a
// recorded gap apart from an unrecorded value and skip formatting it.
if (value == null || !Number.isFinite(value)) {
denormalizedSeriesValues[seriesName][String(null)] = null;
return null;
}
const max = Math.max(
...arr.filter((v): v is number => v != null && Number.isFinite(v)),
);
const normalizedValue = Number((value / max).toFixed(decimals));
denormalizedSeriesValues[seriesName][String(normalizedValue)] = value;
@@ -276,7 +309,11 @@ export default function transformProps(
return {
...series,
value: normalizeArray(series.value as number[], 10, seriesName),
value: normalizeArray(
series.value as (number | null)[],
10,
seriesName,
),
};
}
return series;
@@ -356,7 +393,7 @@ export default function transformProps(
params: CallbackDataParams & {
color: string;
name: string;
value: number[];
value: (number | null)[];
},
) =>
renderNormalizedTooltip(

View File

@@ -85,10 +85,12 @@ export type RadarChartTransformedProps =
CrossFilterTransformedProps;
/**
* Represents a mapping from a normalized value (as string) to an original numeric value.
* Represents a mapping from a normalized value (as string) to an original
* numeric value. A `null` entry is an explicit record of a missing metric,
* distinct from an absent key (which has not been recorded at all).
*/
interface NormalizedValueMap {
[normalized: string]: number;
[normalized: string]: number | null;
}
/**

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { NumberFormatter } from '@superset-ui/core';
/*
@@ -44,7 +45,7 @@ export const findGlobalMax = (
interface TooltipParams {
color: string;
name?: string;
value: number[];
value: (number | null)[];
}
interface TooltipMetricValue {
@@ -55,7 +56,7 @@ interface TooltipMetricValue {
export const renderNormalizedTooltip = (
params: TooltipParams,
metrics: string[],
getDenormalizedValue: (seriesName: string, value: string) => number,
getDenormalizedValue: (seriesName: string, value: string) => number | null,
metricsWithCustomBounds: Set<string>,
formatter?: NumberFormatter,
): string => {
@@ -73,7 +74,14 @@ export const renderNormalizedTooltip = (
return {
metric,
value: formatter ? formatter(originalValue) : originalValue,
// A missing metric stays null; show it plainly rather than running it
// through the formatter, which would render a misleading "NaN".
value:
originalValue == null
? t('N/A')
: formatter
? formatter(originalValue)
: originalValue,
};
});

View File

@@ -42,6 +42,13 @@ interface RadarChartConfig {
interface RadarSeriesData {
value: number[];
name: string;
label?: {
formatter: (params: {
name: string;
value: number | null;
dimensionIndex: number;
}) => string;
};
}
const formData: Partial<EchartsRadarFormData> = {
@@ -204,6 +211,72 @@ describe('legend sorting', () => {
});
});
// Regression for #30270: "Wrong visualization of missing values in radar
// charts". A null metric value must NOT be transformed into 0. In
// `normalizeArray`, `null / max` coerces the null to 0, so a missing data
// point ends up plotted at the center of the radar as if it were a real
// zero, instead of being left out (a gap). This test feeds a datum with a
// null metric and asserts the null is preserved in the normalized series
// value rather than silently becoming 0.
const missingValueData = [
{
data: [
{
name: 'Series A',
'SUM(jp_sales)': 10,
'SUM(other_sales)': null,
'SUM(eu_sales)': 30,
},
],
},
];
const missingValueProps = new ChartProps({
formData: {
...formData,
// No columnConfig custom bounds so every metric is normalized.
columnConfig: {},
groupby: ['name'],
metrics: ['SUM(jp_sales)', 'SUM(other_sales)', 'SUM(eu_sales)'],
},
width: 800,
height: 600,
queriesData: missingValueData,
theme: supersetTheme,
});
test('preserves a null metric instead of plotting it as 0', () => {
const result = transformProps(missingValueProps as EchartsRadarChartProps);
const series = result.echartOptions.series as RadarSeriesOption[];
const value = (series[0].data as RadarSeriesData[])[0].value as (
number | null
)[];
// Index 1 corresponds to 'SUM(other_sales)', which was null in the datum.
// The correct behavior is to keep the gap (null/undefined) so ECharts does
// not draw a point at the center. On master this is 0, so this fails.
expect(value[1]).not.toBe(0);
expect(value[1] == null).toBe(true);
});
test('label formatter renders a missing metric as blank instead of NaN', () => {
const result = transformProps(missingValueProps as EchartsRadarChartProps);
const series = result.echartOptions.series as RadarSeriesOption[];
const seriesData = (series[0].data as RadarSeriesData[])[0];
const { label } = seriesData;
if (!label) throw new Error('expected series data to have a label config');
// Index 1 corresponds to 'SUM(other_sales)', which is null. Denormalizing
// it must not fall through to `Number('null')` (NaN) in the label text.
const formatted = label.formatter({
name: 'Series A',
value: null,
dimensionIndex: 1,
});
expect(formatted).not.toContain('NaN');
});
describe('radar center positioning', () => {
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
const props = new ChartProps({

View File

@@ -52,4 +52,22 @@ describe('renderNormalizedTooltip', () => {
expect(tooltip).toContain('100');
expect(tooltip).toContain('200');
});
test('should render a missing metric as N/A instead of NaN', () => {
// A missing metric is denormalized to `null` (see #30270); the tooltip
// must not run it through the formatter, which would print "NaN".
const getDenormalizedValueWithGap = jest.fn((_, value) =>
value === 'null' ? null : Number(value),
);
const formatter = getNumberFormatter(',.2f');
const tooltip = renderNormalizedTooltip(
{ ...params, value: [100, null] },
metrics,
getDenormalizedValueWithGap,
metricsWithCustomBounds,
formatter,
);
expect(tooltip).toContain('N/A');
expect(tooltip).not.toContain('NaN');
});
});

View File

@@ -133,6 +133,7 @@ Dashboard Management:
- update_dashboard: Update an existing dashboard's title/description/slug/published/layout/theme/CSS (requires write access; editorship-checked per-instance)
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
- delete_dashboard: Delete a dashboard by ID/UUID/slug (requires editor rights — owner or Admin; destructive; does not delete its charts; soft-deletes to trash when the SOFT_DELETE feature flag is on, permanent otherwise)
- manage_native_filters: Add, update, remove, or reorder native filters on a dashboard (requires write access; supports filter_select and filter_time)
- remove_chart_from_dashboard: Remove a chart from an existing dashboard (requires write access)
@@ -186,6 +187,7 @@ Chart Management:
- generate_explore_link: Create an interactive explore URL (preferred for exploration)
- update_chart: Update existing saved chart configuration (requires write access)
- update_chart_preview: Update cached chart preview without saving (requires write access)
- delete_chart: Delete a chart by ID/UUID (requires editor rights — owner or Admin; destructive; soft-deletes to trash when the SOFT_DELETE feature flag is on, permanent otherwise)
SQL Lab Integration:
- execute_sql: Execute SQL queries and get results (requires database_id and SQL access)
@@ -718,6 +720,7 @@ from superset.mcp_service.chart import ( # noqa: F401, E402
resources as chart_resources,
)
from superset.mcp_service.chart.tool import ( # noqa: F401, E402
delete_chart,
generate_chart,
get_chart_data,
get_chart_info,
@@ -730,6 +733,7 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
)
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
delete_dashboard,
duplicate_dashboard,
generate_dashboard,
get_dashboard_datasets,

View File

@@ -2727,3 +2727,49 @@ class ChartFiltersInfo(BaseModel):
# Rebuild ChartInfo so Pydantic can resolve the ChartFiltersInfo forward reference.
ChartInfo.model_rebuild()
class DeleteChartRequest(BaseModel):
"""Request schema for delete_chart."""
identifier: int | str = Field(
...,
description=(
"Chart identifier - numeric ID or UUID string (charts have no slug)."
),
)
@field_validator("identifier", mode="before")
@classmethod
def reject_bool_identifier(cls, value: object) -> object:
"""bool is a subclass of int, so identifier=true would coerce to
chart ID 1 and delete the wrong object; reject it outright."""
if isinstance(value, bool):
raise ValueError("identifier must be an integer ID or UUID string")
return value
class DeleteChartResponse(BaseModel):
"""Result of a delete_chart operation."""
success: bool = Field(description="Whether the chart was deleted")
deleted_id: int | None = Field(None, description="ID of the deleted chart")
deleted_name: str | None = Field(None, description="Name of the deleted chart")
soft_deleted: bool = Field(
False,
description=(
"True when the chart was soft-deleted (moved to trash, because the "
"SOFT_DELETE feature flag is enabled) and can be restored by an "
"owner or Admin. False means the delete was permanent."
),
)
message: str | None = Field(None, description="Human-readable outcome message")
error: str | None = Field(None, description="Error message if the delete failed")
error_type: str | None = Field(None, description="Type of error if failed")
permission_denied: bool = Field(
False,
description=(
"True when the caller lacks permission to delete the chart (do not "
"retry; ask the user)."
),
)

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from .delete_chart import delete_chart
from .generate_chart import generate_chart
from .get_chart_data import get_chart_data
from .get_chart_info import get_chart_info
@@ -28,6 +29,7 @@ from .update_chart_preview import update_chart_preview
__all__ = [
"list_charts",
"get_chart_info",
"delete_chart",
"get_chart_sql",
"generate_chart",
"update_chart",

View File

@@ -0,0 +1,186 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
MCP tool: delete_chart
"""
import logging
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset import is_feature_enabled
from superset.commands.chart.exceptions import (
ChartDeleteFailedReportsExistError,
ChartForbiddenError,
ChartNotFoundError,
)
from superset.commands.exceptions import CommandException
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_helpers import find_chart_by_identifier
from superset.mcp_service.chart.schemas import (
DeleteChartRequest,
DeleteChartResponse,
)
from superset.mcp_service.utils import (
escape_llm_context_delimiters,
sanitize_for_llm_context,
)
logger = logging.getLogger(__name__)
def _rollback() -> None:
"""Best-effort session rollback so a failed delete cannot poison the
request's transaction; rollback failures are logged, not raised."""
from superset import db
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except SQLAlchemyError:
logger.warning("Database rollback failed during delete_chart error handling")
def _routes_to_soft_delete() -> bool:
"""Mirror the ``BaseDAO.delete`` routing predicate so the response can
report whether the row was trashed (restorable) or permanently removed."""
from superset.models.helpers import SoftDeleteMixin
from superset.models.slice import Slice
return issubclass(Slice, SoftDeleteMixin) and is_feature_enabled("SOFT_DELETE")
@tool(
tags=["mutate"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Delete chart",
readOnlyHint=False,
destructiveHint=True,
),
)
async def delete_chart(
request: DeleteChartRequest, ctx: Context
) -> DeleteChartResponse:
"""Delete a saved chart.
Identify the chart by numeric ID or UUID string (NOT chart name). When the
``SOFT_DELETE`` feature flag is enabled the chart is moved to trash and can
be restored by an owner or Admin; otherwise the delete is permanent and
cannot be undone. The ``soft_deleted`` response field reports which
happened. The caller must be an editor of the chart (owners and Admins
qualify); charts with attached alerts/reports cannot be deleted until
those are removed.
Example:
```json
{"identifier": 123}
```
Returns success with the deleted chart's id/name, or an error. When the
caller lacks permission, ``permission_denied`` is true — do not retry; ask
the user.
"""
await ctx.info("Deleting chart: identifier=%s" % (request.identifier,))
try:
chart = find_chart_by_identifier(request.identifier)
except SQLAlchemyError:
_rollback()
logger.exception("Chart lookup failed during delete_chart")
return DeleteChartResponse(
success=False,
error="Chart lookup failed due to a database error.",
error_type="LookupFailed",
)
if not chart:
safe_id = escape_llm_context_delimiters(str(request.identifier)[:200])
msg = (
f"No chart found with identifier: {safe_id}. "
"Use list_charts to get valid chart IDs."
)
return DeleteChartResponse(success=False, error=msg, error_type="NotFound")
chart_id = chart.id
# Chart names are user-controlled; wrap before composing response text.
chart_name = sanitize_for_llm_context(chart.slice_name, field_path=("slice_name",))
# The try/except sits inside log_context so failed attempts (forbidden,
# reports-exist, db errors) are recorded in the audit log too — the
# context manager does not log when an exception propagates through it.
with event_logger.log_context(action="mcp.delete_chart"):
try:
from superset.commands.chart.delete import DeleteChartCommand
DeleteChartCommand([chart_id]).run()
soft_deleted = _routes_to_soft_delete()
if soft_deleted:
message = (
f"Moved chart '{chart_name}' (id={chart_id}) to trash. "
"It can be restored by an owner or Admin."
)
else:
message = f"Permanently deleted chart '{chart_name}' (id={chart_id})."
return DeleteChartResponse(
success=True,
deleted_id=chart_id,
deleted_name=chart_name,
soft_deleted=soft_deleted,
message=message,
)
except ChartForbiddenError:
await ctx.warning("Permission denied deleting chart id=%s" % (chart_id,))
return DeleteChartResponse(
success=False,
permission_denied=True,
error=(
f"You do not have permission to delete chart '{chart_name}' "
f"(id={chart_id}). Ask the user to delete it or grant "
"access; do not retry."
),
error_type="Forbidden",
)
except ChartDeleteFailedReportsExistError as ex:
_rollback()
return DeleteChartResponse(
success=False,
error=(
f"Chart '{chart_name}' (id={chart_id}) cannot be deleted: "
f"{ex}. Remove the associated alerts/reports first."
),
error_type="ReportsExist",
)
except ChartNotFoundError:
msg = f"Chart id={chart_id} no longer exists."
return DeleteChartResponse(success=False, error=msg, error_type="NotFound")
except (CommandException, SQLAlchemyError, ValueError) as ex:
_rollback()
await ctx.error("Chart delete failed: %s: %s" % (type(ex).__name__, ex))
# Raw SQLAlchemy text can leak SQL or connection details; command
# and validation messages are user-facing by design.
if isinstance(ex, SQLAlchemyError):
client_error = "Chart delete failed due to a database error."
else:
client_error = f"Chart delete failed: {ex}"
return DeleteChartResponse(
success=False,
error=client_error,
error_type=type(ex).__name__,
)

View File

@@ -1793,6 +1793,50 @@ def dashboard_layout_serializer(dashboard: "Dashboard") -> DashboardLayout:
)
class DeleteDashboardRequest(BaseModel):
"""Request schema for delete_dashboard."""
identifier: int | str = Field(
...,
description="Dashboard identifier - numeric ID, UUID string, or slug.",
)
@field_validator("identifier", mode="before")
@classmethod
def reject_bool_identifier(cls, value: object) -> object:
"""bool is a subclass of int, so identifier=true would coerce to
dashboard ID 1 and delete the wrong object; reject it outright."""
if isinstance(value, bool):
raise ValueError("identifier must be an integer ID, UUID, or slug string")
return value
class DeleteDashboardResponse(BaseModel):
"""Result of a delete_dashboard operation."""
success: bool = Field(description="Whether the dashboard was deleted")
deleted_id: int | None = Field(None, description="ID of the deleted dashboard")
deleted_name: str | None = Field(None, description="Title of the deleted dashboard")
soft_deleted: bool = Field(
False,
description=(
"True when the dashboard was soft-deleted (moved to trash, because "
"the SOFT_DELETE feature flag is enabled) and can be restored by an "
"owner or Admin. False means the delete was permanent."
),
)
message: str | None = Field(None, description="Human-readable outcome message")
error: str | None = Field(None, description="Error message if the delete failed")
error_type: str | None = Field(None, description="Type of error if failed")
permission_denied: bool = Field(
False,
description=(
"True when the caller lacks permission to delete the dashboard (do "
"not retry; ask the user)."
),
)
# ---------------------------------------------------------------------------
# manage_native_filters schemas
# ---------------------------------------------------------------------------

View File

@@ -16,6 +16,7 @@
# under the License.
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .delete_dashboard import delete_dashboard
from .duplicate_dashboard import duplicate_dashboard
from .generate_dashboard import generate_dashboard
from .get_dashboard_datasets import get_dashboard_datasets
@@ -37,4 +38,5 @@ __all__ = [
"manage_native_filters",
"remove_chart_from_dashboard",
"update_dashboard",
"delete_dashboard",
]

View File

@@ -0,0 +1,235 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
MCP tool: delete_dashboard
"""
import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset import is_feature_enabled
from superset.commands.dashboard.exceptions import (
DashboardAccessDeniedError,
DashboardDeleteFailedReportsExistError,
DashboardForbiddenError,
DashboardNotFoundError,
)
from superset.commands.exceptions import CommandException
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
DeleteDashboardRequest,
DeleteDashboardResponse,
)
from superset.mcp_service.utils import (
escape_llm_context_delimiters,
sanitize_for_llm_context,
)
if TYPE_CHECKING:
from superset.models.dashboard import Dashboard
logger = logging.getLogger(__name__)
def _find_dashboard_by_identifier(identifier: int | str) -> "Dashboard | None":
"""Resolve a dashboard by numeric ID, UUID string, or slug. Returns None."""
from superset.daos.dashboard import DashboardDAO
if isinstance(identifier, int) or (
isinstance(identifier, str) and identifier.isdigit()
):
return DashboardDAO.find_by_id(int(identifier))
# Try UUID, then fall back to slug.
dashboard = DashboardDAO.find_by_id(identifier, id_column="uuid")
if dashboard:
return dashboard
try:
return DashboardDAO.get_by_id_or_slug(identifier)
except DashboardNotFoundError:
return None
def _rollback() -> None:
"""Best-effort session rollback so a failed delete cannot poison the
request's transaction; rollback failures are logged, not raised."""
from superset import db
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except SQLAlchemyError:
logger.warning(
"Database rollback failed during delete_dashboard error handling"
)
def _routes_to_soft_delete() -> bool:
"""Mirror the ``BaseDAO.delete`` routing predicate so the response can
report whether the row was trashed (restorable) or permanently removed."""
from superset.models.dashboard import Dashboard
from superset.models.helpers import SoftDeleteMixin
return issubclass(Dashboard, SoftDeleteMixin) and is_feature_enabled("SOFT_DELETE")
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Delete dashboard",
readOnlyHint=False,
destructiveHint=True,
),
)
async def delete_dashboard(
request: DeleteDashboardRequest, ctx: Context
) -> DeleteDashboardResponse:
"""Delete a dashboard.
Identify the dashboard by numeric ID, UUID string, or slug. When the
``SOFT_DELETE`` feature flag is enabled the dashboard is moved to trash and
can be restored by an owner or Admin; otherwise the delete is permanent and
cannot be undone. The ``soft_deleted`` response field reports which
happened. It removes the dashboard container only — the charts on it are
NOT deleted. The caller must be an editor of the dashboard (owners and
Admins qualify); dashboards with attached alerts/reports cannot be
deleted until those are removed.
Example:
```json
{"identifier": 42}
```
Returns success with the deleted dashboard's id/title, or an error. When the
caller lacks permission, ``permission_denied`` is true — do not retry; ask
the user.
"""
await ctx.info("Deleting dashboard: identifier=%s" % (request.identifier,))
try:
dashboard = _find_dashboard_by_identifier(request.identifier)
except DashboardAccessDeniedError:
# get_by_id_or_slug re-checks view access and raises access-denied for
# dashboards the caller cannot see; surface it as the structured
# permission_denied response instead of an unhandled error.
await ctx.warning("Access denied resolving dashboard identifier for delete")
return DeleteDashboardResponse(
success=False,
permission_denied=True,
error=(
"You do not have permission to access this dashboard. "
"Ask the user to delete it or grant access; do not retry."
),
error_type="Forbidden",
)
except SQLAlchemyError:
_rollback()
logger.exception("Dashboard lookup failed during delete_dashboard")
return DeleteDashboardResponse(
success=False,
error="Dashboard lookup failed due to a database error.",
error_type="LookupFailed",
)
if not dashboard:
safe_id = escape_llm_context_delimiters(str(request.identifier)[:200])
msg = (
f"No dashboard found with identifier: {safe_id}. "
"Use list_dashboards to get valid dashboard IDs."
)
return DeleteDashboardResponse(success=False, error=msg, error_type="NotFound")
dashboard_id = dashboard.id
# Dashboard titles are user-controlled; wrap before composing responses.
dashboard_name = sanitize_for_llm_context(
dashboard.dashboard_title, field_path=("dashboard_title",)
)
# The try/except sits inside log_context so failed attempts (forbidden,
# reports-exist, db errors) are recorded in the audit log too — the
# context manager does not log when an exception propagates through it.
with event_logger.log_context(action="mcp.delete_dashboard"):
try:
from superset.commands.dashboard.delete import DeleteDashboardCommand
DeleteDashboardCommand([dashboard_id]).run()
soft_deleted = _routes_to_soft_delete()
if soft_deleted:
message = (
f"Moved dashboard '{dashboard_name}' (id={dashboard_id}) to "
"trash; it can be restored by an owner or Admin. Its charts "
"were not deleted."
)
else:
message = (
f"Permanently deleted dashboard '{dashboard_name}' "
f"(id={dashboard_id}). Its charts were not deleted."
)
return DeleteDashboardResponse(
success=True,
deleted_id=dashboard_id,
deleted_name=dashboard_name,
soft_deleted=soft_deleted,
message=message,
)
except DashboardForbiddenError:
await ctx.warning(
"Permission denied deleting dashboard id=%s" % (dashboard_id,)
)
return DeleteDashboardResponse(
success=False,
permission_denied=True,
error=(
f"You do not have permission to delete dashboard "
f"'{dashboard_name}' (id={dashboard_id}). Ask the user to "
"delete it or grant access; do not retry."
),
error_type="Forbidden",
)
except DashboardDeleteFailedReportsExistError as ex:
_rollback()
return DeleteDashboardResponse(
success=False,
error=(
f"Dashboard '{dashboard_name}' (id={dashboard_id}) cannot be "
f"deleted: {ex}. Remove the associated alerts/reports first."
),
error_type="ReportsExist",
)
except DashboardNotFoundError:
msg = f"Dashboard id={dashboard_id} no longer exists."
return DeleteDashboardResponse(
success=False, error=msg, error_type="NotFound"
)
except (CommandException, SQLAlchemyError, ValueError) as ex:
_rollback()
await ctx.error("Dashboard delete failed: %s: %s" % (type(ex).__name__, ex))
# Raw SQLAlchemy text can leak SQL or connection details; command
# and validation messages are user-facing by design.
if isinstance(ex, SQLAlchemyError):
client_error = "Dashboard delete failed due to a database error."
else:
client_error = f"Dashboard delete failed: {ex}"
return DeleteDashboardResponse(
success=False,
error=client_error,
error_type=type(ex).__name__,
)

View File

@@ -16,30 +16,11 @@
# under the License.
"""Session-level listeners that drive ``version_changes`` writes.
Two flush events cooperate, plus two post-commit / post-rollback
cleanups:
- ``before_flush``: for each versioned entity in ``session.dirty``,
reads the pre-save scalar state from the DB via raw SQL inside
``session.no_autoflush`` (same idiom as the baseline listener, not
Continuum's internal ``units_of_work`` which is a private API), reads
the post-save state from the in-memory ORM object, calls the diff
engine, and buffers the resulting :class:`ChangeRecord` list on
``session.info``. This must run before the flush because after the
flush the DB already reflects the post-state; we can't recover the
pre-state from it.
- ``after_flush``: drains the buffer, resolves the current Continuum
transaction id via ``versioning_manager.units_of_work``, and bulk-
inserts one ``version_changes`` row per record with a monotonic
``sequence`` number. Records accumulated across multiple before_flush
calls within one transaction share the same ``transaction_id`` and
contiguous sequence numbers.
- ``after_commit`` / ``after_rollback``: clean up session-scoped
state (processed-tx set, ``action_kind`` / ``action_meta`` keys, and
the pending-records buffer) so a long-lived session doesn't carry any
of it into the next transaction.
The listeners retain each entity's first pre-flush state, force the final
flush from ``before_commit``, and write one net initial-to-final semantic
projection for the Continuum transaction. Completion of the outer transaction
clears all session-scoped state so long-lived sessions cannot leak versioning
intent.
Scope:
- Slice, Dashboard, SqlaTable **scalar fields** (via the cached
@@ -51,8 +32,8 @@ Scope:
Child-collection diffs (dataset ``TableColumn`` / ``SqlMetric``,
dashboard ``dashboard_slices``) read the pre- and post-state from
Continuum shadow tables via the helpers in
:mod:`superset.versioning.changes.shadow_queries`, executed in
``after_flush`` once Continuum has written its tx-N rows.
:mod:`superset.versioning.changes.shadow_queries`, executed during commit
finalization after the explicit final flush has written Continuum's tx-N rows.
``session.new`` entities are not processed in this listener:
operation_type=0 transactions (baseline capture and first-save INSERTs)
@@ -67,7 +48,7 @@ from typing import Any
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, SessionTransaction
from superset.versioning.changes.shadow_queries import (
_dashboard_child_records_for_tx_from_shadows,
@@ -75,7 +56,8 @@ from superset.versioning.changes.shadow_queries import (
)
from superset.versioning.changes.state import (
bulk_insert_records,
compute_records_for_entity,
capture_initial_state,
compute_records_from_state,
)
from superset.versioning.changes.table import ENTITY_KIND_BY_CLASS_NAME
from superset.versioning.diff import (
@@ -86,20 +68,9 @@ from superset.versioning.diff import (
logger = logging.getLogger(__name__)
# Key under which the pending-records buffer is stored on ``session.info``.
# Using ``session.info`` (SQLAlchemy's user-data dict) avoids the need
# for a module-level WeakKeyDictionary and keeps buffers naturally scoped
# to the session's lifetime.
_BUFFER_KEY = "_version_changes_pending"
# Key for the set of Continuum transaction ids whose change records
# have already been written in this session. ``after_flush`` can fire
# more than once for a single transaction (e.g. autoflush triggered by
# a mid-commit query), and our child-diff path reads snapshot tables
# that don't care about the buffer state — without this marker we'd
# re-insert the same child records on the second flush and hit the
# UNIQUE(transaction_id, entity_kind, entity_id, sequence) constraint.
_PROCESSED_TXS_KEY = "_version_changes_processed_txs"
# Keys for transaction-scoped state stored on ``session.info``.
_INITIAL_STATES_KEY = "_version_changes_initial_states"
_FINALIZING_KEY = "_version_changes_finalizing"
# Key on ``session.info`` that commands set to declare the high-level
# action that produced the current transaction. Read once per flush by
@@ -112,8 +83,8 @@ _PROCESSED_TXS_KEY = "_version_changes_processed_txs"
# db.session.info[ACTION_KIND_KEY] = ACTION_KIND_RESTORE
# db.session.commit()
#
# The listener pops the key after stamping, and ``after_commit`` /
# ``after_rollback`` cleanup pop it again as a safety net, so a
# The listener pops the key after stamping, and outer-transaction cleanup pops
# it again as a safety net, so a
# long-lived session can't accidentally carry the value into the next
# transaction.
ACTION_KIND_KEY = "_versioning_action_kind"
@@ -199,29 +170,59 @@ def build_action_headline(
_REGISTERED_SENTINEL = "_versioning_change_listener_registered"
def _process_dirty_entity_into_buffer(
def _capture_dirty_entity_initial_state(
session: Session,
obj: Any,
buffer: dict[tuple[str, int], list[ChangeRecord]],
initial_states: dict[tuple[str, int], tuple[Any, dict[str, Any]]],
) -> None:
"""Compute scalar change records for one dirty entity + append to buffer."""
"""Retain one dirty entity's first database state for this transaction."""
entity_kind = ENTITY_KIND_BY_CLASS_NAME.get(type(obj).__name__)
if entity_kind is None:
return
entity_id = getattr(obj, "id", None)
if entity_id is None:
return
try:
records = compute_records_for_entity(session, obj)
except Exception: # pylint: disable=broad-except
logger.exception(
"version_changes: diff failed for %s id=%s",
type(obj).__name__,
entity_id,
)
key = (entity_kind, entity_id)
if key in initial_states:
return
if records:
buffer.setdefault((entity_kind, entity_id), []).extend(records)
if (pre_state := capture_initial_state(session, obj)) is not None:
initial_states[key] = (obj, pre_state)
def _build_scalar_buffer(
initial_states: dict[tuple[str, int], tuple[Any, dict[str, Any]]],
) -> dict[tuple[str, int], list[ChangeRecord]]:
"""Build net scalar records from retained initial and final entity states."""
buffer: dict[tuple[str, int], list[ChangeRecord]] = {}
for key, (obj, pre_state) in initial_states.items():
try:
records = compute_records_from_state(obj, pre_state)
except Exception: # pylint: disable=broad-except
logger.exception(
"version_changes: final diff failed for %s id=%s",
type(obj).__name__,
key[1],
)
continue
if records:
buffer[key] = records
return buffer
def _reset_transaction_state(session: Session) -> None:
"""Discard versioning intent and retained state after a terminal event."""
session.info.pop(ACTION_KIND_KEY, None)
session.info.pop(ACTION_META_KEY, None)
session.info.pop(_INITIAL_STATES_KEY, None)
session.info.pop(_FINALIZING_KEY, None)
def _reset_after_outer_transaction(
session: Session, transaction: SessionTransaction
) -> None:
"""Clear retained state only when the outer transaction has ended."""
if transaction.parent is None:
_reset_transaction_state(session)
def _append_child_records_to_buffer(
@@ -231,8 +232,8 @@ def _append_child_records_to_buffer(
) -> None:
"""Compute dataset + dashboard child-collection records + append to buffer.
Runs in ``after_flush`` so the shadow tables already have the
current-tx rows. Reads from Continuum shadow tables
Runs during commit finalization after the explicit final flush, so the
shadow tables already have the current-tx rows. Reads from Continuum shadow tables
(``table_columns_version`` / ``sql_metrics_version`` /
``dashboard_slices_version`` / ``slices_version``).
"""
@@ -280,17 +281,10 @@ def _inject_action_meta_record(
"""Pop ``ACTION_META_KEY`` and prepend its synthetic headline record
to the owning entity's buffer (the ``__meta__`` record convention).
No-op when no command set the key and, critically, no-op WITHOUT
popping when the buffer is empty: the buffer-empty short-circuit in
``flush_change_records`` exists so a multi-flush transaction can
deliver its records on a later firing, and a headline-only buffer
would defeat it (the first firing would persist just the headline,
mark the tx processed, and the later flush's real records would be
silently dropped). Leaving the key in place parks the headline until
the record-bearing firing. Prepended (not appended) so the headline
gets ``sequence`` 0 and renders first. Malformed payloads are logged
and dropped — a headline is descriptive enrichment, never worth
failing the user's save over.
No-op when no command set the key and, critically, does not pop when the
final buffer is empty. Prepended rather than appended so the headline gets
``sequence`` 0 and renders first. Malformed payloads are logged and dropped:
a headline is descriptive enrichment, never worth failing the user's save.
"""
if not buffer:
return
@@ -379,7 +373,7 @@ def _persist_buffered_records(
def register_change_record_listener() -> None: # noqa: C901
"""Attach the before_flush + after_flush listeners.
"""Attach transaction-scoped version-change listeners.
Registered from :class:`superset.initialization.SupersetAppInitializer`
(``init_versioning``) alongside the baseline, dataset-snapshot,
@@ -398,110 +392,42 @@ def register_change_record_listener() -> None: # noqa: C901
versioned_classes: tuple[type, ...] = (Dashboard, Slice, SqlaTable)
def compute_change_records(
def capture_initial_states(
session: Session, _flush_context: Any, _instances: Any
) -> None:
# session.info persists across before_flush/after_flush within
# a single transaction. The buffer is keyed on
# ``(entity_kind, entity_id)`` so scalar records captured here
# and child records captured in after_flush merge
# under the same entity without duplication.
buffer: dict[tuple[str, int], list[ChangeRecord]] = session.info.setdefault(
_BUFFER_KEY, {}
initial_states: dict[tuple[str, int], tuple[Any, dict[str, Any]]] = (
session.info.setdefault(_INITIAL_STATES_KEY, {})
)
for obj in list(session.dirty):
if isinstance(obj, versioned_classes):
_process_dirty_entity_into_buffer(session, obj, buffer)
_capture_dirty_entity_initial_state(session, obj, initial_states)
def flush_change_records(session: Session, _flush_context: Any) -> None:
buffer: dict[tuple[str, int], list[ChangeRecord]] = session.info.setdefault(
_BUFFER_KEY, {}
)
tx_id = _current_transaction_id(session)
if tx_id is None:
session.info[_BUFFER_KEY] = {}
return
# Skip if we've already written records for this tx (after_flush
# can fire more than once per commit — e.g. autoflush from a
# mid-commit query). Without this guard the child-diff path would
# re-read the same shadow rows and re-emit the same records,
# tripping the UNIQUE(transaction_id, entity_kind, entity_id,
# sequence) constraint on insert.
processed: set[int] = session.info.setdefault(_PROCESSED_TXS_KEY, set())
if tx_id in processed:
# Drop anything buffered after the tx was persisted: records
# left here would otherwise survive on the long-lived scoped
# session and be inserted under the NEXT transaction's id.
session.info[_BUFFER_KEY] = {}
return
# Stamp action_kind eagerly, before the buffer-empty short-
# circuit. Restores / imports / clones may flush across multiple
# cycles; the FIRST firing for this tx is the one with the
# value still on ``session.info``. The helper pops on success
# so subsequent firings see ``None`` and short-circuit cleanly.
_stamp_action_kind_on_transaction(session, tx_id)
_append_child_records_to_buffer(session, tx_id, buffer)
# After the child append and before the emptiness check: the
# headline joins whichever firing carries the transaction's real
# records (scalar or child), and its peek-don't-pop guard parks
# it across record-less firings instead of defeating the
# multi-flush short-circuit below.
_inject_action_meta_record(session, buffer)
if not buffer:
# Don't mark tx as processed when nothing was inserted. A
# later after_flush firing for the same tx may carry the
# records — e.g. when an entity's edit lands across two
# flushes (a child-only flush followed by a parent-dirty
# flush): the parent shadow only lands in the parent-dirty
# flush, so the child-diff path can't find a prior tx to
# compare against until then.
session.info[_BUFFER_KEY] = {}
def finalize_change_records(session: Session) -> None:
if session.in_nested_transaction() or session.info.get(_FINALIZING_KEY):
return
session.info[_FINALIZING_KEY] = True
try:
_persist_buffered_records(session, tx_id, buffer)
session.flush()
initial_states: dict[tuple[str, int], tuple[Any, dict[str, Any]]] = (
session.info.get(_INITIAL_STATES_KEY, {})
)
buffer = _build_scalar_buffer(initial_states)
tx_id = _current_transaction_id(session)
if tx_id is None:
return
_stamp_action_kind_on_transaction(session, tx_id)
_append_child_records_to_buffer(session, tx_id, buffer)
_inject_action_meta_record(session, buffer)
if buffer:
_persist_buffered_records(session, tx_id, buffer)
finally:
session.info[_BUFFER_KEY] = {}
processed.add(tx_id)
session.info.pop(_FINALIZING_KEY, None)
def reset_processed_after_commit(session: Session) -> None:
# ``_PROCESSED_TXS_KEY`` accumulates Continuum tx ids whose change
# records have already been written, to dedup against multiple
# ``after_flush`` firings within one transaction. After commit
# the tx is closed and its id will never recur on this session
# — drop the set so a long-lived session (Celery worker, CLI)
# doesn't grow it without bound.
session.info.pop(_PROCESSED_TXS_KEY, None)
# If a command set the action_kind but no flush fired (e.g. a
# save that touched nothing versioned), the value would
# otherwise leak into the next transaction. Drop it here as a
# belt-and-suspenders cleanup; the
# ``_stamp_action_kind_on_transaction`` helper already pops on
# the normal path.
session.info.pop(ACTION_KIND_KEY, None)
session.info.pop(ACTION_META_KEY, None)
session.info.pop(_BUFFER_KEY, None)
def reset_action_kind_after_rollback(session: Session) -> None:
# When a command sets ``ACTION_KIND_KEY`` and then an exception
# fires before flush (e.g. validation error after the key is
# set), the transaction rolls back without the listener ever
# popping the key. The next save on the same session would
# then inherit the stale value and label an unrelated commit
# as "restore" / "import" / "clone". Pop here so a rolled-back
# action's intent doesn't leak forward.
session.info.pop(ACTION_KIND_KEY, None)
session.info.pop(ACTION_META_KEY, None)
session.info.pop(_BUFFER_KEY, None)
event.listen(db.session, "before_flush", compute_change_records)
event.listen(db.session, "after_flush", flush_change_records)
event.listen(db.session, "after_commit", reset_processed_after_commit)
event.listen(db.session, "after_rollback", reset_action_kind_after_rollback)
event.listen(db.session, "before_flush", capture_initial_states)
event.listen(db.session, "before_commit", finalize_change_records)
event.listen(db.session, "after_transaction_end", _reset_after_outer_transaction)
setattr(db.session, _REGISTERED_SENTINEL, True)

View File

@@ -24,7 +24,7 @@ Three concerns live here:
2. **State capture** — :func:`_orm_to_post_state` serialises the
in-memory ORM object; :func:`_read_pre_state` reads the corresponding
pre-flush row directly from the DB inside ``session.no_autoflush``.
3. **Diff dispatch** — :func:`compute_records_for_entity` routes to the
3. **Diff dispatch** — :func:`compute_records_from_state` routes to the
right :mod:`superset.versioning.diff` helper based on the model
class name (string dispatch keeps this module free of hard imports
on the three entity classes, which avoids import-order coupling at
@@ -156,36 +156,29 @@ def _read_pre_state(
return {key: jsonable(value) for key, value in result.items()}
def compute_records_for_entity(session: Session, obj: Any) -> list[ChangeRecord]:
"""Diff the pre-state (from DB) against the post-state (in memory).
Dispatches to :func:`diff_slice` / :func:`diff_dashboard` /
:func:`diff_dataset` based on the model class name — string-based
dispatch is used to keep this module free of hard imports on the
three entity classes, which in turn avoids import-order coupling
at app-init time.
"""
model_cls = type(obj)
def capture_initial_state(session: Session, obj: Any) -> dict[str, Any] | None:
"""Capture an entity's database state before its first transaction flush."""
entity_id = getattr(obj, "id", None)
if entity_id is None:
return []
return None
try:
pre_state = _read_pre_state(session, model_cls, entity_id)
return _read_pre_state(session, type(obj), entity_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"version_changes: pre-state read failed for %s id=%s",
model_cls.__name__,
"version_changes: initial-state read failed for %s id=%s",
type(obj).__name__,
entity_id,
)
return []
return None
if pre_state is None:
return []
def compute_records_from_state(
obj: Any, pre_state: dict[str, Any]
) -> list[ChangeRecord]:
"""Diff a retained transaction pre-state against an entity's final state."""
post_state = _orm_to_post_state(obj)
model_cls = type(obj)
fields = _cached_scalar_fields(model_cls)
name = model_cls.__name__
if name == "Slice":
return diff_slice(pre_state, post_state, fields=fields)

View File

@@ -0,0 +1,224 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Transaction-level correctness tests for entity version history."""
import pytest
import sqlalchemy as sa
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice
from superset.utils import json
from superset.versioning.changes.table import version_changes_table
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
load_birth_names_dashboard_with_slices,
load_birth_names_data,
)
def _latest_transaction_id() -> int:
"""Return the latest semantic-history transaction boundary."""
return db.session.scalar(
sa.select(
sa.func.coalesce(sa.func.max(version_changes_table.c.transaction_id), 0)
)
)
def _changes_for_chart(
chart_id: int, *, after_transaction_id: int
) -> list[dict[str, object]]:
"""Return ordered semantic changes captured for one chart."""
rows = db.session.execute(
sa.select(version_changes_table)
.where(version_changes_table.c.entity_kind == "chart")
.where(version_changes_table.c.entity_id == chart_id)
.where(version_changes_table.c.transaction_id > after_transaction_id)
.order_by(
version_changes_table.c.transaction_id,
version_changes_table.c.sequence,
)
).mappings()
return [dict(row) for row in rows]
def _changes_for_dataset(
dataset_id: int, *, after_transaction_id: int
) -> list[dict[str, object]]:
"""Return ordered semantic changes captured for one dataset."""
rows = db.session.execute(
sa.select(version_changes_table)
.where(version_changes_table.c.entity_kind == "dataset")
.where(version_changes_table.c.entity_id == dataset_id)
.where(version_changes_table.c.transaction_id > after_transaction_id)
.order_by(
version_changes_table.c.transaction_id,
version_changes_table.c.sequence,
)
).mappings()
return [dict(row) for row in rows]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
class TestVersionHistoryTransactionCorrectness(SupersetTestCase):
"""Verify semantic projections span every flush in one transaction."""
@staticmethod
def _chart(name: str) -> Slice:
chart = db.session.query(Slice).filter(Slice.slice_name == name).one()
db.session.commit()
return chart
def test_same_entity_multiple_flushes_produce_one_net_change(self) -> None:
"""Only the initial-to-final scalar transition is materialized."""
chart = self._chart("Girls")
chart_id = chart.id
boundary = _latest_transaction_id()
try:
chart.slice_name = "Girls intermediate"
db.session.flush()
chart.slice_name = "Girls final"
db.session.commit()
changes = _changes_for_chart(chart_id, after_transaction_id=boundary)
name_changes = [row for row in changes if row["path"] == ["slice_name"]]
assert len(name_changes) == 1
assert name_changes[0]["from_value"] == "Girls"
assert name_changes[0]["to_value"] == "Girls final"
finally:
chart = db.session.get(Slice, chart_id)
assert chart is not None
chart.slice_name = "Girls"
db.session.commit()
def test_different_entities_across_flushes_share_complete_history(self) -> None:
"""A later entity flush is not discarded from the transaction."""
girls = self._chart("Girls")
boys = self._chart("Boys")
boundary = _latest_transaction_id()
try:
girls.slice_name = "Girls transaction edit"
db.session.flush()
boys.slice_name = "Boys transaction edit"
db.session.commit()
girls_changes = [
row
for row in _changes_for_chart(girls.id, after_transaction_id=boundary)
if row["to_value"] == "Girls transaction edit"
]
boys_changes = [
row
for row in _changes_for_chart(boys.id, after_transaction_id=boundary)
if row["to_value"] == "Boys transaction edit"
]
assert len(girls_changes) == 1
assert len(boys_changes) == 1
assert (
girls_changes[0]["transaction_id"] == boys_changes[0]["transaction_id"]
)
finally:
girls.slice_name = "Girls"
boys.slice_name = "Boys"
db.session.commit()
def test_return_to_initial_state_produces_no_duplicate_change(self) -> None:
"""Intermediate-only edits disappear from the net projection."""
chart = self._chart("Girls")
boundary = _latest_transaction_id()
chart.slice_name = "Girls temporary"
db.session.flush()
chart.slice_name = "Girls"
db.session.commit()
assert not [
row
for row in _changes_for_chart(chart.id, after_transaction_id=boundary)
if row["path"] == ["slice_name"]
]
def test_child_changes_across_flushes_use_final_shadow_state(self) -> None:
"""A child-only edit projects the final child state on its parent."""
dataset = (
db.session.query(SqlaTable)
.filter(SqlaTable.table_name == "birth_names")
.first()
)
assert dataset is not None
assert dataset.columns
column = dataset.columns[0]
original = column.description
db.session.commit()
boundary = _latest_transaction_id()
try:
column.description = "intermediate child description"
db.session.flush()
column.description = "final child description"
db.session.commit()
serialized = json.dumps(
_changes_for_dataset(dataset.id, after_transaction_id=boundary)
)
assert "final child description" in serialized
assert "intermediate child description" not in serialized
finally:
column.description = original
db.session.commit()
def _assert_nested_transaction_preserves_outer_initial_state(
self, nested_outcome: str
) -> None:
"""SAVEPOINT completion does not clear the outer transaction registry."""
chart = self._chart("Girls")
chart_id = chart.id
boundary = _latest_transaction_id()
try:
chart.slice_name = "Girls outer edit"
db.session.flush()
nested = db.session.begin_nested()
chart.slice_name = "Girls nested edit"
db.session.flush()
getattr(nested, nested_outcome)()
db.session.commit()
changes = [
row
for row in _changes_for_chart(chart_id, after_transaction_id=boundary)
if row["path"] == ["slice_name"]
]
assert len(changes) == 1
assert changes[0]["from_value"] == "Girls"
assert changes[0]["to_value"] == (
"Girls nested edit"
if nested_outcome == "commit"
else "Girls outer edit"
)
finally:
chart = db.session.get(Slice, chart_id)
assert chart is not None
chart.slice_name = "Girls"
db.session.commit()
def test_nested_commit_preserves_outer_initial_state(self) -> None:
"""Committing a SAVEPOINT keeps the outer transaction registry."""
self._assert_nested_transaction_preserves_outer_initial_state("commit")
def test_nested_rollback_preserves_outer_initial_state(self) -> None:
"""Rolling back a SAVEPOINT keeps the outer transaction registry."""
self._assert_nested_transaction_preserves_outer_initial_state("rollback")

View File

@@ -0,0 +1,229 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for the delete_chart MCP tool.
Run through the async MCP Client (not direct calls); auth is mocked via the
autouse mock_auth fixture, matching the other chart tool test files.
"""
from collections.abc import Iterator
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
@pytest.fixture
def mcp_server() -> object:
"""Provide the FastMCP app instance under test."""
return mcp
@pytest.fixture(autouse=True)
def mock_auth() -> Iterator[Mock]:
"""Authenticate every tool call as a mock admin user."""
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
def _mock_chart(chart_id: int = 10, slice_name: str = "Test Chart") -> Mock:
"""Build a minimal chart stand-in with the attributes the tool reads."""
chart = Mock()
chart.id = chart_id
chart.slice_name = slice_name
return chart
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_not_found(mock_find: Mock, mcp_server: object) -> None:
mock_find.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_chart", {"request": {"identifier": 999}}
)
content = result.structured_content
assert content["success"] is False
assert content["error_type"] == "NotFound"
assert "999" in (content["error"] or "")
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_success(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is True
assert content["deleted_id"] == 10
assert "Sales" in content["deleted_name"]
assert content["permission_denied"] is False
mock_run.assert_called_once()
@patch("superset.mcp_service.chart.tool.delete_chart.is_feature_enabled")
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_soft_delete_reports_restorable(
mock_find: Mock, mock_run: Mock, mock_flag: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.return_value = None
mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is True
assert content["soft_deleted"] is True
assert "restor" in (content["message"] or "").lower()
@patch("superset.mcp_service.chart.tool.delete_chart.is_feature_enabled")
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_hard_delete_reports_permanent(
mock_find: Mock, mock_run: Mock, mock_flag: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.return_value = None
mock_flag.return_value = False
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is True
assert content["soft_deleted"] is False
assert "permanent" in (content["message"] or "").lower()
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_permission_denied(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
from superset.commands.chart.exceptions import ChartForbiddenError
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.side_effect = ChartForbiddenError()
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is False
assert content["permission_denied"] is True
assert "permission" in (content["error"] or "").lower()
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_blocked_by_reports(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
from superset.commands.chart.exceptions import (
ChartDeleteFailedReportsExistError,
)
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.side_effect = ChartDeleteFailedReportsExistError(
"There are associated alerts or reports: R1"
)
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is False
assert content["permission_denied"] is False
assert "report" in (content["error"] or "").lower()
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_generic_command_error(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
"""CommandException messages are user-facing and pass through."""
from superset.commands.exceptions import CommandException
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.side_effect = CommandException("Delete failed for domain reasons")
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is False
assert "Delete failed for domain reasons" in (content["error"] or "")
assert content["error_type"] == "CommandException"
@patch("superset.commands.chart.delete.DeleteChartCommand.run")
@patch("superset.mcp_service.chart.tool.delete_chart.find_chart_by_identifier")
@pytest.mark.asyncio
async def test_delete_chart_sqlalchemy_error_is_generic(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
"""Raw SQLAlchemy text (SQL, connection details) must not reach the client."""
from sqlalchemy.exc import OperationalError
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
mock_run.side_effect = OperationalError(
"DELETE FROM slices WHERE id = 10", {}, Exception("secret-host refused")
)
async with Client(mcp_server) as client:
result = await client.call_tool("delete_chart", {"request": {"identifier": 10}})
content = result.structured_content
assert content["success"] is False
assert content["error"] == "Chart delete failed due to a database error."
assert "secret-host" not in (content["error"] or "")
@pytest.mark.asyncio
async def test_delete_chart_rejects_boolean_identifier(mcp_server: object) -> None:
"""bool subclasses int; identifier=true must not coerce to chart ID 1."""
from fastmcp.exceptions import ToolError
async with Client(mcp_server) as client:
with pytest.raises(ToolError):
await client.call_tool("delete_chart", {"request": {"identifier": True}})

View File

@@ -0,0 +1,254 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for the delete_dashboard MCP tool.
Run through the async MCP Client; auth is mocked via the autouse mock_auth
fixture, matching the other dashboard tool test files.
"""
from collections.abc import Iterator
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
_FIND = (
"superset.mcp_service.dashboard.tool.delete_dashboard._find_dashboard_by_identifier"
)
_RUN = "superset.commands.dashboard.delete.DeleteDashboardCommand.run"
_FLAG = "superset.mcp_service.dashboard.tool.delete_dashboard.is_feature_enabled"
@pytest.fixture
def mcp_server() -> object:
"""Provide the FastMCP app instance under test."""
return mcp
@pytest.fixture(autouse=True)
def mock_auth() -> Iterator[Mock]:
"""Authenticate every tool call as a mock admin user."""
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
def _mock_dashboard(dashboard_id: int = 1, title: str = "Sales Dashboard") -> Mock:
"""Build a minimal dashboard stand-in with the attributes the tool reads."""
dashboard = Mock()
dashboard.id = dashboard_id
dashboard.dashboard_title = title
return dashboard
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_not_found(mock_find: Mock, mcp_server: object) -> None:
mock_find.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 999}}
)
content = result.structured_content
assert content["success"] is False
assert content["error_type"] == "NotFound"
assert "999" in (content["error"] or "")
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_success(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is True
assert content["deleted_id"] == 1
assert "Sales Dashboard" in content["deleted_name"]
assert content["permission_denied"] is False
assert "Its charts were not deleted" in (content["message"] or "")
mock_run.assert_called_once()
@patch(_FLAG)
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_soft_delete_reports_restorable(
mock_find: Mock, mock_run: Mock, mock_flag: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.return_value = None
mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is True
assert content["soft_deleted"] is True
assert "restor" in (content["message"] or "").lower()
assert "charts were not deleted" in (content["message"] or "")
@patch(_FLAG)
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_hard_delete_reports_permanent(
mock_find: Mock, mock_run: Mock, mock_flag: Mock, mcp_server: object
) -> None:
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.return_value = None
mock_flag.return_value = False
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is True
assert content["soft_deleted"] is False
assert "permanent" in (content["message"] or "").lower()
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_permission_denied(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
from superset.commands.dashboard.exceptions import DashboardForbiddenError
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.side_effect = DashboardForbiddenError()
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is False
assert content["permission_denied"] is True
assert "Sales Dashboard" in (content["error"] or "")
assert "permission" in (content["error"] or "").lower()
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_blocked_by_reports(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
from superset.commands.dashboard.exceptions import (
DashboardDeleteFailedReportsExistError,
)
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.side_effect = DashboardDeleteFailedReportsExistError(
"There are associated alerts or reports: R1"
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is False
assert content["permission_denied"] is False
assert "report" in (content["error"] or "").lower()
@patch(_RUN)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_sqlalchemy_error_is_generic(
mock_find: Mock, mock_run: Mock, mcp_server: object
) -> None:
"""Raw SQLAlchemy text (SQL, connection details) must not reach the client."""
from sqlalchemy.exc import OperationalError
mock_find.return_value = _mock_dashboard(1, "Sales Dashboard")
mock_run.side_effect = OperationalError(
"DELETE FROM dashboards WHERE id = 1", {}, Exception("secret-host refused")
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": 1}}
)
content = result.structured_content
assert content["success"] is False
assert content["error"] == "Dashboard delete failed due to a database error."
assert "secret-host" not in (content["error"] or "")
@pytest.mark.asyncio
async def test_delete_dashboard_rejects_boolean_identifier(
mcp_server: object,
) -> None:
"""bool subclasses int; identifier=true must not coerce to dashboard ID 1."""
from fastmcp.exceptions import ToolError
async with Client(mcp_server) as client:
with pytest.raises(ToolError):
await client.call_tool(
"delete_dashboard", {"request": {"identifier": True}}
)
@patch(_FIND)
@pytest.mark.asyncio
async def test_delete_dashboard_access_denied_on_lookup(
mock_find: Mock, mcp_server: object
) -> None:
"""Slug/UUID resolution re-checks view access; access-denied surfaces as
a structured permission_denied response, not an unhandled error."""
from superset.commands.dashboard.exceptions import DashboardAccessDeniedError
mock_find.side_effect = DashboardAccessDeniedError()
async with Client(mcp_server) as client:
result = await client.call_tool(
"delete_dashboard", {"request": {"identifier": "exec-kpis"}}
)
content = result.structured_content
assert content["success"] is False
assert content["permission_denied"] is True

View File

@@ -0,0 +1,204 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for version-change listener transaction lifecycle behavior."""
from collections.abc import Iterator
from typing import Any
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from superset.versioning.changes import listener
from superset.versioning.diff import ChangeRecord
Base: Any = sa.orm.declarative_base()
class LifecycleRow(Base):
"""Minimal row used to characterize SQLAlchemy session events."""
__tablename__ = "versioning_listener_lifecycle"
id = sa.Column(sa.Integer, primary_key=True)
value = sa.Column(sa.String, nullable=False)
@pytest.fixture
def lifecycle_session() -> Iterator[Session]:
"""Yield an isolated SQLAlchemy session backed by in-memory SQLite."""
engine = sa.create_engine("sqlite://")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
try:
yield session
finally:
session.close()
engine.dispose()
def test_before_commit_can_force_final_flush_without_reentry(
lifecycle_session: Session,
) -> None:
"""A before-commit finalizer can flush once before commit preparation."""
events: list[str] = []
@sa.event.listens_for(lifecycle_session, "before_flush")
def before_flush(
_session: Session, _flush_context: object, _instances: object
) -> None:
events.append("before_flush")
@sa.event.listens_for(lifecycle_session, "after_flush")
def after_flush(_session: Session, _flush_context: object) -> None:
events.append("after_flush")
@sa.event.listens_for(lifecycle_session, "before_commit")
def before_commit(session: Session) -> None:
events.append("before_commit:start")
session.flush()
events.append("before_commit:end")
lifecycle_session.add(LifecycleRow(value="saved"))
lifecycle_session.commit()
assert events == [
"before_commit:start",
"before_flush",
"after_flush",
"before_commit:end",
]
def test_before_commit_with_no_work_does_not_flush(
lifecycle_session: Session,
) -> None:
"""A no-work commit invokes the finalizer without a flush cycle."""
events: list[str] = []
@sa.event.listens_for(lifecycle_session, "before_flush")
def before_flush(
_session: Session, _flush_context: object, _instances: object
) -> None:
events.append("before_flush")
@sa.event.listens_for(lifecycle_session, "before_commit")
def before_commit(session: Session) -> None:
events.append("before_commit")
session.flush()
lifecycle_session.commit()
assert events == ["before_commit"]
def test_rollback_event_can_clear_transaction_state(
lifecycle_session: Session,
) -> None:
"""Rollback cleanup runs while session-scoped state is still accessible."""
state_key = "_versioning_test_state"
lifecycle_session.info[state_key] = {"pending": True}
@sa.event.listens_for(lifecycle_session, "after_rollback")
def after_rollback(session: Session) -> None:
session.info.pop(state_key, None)
lifecycle_session.add(LifecycleRow(value="rolled back"))
lifecycle_session.flush()
lifecycle_session.rollback()
assert state_key not in lifecycle_session.info
def test_capture_retains_the_first_pre_flush_state(
lifecycle_session: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Repeated flushes retain the entity's initial database state once."""
class Slice:
id = 7
entity = Slice()
initial = {"slice_name": "initial"}
captures: list[object] = []
def capture(_session: Session, obj: object) -> dict[str, str]:
captures.append(obj)
return initial
monkeypatch.setattr(listener, "capture_initial_state", capture)
states: dict[tuple[str, int], tuple[object, dict[str, object]]] = {}
listener._capture_dirty_entity_initial_state(lifecycle_session, entity, states)
listener._capture_dirty_entity_initial_state(lifecycle_session, entity, states)
assert states == {("chart", 7): (entity, initial)}
assert captures == [entity]
def test_scalar_buffer_materializes_one_final_net_diff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Final materialization diffs each retained entity exactly once."""
entity = object()
initial = {"slice_name": "initial"}
record = ChangeRecord(
kind="property",
operation="edit",
path=["slice_name"],
from_value="initial",
to_value="final",
)
calls: list[tuple[object, dict[str, object]]] = []
def compute(obj: object, pre_state: dict[str, object]) -> list[ChangeRecord]:
calls.append((obj, pre_state))
return [record]
monkeypatch.setattr(listener, "compute_records_from_state", compute)
buffer = listener._build_scalar_buffer({("chart", 7): (entity, initial)})
assert buffer == {("chart", 7): [record]}
assert calls == [(entity, initial)]
@pytest.mark.parametrize("terminal_event", ["commit", "rollback"])
def test_terminal_event_clears_transaction_state(
lifecycle_session: Session, terminal_event: str
) -> None:
"""Commit and rollback cleanup discard all transaction-scoped state."""
lifecycle_session.info.update(
{
listener.ACTION_KIND_KEY: "restore",
listener.ACTION_META_KEY: {"headline": "restored"},
listener._INITIAL_STATES_KEY: {("chart", 7): object()},
listener._FINALIZING_KEY: True,
"unrelated": "preserved",
}
)
sa.event.listen(
lifecycle_session,
"after_transaction_end",
listener._reset_after_outer_transaction,
)
lifecycle_session.add(LifecycleRow(value=terminal_event))
lifecycle_session.flush()
getattr(lifecycle_session, terminal_event)()
assert lifecycle_session.info == {"unrelated": "preserved"}