Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: docker

# Build the production nginx image and publish it multi-arch to GHCR.
# • pull request → build only (validation, no push)
# • push to main → :edge + :sha-<short>
# • push tag v X.Y.Z → :X.Y.Z + :latest (this is the release image)
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: altinity/altinity-sql-browser

jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# No push on PRs — build-only validation. GITHUB_TOKEN is enough for GHCR.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=edge,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha,prefix=sha-

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
# Keep the in-app build stamp in lockstep with the image tag.
build-args: |
ASB_VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- **Production container + GHCR image.** The Docker image is now a static
**nginx** server for the single-file SPA (replacing the containerized Python
runner): it serves `/sql`, `/sql/dashboard`, a mounted `config.json` at
`/sql/config.json`, and `/healthz`, runs non-root on port 8080, and carries the
same security headers/CSP as the on-cluster deployment (`CONNECT_SRC` env fills
the CSP `connect-src`). A new `.github/workflows/docker.yml` publishes a
multi-arch (`amd64`+`arm64`) image to `ghcr.io/altinity/altinity-sql-browser`
— `edge` on `main`, `X.Y.Z`+`latest` on release tags — so the image ships as
part of every release. `deploy/config.json.example` now carries a runnable demo
(antalya + github.demo, each in `demo:demo` and Google-SSO modes) and is baked
in as the default served config. `deploy/k8s/` and `docker-compose.yaml`
updated to the nginx model (config via ConfigMap/mount, `securityContext`,
`/healthz` + `/sql/config.json` probes).

