diff --git a/.dockerignore b/.dockerignore index 7c63e13e1..7da07202f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,6 +34,7 @@ tests playwright-report test-results data-e2e-offline +data-e2e-base-path .loop loop .claude diff --git a/.env.example b/.env.example index 206bcfce3..07a7fc6b6 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ # # ============================================ +# ============================================ +# BUILD-TIME SUBPATH (Optional) +# ============================================ +# For /libredb, /tools/libredb or /~/libredb, build your own image/app with this +# value. It cannot relocate a prebuilt image at runtime. No trailing slash. +# BASE_PATH=/tools/libredb +# Details, reverse-proxy rules and OIDC callback URLs: docs/SUBPATH.md + # ============================================ # SERVER BIND ADDRESS (Optional) # ============================================ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15fa27f81..3e120201d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,6 +250,10 @@ jobs: USER_EMAIL: user@libredb.org USER_PASSWORD: test-user + - name: Run subpath deployment E2E + # Separate build, after the root-path servers have stopped. + run: bun run test:e2e:base-path + - name: Upload Playwright report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() diff --git a/.gitignore b/.gitignore index aa9a4a80a..9920135d6 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,6 @@ deploy/digitalocean/droplet/scripts/99-img-check.sh /probe-*.yaml /probe-results/ /probe-results*.json + +# Isolated data for the production subpath regression suite. +data-e2e-base-path/ diff --git a/Dockerfile b/Dockerfile index e492d3fdc..87eeee17e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,9 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV DOCKER_BUILD=true +# Next.js bakes this prefix into routes and browser bundles. Rebuild to change it. +ARG BASE_PATH="" + ARG JWT_SECRET_BUILD="build-time-placeholder-secret-32ch" ARG ADMIN_PASSWORD_BUILD="build" ARG USER_PASSWORD_BUILD="build" diff --git a/README.md b/README.md index 539377828..2a1651c68 100644 --- a/README.md +++ b/README.md @@ -638,6 +638,9 @@ Deploy your own instance of LibreDB Studio with a single click on DigitalOcean, ## Deployment (DevOps) +For a reverse-proxy path such as `/tools/libredb`, build with `BASE_PATH` and follow the +[subpath deployment guide](docs/SUBPATH.md). Prebuilt images use the root path. + > Maintainers: every distribution channel is inventoried in > [`distribution/channels.yaml`](distribution/channels.yaml); `bun run distribution:check` > reports version drift across all of them (see diff --git a/charts/libredb-studio/Chart.yaml b/charts/libredb-studio/Chart.yaml index c17620f91..c9121f2d9 100644 --- a/charts/libredb-studio/Chart.yaml +++ b/charts/libredb-studio/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: libredb-studio description: Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL type: application -version: 0.1.61 +version: 0.1.62 appVersion: "0.15.0" kubeVersion: ">=1.26.0-0" home: https://github.com/libredb/libredb-studio @@ -113,13 +113,7 @@ annotations: - name: Source url: https://github.com/libredb/libredb-studio artifacthub.io/changes: | - - "The OpenShift operator bundle now reaches both community catalogs on every release. Submissions to operatorhub.io and the OpenShift console catalog were hand-made until now, which is why both sat at 0.9.59 while the product reached 0.14.1 and 0.14.0 was never submitted at all" - - "A database size the server declines to measure is reported as absent rather than as 0 bytes, on MySQL, PostgreSQL, SQL Server and Oracle. An unmeasured database no longer reads as an empty one in the Overview panel" - - "CREATE TABLE emits the DDL of the engine it is aimed at instead of PostgreSQL's, so the generated statement runs where it was generated" - - "Agent grounding no longer captures engine and extension objects, so a run is grounded in the schema the question is about" - - "An agent stop that read nothing is answered for whoever stopped it rather than left silent" - - "The bundled runtime moves to Bun 1.4.2, with the in-range dependency groups" - - "Track app release 0.15.0 (appVersion bump; default image tag follows)" + - "Prefix default health probes with config.basePath for images built to run under a subpath" dependencies: - name: postgresql version: "16.x.x" diff --git a/charts/libredb-studio/README.md b/charts/libredb-studio/README.md index d3ba0c8c5..ae91bc6f9 100644 --- a/charts/libredb-studio/README.md +++ b/charts/libredb-studio/README.md @@ -40,7 +40,7 @@ helm install libredb libredb/libredb-studio \ ```bash helm install libredb oci://ghcr.io/libredb/charts/libredb-studio \ - --version 0.1.61 \ + --version 0.1.62 \ --set secrets.jwtSecret=$(openssl rand -base64 32) \ --set secrets.adminPassword=MyAdmin123 ``` @@ -391,6 +391,7 @@ would otherwise lose reachability. One line in the pod log names the address it `config.bindAddress` overrules the resolver when you would rather state it than leave it to the image: +| `config.basePath` | Must match `BASE_PATH` baked into your custom image; prefixes default health probes | `""` | | `config.bindAddress` | Effect | |---|---| | `""` (default) | the container resolves it, preferring a verified dual-stack `::` | @@ -639,3 +640,14 @@ fixed `runAsUser`/`runAsGroup`/`fsGroup` so the SCC can assign valid IDs; UIDs: every writable path is a volume mount. Set `force` to always adapt (for example when templating manifests offline for an OpenShift cluster) or `disabled` to keep the fixed IDs everywhere. + +## Deployment under a subpath + +Build a custom image with `docker build --build-arg BASE_PATH=/tools/libredb ...`, then set +`config.basePath: /tools/libredb` and the matching image repository/tag. This prefixes the default +startup, readiness and liveness probes. It does not change routes in an already-built image. +Explicit custom probe paths are preserved. Set Ingress paths or HTTPRoute matches to the same +prefix and preserve it when forwarding; do not strip or rewrite it. + +See [subpath deployment](https://github.com/libredb/libredb-studio/blob/main/docs/SUBPATH.md) +for complete build, reverse-proxy and OIDC examples. diff --git a/charts/libredb-studio/templates/_helpers.tpl b/charts/libredb-studio/templates/_helpers.tpl index c83ba687a..22404d13d 100644 --- a/charts/libredb-studio/templates/_helpers.tpl +++ b/charts/libredb-studio/templates/_helpers.tpl @@ -386,3 +386,12 @@ Return the PostgreSQL URL when subchart is enabled {{- define "libredb-studio.postgresql.url" -}} {{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:5432/%s" .Values.postgresql.auth.username (include "libredb-studio.postgresql.fullname" .) .Values.postgresql.auth.database }} {{- end }} + +{{/* Prefix only the shipped health path; preserve explicit HTTP/exec/TCP probes. */}} +{{- define "libredb-studio.probe" -}} +{{- $probe := deepCopy .probe -}} +{{- if and $probe.httpGet (eq ($probe.httpGet.path | default "") "/api/db/health") -}} +{{- $_ := set $probe.httpGet "path" (printf "%s/api/db/health" .basePath) -}} +{{- end -}} +{{- toYaml $probe -}} +{{- end -}} diff --git a/charts/libredb-studio/templates/deployment.yaml b/charts/libredb-studio/templates/deployment.yaml index c750db573..506227cdd 100644 --- a/charts/libredb-studio/templates/deployment.yaml +++ b/charts/libredb-studio/templates/deployment.yaml @@ -270,15 +270,15 @@ spec: {{- end }} {{- with .Values.startupProbe }} startupProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} {{- with .Values.readinessProbe }} readinessProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} {{- with .Values.livenessProbe }} livenessProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} diff --git a/charts/libredb-studio/values.schema.json b/charts/libredb-studio/values.schema.json index 4220830db..3c94e465c 100644 --- a/charts/libredb-studio/values.schema.json +++ b/charts/libredb-studio/values.schema.json @@ -124,6 +124,12 @@ "config": { "type": "object", "properties": { + "basePath": { + "type": "string", + "description": "Build-time BASE_PATH of the custom image; prefixes default probes, not a runtime app setting", + "pattern": "^$|^(/[A-Za-z0-9._~-]+)+$", + "not": { "pattern": "(^|/)\\.\\.?(/|$)" } + }, "logLevel": { "type": "string", "enum": ["debug", "info", "warn", "error"], diff --git a/charts/libredb-studio/values.yaml b/charts/libredb-studio/values.yaml index ef0501220..472ecf7d4 100644 --- a/charts/libredb-studio/values.yaml +++ b/charts/libredb-studio/values.yaml @@ -92,6 +92,9 @@ secrets: authProvider: "local" config: + # -- Must match BASE_PATH baked into a custom image. Prefixes default health probes; + # this value cannot change the routes in a prebuilt image. Empty means root. + basePath: "" # -- Log level logLevel: "info" # -- Address the container listens on, written to HOSTNAME. Empty is the diff --git a/docker-compose.example.yml b/docker-compose.example.yml index bacde5803..7b2250be6 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -109,8 +109,9 @@ services: # - ./seed-connections.yaml:/app/config/seed-connections.yaml:ro # Image runs as node:24.16.0-trixie-slim (no curl/wget) — use Node's built-in fetch. + # BASE_PATH must match a custom image built with that prefix (docs/SUBPATH.md). healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/db/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:3000${BASE_PATH:-}/api/db/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index c1a538b46..17faf9cb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: build: context: . dockerfile: Dockerfile + args: + BASE_PATH: ${BASE_PATH:-} ports: - "3000:3000" environment: diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index a8b6acc0d..544cefe05 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -35,7 +35,7 @@ None of it is a GitHub issue. - [Dependencies](#dependencies) — P1–P5 · 5 - [Documentation](#documentation) — DOC3, DOC4 · 2 - [Release pipeline](#release-pipeline) — REL1–REL3 · 3 -- [Chart configuration surface](#chart-configuration-surface) — N1, N3 · 2 +- [Chart configuration surface](#chart-configuration-surface) — N1 · 1 - [Security Phase 1 deferrals](#security-phase-1-deferrals) — H1–H8 · 2 - [Security Phase 2 deferrals](#security-phase-2-deferrals) — C3–C11 · 7 - [Security Phase 3 deferrals](#security-phase-3-deferrals) — K4 @@ -988,39 +988,6 @@ Note the naming collision: `route.*` in `values.yaml` means Gateway API as of #3 **Done when:** an OpenShift cluster can be served by the chart alone, with TLS termination selectable, and the README says which of the three exposure mechanisms belongs to which platform. -### N3. Subpath deployment is build-time only, which is why #369 is deferred rather than scheduled - -[#369](https://github.com/libredb/libredb-studio/issues/369) asks to serve Studio under a path prefix -on a shared domain — `https://example.com/libredb` next to `https://example.com/grafana`. -`next.config.ts` sets no `basePath` and no `assetPrefix`, so there is zero support today. - -The constraint, recorded so nobody rediscovers it: **Next.js `basePath` is baked at build, not read at -runtime.** Asset URLs (`/_next/static/...`) are emitted into the HTML and JS at build time and there -is no supported runtime override. So a `BASE_PATH` env var on the prebuilt image cannot work — the -feature has to be a build arg and a rebuilt image. - -A reverse-proxy `StripPrefix` is not a workaround either. The browser asks for `/libredb/`, the proxy -strips it, the app answers with HTML referencing `/_next/static/...` at the root, and that follow-up -request no longer matches the `/libredb` router rule. Grafana can do this at runtime because it is a -Go server templating its own HTML; a statically built Next.js app is structurally different. - -The surface a build-time implementation touches: roughly 40 `fetch('/api/...')` call sites, roughly 15 -`router.push('/...')`, the cookie `path: "/"` in `src/lib/auth.ts` and the OIDC login route, OIDC -redirect URIs, the `src/proxy.ts` matcher, the Docker healthcheck, the chart's ingress and route -paths, the npm library surface, the E2E suite and the docs of roughly 27 distribution channels. -`next/link` and the app-router `router` prefix automatically; `fetch`, middleware redirects and cookie -paths do not. - -Deferred rather than scheduled because the acquisition-relevant PaaS one-click listings hand out -subdomains, not subpaths, so no shipped channel needs it. - -Related sharp edge, same silent-no-op class as #366: `values.yaml` already lets a user set -`ingress.hosts[].paths[].path` to `/libredb`, the install succeeds, and the app is unreachable. - -**Done when:** a `BASE_PATH` build arg produces an image reachable under a path prefix — assets, API -calls, auth cookie and OIDC redirect included — verified against a real path-routing proxy, or the -chart refuses a non-root ingress path outright. - --- ## Security Phase 1 deferrals diff --git a/docs/SUBPATH.md b/docs/SUBPATH.md new file mode 100644 index 000000000..7d6ae2480 --- /dev/null +++ b/docs/SUBPATH.md @@ -0,0 +1,126 @@ +# Deploy Studio under a subpath + +Studio can run at `/libredb`, `/tools/libredb`, or `/~/libredb` behind a reverse proxy. +Set `BASE_PATH` **when building**. Next.js bakes this prefix into routes and browser bundles; +setting it only on a prebuilt image cannot relocate that image. The published images use `/`. + +Use a leading slash and no trailing slash. Empty or `/` selects the root. Path segments may +contain letters, digits, `.`, `_`, `~`, and `-`; dot segments, encoded paths, query strings, +fragments, backslashes, and repeated slashes are rejected during the build. + +## Build from source + +Keep the same value for build and `next start`: + +```sh +export BASE_PATH=/tools/libredb +bun install --frozen-lockfile +bun run build +bun start +``` + +With Docker, build a custom image from the source revision containing subpath support: + +```sh +docker build --build-arg BASE_PATH=/tools/libredb -t studio-subpath:local . +docker run --rm -p 3000:3000 --env-file .env.local studio-subpath:local +``` + +The build uses the prefix from the build arg. Supply your normal runtime authentication, +storage, and LLM settings as usual. `BASE_PATH` is not a replacement for a public origin or +for `ALLOWED_ORIGINS`; that setting still takes origins such as `https://example.com`. + +The source `docker-compose.yml` forwards `BASE_PATH` as a build arg: + +```sh +BASE_PATH=/tools/libredb docker compose up --build +``` + +When using `docker-compose.example.yml`, select your custom image and set the same `BASE_PATH` +in Compose's environment so its health check uses the built path. + +## Preserve the prefix at the reverse proxy + +Forward `/tools/libredb` and everything below it **with the path intact**. Do not use +Traefik `StripPrefix`, an Nginx rewrite, or an HTTPRoute `URLRewrite` to remove the prefix. +For example, an Nginx upstream without a URI suffix preserves the original path: + +```nginx +location ~ ^/tools/libredb(?:/|$) { + proxy_pass http://studio:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +For Traefik, match ``PathPrefix(`/tools/libredb`)`` and forward to Studio without a strip-prefix +middleware. Restrict the rule to your intended hostname as usual. Assets, API calls, +streamed agent responses, and native authentication redirects all use the configured prefix. +Next's router and `Link` apply it automatically. Studio has no WebSocket endpoint to configure. + +## Helm and Gateway API + +Build and publish your own image with the prefix first. Then use matching chart values: + +```yaml +image: + repository: registry.example.com/studio-subpath + tag: my-build +config: + basePath: /tools/libredb + +ingress: + enabled: true + hosts: + - host: example.com + paths: + - path: /tools/libredb + pathType: Prefix +``` + +`config.basePath` prefixes the chart's default startup, readiness, and liveness health paths. +It does not set a runtime app environment variable. Explicit custom probe paths, exec probes, +and TCP probes are preserved. A mismatched image and chart prefix fails readiness. + +For a Gateway API deployment, use an HTTPRoute match with the same prefix and your cluster's +actual Gateway reference: + +```yaml +route: + main: + enabled: true + parentRefs: + - name: my-gateway + hostnames: + - example.com + matches: + - path: + type: PathPrefix + value: /tools/libredb +``` + +Do not add a rewrite filter. If you use external health checks, request +`/tools/libredb/api/db/health`. + +## Authentication and editor assets + +Register the full OIDC callback URL, for example +`https://example.com/tools/libredb/api/auth/oidc/callback`, and allow the logout return URL +`https://example.com/tools/libredb/login` at your identity provider. Session and OIDC state +cookies use `/tools/libredb` as their path, including when they are removed. + +The default Monaco asset URL becomes `/tools/libredb/monaco/vs`. An explicit +`NEXT_PUBLIC_MONACO_VS_PATH` override is used exactly as configured, including an external +asset origin; do not add the prefix a second time. + +For the embedded npm library, the host application's routing remains its responsibility. +Standalone builds publish the internal `NEXT_PUBLIC_BASE_PATH` value from `BASE_PATH`; it is +not a separate operator setting. + +## Verification + +`bun run test:e2e:base-path` builds at `/~/libredb`, starts a local path-preserving proxy that +returns 404 outside that prefix, and tests login, RBAC, cookie deletion, a real SQLite query, origin checks, OIDC error +redirects, and self-hosted editor assets in Chromium. Run it after the ordinary E2E suite; +the two configurations build different versions of `.next`. diff --git a/e2e/base-path.spec.ts b/e2e/base-path.spec.ts new file mode 100644 index 000000000..42b0baf26 --- /dev/null +++ b/e2e/base-path.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; + +const prefix = "/~/libredb"; + +test("production deployment behind a path-preserving reverse proxy", async ({ page, context, request, baseURL }) => { + const failedAppRequests: string[] = []; + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("response", (response) => { + if (response.url().startsWith(baseURL!) && response.status() >= 400) + failedAppRequests.push(`${response.status()} ${response.url()}`); + }); + await page.route("**/*", (route) => (route.request().url().startsWith(baseURL!) ? route.continue() : route.abort())); + + // The proxy makes escaping the prefix observable instead of letting root URLs work by accident. + expect((await request.get("/api/db/health")).status()).toBe(404); + expect((await request.get(`${prefix}-other/api/db/health`)).status()).toBe(404); + expect((await request.get(`${prefix}/api/db/health`)).status()).toBe(200); + expect( + ( + await request.post(`${prefix}/api/db/health`, { headers: { origin: "https://untrusted.example" }, data: {} }) + ).status(), + ).toBe(403); + const rootRedirect = await request.get(prefix, { maxRedirects: 0 }); + expect(new URL(rootRedirect.headers().location, baseURL).href).toBe(`${baseURL}${prefix}/login`); + const redirect = await request.get(`${prefix}/admin`, { maxRedirects: 0 }); + expect(new URL(redirect.headers().location, baseURL).href).toBe(`${baseURL}${prefix}/login`); + const oidcError = await request.get(`${prefix}/api/auth/oidc/login`, { maxRedirects: 0 }); + expect(new URL(oidcError.headers().location, baseURL).href).toBe(`${baseURL}${prefix}/login?error=oidc_config`); + + await page.goto(`${prefix}/login`); + await page.locator('input[type="email"]:visible').fill("user@libredb.org"); + await page.locator('input[type="password"]:visible').fill("test-user"); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL(new RegExp(`${prefix}/?$`)); + await expect(page.locator(".monaco-editor").first()).toBeVisible({ timeout: 30_000 }); + const cookie = (await context.cookies()).find((value) => value.name === "auth-token"); + expect(cookie).toMatchObject({ path: prefix, httpOnly: true, sameSite: "Lax" }); + + await page.getByText("Sample (Employees)", { exact: true }).first().click(); + await page.waitForFunction( + () => + ((window as unknown as { monaco?: { editor: { getEditors(): unknown[] } } }).monaco?.editor.getEditors().length ?? + 0) > 0, + ); + await page.evaluate(() => { + const monaco = (window as unknown as { monaco: { editor: { getEditors(): { setValue(value: string): void }[] } } }) + .monaco; + monaco.editor.getEditors()[0].setValue("SELECT COUNT(*) AS employee_count FROM employee"); + }); + await page.getByRole("button", { name: "RUN", exact: true }).click(); + await expect(page.getByText("employee_count", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("1000", { exact: true }).first()).toBeVisible(); + + const session = await page.evaluate(async (path) => (await fetch(`${path}/api/auth/me`)).json(), prefix); + expect(session.user.role).toBe("user"); + await page.goto(`${prefix}/admin`); + await expect(page).toHaveURL(new RegExp(`${prefix}/?$`)); + + expect((await request.get(`${prefix}/logo.svg`)).status()).toBe(200); + expect((await request.get(`${prefix}/monaco/vs/loader.js`)).status()).toBe(200); + expect(failedAppRequests).toEqual([]); + expect(pageErrors).toEqual([]); + + const logoutStatus = await page.evaluate( + async (path) => (await fetch(`${path}/api/auth/logout`, { method: "POST" })).status, + prefix, + ); + expect(logoutStatus).toBe(200); + expect((await context.cookies()).some((value) => value.name === "auth-token")).toBe(false); + await page.goto(`${prefix}/`); + await expect(page).toHaveURL(`${baseURL}${prefix}/login`); + + await page.locator('input[type="email"]:visible').fill("admin@libredb.org"); + await page.locator('input[type="password"]:visible').fill("test-admin"); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL(`${baseURL}${prefix}/admin/overview`); + await expect(page.getByTestId("admin-content-overview")).toBeVisible(); + // Server redirects, Next links and native quick actions all stay inside the mount. + await expect(page.getByRole("link", { name: "Operations", exact: true })).toHaveAttribute( + "href", + `${prefix}/admin/operations`, + ); + const maintenance = page.getByRole("link", { name: /^Maintenance VACUUM/ }); + await expect(maintenance).toHaveAttribute("href", `${prefix}/admin/operations`); + await maintenance.click(); + await expect(page).toHaveURL(`${baseURL}${prefix}/admin/operations`); + await page.goto(`${prefix}/admin?tab=audit`); + await expect(page).toHaveURL(`${baseURL}${prefix}/admin/audit`); + expect(failedAppRequests).toEqual([]); + expect(pageErrors).toEqual([]); + await page.getByRole("button", { name: "Logout", exact: true }).click(); + await expect(page).toHaveURL(`${baseURL}${prefix}/login`); + expect((await context.cookies()).some((value) => value.name === "auth-token")).toBe(false); +}); diff --git a/e2e/helpers/base-path-proxy.mjs b/e2e/helpers/base-path-proxy.mjs new file mode 100644 index 000000000..1b1d38dfe --- /dev/null +++ b/e2e/helpers/base-path-proxy.mjs @@ -0,0 +1,28 @@ +// A path-preserving reverse proxy: root-relative mistakes must fail with 404. +import { createServer, request as upstreamRequest } from "node:http"; + +const prefix = process.env.E2E_BASE_PATH || "/~/libredb"; +const upstreamPort = Number(process.env.E2E_BASE_PATH_APP_PORT || 3020); +const port = Number(process.env.E2E_BASE_PATH_PROXY_PORT || 3021); +createServer((request, response) => { + const pathname = new URL(request.url, "http://localhost").pathname; + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) { + response.writeHead(404).end("Outside Studio's path prefix"); + return; + } + const upstream = upstreamRequest( + { + hostname: "127.0.0.1", + port: upstreamPort, + path: request.url, + method: request.method, + headers: { ...request.headers, "x-forwarded-host": request.headers.host, "x-forwarded-proto": "http" }, + }, + (result) => { + response.writeHead(result.statusCode, result.headers); + result.pipe(response); + }, + ); + upstream.on("error", () => response.writeHead(502).end("Upstream unavailable")); + request.pipe(upstream); +}).listen(port, "127.0.0.1"); diff --git a/next.config.ts b/next.config.ts index 7cc62af98..ffcb9be34 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ import type { NextConfig } from "next"; import packageJson from "./package.json"; +import { readBasePath } from "./src/lib/config/base-path"; // Relative, not "@/": Next loads this file before any tsconfig path alias exists for it, and // src/lib/security/headers.ts is import-free by design precisely so a next.config can read it. import { securityHeaders, type SecurityHeaderOptions } from "./src/lib/security/headers"; @@ -197,9 +198,13 @@ function staticAssetHeaders(): { key: string; value: string }[] { return selectStaticAssetHeaders(securityHeaders(), securityHeaders(OPTION_PROBE)); } +const basePath = readBasePath(process.env.BASE_PATH); + const nextConfig: NextConfig = { + basePath, env: { NEXT_PUBLIC_APP_VERSION: packageJson.version, + NEXT_PUBLIC_BASE_PATH: basePath, }, // Use standalone output for Docker/Kubernetes deployments // For Vercel, this is automatically handled diff --git a/operator/helm-charts/libredb-studio/Chart.yaml b/operator/helm-charts/libredb-studio/Chart.yaml index c17620f91..c9121f2d9 100644 --- a/operator/helm-charts/libredb-studio/Chart.yaml +++ b/operator/helm-charts/libredb-studio/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: libredb-studio description: Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL type: application -version: 0.1.61 +version: 0.1.62 appVersion: "0.15.0" kubeVersion: ">=1.26.0-0" home: https://github.com/libredb/libredb-studio @@ -113,13 +113,7 @@ annotations: - name: Source url: https://github.com/libredb/libredb-studio artifacthub.io/changes: | - - "The OpenShift operator bundle now reaches both community catalogs on every release. Submissions to operatorhub.io and the OpenShift console catalog were hand-made until now, which is why both sat at 0.9.59 while the product reached 0.14.1 and 0.14.0 was never submitted at all" - - "A database size the server declines to measure is reported as absent rather than as 0 bytes, on MySQL, PostgreSQL, SQL Server and Oracle. An unmeasured database no longer reads as an empty one in the Overview panel" - - "CREATE TABLE emits the DDL of the engine it is aimed at instead of PostgreSQL's, so the generated statement runs where it was generated" - - "Agent grounding no longer captures engine and extension objects, so a run is grounded in the schema the question is about" - - "An agent stop that read nothing is answered for whoever stopped it rather than left silent" - - "The bundled runtime moves to Bun 1.4.2, with the in-range dependency groups" - - "Track app release 0.15.0 (appVersion bump; default image tag follows)" + - "Prefix default health probes with config.basePath for images built to run under a subpath" dependencies: - name: postgresql version: "16.x.x" diff --git a/operator/helm-charts/libredb-studio/README.md b/operator/helm-charts/libredb-studio/README.md index d3ba0c8c5..ae91bc6f9 100644 --- a/operator/helm-charts/libredb-studio/README.md +++ b/operator/helm-charts/libredb-studio/README.md @@ -40,7 +40,7 @@ helm install libredb libredb/libredb-studio \ ```bash helm install libredb oci://ghcr.io/libredb/charts/libredb-studio \ - --version 0.1.61 \ + --version 0.1.62 \ --set secrets.jwtSecret=$(openssl rand -base64 32) \ --set secrets.adminPassword=MyAdmin123 ``` @@ -391,6 +391,7 @@ would otherwise lose reachability. One line in the pod log names the address it `config.bindAddress` overrules the resolver when you would rather state it than leave it to the image: +| `config.basePath` | Must match `BASE_PATH` baked into your custom image; prefixes default health probes | `""` | | `config.bindAddress` | Effect | |---|---| | `""` (default) | the container resolves it, preferring a verified dual-stack `::` | @@ -639,3 +640,14 @@ fixed `runAsUser`/`runAsGroup`/`fsGroup` so the SCC can assign valid IDs; UIDs: every writable path is a volume mount. Set `force` to always adapt (for example when templating manifests offline for an OpenShift cluster) or `disabled` to keep the fixed IDs everywhere. + +## Deployment under a subpath + +Build a custom image with `docker build --build-arg BASE_PATH=/tools/libredb ...`, then set +`config.basePath: /tools/libredb` and the matching image repository/tag. This prefixes the default +startup, readiness and liveness probes. It does not change routes in an already-built image. +Explicit custom probe paths are preserved. Set Ingress paths or HTTPRoute matches to the same +prefix and preserve it when forwarding; do not strip or rewrite it. + +See [subpath deployment](https://github.com/libredb/libredb-studio/blob/main/docs/SUBPATH.md) +for complete build, reverse-proxy and OIDC examples. diff --git a/operator/helm-charts/libredb-studio/templates/_helpers.tpl b/operator/helm-charts/libredb-studio/templates/_helpers.tpl index c83ba687a..22404d13d 100644 --- a/operator/helm-charts/libredb-studio/templates/_helpers.tpl +++ b/operator/helm-charts/libredb-studio/templates/_helpers.tpl @@ -386,3 +386,12 @@ Return the PostgreSQL URL when subchart is enabled {{- define "libredb-studio.postgresql.url" -}} {{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:5432/%s" .Values.postgresql.auth.username (include "libredb-studio.postgresql.fullname" .) .Values.postgresql.auth.database }} {{- end }} + +{{/* Prefix only the shipped health path; preserve explicit HTTP/exec/TCP probes. */}} +{{- define "libredb-studio.probe" -}} +{{- $probe := deepCopy .probe -}} +{{- if and $probe.httpGet (eq ($probe.httpGet.path | default "") "/api/db/health") -}} +{{- $_ := set $probe.httpGet "path" (printf "%s/api/db/health" .basePath) -}} +{{- end -}} +{{- toYaml $probe -}} +{{- end -}} diff --git a/operator/helm-charts/libredb-studio/templates/deployment.yaml b/operator/helm-charts/libredb-studio/templates/deployment.yaml index c750db573..506227cdd 100644 --- a/operator/helm-charts/libredb-studio/templates/deployment.yaml +++ b/operator/helm-charts/libredb-studio/templates/deployment.yaml @@ -270,15 +270,15 @@ spec: {{- end }} {{- with .Values.startupProbe }} startupProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} {{- with .Values.readinessProbe }} readinessProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} {{- with .Values.livenessProbe }} livenessProbe: - {{- toYaml . | nindent 12 }} + {{- include "libredb-studio.probe" (dict "probe" . "basePath" ($.Values.config.basePath | default "")) | nindent 12 }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} diff --git a/operator/helm-charts/libredb-studio/values.schema.json b/operator/helm-charts/libredb-studio/values.schema.json index 4220830db..3c94e465c 100644 --- a/operator/helm-charts/libredb-studio/values.schema.json +++ b/operator/helm-charts/libredb-studio/values.schema.json @@ -124,6 +124,12 @@ "config": { "type": "object", "properties": { + "basePath": { + "type": "string", + "description": "Build-time BASE_PATH of the custom image; prefixes default probes, not a runtime app setting", + "pattern": "^$|^(/[A-Za-z0-9._~-]+)+$", + "not": { "pattern": "(^|/)\\.\\.?(/|$)" } + }, "logLevel": { "type": "string", "enum": ["debug", "info", "warn", "error"], diff --git a/operator/helm-charts/libredb-studio/values.yaml b/operator/helm-charts/libredb-studio/values.yaml index ef0501220..472ecf7d4 100644 --- a/operator/helm-charts/libredb-studio/values.yaml +++ b/operator/helm-charts/libredb-studio/values.yaml @@ -92,6 +92,9 @@ secrets: authProvider: "local" config: + # -- Must match BASE_PATH baked into a custom image. Prefixes default health probes; + # this value cannot change the routes in a prebuilt image. Empty means root. + basePath: "" # -- Log level logLevel: "info" # -- Address the container listens on, written to HOSTNAME. Empty is the diff --git a/package.json b/package.json index 6b0d2067c..0c942519c 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,8 @@ "channels:showcase": "node scripts/generate-channel-showcase.mjs", "channels:showcase:check": "node scripts/generate-channel-showcase.mjs --check", "readme:check": "node scripts/readme-check.mjs", - "security:check": "node scripts/security-check.mjs" + "security:check": "node scripts/security-check.mjs", + "test:e2e:base-path": "playwright test --config=playwright.base-path.config.ts" }, "//dependencies": "@duckdb/node-api is the one exact pin in this list. Its versions carry a prerelease suffix (1.5.5-r.4 = DuckDB 1.5.5, driver revision 4), and npm and bun treat a caret over a prerelease tag inconsistently - `^1.5.5-r.4` would resolve forward across 1.x in a way neither tool agrees on - so the range is written out. The driver is four packages, not one: @duckdb/node-api -> @duckdb/node-bindings -> a per-platform, per-libc @duckdb/node-bindings--[-musl] holding duckdb.node next to the ~70 MB libduckdb.so it links against. None of the four declares a scripts block or a binding.gyp, so no trustedDependencies entry is needed. Bumping it is a provider change, not a routine bump (see docs/providers/duckdb.md and the tri-sync rule in CLAUDE.md).", "dependencies": { diff --git a/playwright.base-path.config.ts b/playwright.base-path.config.ts new file mode 100644 index 000000000..afcb1a3c4 --- /dev/null +++ b/playwright.base-path.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +const prefix = "/~/libredb"; +const appPort = Number(process.env.E2E_BASE_PATH_APP_PORT || 3020); +const proxyPort = Number(process.env.E2E_BASE_PATH_PROXY_PORT || 3021); + +export default defineConfig({ + testDir: "./e2e", + testMatch: /base-path\.spec\.ts/, + workers: 1, + forbidOnly: !!process.env.CI, + retries: 0, + timeout: 60_000, + reporter: "list", + use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${proxyPort}`, trace: "retain-on-failure" }, + webServer: [ + { + // Run after the ordinary suite: this rebuilds .next with a different prefix. + command: "bun run build && bun start", + url: `http://127.0.0.1:${appPort}${prefix}/api/db/health`, + timeout: 180_000, + reuseExistingServer: false, + env: { + BASE_PATH: prefix, + PORT: String(appPort), + HOSTNAME: "127.0.0.1", + JWT_SECRET: "test-jwt-secret-for-e2e-tests-32ch", + ADMIN_EMAIL: "admin@libredb.org", + ADMIN_PASSWORD: "test-admin", + USER_EMAIL: "user@libredb.org", + USER_PASSWORD: "test-user", + STORAGE_SQLITE_PATH: "./data-e2e-base-path/libredb-storage.db", + NEXT_TELEMETRY_DISABLED: "1", + }, + }, + { + command: "node e2e/helpers/base-path-proxy.mjs", + url: `http://127.0.0.1:${proxyPort}${prefix}/api/db/health`, + timeout: 180_000, + reuseExistingServer: false, + env: { + E2E_BASE_PATH: prefix, + E2E_BASE_PATH_APP_PORT: String(appPort), + E2E_BASE_PATH_PROXY_PORT: String(proxyPort), + }, + }, + ], +}); diff --git a/playwright.config.ts b/playwright.config.ts index 22eb74b29..669c369d7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -45,7 +45,7 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, // offline-editor.spec.ts runs under "chromium-offline-editor" below, against its own server. - testIgnore: /offline-editor\.spec\.ts/, + testIgnore: /(?:offline-editor|base-path)\.spec\.ts/, }, { // Every other spec in this suite signs in as the same shared user@libredb.org account diff --git a/scripts/operator-catalog-submission.mjs b/scripts/operator-catalog-submission.mjs index ba53011c7..0951f9d96 100644 --- a/scripts/operator-catalog-submission.mjs +++ b/scripts/operator-catalog-submission.mjs @@ -251,9 +251,9 @@ export function readOperatorEntries(operatorDir) { } const parent = path.dirname(operatorDir); if (!fs.statSync(parent, { throwIfNoEntry: false })?.isDirectory()) { - throw new Error( - `${parent} is not a directory: this is not a catalog checkout, or its layout changed upstream`, - ); + throw new Error(`${parent} is not a directory: this is not a catalog checkout, or its layout changed upstream`, { + cause: error, + }); } return null; } diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx index c35ad1d71..292c63939 100644 --- a/src/app/admin/error.tsx +++ b/src/app/admin/error.tsx @@ -1,5 +1,6 @@ "use client"; +import { withBasePath } from "@/lib/config/base-path"; import { useEffect } from "react"; export default function AdminError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { @@ -24,7 +25,7 @@ export default function AdminError({ error, reset }: { error: Error & { digest?: