Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Allow superset to be deployed under a prefixed URL #30134

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ ARG PY_VER=3.10-slim-bookworm
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
FROM --platform=${BUILDPLATFORM} node:20-bullseye-slim AS superset-node

ARG ASSET_BASE_URL
ARG BASE_PATH
ARG NPM_BUILD_CMD="build"
ENV ASSET_BASE_URL=${ASSET_BASE_URL:-}
ENV BASE_PATH=${BASE_PATH:-}

# Include translations in the final build. The default supports en only to
# reduce complexity and weight for those only using en
Expand Down Expand Up @@ -89,13 +93,18 @@ RUN rm /app/superset/translations/messages.pot
# Final lean image...
######################################################################
FROM python:${PY_VER} AS lean
ARG ASSET_BASE_URL
ARG BASE_PATH

# Include translations in the final build. The default supports en only to
# reduce complexity and weight for those only using en
ARG BUILD_TRANSLATIONS="false"

WORKDIR /app
ENV LANG=C.UTF-8 \

ENV ASSET_BASE_URL=${ASSET_BASE_URL:-} \
BASE_PATH=${BASE_PATH:-} \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
SUPERSET_ENV=production \
FLASK_APP="superset.app:create_app()" \
Expand Down
17 changes: 14 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,20 @@ x-superset-volumes: &superset-volumes
- ./superset-frontend:/app/superset-frontend
- superset_home:/app/superset_home
- ./tests:/app/tests

x-environ-base-path: &superset-base-path
# Set the following variables if you are running superset under a prefixed path.
# Also set the value in the nginx.conf file
ASSET_BASE_URL: ''
BASE_PATH: ''
x-common-build: &common-build
args:
<<: *superset-base-path
PY_VER: 3.10-slim-bookworm
DEV_MODE: "true"
context: .
target: dev
cache_from:
- apache/superset-cache:3.10-slim-bookworm
args:
DEV_MODE: "true"

services:
nginx:
Expand All @@ -52,6 +58,7 @@ services:
- "host.docker.internal:host-gateway"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro

redis:
image: redis:7
container_name: superset_cache
Expand Down Expand Up @@ -95,6 +102,7 @@ services:
depends_on: *superset-depends-on
volumes: *superset-volumes
environment:
<<: *superset-base-path
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"

superset-websocket:
Expand Down Expand Up @@ -144,6 +152,7 @@ services:
user: *superset-user
volumes: *superset-volumes
environment:
<<: *superset-base-path
CYPRESS_CONFIG: "${CYPRESS_CONFIG:-}"
healthcheck:
disable: true
Expand All @@ -158,6 +167,7 @@ services:
# it'll mount and watch local files and rebuild as you update them
DEV_MODE: "true"
environment:
<<: *superset-base-path
# set this to false if you have perf issues running the npm i; npm run dev in-docker
# if you do so, you have to run this manually on the host, which should perform better!
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
Expand All @@ -183,6 +193,7 @@ services:
- path: docker/.env-local # optional override
required: false
environment:
<<: *superset-base-path
CELERYD_CONCURRENCY: 2
restart: unless-stopped
depends_on: *superset-depends-on
Expand Down
26 changes: 20 additions & 6 deletions docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,30 @@ http {
proxy_set_header Host $host;
}

# If you run on a prefixed-path comment this location block out
# and uncomment the block below and set your prefix. Also set the value
# in docker-compose
location / {
proxy_pass http://superset_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://superset_app/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
port_in_redirect off;
proxy_connect_timeout 300;
}

# location ^~/my-prefix/ {
# proxy_pass http://superset_app/;
# proxy_set_header Host $http_host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Prefix "/my-prefix";
# proxy_http_version 1.1;
# port_in_redirect off;
# proxy_connect_timeout 300;
# }
}
}
23 changes: 23 additions & 0 deletions docker/pythonpath_dev/superset_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@

logger = logging.getLogger()


