mirror of
https://github.com/apache/superset.git
synced 2026-07-16 11:46:09 +00:00
Compare commits
13 Commits
chore/sqla
...
tdd/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9826ce9100 | ||
|
|
c346fba656 | ||
|
|
8794b8a729 | ||
|
|
53076ec5e7 | ||
|
|
8c959a3008 | ||
|
|
5881cc8de0 | ||
|
|
b94aa33de9 | ||
|
|
df209cedbf | ||
|
|
b641008da6 | ||
|
|
678fcba8b4 | ||
|
|
5c12f216d5 | ||
|
|
7a752d1c22 | ||
|
|
51ce2b11ad |
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
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
161
helm/superset/UPGRADING.md
Normal 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.
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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" . }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
132
helm/superset/tests/checksum_test.yaml
Normal file
132
helm/superset/tests/checksum_test.yaml
Normal 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
|
||||
94
helm/superset/tests/config_test.yaml
Normal file
94
helm/superset/tests/config_test.yaml
Normal 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\('}
|
||||
28
helm/superset/tests/deprecation_test.yaml
Normal file
28
helm/superset/tests/deprecation_test.yaml
Normal 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}
|
||||
63
helm/superset/tests/initscript_test.yaml
Normal file
63
helm/superset/tests/initscript_test.yaml
Normal 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'
|
||||
71
helm/superset/tests/secret_env_test.yaml
Normal file
71
helm/superset/tests/secret_env_test.yaml
Normal 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'}
|
||||
28
helm/superset/tests/serviceaccount_test.yaml
Normal file
28
helm/superset/tests/serviceaccount_test.yaml
Normal 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}
|
||||
30
helm/superset/tests/values_defaults_test.yaml
Normal file
30
helm/superset/tests/values_defaults_test.yaml
Normal 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
|
||||
140
helm/superset/values.schema.json
Normal file
140
helm/superset/values.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
186
superset/mcp_service/chart/tool/delete_chart.py
Normal file
186
superset/mcp_service/chart/tool/delete_chart.py
Normal 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__,
|
||||
)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
235
superset/mcp_service/dashboard/tool/delete_dashboard.py
Normal file
235
superset/mcp_service/dashboard/tool/delete_dashboard.py
Normal 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__,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
229
tests/unit_tests/mcp_service/chart/tool/test_delete_chart.py
Normal file
229
tests/unit_tests/mcp_service/chart/tool/test_delete_chart.py
Normal 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}})
|
||||
@@ -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
|
||||
204
tests/unit_tests/versioning/test_listener.py
Normal file
204
tests/unit_tests/versioning/test_listener.py
Normal 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"}
|
||||
Reference in New Issue
Block a user