Compare commits

...

18 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
8bf3933972 fix(dashboard): show a not-found state for a deleted dashboard (#41686) 2026-07-02 22:15:25 +03:00
yousoph
19e94855a1 fix(explore): prevent Results FilterInput from stealing focus during remount (#41100)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 11:33:17 -07:00
Brian Maina
139df20cde fix(i18n): update German security menu translations (#41587) 2026-07-03 01:13:14 +07:00
Imad Helal
4c193d4dbc feat(i18n): wrap description strings in translation function (#41626) 2026-07-03 00:44:25 +07:00
Evan Rusackas
aa40934e7f fix(i18n): compile fuzzy translations into the backend .mo files (#41648)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:43:13 -07:00
jack
6c2c814b5c fix(dashboard): not filterable column now not emitting cross-filters in table charts (#30827)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-02 10:42:53 -07:00
Evan Rusackas
9769380d6d feat(i18n): backfill Polish (pl) translations (AI-generated, needs review) (#41660) 2026-07-03 00:40:28 +07:00
dependabot[bot]
be29d877d2 chore(deps-dev): bump @storybook/addon-docs from 10.4.5 to 10.4.6 in /superset-frontend (#41375)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:37:26 -07:00
dependabot[bot]
e3b2992d6e chore(deps): update lodash-es requirement from ^4.17.21 to ^4.18.1 in /superset-frontend/packages/superset-ui-core (#41565)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:37:05 -07:00
Joe Li
c1bd45f561 fix(ci): allow showtime to check out fork PR code under checkout v7 (#41643) 2026-07-03 00:31:43 +07:00
Đỗ Trọng Hải
7214e9f9f6 build(dev-deps): upgrade Storybook to v10 in docs subproject (#41679)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-02 10:31:01 -07:00
Đỗ Trọng Hải
d7e2f18d00 fix(dockerfile): allow GH auth-less fetch of uv when building Superset image locally (#41682) 2026-07-03 00:30:03 +07:00
Luis Carbonell
6309d08d59 feat(helm): standardize to Kubernetes recommended labels (app.kubernetes.io/*) (#39350) 2026-07-03 00:26:35 +07:00
dependabot[bot]
afebdd58d1 chore(deps): bump docusaurus-theme-openapi-docs from 5.0.2 to 5.1.0 in /docs (#41669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:25:41 -07:00
Mike Bridge
be46d65e3b fix(dao): SQL-faithful NULL and non-string handling in LIKE-family operators (#41653)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-02 10:04:35 -07:00
Evan Rusackas
2992d7b4c8 feat(database): add databricks oauth support (#41421)
Co-authored-by: fabian_zse <fabian@zalando.de>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:26:08 -07:00
dependabot[bot]
80344852b7 chore(deps): bump docusaurus-plugin-openapi-docs from 5.0.2 to 5.1.0 in /docs (#41671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 22:32:18 +07:00
dependabot[bot]
8210904e95 chore(deps): bump nanoid from 5.1.15 to 5.1.16 in /superset-frontend (#41673)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-07-02 22:31:16 +07:00
58 changed files with 5057 additions and 944 deletions

View File

@@ -2,7 +2,8 @@ name: 🎪 Superset Showtime
# Ultra-simple: just sync on any PR state change
on:
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; this workflow does not check out or execute PR-provided code
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; PR code is
# only checked out and built after the maintainer-authorization gate (write/admin actors)
pull_request_target:
types: [labeled, unlabeled, synchronize, closed]
@@ -156,6 +157,10 @@ jobs:
with:
ref: ${{ steps.check.outputs.target_sha }}
persist-credentials: false
# Building fork PR code is Showtime's purpose: deploys are gated on the
# maintainer-authorization step above (write/admin actors only), so this
# checkout is an explicit, authorized opt-in rather than an automatic one.
allow-unsafe-pr-checkout: true
- name: Setup Docker Environment (only if build needed)
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'

View File

@@ -120,7 +120,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
# Some bash scripts needed throughout the layers
COPY --chmod=755 docker/*.sh /app/docker/
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN pip install --no-cache-dir --upgrade uv
# Using uv as it's faster/simpler than pip
RUN uv venv /app/.venv
@@ -141,7 +141,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY superset/translations/ /app/translations_mo/
RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
pybabel compile -d /app/translations_mo | true; \
pybabel compile --use-fuzzy -d /app/translations_mo || true; \
fi; \
rm -f /app/translations_mo/*/*/*.[po,json]

View File

@@ -79,6 +79,19 @@ When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audien
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
### Helm chart adopts Kubernetes recommended labels (breaking upgrade)
The Helm chart now labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. Because a Deployment's `spec.selector.matchLabels` is immutable, `helm upgrade` against an existing release will fail with a `field is immutable` error.
To upgrade, delete the affected workloads (which selector labels changed) before upgrading, then run the upgrade so they are recreated with the new labels:
```bash
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.

View File

@@ -332,15 +332,28 @@ cd superset-frontend
npm run build-translation
# Backend
pybabel compile -d superset/translations
pybabel compile --use-fuzzy -d superset/translations
```
`--use-fuzzy` includes `#, fuzzy` entries in the compiled `.mo` files. Superset
serves fuzzy translations on purpose: the frontend build (`po2json --fuzzy`)
already includes them, `flask fab babel-compile` (used by the release images)
compiles with `-f`, and the production `Dockerfile` compiles with `--use-fuzzy`
as well. This keeps machine-generated (and other draft) translations visible in
the UI rather than falling back to English while they await review.
### Backfilling missing translations with AI
For languages with many untranslated strings, the repo includes a script that
uses Claude AI to generate draft translations for any missing entries. All
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
comment so that human reviewers know they need to be checked before merging.
comment so that human reviewers know they need to be checked.
Note that `#, fuzzy` marks a translation as *needing review*, not as *withheld*:
both the frontend and backend builds serve fuzzy entries (see [Applying
translations](#applying-translations) above), so an AI-generated string is shown
in the UI as soon as it is built and deployed. Reviewers should verify each
entry and remove the `#, fuzzy` flag to promote it to a confirmed translation.
#### Prerequisites

View File

@@ -749,7 +749,7 @@ const config: Config = {
showReadingTime: true,
// Please change this to your repo.
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/blog/',
'https://github.com/apache/superset/tree/master/docs',
},
theme: {
customCss: require.resolve('./src/styles/custom.css'),

View File

@@ -58,24 +58,14 @@
"@fontsource/inter": "^5.2.8",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.18",
"@storybook/blocks": "^8.6.15",
"@storybook/channels": "^8.6.18",
"@storybook/client-logger": "^8.6.18",
"@storybook/components": "^8.6.18",
"@storybook/core": "^8.6.18",
"@storybook/core-events": "^8.6.18",
"@storybook/csf": "^0.1.13",
"@storybook/docs-tools": "^8.6.18",
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@storybook/addon-docs": "^10.4.5",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.43",
"antd": "^6.4.5",
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"docusaurus-plugin-openapi-docs": "^5.1.0",
"docusaurus-theme-openapi-docs": "^5.1.0",
"js-yaml": "^5.1.0",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
@@ -88,7 +78,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^8.6.18",
"storybook": "^10.4.5",
"swagger-ui-react": "^5.32.8",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",

View File

@@ -168,60 +168,6 @@ export default function webpackExtendPlugin(): Plugin<void> {
__dirname,
'../../superset-frontend/packages/superset-core/src',
),
// Add proper Storybook aliases
'@storybook/blocks': path.resolve(
__dirname,
'../node_modules/@storybook/blocks',
),
'@storybook/components': path.resolve(
__dirname,
'../node_modules/@storybook/components',
),
'@storybook/theming': path.resolve(
__dirname,
'../node_modules/@storybook/theming',
),
'@storybook/client-logger': path.resolve(
__dirname,
'../node_modules/@storybook/client-logger',
),
'@storybook/core-events': path.resolve(
__dirname,
'../node_modules/@storybook/core-events',
),
// Add internal Storybook aliases
'storybook/internal/components': path.resolve(
__dirname,
'../node_modules/@storybook/components',
),
'storybook/internal/theming': path.resolve(
__dirname,
'../node_modules/@storybook/theming',
),
'storybook/internal/client-logger': path.resolve(
__dirname,
'../node_modules/@storybook/client-logger',
),
'storybook/internal/csf': path.resolve(
__dirname,
'../node_modules/@storybook/csf',
),
'storybook/internal/preview-api': path.resolve(
__dirname,
'../node_modules/@storybook/preview-api',
),
'storybook/internal/docs-tools': path.resolve(
__dirname,
'../node_modules/@storybook/docs-tools',
),
'storybook/internal/core-events': path.resolve(
__dirname,
'../node_modules/@storybook/core-events',
),
'storybook/internal/channels': path.resolve(
__dirname,
'../node_modules/@storybook/channels',
),
},
},
};

View File

@@ -519,6 +519,80 @@ For a connection to a SQL endpoint you need to use the HTTP path from the endpoi
{"connect_args": {"http_path": "/sql/1.0/endpoints/****", "driver_path": "/path/to/odbc/driver"}}
```
##### OAuth2 Authentication
Superset supports OAuth2 authentication for Databricks, allowing users to authenticate with their personal Databricks accounts instead of using shared access tokens. This provides better security and audit capabilities.
###### Prerequisites
1. Create an OAuth2 application in your Databricks account:
- Go to your Databricks account console
- Navigate to **Settings** → **Developer** → **OAuth apps**
- Create a new OAuth app with the redirect URI: `http://your-superset-host:port/api/v1/database/oauth2/`
2. Configure OAuth2 in your `superset_config.py`:
```python
from datetime import timedelta
# OAuth2 configuration for Databricks
# The authorization endpoint is derived from your Databricks workspace host; the
# token endpoint must be set explicitly (see notes below).
DATABASE_OAUTH2_CLIENTS = {
"Databricks (legacy)": {
"id": "your-databricks-client-id",
"secret": "your-databricks-client-secret",
"scope": "sql",
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
},
"Databricks": {
"id": "your-databricks-client-id",
"secret": "your-databricks-client-secret",
"scope": "sql",
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
},
}
# OAuth2 redirect URI (adjust hostname/port for your setup)
DATABASE_OAUTH2_REDIRECT_URI = "http://your-superset-host:port/api/v1/database/oauth2/"
# Optional: OAuth2 timeout
DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
```
Replace the following placeholders:
- `your-databricks-client-id`: Your Databricks OAuth2 application client ID
- `your-databricks-client-secret`: Your Databricks OAuth2 application client secret
- `your-superset-host:port`: Your Superset instance hostname and port
**Multi-Cloud Provider Support**
Databricks fronts the user-to-machine (U2M) OAuth2 flow on every workspace at
`https://<workspace-host>/oidc/v1/authorize` and
`https://<workspace-host>/oidc/v1/token`, regardless of whether the workspace
runs on AWS, Azure, or GCP. Superset derives the **authorization** endpoint
directly from your connection's host, so no cloud provider or account/tenant
identifier needs to be configured.
The **token** endpoint cannot be auto-derived (token exchange has no database
context to read the host), so you must supply `token_request_uri` in
`DATABASE_OAUTH2_CLIENTS`, set to `https://<workspace-host>/oidc/v1/token` for
your workspace.
If you supply a fully-resolved `authorization_request_uri` (and/or
`token_request_uri`), those values take precedence over the host-derived
defaults.
###### Usage
Once configured, users can:
1. Connect to Databricks databases normally using access tokens
2. When querying data, Superset will automatically redirect users to authenticate with Databricks if needed
3. User-specific OAuth2 tokens will be used for database connections, providing better security and audit trails
This feature works with both "Databricks (legacy)" and "Databricks" engine types and automatically supports all major cloud providers (AWS, Azure, GCP).
#### Denodo
The recommended connector library for Denodo is

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square)
![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -46,6 +46,21 @@ It should be a long random bytes or str.
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
## Upgrade Notes
### Kubernetes recommended labels (breaking)
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
```console
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
## Requirements
| Repository | Name | Version |

View File

@@ -45,6 +45,21 @@ It should be a long random bytes or str.
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
## Upgrade Notes
### Kubernetes recommended labels (breaking)
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
```console
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
{{ template "chart.requirementsSection" . }}
{{ template "chart.valuesSection" . }}

View File

@@ -61,6 +61,49 @@ Create chart name and version as used by the chart label.
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common labels for all resources - follows Kubernetes recommended labels
https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
*/}}
{{- define "superset.labels" -}}
helm.sh/chart: {{ include "superset.chart" . }}
{{ include "superset.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: superset
{{- if .Values.extraLabels }}
{{ toYaml .Values.extraLabels }}
{{- end }}
{{- end -}}
{{/*
Selector labels - used by selectors and matchLabels
*/}}
{{- define "superset.selectorLabels" -}}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{/*
Component labels - extends superset.labels with component-specific labels
Usage: {{ include "superset.componentLabels" (dict "component" "web" "root" .) }}
*/}}
{{- define "superset.componentLabels" -}}
{{ include "superset.labels" .root }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{/*
Component selector labels - for matchLabels with component
Usage: {{ include "superset.componentSelectorLabels" (dict "component" "web" "root" .) }}
*/}}
{{- define "superset.componentSelectorLabels" -}}
{{ include "superset.selectorLabels" .root }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{- define "superset-config" }}
import os
@@ -146,27 +189,32 @@ RESULTS_BACKEND = RedisCache(
{{- end }}
{{- define "supersetNode.selectorLabels" -}}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
{{- end }}
{{- define "supersetCeleryBeat.selectorLabels" -}}
app: {{ include "superset.name" . }}-celerybeat
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: celerybeat
{{- end }}
{{- define "supersetCeleryFlower.selectorLabels" -}}
app: {{ include "superset.name" . }}-flower
release: {{ .Release.Name }}
{{- end }}
{{- define "supersetNode.selectorLabels" -}}
app: {{ include "superset.name" . }}
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: flower
{{- end }}
{{- define "supersetWebsockets.selectorLabels" -}}
app: {{ include "superset.name" . }}-ws
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: websocket
{{- end }}
{{- define "supersetWorker.selectorLabels" -}}
app: {{ include "superset.name" . }}-worker
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-extra-config
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
data:
{{- range $path, $config := .Values.extraConfigs }}
{{ $path }}: |

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-celerybeat
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-celerybeat
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" .) | nindent 4 }}
{{- if .Values.supersetCeleryBeat.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -59,11 +53,7 @@ spec:
{{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-celerybeat"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryBeat.podLabels }}
{{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-flower
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-flower
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
{{- if .Values.supersetCeleryFlower.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -48,11 +42,7 @@ spec:
{{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-flower"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryFlower.podLabels }}
{{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,15 +23,9 @@ metadata:
name: {{ template "superset.fullname" . }}-worker
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-worker
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.supersetWorker.deploymentLabels }}
{{- toYaml .Values.supersetWorker.deploymentLabels | nindent 4 }}
{{- include "superset.componentLabels" (dict "component" "worker" "root" .) | nindent 4 }}
{{- with .Values.supersetWorker.deploymentLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.supersetWorker.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }}
@@ -65,11 +59,7 @@ spec:
{{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}-worker
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetWorker.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWorker.podLabels }}
{{- toYaml .Values.supersetWorker.podLabels | nindent 8 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws"
namespace: {{ .Release.Namespace }}
labels:
app: "{{ template "superset.name" . }}-ws"
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
{{- if .Values.supersetWebsockets.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -51,11 +45,7 @@ spec:
{{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-ws"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetWebsockets.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWebsockets.podLabels }}
{{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,15 +23,9 @@ metadata:
name: {{ template "superset.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.supersetNode.deploymentLabels }}
{{- toYaml .Values.supersetNode.deploymentLabels | nindent 4 }}
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
{{- with .Values.supersetNode.deploymentLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.supersetNode.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }}
@@ -67,11 +61,7 @@ spec:
{{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetNode.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetNode.podLabels }}
{{- toYaml .Values.supersetNode.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
metadata:
name: {{ include "superset.fullname" . }}-hpa
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1

View File

@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
metadata:
name: {{ include "superset.fullname" . }}-hpa-worker
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1

View File

@@ -25,13 +25,7 @@ metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "ingress" "root" .) | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-init-db
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "init" "root" .) | nindent 4 }}
{{- if .Values.init.jobAnnotations }}
annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }}
{{- end }}
@@ -42,9 +36,7 @@ spec:
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
{{- include "superset.componentSelectorLabels" (dict "component" "init" "root" .) | nindent 8 }}
job: {{ template "superset.fullname" . }}-init-db
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-celerybeat-pdb
labels:
app: {{ template "superset.name" $ }}-celerybeat
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-flower-pdb
labels:
app: {{ template "superset.name" $ }}-flower
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-worker-pdb
labels:
app: {{ template "superset.name" $ }}-worker
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "worker" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-ws-pdb
labels:
app: {{ template "superset.name" $ }}-ws
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-pdb
labels:
app: {{ template "superset.name" $ }}
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "web" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}-env
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
superset_config.py: |

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws-config"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
config.json: |

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-flower"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
{{- with .Values.supersetCeleryFlower.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -45,8 +39,7 @@ spec:
nodePort: {{ .Values.supersetCeleryFlower.service.nodePort.http }}
{{- end }}
selector:
app: {{ template "superset.name" . }}-flower
release: {{ .Release.Name }}
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 4 }}
{{- if .Values.supersetCeleryFlower.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.supersetCeleryFlower.service.loadBalancerIP }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
{{- with .Values.supersetWebsockets.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -45,8 +39,7 @@ spec:
nodePort: {{ .Values.supersetWebsockets.service.nodePort.http }}
{{- end }}
selector:
app: "{{ template "superset.name" . }}-ws"
release: {{ .Release.Name }}
{{- include "supersetWebsockets.selectorLabels" . | nindent 4 }}
{{- if .Values.supersetWebsockets.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.supersetWebsockets.service.loadBalancerIP }}
{{- end }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
{{- with .Values.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -44,8 +38,7 @@ spec:
nodePort: {{ .Values.service.nodePort.http }}
{{- end }}
selector:
app: {{ template "superset.name" . }}
release: {{ .Release.Name }}
{{- include "supersetNode.selectorLabels" . | nindent 4 }}
{{- if .Values.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- end }}

View File

@@ -24,17 +24,11 @@ metadata:
name: {{ include "superset.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ include "superset.name" . }}
helm.sh/chart: {{ include "superset.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- include "superset.labels" . | nindent 4 }}
{{- if semverCompare "> 1.6" .Capabilities.KubeVersion.GitVersion }}
kubernetes.io/cluster-service: "true"
{{- end }}
addonmanager.kubernetes.io/mode: Reconcile
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.serviceAccount.annotations }}
annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,520 @@
#
# 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: Label Consistency Tests
templates:
- deployment.yaml
- deployment-worker.yaml
- deployment-beat.yaml
- deployment-flower.yaml
- deployment-ws.yaml
- service.yaml
- service-ws.yaml
- service-flower.yaml
- init-job.yaml
- ingress.yaml
- configmap-superset.yaml
- secret-superset-config.yaml
- secret-ws.yaml
- pdb.yaml
- pdb-worker.yaml
- pdb-beat.yaml
- pdb-flower.yaml
- pdb-ws.yaml
# These tests validate that Kubernetes recommended labels are consistently applied
# across all chart resources per https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
#
# Required Labels (app.kubernetes.io/):
# - name: The name of the application
# - instance: A unique name identifying the instance of an application
# - version: The current version of the application
# - component: The component within the architecture
# - part-of: The name of a higher level application this one is part of
# - managed-by: The tool being used to manage the operation of an application
#
# Helm-specific Labels:
# - helm.sh/chart: The chart name and version
tests:
# =============================================================================
# Main Deployment Labels
# =============================================================================
- it: should have all recommended labels on main deployment
template: deployment.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/version"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/part-of"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- isNotNull:
path: metadata.labels["helm.sh/chart"]
- it: should have correct component label on main deployment
template: deployment.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- it: should have part-of label set to superset on main deployment
template: deployment.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
# =============================================================================
# Worker Deployment Labels
# =============================================================================
- it: should have all recommended labels on worker deployment
template: deployment-worker.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/version"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/part-of"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: worker
# =============================================================================
# Celery Beat Deployment Labels
# =============================================================================
- it: should have all recommended labels on celerybeat deployment
template: deployment-beat.yaml
set:
supersetCeleryBeat.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on celerybeat deployment
template: deployment-beat.yaml
set:
supersetCeleryBeat.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: celerybeat
# =============================================================================
# Flower Deployment Labels
# =============================================================================
- it: should have all recommended labels on flower deployment
template: deployment-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on flower deployment
template: deployment-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
# =============================================================================
# WebSocket Deployment Labels
# =============================================================================
- it: should have all recommended labels on websocket deployment
template: deployment-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on websocket deployment
template: deployment-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
# =============================================================================
# Service Labels
# =============================================================================
- it: should have all recommended labels on main service
template: service.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on main service
template: service.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- it: should have all recommended labels on websocket service
template: service-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on websocket service
template: service-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
- it: should have all recommended labels on flower service
template: service-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on flower service
template: service-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
# =============================================================================
# Init Job Labels
# =============================================================================
- it: should have all recommended labels on init job
template: init-job.yaml
set:
init.enabled: true
init.createAdmin: true
init.adminUser.password: "test-password"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on init job
template: init-job.yaml
set:
init.enabled: true
init.createAdmin: true
init.adminUser.password: "test-password"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: init
# =============================================================================
# Ingress Labels
# =============================================================================
- it: should have all recommended labels on ingress
template: ingress.yaml
set:
ingress.enabled: true
ingress.hosts:
- host: superset.example.com
paths:
- path: /
pathType: Prefix
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on ingress
template: ingress.yaml
set:
ingress.enabled: true
ingress.hosts:
- host: superset.example.com
paths:
- path: /
pathType: Prefix
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: ingress
# =============================================================================
# Selector Label Consistency
#
# These use value assertions (not isNotNull) on purpose: a missing/misscoped
# release name renders as the string "<no value>", which is non-null and would
# silently pass isNotNull. Asserting the concrete value catches that class of
# bug, and asserting the pod template labels equal the selector guards the
# immutable spec.selector.matchLabels <-> pod label invariant.
# =============================================================================
- it: should set selector matchLabels to concrete values on main deployment
template: deployment.yaml
asserts:
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/name"]
value: superset
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: web
- it: should match pod template labels to the selector on main deployment
template: deployment.yaml
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: superset
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: web
- it: should set selector matchLabels to concrete values on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: worker
- it: should match pod template labels to the selector on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: worker
# =============================================================================
# Extra Labels Support
# =============================================================================
- it: should include extraLabels when specified
template: deployment.yaml
set:
extraLabels:
custom-label: custom-value
environment: production
asserts:
- equal:
path: metadata.labels.custom-label
value: custom-value
- equal:
path: metadata.labels.environment
value: production
- it: should include extraLabels in service
template: service.yaml
set:
extraLabels:
custom-label: custom-value
asserts:
- equal:
path: metadata.labels.custom-label
value: custom-value
# =============================================================================
# ConfigMap / Secret Labels
# =============================================================================
- it: should have recommended labels on extra-config configmap
template: configmap-superset.yaml
set:
extraConfigs:
custom.py: "FOO = 1"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
- it: should have recommended labels on superset config secret
template: secret-superset-config.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
- it: should have recommended labels on websocket config secret
template: secret-ws.yaml
set:
supersetWebsockets.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
# =============================================================================
# PodDisruptionBudget Labels (metadata must match the selector)
# =============================================================================
- it: should have recommended labels and matching selector on main pdb
template: pdb.yaml
set:
supersetNode.podDisruptionBudget.enabled: true
supersetNode.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: web
- it: should set correct component on worker pdb
template: pdb-worker.yaml
set:
supersetWorker.podDisruptionBudget.enabled: true
supersetWorker.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: worker
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: worker
- it: should set correct component on celerybeat pdb
template: pdb-beat.yaml
set:
supersetCeleryBeat.podDisruptionBudget.enabled: true
supersetCeleryBeat.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: celerybeat
- it: should set correct component on flower pdb
template: pdb-flower.yaml
set:
supersetCeleryFlower.podDisruptionBudget.enabled: true
supersetCeleryFlower.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
- it: should set correct component on websocket pdb
template: pdb-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.podDisruptionBudget.enabled: true
supersetWebsockets.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: websocket
- it: should use recommended labels on init job pod template
template: init-job.yaml
set:
init.enabled: true
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: init
- isNotNull:
path: spec.template.metadata.labels.job

View File

@@ -116,7 +116,7 @@
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.15",
"nanoid": "^5.1.16",
"ol": "^10.9.0",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
@@ -182,7 +182,7 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.4.5",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-links": "10.4.4",
"@storybook/react-webpack5": "10.4.4",
"@storybook/test-runner": "0.24.4",
@@ -9718,16 +9718,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.5.tgz",
"integrity": "sha512-9mIV0maIxixfuvdpNhr3QMeU/gbJKeaBcWhPYuf176cqDZAG9EUhZ50TIinxeFRbyEGRJqaLPoiYwIu4GJu3jA==",
"version": "10.4.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz",
"integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.4.5",
"@storybook/csf-plugin": "10.4.6",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.4.5",
"@storybook/react-dom-shim": "10.4.6",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -9738,7 +9738,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.4.5"
"storybook": "^10.4.6"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -9747,9 +9747,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.5.tgz",
"integrity": "sha512-fKdikHC7cDgSuaBirPwvgFBmfO//3cln0y3GmDEQchUV2VFDrZ7ZL1/iH7dA21XuiFFhQcDRRkArJmvMAGG5Cg==",
"version": "10.4.6",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz",
"integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -9761,7 +9761,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.4.5"
"storybook": "^10.4.6"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -9800,9 +9800,9 @@
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.5.tgz",
"integrity": "sha512-OsSsSLulBmdKTz7MIKLgoWADZB8bjYaAjZZy/THdI50G/TTd6FVSXQMCM7GO7xQZ/EguRY1PmjOVCLbgcnXsDA==",
"version": "10.4.6",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz",
"integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9815,7 +9815,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.4.5",
"storybook": "^10.4.6",
"vite": "*",
"webpack": "*"
},
@@ -31349,9 +31349,9 @@
}
},
"node_modules/nanoid": {
"version": "5.1.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",

View File

@@ -198,7 +198,7 @@
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.15",
"nanoid": "^5.1.16",
"ol": "^10.9.0",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
@@ -265,7 +265,7 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.4.5",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-links": "10.4.4",
"@storybook/react-webpack5": "10.4.4",
"@storybook/test-runner": "0.24.4",

View File

@@ -713,4 +713,5 @@ export interface DataColumnMeta {
isChildColumn?: boolean;
description?: string;
currencyCodeColumn?: string;
isFilterable?: boolean;
}

View File

@@ -67,7 +67,7 @@
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"devDependencies": {
"@emotion/styled": "^11.14.1",

View File

@@ -33,8 +33,9 @@ import { getLayerConfig } from '../util/controlPanelUtil';
export default class CartodiagramPlugin extends ChartPlugin {
constructor(opts: CartodiagramPluginConstructorOpts) {
const metadata = new ChartMetadata({
description:
description: t(
'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.',
),
name: t('Cartodiagram'),
thumbnail,
thumbnailDark,

View File

@@ -28,8 +28,9 @@ export default class PopKPIPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
category: t('KPI'),
description:
description: t(
'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.',
),
name: t('Big Number with Time Period Comparison'),
tags: [
t('Comparison'),

View File

@@ -1204,8 +1204,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
onClick:
emitCrossFilters && !valueRange && !isMetric
? () => {
const isFilterable = columnsMeta.find(
(cm: DataColumnMeta) => cm.key === key,
)?.isFilterable;
// allow selecting text in a cell
if (!getSelectedText()) {
if (!getSelectedText() && isFilterable !== false) {
toggleFilter(key, value);
}
}

View File

@@ -232,6 +232,9 @@ const processColumns = memoizeOne(function processColumns(
const metricsSet = new Set(metrics);
const percentMetricsSet = new Set(percentMetrics);
const rawPercentMetricsSet = new Set(rawPercentMetrics);
const columnsByName = new Map(
(props.datasource.columns ?? []).map(col => [col.column_name, col]),
);
const columns: DataColumnMeta[] = (colnames || [])
.filter(
@@ -244,6 +247,7 @@ const processColumns = memoizeOne(function processColumns(
const config = columnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isFilterable = columnsByName.get(key)?.filterable;
const isMetric = metricsSet.has(key) && isNumeric(key, records);
const isPercentMetric = percentMetricsSet.has(key);
const label =
@@ -326,6 +330,7 @@ const processColumns = memoizeOne(function processColumns(
isPercentMetric,
formatter,
config,
isFilterable,
description,
currencyCodeColumn,
};

View File

@@ -2534,3 +2534,33 @@ test('sorts genuinely string columns alphanumerically', () => {
const values = Array.from(cells).map(td => td.textContent);
expect(values).toEqual(['apple', 'banana', 'cherry']);
});
test('TableChart should NOT emit cross-filter when clicking a cell in a not-filterable column', () => {
const setDataMask = jest.fn();
const props = transformProps({
...testData.basic,
datasource: {
...testData.basic.datasource,
columns: [{ column_name: 'name', filterable: false } as any],
},
hooks: { setDataMask },
emitCrossFilters: true,
});
render(
<ProviderWrapper>
<TableChart
{...props}
emitCrossFilters
setDataMask={setDataMask}
sticky={false}
/>
</ProviderWrapper>,
);
fireEvent.click(screen.getByText('Michael'));
const crossFilterCall = setDataMask.mock.calls.find(
(call: any[]) => call[0]?.filterState?.filters,
);
expect(crossFilterCall).toBeUndefined();
});

View File

@@ -22,6 +22,7 @@ import {
createStore,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
@@ -30,7 +31,7 @@ import {
useDashboardCharts,
useDashboardDatasets,
} from 'src/hooks/apiResources';
import { SupersetClient } from '@superset-ui/core';
import { SupersetApiError, SupersetClient } from '@superset-ui/core';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import {
@@ -559,6 +560,48 @@ test('does not overwrite filterState when modern native_filters URL format is us
).toBeUndefined();
});
test('renders a not-found state instead of throwing when the dashboard 404s', async () => {
mockUseDashboard.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
});
mockUseDashboardCharts.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
});
mockUseDashboardDatasets.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
status: 'error',
});
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="404" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: {},
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
expect(
await screen.findByText('This dashboard does not exist'),
).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-builder')).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'See all dashboards' }),
);
expect(window.location.pathname).toBe('/dashboard/list/');
});
test('clears undo history after hydrating the dashboard', async () => {
render(
<Suspense fallback="loading">

View File

@@ -24,7 +24,7 @@ import { useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { Loading } from '@superset-ui/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import {
useDashboard,
useDashboardCharts,
@@ -67,7 +67,8 @@ import SyncDashboardState, {
getDashboardContextLocalStorage,
} from '../components/SyncDashboardState';
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
import { Filter, PartialFilters } from '@superset-ui/core';
import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core';
import { RoutePaths } from 'src/views/routePaths';
import {
parseRisonFilters,
risonFiltersToExtraFormDataFilters,
@@ -151,6 +152,9 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
const isDashboardHydrated = useRef(false);
const error = dashboardApiError || chartsApiError;
// Only 404 gets a graceful not-found state; a 403 (access denied) still
// surfaces through the error boundary.
const isNotFoundError = (error as SupersetApiError | null)?.status === 404;
const readyToRender = Boolean(dashboard && charts);
const { dashboard_title, id = 0 } = dashboard || {};
@@ -365,18 +369,21 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
useEffect(() => {
if (datasetsApiError) {
addDangerToast(
t('Error loading chart datasources. Filters may not work correctly.'),
);
// A missing dashboard also 404s its datasets; the not-found state covers it.
if (!isNotFoundError) {
addDangerToast(
t('Error loading chart datasources. Filters may not work correctly.'),
);
}
} else {
dispatch(setDatasources(datasets));
}
}, [addDangerToast, datasets, datasetsApiError, dispatch]);
}, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
const relevantDataMask = useSelector(selectRelevantDatamask);
const activeFilters = useSelector(selectActiveFilters);
if (error) throw error; // caught in error boundary
if (error && !isNotFoundError) throw error; // caught in error boundary
const globalStyles = useMemo(
() => [
@@ -389,9 +396,25 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
[theme],
);
if (error) throw error; // caught in error boundary
if (error && !isNotFoundError) throw error; // caught in error boundary
const DashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
if (isNotFoundError) {
return (
<EmptyState
size="large"
image="empty-dashboard.svg"
title={t('This dashboard does not exist')}
description={t(
'The dashboard you are looking for may have been deleted or moved.',
)}
buttonText={t('See all dashboards')}
buttonAction={() => history.push(RoutePaths.DASHBOARD_LIST)}
/>
);
}
return (
<>
<Global styles={globalStyles} />

View File

@@ -34,3 +34,40 @@ test('Render a FilterInput', async () => {
expect(onChangeHandler).toHaveBeenCalledTimes(4);
});
test('FilterInput auto-focuses when a non-editable element (e.g. a tab) has focus', () => {
const onChangeHandler = jest.fn();
const button = document.createElement('button');
document.body.appendChild(button);
try {
button.focus();
expect(document.activeElement).toBe(button);
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
const filterInput = screen.getByPlaceholderText('Search');
// Auto-focus should fire — a button is not an editable element
expect(document.activeElement).toBe(filterInput);
} finally {
document.body.removeChild(button);
}
});
test('FilterInput does not steal focus when another input already has focus', () => {
const onChangeHandler = jest.fn();
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
try {
otherInput.focus();
expect(document.activeElement).toBe(otherInput);
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
const filterInput = screen.getByPlaceholderText('Search');
// FilterInput should not have stolen focus from the already-focused input
expect(document.activeElement).not.toBe(filterInput);
expect(document.activeElement).toBe(otherInput);
} finally {
document.body.removeChild(otherInput);
}
});

View File

@@ -98,9 +98,20 @@ export const FilterInput = ({
const inputRef: RefObject<any> = useRef(null);
useEffect(() => {
// Focus the input element when the component mounts
if (inputRef.current && shouldFocus) {
inputRef.current.focus();
// Skip auto-focus only when an editable element already has focus (e.g.
// user is typing in a form control when this pane remounts after a data
// refresh). Non-editable focused elements like tabs/buttons still allow
// auto-focus so the search box focuses on first open.
const activeEl = document.activeElement;
const editableFocused =
activeEl instanceof HTMLElement &&
(activeEl.tagName === 'INPUT' ||
activeEl.tagName === 'TEXTAREA' ||
activeEl.isContentEditable);
if (!editableFocused) {
inputRef.current.focus();
}
}
}, []);

View File

@@ -541,8 +541,9 @@ function SavedQueryList({
key: 'search',
input: 'search',
operator: FilterOperator.AllText,
toolTipDescription:
toolTipDescription: t(
'Searches all text fields: Name, Description, Database & Schema',
),
},
{
Header: t('Database'),

View File

@@ -133,8 +133,9 @@ function TagList(props: TagListProps) {
const emptyState = {
title: t('No Tags created'),
image: 'dashboard.svg',
description:
description: t(
'Create a new tag and assign it to existing entities like charts or dashboards',
),
buttonAction: () => setShowTagModal(true),
buttonIcon: <Icons.PlusOutlined iconSize="m" data-test="add-rule-empty" />,
buttonText: t('Create a new Tag'),

View File

@@ -37,7 +37,7 @@ from flask import current_app
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla.interface import SQLAInterface
from pydantic import BaseModel, Field
from sqlalchemy import asc, cast, desc, or_, Text
from sqlalchemy import asc, cast, desc, false, or_, Text
from sqlalchemy.exc import SQLAlchemyError, StatementError
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect
@@ -82,24 +82,46 @@ class ColumnOperatorEnum(str, Enum):
return op_func(column, value)
def _escape_like(value: str) -> str:
"""Escape LIKE/ILIKE wildcards to prevent wildcard injection."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _escape_like(value: Any) -> str:
"""Escape LIKE/ILIKE wildcards to prevent wildcard injection.
The filter payload is typed ``Any``, so non-string scalars (e.g. numeric
JSON values) can reach LIKE-family operators; coerce them to ``str`` so
they degrade to a literal match instead of raising ``AttributeError``.
``None`` never reaches this function — ``_like_op`` short-circuits it
first, because coercing ``None`` to ``""`` would build a wildcard-only
pattern (``%%``) that matches every row.
"""
return str(value).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _like_op(template: str, case_insensitive: bool = False) -> Any:
"""Build a LIKE-family operator with SQL-faithful NULL semantics.
``template`` places the escaped value inside the pattern (e.g. ``"%{}%"``
for contains). A ``None`` value matches no rows — mirroring SQL's
three-valued logic, where ``x LIKE NULL`` evaluates to NULL — rather
than raising or degenerating into a match-everything pattern.
"""
def op(col: Any, val: Any) -> Any:
if val is None:
return false()
pattern = template.format(_escape_like(val))
if case_insensitive:
return col.ilike(pattern, escape="\\")
return col.like(pattern, escape="\\")
return op
# Define operator_map as a module-level dict after the enum is defined
operator_map: Dict[ColumnOperatorEnum, Any] = {
ColumnOperatorEnum.eq: lambda col, val: col == val,
ColumnOperatorEnum.ne: lambda col, val: col != val,
ColumnOperatorEnum.sw: lambda col, val: col.like(
f"{_escape_like(val)}%", escape="\\"
),
ColumnOperatorEnum.ew: lambda col, val: col.like(
f"%{_escape_like(val)}", escape="\\"
),
ColumnOperatorEnum.ct: lambda col, val: col.ilike(
f"%{_escape_like(val)}%", escape="\\"
),
ColumnOperatorEnum.sw: _like_op("{}%"),
ColumnOperatorEnum.ew: _like_op("%{}"),
ColumnOperatorEnum.ct: _like_op("%{}%", case_insensitive=True),
ColumnOperatorEnum.in_: lambda col, val: col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
@@ -110,12 +132,8 @@ operator_map: Dict[ColumnOperatorEnum, Any] = {
ColumnOperatorEnum.gte: lambda col, val: col >= val,
ColumnOperatorEnum.lt: lambda col, val: col < val,
ColumnOperatorEnum.lte: lambda col, val: col <= val,
ColumnOperatorEnum.like: lambda col, val: col.like(
f"%{_escape_like(val)}%", escape="\\"
),
ColumnOperatorEnum.ilike: lambda col, val: col.ilike(
f"%{_escape_like(val)}%", escape="\\"
),
ColumnOperatorEnum.like: _like_op("%{}%"),
ColumnOperatorEnum.ilike: _like_op("%{}%", case_insensitive=True),
ColumnOperatorEnum.is_null: lambda col, _: col.is_(None),
ColumnOperatorEnum.is_not_null: lambda col, _: col.isnot(None),
}

View File

@@ -16,11 +16,13 @@
# under the License.
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Callable, TYPE_CHECKING, TypedDict, Union
from typing import Any, Callable, cast, TYPE_CHECKING, TypedDict, Union
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask import g
from flask_babel import gettext as __
from marshmallow import fields, Schema
from marshmallow.validate import Range
@@ -38,12 +40,21 @@ from superset.db_engine_specs.base import (
)
from superset.db_engine_specs.hive import HiveEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2Error
from superset.utils import json
from superset.utils.core import get_user_agent, QuerySource
from superset.utils.network import is_hostname_valid, is_port_open
if TYPE_CHECKING:
from superset.models.core import Database
from superset.superset_typing import (
OAuth2ClientConfig,
OAuth2State,
OAuth2TokenResponse,
)
logger = logging.getLogger(__name__)
try:
@@ -277,6 +288,168 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
"port": "port",
}
# The Databricks SQL driver has no dedicated authentication exception, so an
# expired or missing token surfaces as a generic driver error. These case-
# insensitive substrings flag the errors that should bootstrap a re-auth.
oauth2_auth_failure_signals = (
"http 401",
"unauthorized",
"unauthenticated",
"invalid access token",
"invalid token",
"expired token",
"token expired",
# Raised by the databricks-sql-connector when no usable credentials are
# present (e.g. an OAuth2 token that has been cleared/expired).
"no valid authentication settings",
)
@classmethod
def _workspace_oauth2_endpoint(cls, database: Database, path: str) -> str:
"""
Build a Databricks OAuth2 (U2M) endpoint from the workspace host.
Databricks fronts the user-to-machine OAuth2 flow on every workspace at
``https://<workspace-host>/oidc/v1/{authorize,token}`` across AWS, Azure
and GCP, so the endpoints derive directly from the connection host and
need no account or tenant identifier.
"""
host = database.url_object.host
if not host:
raise OAuth2Error(
"Databricks OAuth2 endpoint could not be resolved: the database "
"connection has no host."
)
return f"https://{host}/oidc/v1/{path}"
@classmethod
def needs_oauth2(cls, ex: Exception) -> bool:
"""
Identify driver errors that should trigger the OAuth2 dance.
Unlike Trino (``TrinoAuthError``) or GSheets (``UnauthenticatedError``),
the Databricks driver raises no dedicated auth exception, so in addition
to the base ``isinstance`` check we match the auth signals above on the
error message (mirrors ``GSheetsEngineSpec.needs_oauth2``).
"""
if not (g and hasattr(g, "user")):
return False
if isinstance(ex, cls.oauth2_exception):
return True
message = str(ex).lower()
return any(signal in message for signal in cls.oauth2_auth_failure_signals)
@classmethod
def get_oauth2_authorization_uri(
cls,
config: "OAuth2ClientConfig",
state: "OAuth2State",
code_verifier: str | None = None,
) -> str:
"""
Return the URI for the initial OAuth2 request.
A fully-resolved ``authorization_request_uri`` from
``DATABASE_OAUTH2_CLIENTS`` is preserved; otherwise the endpoint is
derived from the workspace host (``https://<host>/oidc/v1/authorize``),
which is valid on AWS, Azure and GCP.
"""
if not config.get("authorization_request_uri"):
from superset import db
from superset.models.core import Database
database_id = state["database_id"]
if database := db.session.get(Database, database_id):
config = cast(
"OAuth2ClientConfig",
dict(config)
| {
"authorization_request_uri": cls._workspace_oauth2_endpoint(
database, "authorize"
)
},
)
return super().get_oauth2_authorization_uri(config, state, code_verifier)
@classmethod
def get_oauth2_token(
cls,
config: "OAuth2ClientConfig",
code: str,
code_verifier: str | None = None,
) -> "OAuth2TokenResponse":
"""
Exchange the authorization code for refresh/access tokens.
Token exchange runs in a separate request with no database context, so
the workspace host is not available to derive the endpoint here. Require
a configured ``token_request_uri``
(``https://<workspace-host>/oidc/v1/token``) and fail fast rather than
POST to an unresolved endpoint.
"""
if not config.get("token_request_uri"):
raise OAuth2Error(
"Databricks OAuth2 token endpoint is not configured: set "
"`token_request_uri` to https://<workspace-host>/oidc/v1/token "
"in DATABASE_OAUTH2_CLIENTS."
)
return super().get_oauth2_token(config, code, code_verifier)
@classmethod
def impersonate_user(
cls,
database: Database,
username: str | None,
user_token: str | None,
url: URL,
engine_kwargs: dict[str, Any],
) -> tuple[URL, dict[str, Any]]:
"""
Update connection with the user's OAuth2 access token for impersonation.
When impersonation is enabled but no user token is available yet (e.g. the
first connection, before the OAuth2 dance has run), the stored credential
is cleared rather than left in place. The driver then raises a "no valid
authentication settings" error, which ``needs_oauth2`` catches to bootstrap
the OAuth2 flow instead of silently connecting with a stale credential.
"""
# Replace the credential in the URL with the user's OAuth2 token, falling
# back to an empty string to force re-authentication when none is set.
url = url.set(password=user_token or "")
# The Python connector passes the token via ``connect_args`` instead of the
# URL password, so keep it in sync (clearing it likewise forces re-auth).
connect_args = engine_kwargs.setdefault("connect_args", {})
if "access_token" in connect_args:
connect_args["access_token"] = user_token or ""
return url, engine_kwargs
@staticmethod
def update_params_from_encrypted_extra(
database: Database, params: dict[str, Any]
) -> None:
"""
Merge ``encrypted_extra`` into the connection params, dropping the
``oauth2_client_info`` block.
``oauth2_client_info`` holds the per-database OAuth2 client configuration
consumed by ``Database.get_oauth2_config``; it is not a Databricks driver
connection argument, so it must be stripped here to avoid poisoning the
connection when OAuth2 is configured on the database itself.
"""
if not database.encrypted_extra:
return
try:
encrypted_extra = json.loads(database.encrypted_extra)
except json.JSONDecodeError as ex:
logger.error(ex, exc_info=True)
raise
encrypted_extra.pop("oauth2_client_info", None)
params.update(encrypted_extra)
@staticmethod
def get_extra_params(
database: Database, source: QuerySource | None = None
@@ -474,6 +647,16 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
supports_dynamic_catalog = True
supports_cross_catalog_queries = True
# OAuth 2.0 support. The flow (endpoint resolution from the workspace host,
# `needs_oauth2` detection) is shared via `DatabricksDynamicBaseEngineSpec`.
supports_oauth2 = True
oauth2_scope = "sql"
# Authorization endpoint is derived from the workspace host at runtime; the
# token endpoint must be configured (no DB context at exchange time).
oauth2_authorization_request_uri = ""
oauth2_token_request_uri = ""
@classmethod
def build_sqlalchemy_uri( # type: ignore
cls, parameters: DatabricksNativeParametersType, *_
@@ -685,6 +868,16 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec):
supports_dynamic_schema = supports_catalog = supports_dynamic_catalog = True
# OAuth 2.0 support. The flow (endpoint resolution from the workspace host,
# `needs_oauth2` detection) is shared via `DatabricksDynamicBaseEngineSpec`.
supports_oauth2 = True
oauth2_scope = "sql"
# Authorization endpoint is derived from the workspace host at runtime; the
# token endpoint must be configured (no DB context at exchange time).
oauth2_authorization_request_uri = ""
oauth2_token_request_uri = ""
@classmethod
def build_sqlalchemy_uri( # type: ignore
cls, parameters: DatabricksPythonConnectorParametersType, *_

View File

@@ -8250,16 +8250,16 @@ msgid "List"
msgstr "Auflisten"
msgid "List Groups"
msgstr "Gruppen"
msgstr "Gruppen auflisten"
msgid "List Roles"
msgstr "Rollen"
msgstr "Rollen auflisten"
msgid "List Unique Values"
msgstr "Eindeutige Werte auflisten"
msgid "List Users"
msgstr "Benutzer*innen"
msgstr "Benutzer*innen auflisten"
msgid "List of extra columns made available in JavaScript functions"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -309,3 +309,67 @@ def test_list_page_size_below_one_is_floored():
mock_query = _list_with_page_size(0)
mock_query.limit.assert_called_once_with(1)
def test_like_operators_none_value_matches_no_rows() -> None:
"""A ``None`` value on a LIKE-family operator must match no rows.
Mirrors SQL three-valued logic (``x LIKE NULL`` is NULL). The failure
modes this pins against: raising ``AttributeError`` (``None`` has no
``.replace``) and, worse, coercing ``None`` to ``""`` — which builds a
wildcard-only pattern (``%%``/``%``) that silently matches every row.
"""
Base_test = declarative_base() # noqa: N806
class NoneValueModel(Base_test): # type: ignore
__tablename__ = "none_value_model"
id = Column(Integer, primary_key=True)
name = Column(String(50))
like_family = [
ColumnOperatorEnum.sw,
ColumnOperatorEnum.ew,
ColumnOperatorEnum.ct,
ColumnOperatorEnum.like,
ColumnOperatorEnum.ilike,
]
for operator in like_family:
clause = operator.apply(NoneValueModel.name, None)
sql = str(clause.compile(compile_kwargs={"literal_binds": True}))
assert sql.strip().lower() == "false", (
f"{operator.name} with None should compile to a no-match clause, "
f"got: {sql!r}"
)
assert "%" not in sql, (
f"{operator.name} with None must not build a wildcard pattern "
f"(would match every row): {sql!r}"
)
def test_like_operators_non_string_value_matches_literally() -> None:
"""Non-string scalars (e.g. numeric JSON payloads) degrade to a literal
match instead of raising ``AttributeError``."""
Base_test = declarative_base() # noqa: N806
class NumericValueModel(Base_test): # type: ignore
__tablename__ = "numeric_value_model"
id = Column(Integer, primary_key=True)
name = Column(String(50))
clause = ColumnOperatorEnum.ct.apply(NumericValueModel.name, 123)
sql = str(clause.compile(compile_kwargs={"literal_binds": True}))
assert "'%123%'" in sql
def test_like_operators_escape_wildcards_in_value() -> None:
"""User-supplied ``%``/``_`` are escaped, not treated as wildcards."""
Base_test = declarative_base() # noqa: N806
class WildcardValueModel(Base_test): # type: ignore
__tablename__ = "wildcard_value_model"
id = Column(Integer, primary_key=True)
name = Column(String(50))
clause = ColumnOperatorEnum.ct.apply(WildcardValueModel.name, "100%_done")
sql = str(clause.compile(compile_kwargs={"literal_binds": True}))
assert "100\\%\\_done" in sql

View File

@@ -17,14 +17,23 @@
# pylint: disable=unused-argument, import-outside-toplevel, protected-access
from datetime import datetime
from typing import Optional
from typing import Any, Optional
from urllib.parse import parse_qs, urlparse
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine.url import make_url
from superset.db_engine_specs.databricks import DatabricksNativeEngineSpec
from superset.db_engine_specs.base import OAuth2State
from superset.db_engine_specs.databricks import (
DatabricksNativeEngineSpec,
DatabricksPythonConnectorEngineSpec,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2Error, OAuth2RedirectError
from superset.superset_typing import OAuth2ClientConfig
from superset.utils import json
from superset.utils.oauth2 import decode_oauth2_state
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
@@ -291,3 +300,721 @@ def test_get_prequeries(mocker: MockerFixture) -> None:
"USE CATALOG `evil`` USE CATALOG bad`",
"USE SCHEMA `evil`` USE SCHEMA bad`",
]
# OAuth2 Tests
def test_oauth2_attributes() -> None:
"""
Test that OAuth2 attributes are properly set for both engine specs.
"""
# Test DatabricksNativeEngineSpec
assert DatabricksNativeEngineSpec.supports_oauth2 is True
assert DatabricksNativeEngineSpec.oauth2_scope == "sql"
# The authorization endpoint is derived from the workspace host at runtime;
# the token endpoint must be configured explicitly.
assert DatabricksNativeEngineSpec.oauth2_authorization_request_uri == ""
assert DatabricksNativeEngineSpec.oauth2_token_request_uri == ""
# Test DatabricksPythonConnectorEngineSpec
assert DatabricksPythonConnectorEngineSpec.supports_oauth2 is True
assert DatabricksPythonConnectorEngineSpec.oauth2_scope == "sql"
assert DatabricksPythonConnectorEngineSpec.oauth2_authorization_request_uri == ""
assert DatabricksPythonConnectorEngineSpec.oauth2_token_request_uri == ""
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
@pytest.mark.parametrize(
"message",
[
"Error during request to server: HTTP 401 Unauthorized",
"Invalid access token",
"The access token expired",
"UNAUTHENTICATED: token is no longer valid",
"RuntimeError: No valid authentication settings!",
],
)
def test_needs_oauth2_detects_auth_failure_from_message(
mocker: MockerFixture,
spec: Any,
message: str,
) -> None:
"""
The Databricks driver has no dedicated auth exception, so `needs_oauth2`
matches auth-failure signals in the error message to bootstrap a re-auth.
"""
g = mocker.patch("superset.db_engine_specs.databricks.g")
g.user = mocker.MagicMock()
assert spec.needs_oauth2(Exception(message)) is True
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
@pytest.mark.parametrize(
"message",
[
"Table not found",
# A bare 401 in an unrelated position must not look like an auth failure.
"Query failed at line 401: syntax error",
],
)
def test_needs_oauth2_ignores_unrelated_errors(
mocker: MockerFixture,
spec: Any,
message: str,
) -> None:
"""
A non-auth driver error must not trigger the OAuth2 dance.
"""
g = mocker.patch("superset.db_engine_specs.databricks.g")
g.user = mocker.MagicMock()
assert spec.needs_oauth2(Exception(message)) is False
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
def test_needs_oauth2_matches_oauth2_redirect_error(
mocker: MockerFixture,
spec: Any,
) -> None:
"""
The inherited `isinstance` check against `oauth2_exception` still holds.
"""
g = mocker.patch("superset.db_engine_specs.databricks.g")
g.user = mocker.MagicMock()
ex = OAuth2RedirectError("https://example/authorize", "tab", "redirect")
assert spec.needs_oauth2(ex) is True
def test_impersonate_user_with_token(mocker: MockerFixture) -> None:
"""
Test impersonate_user method with OAuth2 token for DatabricksNativeEngineSpec.
"""
database = mocker.MagicMock()
original_url = make_url(
"databricks+connector://token:original-token@host:443/database"
)
engine_kwargs = {"connect_args": {"access_token": "original-token"}}
# Test with user token
url, kwargs = DatabricksNativeEngineSpec.impersonate_user(
database=database,
username="user1",
user_token="user-oauth-token", # noqa: S106
url=original_url,
engine_kwargs=engine_kwargs,
)
# Check that the password (token) was updated in the URL
assert url.password == "user-oauth-token" # noqa: S105
# Check that access_token was updated in connect_args
assert kwargs["connect_args"]["access_token"] == "user-oauth-token" # noqa: S105
def test_impersonate_user_without_token_forces_oauth2(mocker: MockerFixture) -> None:
"""
Without a user token, impersonate_user clears any stored credential so the
driver fails authentication and the OAuth2 dance is bootstrapped, rather than
silently connecting with a stale credential.
"""
database = mocker.MagicMock()
original_url = make_url(
"databricks+connector://token:original-token@host:443/database"
)
engine_kwargs = {"connect_args": {"access_token": "original-token"}}
# Test without user token
url, kwargs = DatabricksNativeEngineSpec.impersonate_user(
database=database,
username="user1",
user_token=None,
url=original_url,
engine_kwargs=engine_kwargs,
)
# The stored credential is cleared in both the URL and connect_args
assert url.password == "" # noqa: S105
assert kwargs["connect_args"]["access_token"] == "" # noqa: S105
def test_impersonate_user_python_connector(mocker: MockerFixture) -> None:
"""
Test impersonate_user method for DatabricksPythonConnectorEngineSpec.
"""
database = mocker.MagicMock()
original_url = make_url(
"databricks://token:original-token@host:443?http_path=path&catalog=main&schema=default"
)
engine_kwargs = {"connect_args": {"access_token": "original-token"}}
# Test with user token
url, kwargs = DatabricksPythonConnectorEngineSpec.impersonate_user(
database=database,
username="user1",
user_token="user-oauth-token", # noqa: S106
url=original_url,
engine_kwargs=engine_kwargs,
)
# Check that the password (token) was updated in the URL
assert url.password == "user-oauth-token" # noqa: S105
# Check that access_token was updated in connect_args
assert kwargs["connect_args"]["access_token"] == "user-oauth-token" # noqa: S105
def test_impersonate_user_python_connector_without_token_forces_oauth2(
mocker: MockerFixture,
) -> None:
"""
The Python connector path also clears the credential when no user token is
available, so per-user OAuth2 is enforced rather than falling back to a stale
connection-level token.
"""
database = mocker.MagicMock()
original_url = make_url(
"databricks://token:original-token@host:443?http_path=path&catalog=main&schema=default"
)
engine_kwargs = {"connect_args": {"access_token": "original-token"}}
url, kwargs = DatabricksPythonConnectorEngineSpec.impersonate_user(
database=database,
username="user1",
user_token=None,
url=original_url,
engine_kwargs=engine_kwargs,
)
assert url.password == "" # noqa: S105
assert kwargs["connect_args"]["access_token"] == "" # noqa: S105
def test_impersonate_user_without_connect_args_token(mocker: MockerFixture) -> None:
"""
When ``connect_args`` carries no ``access_token`` (the URL-only auth path), the
token is applied to the URL password and no spurious ``access_token`` key is
introduced into ``connect_args``.
"""
database = mocker.MagicMock()
original_url = make_url(
"databricks+connector://token:original-token@host:443/database"
)
engine_kwargs: dict[str, Any] = {"connect_args": {"http_path": "/sql/path"}}
url, kwargs = DatabricksNativeEngineSpec.impersonate_user(
database=database,
username="user1",
user_token="user-oauth-token", # noqa: S106
url=original_url,
engine_kwargs=engine_kwargs,
)
assert url.password == "user-oauth-token" # noqa: S105
assert "access_token" not in kwargs["connect_args"]
# Unrelated connect_args are preserved
assert kwargs["connect_args"]["http_path"] == "/sql/path"
def test_update_params_strips_oauth2_client_info(mocker: MockerFixture) -> None:
"""
``oauth2_client_info`` is the per-database OAuth2 client config consumed by
``Database.get_oauth2_config``; it must not leak into the driver connection
params, while the rest of ``encrypted_extra`` is still merged.
"""
database = mocker.MagicMock()
database.encrypted_extra = json.dumps(
{
"oauth2_client_info": {
"id": "client-id",
"secret": "client-secret",
"authorization_request_uri": "https://host/oidc/v1/authorize",
"token_request_uri": "https://host/oidc/v1/token",
},
"http_headers": [["X-Custom", "value"]],
}
)
params: dict[str, Any] = {}
DatabricksNativeEngineSpec.update_params_from_encrypted_extra(database, params)
assert "oauth2_client_info" not in params
assert params == {"http_headers": [["X-Custom", "value"]]}
def test_update_params_no_encrypted_extra(mocker: MockerFixture) -> None:
"""
A database without ``encrypted_extra`` leaves the params untouched.
"""
database = mocker.MagicMock()
database.encrypted_extra = None
params: dict[str, Any] = {"existing": "value"}
DatabricksNativeEngineSpec.update_params_from_encrypted_extra(database, params)
assert params == {"existing": "value"}
def test_update_params_merges_when_no_oauth2_client_info(
mocker: MockerFixture,
) -> None:
"""
``encrypted_extra`` without an ``oauth2_client_info`` block is merged in full,
so the strip never removes legitimate driver params.
"""
database = mocker.MagicMock()
database.encrypted_extra = json.dumps(
{"http_headers": [["X-Custom", "value"]], "_tls_verify_hostname": True}
)
params: dict[str, Any] = {}
DatabricksNativeEngineSpec.update_params_from_encrypted_extra(database, params)
assert params == {
"http_headers": [["X-Custom", "value"]],
"_tls_verify_hostname": True,
}
def test_update_params_invalid_encrypted_extra_raises(mocker: MockerFixture) -> None:
"""
Malformed ``encrypted_extra`` JSON raises rather than silently connecting.
"""
database = mocker.MagicMock()
database.encrypted_extra = "{not valid json"
with pytest.raises(json.JSONDecodeError):
DatabricksNativeEngineSpec.update_params_from_encrypted_extra(database, {})
@pytest.fixture
def oauth2_config_native() -> OAuth2ClientConfig:
"""
Config for Databricks Native OAuth2.
"""
return {
"id": "databricks-client-id",
"secret": "databricks-client-secret",
"scope": "sql",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"authorization_request_uri": "https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/authorize",
"token_request_uri": "https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
"request_content_type": "json",
}
@pytest.fixture
def oauth2_config_python() -> OAuth2ClientConfig:
"""
Config for Databricks Python Connector OAuth2.
"""
return {
"id": "databricks-client-id",
"secret": "databricks-client-secret",
"scope": "sql",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"authorization_request_uri": "https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/authorize",
"token_request_uri": "https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
"request_content_type": "json",
}
def test_is_oauth2_enabled_no_config_native(mocker: MockerFixture) -> None:
"""
Test `is_oauth2_enabled` when OAuth2 is not configured for Native engine.
"""
mocker.patch(
"flask.current_app.config",
new={"DATABASE_OAUTH2_CLIENTS": {}},
)
assert DatabricksNativeEngineSpec.is_oauth2_enabled() is False
def test_is_oauth2_enabled_config_native(mocker: MockerFixture) -> None:
"""
Test `is_oauth2_enabled` when OAuth2 is configured for Native engine.
"""
mocker.patch(
"flask.current_app.config",
new={
"DATABASE_OAUTH2_CLIENTS": {
"Databricks (legacy)": {
"id": "client-id",
"secret": "client-secret",
},
}
},
)
assert DatabricksNativeEngineSpec.is_oauth2_enabled() is True
def test_is_oauth2_enabled_no_config_python(mocker: MockerFixture) -> None:
"""
Test `is_oauth2_enabled` when OAuth2 is not configured for Python Connector engine.
"""
mocker.patch(
"flask.current_app.config",
new={"DATABASE_OAUTH2_CLIENTS": {}},
)
assert DatabricksPythonConnectorEngineSpec.is_oauth2_enabled() is False
def test_is_oauth2_enabled_config_python(mocker: MockerFixture) -> None:
"""
Test `is_oauth2_enabled` when OAuth2 is configured for Python Connector engine.
"""
mocker.patch(
"flask.current_app.config",
new={
"DATABASE_OAUTH2_CLIENTS": {
"Databricks": {
"id": "client-id",
"secret": "client-secret",
},
}
},
)
assert DatabricksPythonConnectorEngineSpec.is_oauth2_enabled() is True
def test_get_oauth2_authorization_uri_native(
mocker: MockerFixture,
oauth2_config_native: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_authorization_uri` for Native engine.
"""
from superset.db_engine_specs.base import OAuth2State
state: OAuth2State = {
"database_id": 1,
"user_id": 1,
"default_redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"tab_id": "1234",
}
url = DatabricksNativeEngineSpec.get_oauth2_authorization_uri(
oauth2_config_native, state
)
parsed = urlparse(url)
assert parsed.netloc == "accounts.cloud.databricks.com"
assert parsed.path == "/oidc/accounts/12345/v1/authorize"
query = parse_qs(parsed.query)
assert query["scope"][0] == "sql"
encoded_state = query["state"][0].replace("%2E", ".")
assert decode_oauth2_state(encoded_state) == state
def test_get_oauth2_authorization_uri_python(
mocker: MockerFixture,
oauth2_config_python: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_authorization_uri` for Python Connector engine.
"""
from superset.db_engine_specs.base import OAuth2State
state: OAuth2State = {
"database_id": 1,
"user_id": 1,
"default_redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"tab_id": "1234",
}
url = DatabricksPythonConnectorEngineSpec.get_oauth2_authorization_uri(
oauth2_config_python, state
)
parsed = urlparse(url)
assert parsed.netloc == "accounts.cloud.databricks.com"
assert parsed.path == "/oidc/accounts/12345/v1/authorize"
query = parse_qs(parsed.query)
assert query["scope"][0] == "sql"
encoded_state = query["state"][0].replace("%2E", ".")
assert decode_oauth2_state(encoded_state) == state
def test_get_oauth2_token_native(
mocker: MockerFixture,
oauth2_config_native: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_token` for Native engine.
"""
requests = mocker.patch("superset.db_engine_specs.base.requests")
requests.post().json.return_value = {
"access_token": "access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "refresh-token",
}
assert DatabricksNativeEngineSpec.get_oauth2_token(
oauth2_config_native, "authorization-code"
) == {
"access_token": "access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "refresh-token",
}
requests.post.assert_called_with(
"https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
json={
"code": "authorization-code",
"client_id": "databricks-client-id",
"client_secret": "databricks-client-secret",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"grant_type": "authorization_code",
},
timeout=30.0,
)
def test_get_oauth2_token_python(
mocker: MockerFixture,
oauth2_config_python: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_token` for Python Connector engine.
"""
requests = mocker.patch("superset.db_engine_specs.base.requests")
requests.post().json.return_value = {
"access_token": "access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "refresh-token",
}
assert DatabricksPythonConnectorEngineSpec.get_oauth2_token(
oauth2_config_python, "authorization-code"
) == {
"access_token": "access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "refresh-token",
}
requests.post.assert_called_with(
"https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
json={
"code": "authorization-code",
"client_id": "databricks-client-id",
"client_secret": "databricks-client-secret",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"grant_type": "authorization_code",
},
timeout=30.0,
)
def test_get_oauth2_fresh_token_native(
mocker: MockerFixture,
oauth2_config_native: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_fresh_token` for Native engine.
"""
requests = mocker.patch("superset.db_engine_specs.base.requests")
requests.post().json.return_value = {
"access_token": "new-access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "new-refresh-token",
}
assert DatabricksNativeEngineSpec.get_oauth2_fresh_token(
oauth2_config_native, "old-refresh-token"
) == {
"access_token": "new-access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "new-refresh-token",
}
requests.post.assert_called_with(
"https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
json={
"client_id": "databricks-client-id",
"client_secret": "databricks-client-secret",
"refresh_token": "old-refresh-token",
"grant_type": "refresh_token",
},
timeout=30.0,
)
def _oauth2_state() -> OAuth2State:
"""
Build the default OAuth2 state shared by the OAuth2 tests.
"""
state: OAuth2State = {
"database_id": 1,
"user_id": 1,
"default_redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"tab_id": "1234",
}
return state
def _unresolved_oauth2_config() -> OAuth2ClientConfig:
"""
Config as built by `get_oauth2_config` when no endpoints are overridden:
the URIs default to the spec's empty class attributes.
"""
return {
"id": "databricks-client-id",
"secret": "databricks-client-secret",
"scope": "sql",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"authorization_request_uri": "",
"token_request_uri": "",
"request_content_type": "json",
}
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
@pytest.mark.parametrize(
"host",
[
"dbc-abc.cloud.databricks.com",
"adb-123456789.12.azuredatabricks.net",
"123456789.gcp.databricks.com",
],
)
def test_get_oauth2_authorization_uri_derives_from_workspace_host(
mocker: MockerFixture,
spec: Any,
host: str,
) -> None:
"""
With no configured `authorization_request_uri`, the endpoint is derived from
the workspace host (`https://<host>/oidc/v1/authorize`) on every cloud, with
no account/tenant identifier required.
"""
database = mocker.MagicMock()
database.url_object.host = host
mocker.patch("superset.db.session.get", return_value=database)
url = spec.get_oauth2_authorization_uri(
_unresolved_oauth2_config(), _oauth2_state()
)
parsed = urlparse(url)
assert parsed.netloc == host
assert parsed.path == "/oidc/v1/authorize"
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
def test_get_oauth2_authorization_uri_preserves_configured(
mocker: MockerFixture,
spec: Any,
) -> None:
"""
A fully-resolved `authorization_request_uri` is never overwritten by the
host-derived endpoint, and no database lookup is needed.
"""
session_get = mocker.patch("superset.db.session.get")
config = _unresolved_oauth2_config()
config["authorization_request_uri"] = (
"https://accounts.cloud.databricks.com/oidc/accounts/override/v1/authorize"
)
url = spec.get_oauth2_authorization_uri(config, _oauth2_state())
assert urlparse(url).path == "/oidc/accounts/override/v1/authorize"
session_get.assert_not_called()
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
def test_get_oauth2_authorization_uri_fails_without_host(
mocker: MockerFixture,
spec: Any,
) -> None:
"""
When the endpoint must be derived but the connection has no host, fail fast
instead of emitting an invalid `https:///oidc/v1/authorize` URL.
"""
database = mocker.MagicMock()
database.url_object.host = None
mocker.patch("superset.db.session.get", return_value=database)
with pytest.raises(OAuth2Error):
spec.get_oauth2_authorization_uri(_unresolved_oauth2_config(), _oauth2_state())
@pytest.mark.parametrize(
"spec",
[DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
)
def test_get_oauth2_token_fails_without_uri(
mocker: MockerFixture,
spec: Any,
) -> None:
"""
Token exchange has no database context to auto-detect the endpoint, so a
missing `token_request_uri` fails fast rather than POSTing to `.../{}/...`.
"""
with pytest.raises(OAuth2Error):
spec.get_oauth2_token(_unresolved_oauth2_config(), "authorization-code")
def test_get_oauth2_fresh_token_python(
mocker: MockerFixture,
oauth2_config_python: OAuth2ClientConfig,
) -> None:
"""
Test `get_oauth2_fresh_token` for Python Connector engine.
"""
requests = mocker.patch("superset.db_engine_specs.base.requests")
requests.post().json.return_value = {
"access_token": "new-access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "new-refresh-token",
}
assert DatabricksPythonConnectorEngineSpec.get_oauth2_fresh_token(
oauth2_config_python, "old-refresh-token"
) == {
"access_token": "new-access-token",
"expires_in": 3600,
"scope": "sql",
"token_type": "Bearer",
"refresh_token": "new-refresh-token",
}
requests.post.assert_called_with(
"https://accounts.cloud.databricks.com/oidc/accounts/12345/v1/token",
json={
"client_id": "databricks-client-id",
"client_secret": "databricks-client-secret",
"refresh_token": "old-refresh-token",
"grant_type": "refresh_token",
},
timeout=30.0,
)

View File

@@ -0,0 +1,127 @@
# 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.
# pylint: disable=unused-argument, import-outside-toplevel, protected-access
from typing import Any
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlparse
import pytest
from pytest_mock import MockerFixture
from superset.db_engine_specs.databricks import (
DatabricksNativeEngineSpec,
DatabricksPythonConnectorEngineSpec,
)
from superset.superset_typing import OAuth2ClientConfig
from superset.utils.oauth2 import decode_oauth2_state
# Multi-Cloud Provider Tests
#
# Databricks fronts the user-to-machine OAuth2 flow on every workspace at
# `https://<workspace-host>/oidc/v1/{authorize,token}`, regardless of cloud, so
# the authorization endpoint derives from the connection host with no per-cloud
# account/tenant identifier.
SPECS = [DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec]
# Representative workspace hosts for each cloud provider.
CLOUD_HOSTS = [
"my-cluster.cloud.databricks.com", # AWS
"adb-123456789.12.azuredatabricks.net", # Azure
"123456789.gcp.databricks.com", # GCP
]
@pytest.fixture
def oauth2_config_no_uri() -> OAuth2ClientConfig:
"""
Config for Databricks OAuth2 without a pre-configured endpoint, so the
authorization endpoint is derived from the workspace host.
"""
return {
"id": "databricks-client-id",
"secret": "databricks-client-secret",
"scope": "sql",
"redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"authorization_request_uri": "",
"token_request_uri": "",
"request_content_type": "json",
}
def _mock_database(mocker: MockerFixture, host: str) -> MagicMock:
"""
Build a mock database whose URL resolves to the given workspace host.
"""
database = mocker.MagicMock()
database.url_object.host = host
database.id = 1
return database
@pytest.mark.parametrize("spec", SPECS)
@pytest.mark.parametrize("host", CLOUD_HOSTS)
def test_get_oauth2_authorization_uri_uses_workspace_host(
mocker: MockerFixture,
spec: Any,
host: str,
oauth2_config_no_uri: OAuth2ClientConfig,
) -> None:
"""
The authorization endpoint is the workspace host on AWS, Azure, and GCP.
"""
from superset.db_engine_specs.base import OAuth2State
mocker.patch(
"superset.db.session.get",
return_value=_mock_database(mocker, host),
)
state: OAuth2State = {
"database_id": 1,
"user_id": 1,
"default_redirect_uri": "http://localhost:8088/api/v1/database/oauth2/",
"tab_id": "1234",
}
url = spec.get_oauth2_authorization_uri(oauth2_config_no_uri, state)
parsed = urlparse(url)
assert parsed.netloc == host
assert parsed.path == "/oidc/v1/authorize"
query = parse_qs(parsed.query)
assert query["scope"][0] == "sql"
encoded_state = query["state"][0].replace("%2E", ".")
assert decode_oauth2_state(encoded_state) == state
@pytest.mark.parametrize("spec", SPECS)
@pytest.mark.parametrize("host", CLOUD_HOSTS)
def test_workspace_oauth2_endpoint_builds_token_uri(
mocker: MockerFixture,
spec: Any,
host: str,
) -> None:
"""
The helper builds the matching token endpoint from the same workspace host.
"""
database = _mock_database(mocker, host)
assert (
spec._workspace_oauth2_endpoint(database, "token")
== f"https://{host}/oidc/v1/token"
)