# If BASE_PATH is set we are assumed to be proxyed at this prefix location.
base_path = os.getenv("BASE_PATH", "")
if base_path:
ENABLE_PROXY_FIX = True
# Change x_port to 1 if the we are on the same port as the proxy server
PROXY_FIX_CONFIG = {
"x_for": 1,
"x_proto": 1,
"x_host": 1,
"x_port": 0,
"x_prefix": 1,
}
logger.debug(f"Non-empty base path detected '{base_path}'. Enabled proxy fix.")
# If ASSET_BASE_URL is an empty string but BASE_PATH is set we should pick up BASE_PATH
asset_base = (
os.getenv("ASSET_BASE_URL") if os.getenv("ASSET_BASE_URL", "") else base_path
)
if asset_base:
STATIC_ASSETS_PREFIX = asset_base
APP_ICON = f"{STATIC_ASSETS_PREFIX}/static/assets/images/superset-logo-horiz.png"
logger.debug(f"Non-empty asset base detected '{STATIC_ASSETS_PREFIX}'")

DATABASE_DIALECT = os.getenv("DATABASE_DIALECT")
DATABASE_USER = os.getenv("DATABASE_USER")
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
Expand Down
55 changes: 55 additions & 0 deletions docs/docs/configuration/configuring-superset.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,61 @@ In case the reverse proxy is used for providing SSL encryption, an explicit defi
RequestHeader set X-Forwarded-Proto "https"
```

## Configuration Under a Prefixed Path

If you need to run superset on a prefixed path you first need to configure the
load balancer or reverse proxy as above and ensure it is inserting the `X-Forward-*`
headers along with enabling the `ENABLE_PROXY_FIX` flag in `superset_config.py`.

An Example NGINX configuration would look like:

```
location ^~/analytics/ {
# The trailing '/' is key both in the location argument
# argument on the line above and the proxy_pass line below so that nginx strips
# the prefix before forwarding it to the application server.
proxy_pass http://localhost:8088/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix "/analytics";
proxy_http_version 1.1;
port_in_redirect off;
proxy_connect_timeout 300;
}
```

If you are running superset on a different port than the load balancer or reverse proxy
is listening then along with `ENABLE_PROXY_FIX = True` the proxy fix should be configured
to ignore the port:

```python
ENABLE_PROXY_FIX = True
PROXY_FIX_CONFIG = {"x_for": 1, "x_proto": 1, "x_host": 1, "x_port": 0, "x_prefix": 1}
```

Once the proxy is configured the frontend assets need to be [built](/docs/contributing/development#build-assets)
to include the prefix. The prefix path is controlled by two environment variables (one of which is optional):

- `BASE_PATH`: Set this to the absolute path for the base of the application,
e.g. `/analytics` would place the login page at `http://host.domain/analytics/login/`
- `ASSET_BASE_URL` _(optional)_: This is an optional second variable to place the static
assets at a different url, e.g. on a CDN. If unset this defaults to the same value
as `BASE_PATH`.

Build the frontend with these variables set, e.g. `BASE_PATH=/analytics npm run build`.

### Docker builds

The default Docker builds are built with no prefix defined and will require a custom
image build defining the above variables appropriately.
See [Docker Builds](docs/installation/docker-builds#key-args-in-dockerfile) for a
description of all available arguments.

For development the docker compose setup allows these variables to be set within
the `docker-compose.yml` file.

## Custom OAuth2 Configuration

Superset is built on Flask-AppBuilder (FAB), which supports many providers out of the box
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configuration/networking-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@ of your additional middleware classes.

For example, to use `AUTH_REMOTE_USER` from behind a proxy server like nginx, you have to add a
simple middleware class to add the value of `HTTP_X_PROXY_REMOTE_USER` (or any other custom header
from the proxy) to Gunicorn’s `REMOTE_USER` environment variable:
from the proxy) to Gunicorn’s `REMOTE_USER` environment variable.
5 changes: 5 additions & 0 deletions docs/docs/installation/docker-builds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ script and the [docker.yml](https://github.com/apache/superset/blob/master/.gith
GitHub action.

## Key ARGs in Dockerfile
- `ASSET_BASE_URL`: specifies the base URL for static assets, for example allowing
them to be served on a separate server/configure CDN. If unset this is set to the value
of `BASE_PATH` in both the `webpack` config and `superset_config`
- `BASE_PATH`: specifies the prefix path that the application is being served on by
a proxy server
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the
frontend build this tells webpack to strip out all locales other than `en` from
the `moment-timezone` library. For the backendthis skips compiling the
Expand Down
10 changes: 7 additions & 3 deletions superset-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ import {
RequestConfig,
ParseMethod,
} from './types';
import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_BASE_URL } from './constants';