### Changed
- **Chart style internals consolidated (post-#258 review).** The per-chart-type
style surface (which fields each type owns, their accepted values, and their
Expand Down
68 changes: 46 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# syntax=docker/dockerfile:1
#
# Production image: build the single-file SPA with Node, serve it with nginx.
# The browser POSTs queries cross-origin straight to the chosen ClickHouse
# cluster, so nginx only serves the SPA (/sql) and a mounted config.json
# (/sql/config.json) — mirroring the on-ClickHouse http_handlers deployment.
# Published multi-arch to ghcr.io/altinity/altinity-sql-browser (see
# .github/workflows/docker.yml).

FROM node:22-bookworm-slim AS build

WORKDIR /app
Expand All @@ -8,29 +17,44 @@ COPY schemas ./schemas
COPY src ./src
COPY THIRD-PARTY-NOTICES.md ./

RUN npm ci --no-audit --no-fund
RUN npm run build

FROM python:3.12-slim AS runtime
# Stamp the in-HTML build marker with the image version so the in-app build
# stamp matches the published tag (build.mjs honours $ASB_VERSION over package.json).
ARG ASB_VERSION
ENV ASB_VERSION=${ASB_VERSION}

ENV HOME=/home/asb \
HOST=0.0.0.0 \
PORT=8900 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN npm ci --no-audit --no-fund && npm run build

RUN useradd --create-home --home-dir /home/asb --shell /usr/sbin/nologin asb \
&& mkdir -p /app \
&& chown -R asb:asb /app /home/asb

WORKDIR /app
# nginx-unprivileged: runs as non-root (uid 101) and listens on 8080 out of the
# box — no CAP_NET_BIND_SERVICE, no root. Its entrypoint runs envsubst on
# /etc/nginx/templates/*.template → /etc/nginx/conf.d/, substituting only
# variables that are actually set in the environment (so nginx's own $uri etc.
# survive), which is how ${CONNECT_SRC} lands in the CSP.
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime

# The single-file SPA and the default served config. Mount your own config.json
# at /config/config.json to override the baked demo.
COPY --from=build /app/dist/sql.html /app/sql.html
COPY build/local.py /app/local.py
COPY deploy/sql-browser.xml /app/sql-browser.xml

USER asb

EXPOSE 8900

CMD ["python3", "/app/local.py"]
COPY deploy/config.json.example /config/config.json
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template

# COPY preserves the source file mode, and a checkout under a restrictive umask
# can yield 0600 files the non-root nginx user (uid 101) then can't read at
# startup (envsubst on the template) or serve time. Normalise to world-readable.
# (Builder-agnostic — avoids requiring BuildKit's COPY --chmod.)
USER root
RUN chmod 0644 /app/sql.html /config/config.json \
/etc/nginx/templates/default.conf.template
USER nginx

# CSP connect-src origins: same-origin ('self') is added by the template; this
# lists the IdP endpoints plus the ClickHouse cluster origins the SPA POSTs to
# cross-origin. Override for your own IdP/clusters. Defaults cover the public
# Altinity demos in the baked config.json.
ENV CONNECT_SRC="https://accounts.google.com https://oauth2.googleapis.com https://antalya.demo.altinity.cloud https://github.demo.altinity.cloud"

EXPOSE 8080

# 127.0.0.1, not localhost: nginx listens IPv4-only here, while `localhost`
# resolves to ::1 first inside the container (→ connection refused).
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
78 changes: 47 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,38 +497,63 @@ npm run dev # build + serve dist/ at http://localhost:8900

### Run in Docker

The Docker image packages the existing zero-dependency Python runner, so the
container keeps the same behavior as the local app: it serves `/sql`,
generates `/config.json`, reads ClickHouse connections from
`~/.clickhouse-client`, and optionally probes which hosts are reachable.
The production image is a static **nginx** server for the single-file SPA — no
application backend. It serves `/sql` (and the `/sql/dashboard` client route),
serves a `config.json` you provide at `/sql/config.json`, and answers `/healthz`
for probes. Queries are **not** proxied: the browser POSTs them straight to the
chosen ClickHouse cluster, exactly like the on-cluster deployment.

Pull the published multi-arch image (`linux/amd64` + `linux/arm64`):

```bash
docker compose up --build
docker run --rm -p 8900:8080 \
-v "$PWD/config.json:/config/config.json:ro" \
ghcr.io/altinity/altinity-sql-browser:latest
```

Then open `http://localhost:8900/sql`.
Then open `http://localhost:8900/sql`. Tags: `latest` and `X.Y.Z` (releases),
`edge` (main), `sha-<commit>`.

The container listens on **8080** (non-root nginx). Provide your OAuth/host
config as a `config.json` (see [`deploy/config.json.example`](deploy/config.json.example)
and [docs/LOGIN-SCREEN.md](docs/LOGIN-SCREEN.md)) mounted at
`/config/config.json`. **With no mount** the image serves a built-in demo config
for the public Altinity clusters (antalya + github.demo), each offered as a
`demo:demo` credentials entry and a Google-SSO entry — so a bare
`docker run -p 8900:8080 ghcr.io/altinity/altinity-sql-browser:latest` is
immediately usable.

By default `docker-compose.yaml` bind-mounts your host `~/.clickhouse-client` directory
into the container read-only and disables the startup reachability probe. That is
intentional: the probe runs inside the container, while the browser connects to
ClickHouse directly from your host, so host-only names like `localhost` or
entries resolved via your host `/etc/hosts` can be valid in the browser but fail
when probed from Docker. Useful overrides:
Set **`CONNECT_SRC`** to the space-separated origins the browser must reach —
your IdP endpoints plus every ClickHouse cluster origin in your `config.json`;
it fills the CSP `connect-src` (same-origin `'self'` is always included):

```bash
SQL_BROWSER_PROBE=1 docker compose up --build # re-enable container-side /ping checks
PORT=9000 docker compose up --build # publish on another local port
docker run --rm -p 8900:8080 \
-v "$PWD/config.json:/config/config.json:ro" \
-e CONNECT_SRC="https://accounts.google.com https://oauth2.googleapis.com https://clickhouse.example.com" \
ghcr.io/altinity/altinity-sql-browser:latest
```

You can also build and run the image directly:
Or with Compose (builds locally, mounts the demo config, publishes on `$PORT`):

```bash
docker build -t altinity-sql-browser:local .
docker run --rm -p 8900:8900 \
-v "$HOME/.clickhouse-client:/home/asb/.clickhouse-client:ro" \
altinity-sql-browser:local
docker compose up --build # → http://localhost:8900/sql
PORT=9000 docker compose up --build
```

Two caveats for the baked demo config:

- **OAuth from `localhost` won't complete** — the demo clusters' Google clients
register redirect URIs on their own hosts, not `http://localhost:8900/sql`. Use
the `demo:demo` credentials entries locally; the SSO entries work once the app
is served from a registered origin.
- **Cross-origin queries need CORS** on the target cluster. ClickHouse's HTTP
interface sends `Access-Control-Allow-Origin` for requests carrying an `Origin`
by default, so the public demos work out of the box.

Kubernetes manifests (Deployment + Service, non-root `securityContext`,
config via ConfigMap) are in [`deploy/k8s/`](deploy/k8s/).

### Run locally against your own ClickHouse

**Install (no clone, no Node — just `python3`):**
Expand Down Expand Up @@ -578,18 +603,9 @@ by default, so a stock server works. For an **OAuth** connection you also regist
`http://localhost:8900/sql` as a redirect URI with the IdP. Override the serve port
with `PORT` and the config path with `LOCAL_CH_CONFIG`. Ctrl-C stops it.

**From Docker** (no Node on the host, same runner behavior):

```bash
docker compose up --build
```

The container exposes `http://localhost:8900/sql` and reads saved connections
from a read-only mount of `~/.clickhouse-client` into `/home/asb/.clickhouse-client`.
Docker Compose disables `SQL_BROWSER_PROBE` by default because the probe runs in
the container and may incorrectly drop host-only aliases such as `localhost` or
names resolved through your host `/etc/hosts`. Re-enable it with
`SQL_BROWSER_PROBE=1` if your saved hosts are reachable from inside Docker too.
**From Docker** — the container is a static nginx server that takes an explicit
`config.json` rather than reading `~/.clickhouse-client`. See
[Run in Docker](#run-in-docker) above.

## Installing on any ClickHouse cluster

Expand Down
21 changes: 18 additions & 3 deletions deploy/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,25 @@
"basic_login": true,
"idps": [
{
"id": "google",
"label": "Google",
"id": "google-antalya",
"label": "Google (Antalya)",
"issuer": "https://accounts.google.com",
"client_id": "REPLACE_WITH_OAUTH_CLIENT_ID"
"client_id": "925653064731-tpj128lp5qm5vkqn4llgbl96udvt3lsf.apps.googleusercontent.com",
"client_secret": "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"
},
{
"id": "google-github",
"label": "Google (github.demo)",
"issuer": "https://accounts.google.com",
"client_id": "925653064731-vsep3e0vjq74q16svj5jshgvdlm6uptm.apps.googleusercontent.com",
"client_secret": "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",
"ch_auth": "basic"
}
],
"hosts": [
{ "label": "Antalya (demo:demo)", "url": "https://antalya.demo.altinity.cloud", "auth": "basic", "user": "demo", "password": "demo" },
{ "label": "Antalya (Google SSO)", "url": "https://antalya.demo.altinity.cloud", "auth": "oauth", "idp": "google-antalya" },
{ "label": "github.demo (demo:demo)", "url": "https://github.demo.altinity.cloud", "auth": "basic", "user": "demo", "password": "demo" },
{ "label": "github.demo (Google SSO)", "url": "https://github.demo.altinity.cloud", "auth": "oauth", "idp": "google-github" }
]
}
Loading
Loading