const defaultUnauthorizedHandler = () => {
if (!window.location.pathname.startsWith('/login')) {
window.location.href = `/login?next=${window.location.href}`;
import {
DEFAULT_FETCH_RETRY_OPTIONS,
DEFAULT_BASE_PATH,
DEFAULT_BASE_URL,
} from './constants';

const defaultUnauthorizedHandlerForPrefix = (basePath: string) => () => {
if (!window.location.pathname.startsWith(`${basePath}/login`)) {
window.location.href = `${basePath}/login?next=${window.location.href}`;
}
};

Expand All @@ -52,7 +56,7 @@ export default class SupersetClientClass {

fetchRetryOptions?: FetchRetryOptions;

baseUrl: string;
basePath: string;

protocol: Protocol;

Expand All @@ -67,7 +71,7 @@ export default class SupersetClientClass {
handleUnauthorized: () => void;

constructor({
baseUrl = DEFAULT_BASE_URL,
basePath = DEFAULT_BASE_PATH,
host,
protocol,
headers = {},
Expand All @@ -78,17 +82,15 @@ export default class SupersetClientClass {
csrfToken = undefined,
guestToken = undefined,
guestTokenHeaderName = 'X-GuestToken',
unauthorizedHandler = defaultUnauthorizedHandler,
unauthorizedHandler = undefined,
}: ClientConfig = {}) {
const url = new URL(
host || protocol
? `${protocol || 'https:'}//${host || 'localhost'}`
: baseUrl,
// baseUrl for API could also be relative, so we provide current location.href
// as the base of baseUrl
window.location.href,
: '/',
DEFAULT_BASE_URL,
);
this.baseUrl = url.href.replace(/\/+$/, ''); // always strip trailing slash
this.basePath = basePath.replace(/\/+$/, ''); // always strip trailing slash

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
this.host = url.host;
this.protocol = url.protocol as Protocol;
this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json
Expand All @@ -109,7 +111,10 @@ export default class SupersetClientClass {
if (guestToken) {
this.headers[guestTokenHeaderName] = guestToken;
}
this.handleUnauthorized = unauthorizedHandler;
this.handleUnauthorized =
unauthorizedHandler !== undefined
? unauthorizedHandler
: defaultUnauthorizedHandlerForPrefix(basePath);
}

async init(force = false): CsrfPromise {
Expand Down Expand Up @@ -239,7 +244,7 @@ export default class SupersetClientClass {
method: 'GET',
mode: this.mode,
timeout: this.timeout,
url: this.getUrl({ endpoint: 'api/v1/security/csrf_token/' }),
url: this.getUrl({ endpoint: '/api/v1/security/csrf_token/' }),
parseMethod: 'json',
}).then(({ json }) => {
if (typeof json === 'object') {
Expand Down Expand Up @@ -271,7 +276,7 @@ export default class SupersetClientClass {
const host = inputHost ?? this.host;
const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash

return `${this.protocol}//${cleanHost}/${
return `${this.protocol}//${cleanHost}${this.basePath}/${
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { FetchRetryOptions } from './types';

export const DEFAULT_BASE_URL = 'http://localhost';
export const DEFAULT_BASE_PATH = '';

// HTTP status codes
export const HTTP_STATUS_OK = 200;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export type CsrfPromise = Promise<string | undefined>;
export type Protocol = 'http:' | 'https:';

export interface ClientConfig {
baseUrl?: string;
basePath?: string;
host?: Host;
protocol?: Protocol;
credentials?: Credentials;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ describe('SupersetClientClass', () => {
describe('new SupersetClientClass()', () => {
it('fallback protocol to https when setting only host', () => {
const client = new SupersetClientClass({ host: 'TEST-HOST' });
expect(client.baseUrl).toEqual('https://test-host');
expect(client.host).toEqual('https://test-host');
expect(client.basePath).toEqual('');
});
});

Expand Down
Loading