Skip to content

Latest commit

 

History

History
691 lines (566 loc) · 55.3 KB

HELM_CHART.md

File metadata and controls

691 lines (566 loc) · 55.3 KB

Helm Chart Architecture

Overview

LibreDB Studio's Helm chart provides a production-grade Kubernetes deployment with security hardening, pluggable storage, autoscaling, and dual distribution (GitHub Pages + OCI).

Distribution Channels

Channel URL Command
ArtifactHub artifacthub.io/packages/helm/libredb-studio/libredb-studio Browse & discover
Helm Repo https://libredb.org/libredb-studio/ helm repo add libredb https://libredb.org/libredb-studio/
OCI Registry oci://ghcr.io/libredb/charts/libredb-studio helm install libredb oci://ghcr.io/libredb/charts/libredb-studio

Chart Structure

charts/libredb-studio/
├── Chart.yaml                 # Metadata, appVersion, Bitnami PostgreSQL dependency
├── values.yaml                # All configurable defaults
├── values.schema.json         # JSON Schema validation (helm lint --strict)
├── .helmignore                # Package exclusion patterns
├── README.md                  # Chart-level documentation
├── questions.yaml             # Rancher Apps form for the Rancher-mode values
├── files/logo.svg             # Copy of public/logo.svg, the Rancher NavLink icon
└── templates/
    ├── _helpers.tpl           # Named templates (labels, names, image, storage logic)
    ├── deployment.yaml        # App Deployment (checksum restart, emptyDir, probes)
    ├── service.yaml           # ClusterIP / NodePort / LoadBalancer
    ├── ingress.yaml           # Optional Ingress (nginx/traefik)
    ├── route.yaml             # Optional Gateway API HTTPRoute (default off)
    ├── configmap.yaml         # Non-sensitive env vars (PORT, storage, LLM, OIDC)
    ├── seed-configmap.yaml    # Optional seed-connections config (rendered when enabled)
    ├── secret.yaml            # Sensitive env vars (JWT, passwords, API keys)
    ├── serviceaccount.yaml    # SA with IRSA/Workload Identity annotations
    ├── hpa.yaml               # HorizontalPodAutoscaler (CPU + memory)
    ├── pdb.yaml               # PodDisruptionBudget
    ├── pvc.yaml               # PersistentVolumeClaim (SQLite mode)
    ├── networkpolicy.yaml     # Ingress/egress rules (DB ports, DNS, HTTPS)
    ├── extra-objects.yaml     # extraObjects: user manifests rendered through tpl (default none)
    ├── navlink.yaml           # Rancher NavLink (rancher.navLink, default off)
    ├── access-review.yaml     # ClusterRole and binding for the Kubernetes access review (default off)
    ├── kubernetes-discovery.yaml # Discovery exporter, its RBAC and the export Secret (default off)
    ├── consolelink.yaml       # OpenShift console link (openshift.consoleLink, default off)
    ├── console-plugin.yaml    # OpenShift console plugin server and ConsolePlugin (openshift.consolePlugin, default off)
    └── NOTES.txt              # Post-install usage instructions

Architecture Decisions

1. Security Hardening

The chart enforces a restrictive security posture by default:

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1001          # Matches Dockerfile (adduser --uid 1001 nextjs)
  runAsGroup: 1001
  fsGroup: 1001
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: [ALL]

readOnlyRootFilesystem + writable volumes: Next.js writes to .next/cache at runtime, and the app writes generated first-run credentials and the sample database into its data directory. Three writable mounts solve this without relaxing security:

  • /app/.next/cache — emptyDir; Next.js ISR/build cache (ephemeral, per-pod)
  • /tmp — emptyDir; temporary files
  • /app/data — data directory (auth-bootstrap.json, sample database): emptyDir by default, the PVC when persistence is enabled

No API token in the pod by default: serviceAccount.automountServiceAccountToken defaults to false and is written on the pod spec as well as on the ServiceAccount the chart creates. The pod field wins over the account's, so serviceAccount.create=false with the namespace's default account mounts no token either; only an explicit true mounts one. Studio reads the pod's token only for Vault Kubernetes auth (VAULT_ROLE, docs/SEED_CONNECTIONS.md); such an install sets the value to true, or projects a token through extraVolumes and points VAULT_K8S_TOKEN_PATH at it. tests/unit/helm-chart-service-account-token.test.ts renders both serviceAccount.create values.

OpenShift adaptation: OpenShift's restricted-v2 SCC assigns runAsUser/fsGroup from a per-namespace range and rejects pods that hard-code IDs outside it. global.compatibility.openshift.adaptSecurityContext (default auto; same contract as the Bitnami subchart, so one value covers both) makes the libredb-studio.podSecurityContext helper omit runAsUser/runAsGroup/fsGroup when the API server exposes security.openshift.io/v1, keeping runAsNonRoot and the seccomp profile. Arbitrary UIDs are safe because every writable path is a volume mount and the entrypoint execs directly when not running as root.

2. Dockerfile Alignment

The chart is tightly coupled to the Dockerfile:

Dockerfile Chart
EXPOSE 3000/tcp service.targetPort: 3000
adduser --uid 1001 nextjs podSecurityContext.runAsUser: 1001
WORKDIR /app Volume mounts under /app/
mkdir -p data /app/data volume: PVC when persistence is enabled, emptyDir otherwise
GET /api/db/health Startup/readiness/liveness probes
GET /api/ready Readiness endpoint: 200 without governance, the governance freshness state under governance

Liveness stays on GET /api/db/health, which depends on nothing, so a governance source that is down never gets a healthy pod restarted. GET /api/ready is the readiness endpoint (API_DOCS.md, GOVERNANCE.md): it answers 200 with status ready without governance, and under governance 503 while the snapshot is loading, misconfigured or incompatible, so a pod receives traffic only once it can serve data routes. It reads only in-process state and opens no user database, so it is as cheap to probe as the liveness path. Under governance.enabled the chart moves the shipped readiness path to /api/ready itself (section 11), and a readiness probe the operator wrote with another path is kept as it is.

3. Storage Modes

                ┌─────────────┐
                │ values.yaml │
                │ storageProvider │
                └──────┬──────┘
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
      ┌───────┐   ┌────────┐   ┌──────────┐
      │ local │   │ sqlite │   │ postgres │
      │       │   │        │   │          │
      │ No PVC│   │ Auto   │   │ External │
      │ No DB │   │ PVC    │   │ URL or   │
      │       │   │ create │   │ Subchart │
      └───────┘   └────────┘   └──────────┘
  • local (default): Browser localStorage only. No server-side persistence.
  • sqlite: Auto-creates PVC. Single-writer — do not use with multiple replicas.
  • postgres: Two options:
    • postgresql.enabled=true → Deploys Bitnami subchart, auto-wires STORAGE_POSTGRES_URL
    • secrets.storagePostgresUrl → External PostgreSQL connection

Auto-wiring logic (_helpers.tpl):

if postgresql.enabled AND storageProvider == "local":
    effective storageProvider = "postgres"    # auto-switch

4. PostgreSQL Subchart Integration

When postgresql.enabled=true:

┌──────────────────────┐      ┌───────────────────────┐
│  LibreDB Studio Pod  │      │  PostgreSQL Pod        │
│                      │      │  (Bitnami subchart)    │
│  STORAGE_POSTGRES_URL├─────►│  :5432                 │
│  = postgresql://     │      │                        │
│    libredb:$PASS@    │      │  Secret:               │
│    <release>-pg:5432 │      │  <release>-postgresql   │
│    /libredb_storage  │      │                        │
└──────────────────────┘      └───────────────────────┘

Subchart secret name follows Bitnami convention: <release-name>-postgresql (not <release>-<chart>-postgresql).

5. Secret Management

┌─────────────────────────────────────────────┐
│            secrets.existingSecret            │
│                                             │
│  Set?  ──Yes──► Use external secret         │
│    │            (Vault/Sealed Secrets/ESO)   │
│    No           Skip secret.yaml rendering   │
│    │                                         │
│    ▼                                         │
│  secret.yaml rendered with:                  │
│  - strict (authBootstrap=off): required      │
│    jwtSecret, adminPassword                  │
│  - zero-config (default): only provided      │
│    values are written, missing ones are      │
│    generated by the app on first start       │
│  - optional: llmApiKey, oidcClientId/Secret, │
│              storagePostgresUrl              │
└─────────────────────────────────────────────┘

existingSecretKeys allows custom key name mapping for external secrets.

6. ConfigMap / Environment Variables

Most non-sensitive configuration flows through a ConfigMap (the seed-connection vars are the exception — see the note below the table):

Variable Source Conditional
NODE_ENV Fixed production Always
PORT service.targetPort Always
HOSTNAME config.bindAddress — empty by default, which is the "let the image resolve it" sentinel; extraEnv overrides it Always
NEXT_TELEMETRY_DISABLED Fixed 1 Always
NODE_OPTIONS Fixed --max-old-space-size=384 Always
NEXT_PUBLIC_AUTH_PROVIDER authProvider Always
LOG_LEVEL config.logLevel Always
AUTH_BOOTSTRAP config.authBootstrap When non-empty (chart default: "" — omitted)
STORAGE_PROVIDER Auto-wired (see above) Always
STORAGE_SQLITE_PATH config.storageSqlitePath When sqlite
PASSKEY_ORIGIN config.passkeyOrigin When non-empty (chart default: "", omitted)
SEED_CONFIG_PATH / SEED_CACHE_TTL_MS seedConnections.* — set directly on the Deployment (not via the ConfigMap) When seedConnections.enabled
LLM_PROVIDER/MODEL/API_URL config.llm* When set
OIDC_* config.oidc* When authProvider=oidc; OIDC_USER_ROLES, OIDC_PROMPT, OIDC_AUTO_REDIRECT and OIDC_LOGOUT_HINT only when set
AUTH_SESSION_TTL_SECONDS config.sessionTtlSeconds When set
STORAGE_POSTGRES_SCHEMA config.storagePostgresSchema When non-empty
GOVERNANCE_* governance.* (section 11) When governance.enabled

"Fixed" above describes what the ConfigMap writes, not what the container ends up with: the ConfigMap arrives through envFrom, extraEnv renders into the container's env: list, and an explicit env entry always wins over an envFrom key of the same name. Any row above can therefore be overridden through extraEnv, though only some are safe to: PORT is rendered from service.targetPort and also drives the container's named http port and all three probes, so overriding it through extraEnv alone moves the app off the port the probes still target and the pod never becomes Ready. HOSTNAME is the one row whose ConfigMap value is deliberately empty. Empty is a sentinel, not an omission: it means "nobody chose", and the container's entrypoint then resolves its own bind address, preferring :: — which it proves is dual-stack by connecting an IPv4 client to a throwaway :: listener, rather than inferring it from a sysctl. It falls back to 0.0.0.0 when the namespace has no IPv6, or when :: really is IPv6-only while a non-loopback IPv4 address exists. The key is written rather than omitted so that a pod name injected as HOSTNAME by the runtime can never reach the server; config.bindAddress overrules the resolver:

config:
  bindAddress: "0.0.0.0"   # or "::" to force dual-stack; "" (default) resolves it

Dual-stack is one setting now, not two. service.ipFamilyPolicy (PreferDualStack / RequireDualStack) and service.ipFamilies give the Service an IPv6 address, and that is all an operator has to set — the pod listens on both families by default. What an operator must not do is the inverse pairing: config.bindAddress (or an extraEnv HOSTNAME) pinned to an IPv4 literal alongside a dual-stack Service. Kubernetes never inspects what the container bound, so such a Service populates an IPv6 EndpointSlice from the pod's IPv6 address and kube-proxy routes IPv6 traffic to a socket that is not there — the client gets connection refused. It never self-heals and it is invisible to the probes, because the kubelet probes the pod's primary IP (IPv4 on a typical IPv4-primary cluster), so the pod reports Ready while its IPv6 path is dead. NOTES.txt warns on exactly that combination at install time. Values reference: the chart README.md.

The chart follows the application's zero-config default (config.authBootstrap: "" omits AUTH_BOOTSTRAP, so missing JWT_SECRET/ADMIN_PASSWORD are generated at first boot): a default-values install is fully working, which certified catalogs such as the Rancher partner-charts repository require. The architectural consequence for this document is that the deployment may only reference Secret keys that actually exist — the env entries for JWT_SECRET, ADMIN_PASSWORD, USER_EMAIL, USER_PASSWORD render only when their value is set or an existingSecret is used, and a mandatory secretKeyRef is reserved for the combinations where a missing key really is an error (strict mode with authProvider=local, and secrets.oidcExistingSecret, whose OIDC_CLIENT_ID and OIDC_CLIENT_SECRET references are mandatory on purpose, so a missing Secret stops the pod instead of starting Studio without its client credentials).

The behaviour itself — what gets generated, how to retrieve the credentials, what strict mode requires per auth provider, and the single-replica constraint — is documented once, in the chart's README.md. Treat that section as canonical and do not restate it here.

For the complete, authoritative list of configurable values and defaults, see the chart's own README.md. This document covers architecture and rationale; the chart README is the values reference.

7. Pod Restart on Config Change

Deployment annotations include checksums of ConfigMap and Secret:

annotations:
  checksum/config: {{ sha256sum configmap.yaml }}
  checksum/secret: {{ sha256sum secret.yaml }}

Any change to configuration values triggers a rolling restart automatically.

With a volume claim on /app/data (persistence.enabled, or config.storageProvider=sqlite, which turns it on) and one replica, that restart stops the old pod before it creates the new one, which then waits for the claim: the Deployment gets maxSurge: 0 and maxUnavailable: 1. The Kubernetes default keeps the old pod until the new one is ready, and the new one can never mount an exclusive claim the old one holds, a ReadWriteOnce volume attached to another node (Longhorn, a cloud block volume) or a ReadWriteOncePod claim, so the rollout waits until helm --wait gives up. Every Rancher-mode install has that shape, one replica with persistence on. The cost is a short outage on each upgrade or restart while the new pod starts. The type stays RollingUpdate: the API server defaults rollingUpdate on a Deployment the chart already installed, and a server-side apply (Helm 4, Rancher's Apps page) cannot remove a field no manager owns, so an upgrade to Recreate is refused. More than one replica shares the claim, which needs ReadWriteMany, and keeps the default; tests/unit/helm-chart-rollout-strategy.test.ts pins both.

8. Seed Connections

When seedConnections.enabled=true, the chart provisions a set of pre-defined database connections at startup:

  • You must supply the definitions via either inline seedConnections.config (rendered into seed-configmap.yaml) or an existingConfigMap. Enabling the feature with neither fails the render with an explicit message (a template guard in deployment.yaml) — previously the Deployment shipped mounting a ConfigMap that was never rendered, and the pods failed at startup instead.
  • The deployment mounts the ConfigMap at /app/config/<key>, where <key> is seedConnections.configMapKey (default seed-connections.yaml), and sets SEED_CONFIG_PATH to that path (plus SEED_CACHE_TTL_MS from seedConnections.cacheTTL). These two env vars are set on the Deployment directly, not through the app ConfigMap.
  • Credentials referenced by the seed config resolve from environment/secret at runtime, so secrets stay out of the ConfigMap.

9. Agent Runtime

The agent has no on-switch: the app derives whether it can run from a configured model plus a writable ledger (docs/AGENT.md), so the chart's job is to write less, not more.

  • agent.enabled unset — the default — writes no LIBREDB_AGENT_ENABLED at all, leaving the derivation intact; false writes the off-switch (quoted, because EnvVar.value is a string); true is accepted and explicit.
  • The chart does write one WORKFLOW_* variable: WORKFLOW_LOCAL_DATA_DIR=/app/data/workflow, inside the volume mounted on /app/data in every render. The reason is release topology, not preference. image.tag defaults to .Chart.AppVersion, which now names an image whose Dockerfile carries the same default, but that default landed in the Dockerfile only after the 0.11.0 tag, so an install pinned to an older image.tag still resolves an unset variable to .workflow-data under WORKDIR /app, where readOnlyRootFilesystem: true makes it unwritable and resolveAgentLedgerDirectory's probe answers LEDGER_UNAVAILABLE. The chart therefore keeps writing the path itself. Both copies name the same path and tests/unit/helm-chart-agent.test.ts asserts they stay equal, so the duplication cannot drift; it is written before extraEnv, so an operator can still move the ledger.
  • A release where an agent could run and more than one replica is asked for fails the render (a template guard in deployment.yaml, same shape as the seed guard above). The zero-config ledger takes file locks and each pod mounts its own /app/data, so a run started on one pod is invisible to the next request; the only backend that lifts the constraint (@workflow/world-postgres) is not loadable in the published image (B16 in docs/BACKLOG.md), which the message says rather than selling an opt-in that does not exist yet. "Could run" is read conservatively from these values: an inline secrets.llmApiKey, config.llmProvider set to one of the key-optional providers (ollama, custom — the app's validateConfig demands a key only for gemini and openai), or an explicit agent.enabled=true. So an existing multi-replica install that configures no AI keeps rendering. The guard's blind spots are secrets.existingSecret, extraEnvFrom and extraEnv, none of which a template can read a model out of; all three are listed in the chart README and in the helper's own comment.
  • Run history lives in that ledger, so persistence.enabled=false means an emptyDir and a history that goes with the pod. values.yaml, the chart README and NOTES.txt all say so.

10. MCP Server

The MCP server is off by default, and the chart's job is to write nothing until the operator asks (docs/MCP.md).

  • mcp.enabled: false, the default, writes no LIBREDB_MCP_* variable, so an install that never mentions MCP renders exactly as before, and the endpoint answers an authenticated request with 404.
  • mcp.enabled: true writes LIBREDB_MCP_ENABLED, LIBREDB_MCP_URL, LIBREDB_MCP_TOKEN_LABEL and LIBREDB_MCP_TOKEN_TTL_DAYS, each quoted, because EnvVar.value is a string; they are written before extraEnv, so an operator can still override one.
  • An enabled block without mcp.url or mcp.tokenLabel fails the render and names the value, because the app accepts no token without either, and only the operator knows the public address, its TLS termination and its basePath.
  • The block is read through default dict, so a values set that predates it, as an upgrade with --reuse-values carries, keeps rendering; tests/unit/helm-chart-mcp.test.ts pins all four rules.

11. Governance

Governance is off by default, and the chart writes no GOVERNANCE_* variable, mounts no keyring and keeps readiness on /api/db/health until governance.enabled is true (GOVERNANCE.md). Enabled, the chart wires the environment that Studio configuration describes:

  • The ConfigMap carries GOVERNANCE_SOURCE=postgres, GOVERNANCE_POSTGRES_SCHEMA (default bridge), GOVERNANCE_PRIVATE_KEYS_FILE and, only when set, GOVERNANCE_REFRESH_MS, GOVERNANCE_MAX_STALENESS_MS and GOVERNANCE_ALLOWED_ENGINES.
  • GOVERNANCE_POSTGRES_URL is a mandatory secretKeyRef to the chart's Secret or to secrets.existingSecret, under the mapped key governancePostgresUrl (default governance-postgres-url).
  • The keyring comes from the same Secret, under the mapped key governancePrivateKeys (default governance-private-keys), as one file at /etc/libredb/governance/keyring.json in a read-only volume with defaultMode 0400, never as an environment variable. The file is root-owned, and the kubelet adds group read for podSecurityContext.fsGroup, which is how uid 1001 reads it, so the render fails without an fsGroup unless the OpenShift adaptation assigns one.
  • The readiness probe moves to /api/ready, and liveness and startup stay on /api/db/health, so a governance source that is down never gets a healthy pod restarted.

An installer therefore writes two Secret keys beyond the usual ones, governance-postgres-url and governance-private-keys.

Instance id. GOVERNANCE_INSTANCE_ID is governance.instanceId, or the chart's fullname when it is empty, so a rollout or a recreated pod updates the same heartbeat row. With more than one replica each pod reports its own name through the downward API, because one shared id would merge the heartbeats of different pods, and a set governance.instanceId then fails the render.

Render guards. A values set the boot preflight would refuse fails the render instead, naming the value: authProvider other than oidc, an empty config.oidcRoleClaim, config.oidcUserRoles or config.storagePostgresSchema, a governance URL or keyring with neither an inline value nor secrets.existingSecret, seedConnections.enabled, mcp.enabled, agent.enabled=true or config.allowCustomConnections=true, a set secrets.adminPassword or secrets.userPassword, or an extraEnv entry whose name starts with GOVERNANCE_. Under governance the chart also takes no ADMIN_PASSWORD, USER_EMAIL or USER_PASSWORD from secrets.existingSecret.

Replicas. Studio keeps interactive transactions, rate limits and per-user providers in process memory, so replicaCount above 1 or autoscaling.enabled renders only with stickySessions.enabled=true. That value only records that the operator configured cookie affinity at the ingress or load balancer: the chart renders no affinity annotation and has no cookie name value, because the affinity belongs to the ingress controller.

tests/unit/helm-chart-governance.test.ts pins these rules, and the block is read through default dict, so a values set that predates it keeps rendering.

12. Rancher mode

A Rancher-mode install sets values that every other install leaves alone, and the default install renders none of their objects. The first half wires custom connections, literal seed values, a seed Secret, the OIDC client Secret and the NavLink; the second half, accessReview and discovery.kubernetes, renders the Kubernetes access review and the discovery exporter (chart README, SEED_CONNECTIONS.md).

  • The access review's only right is create on subjectaccessreviews, through a ClusterRole and binding named <namespace>.<fullname>-access-review, cluster-scoped like the NavLink, so the namespace in the name keeps two releases apart. The reviews use a projected token of Studio's ServiceAccount with the cluster's CA from kube-root-ca.crt, so the pod's own token mount stays off and the token never outlives accessReview.tokenExpirationSeconds.
  • The exporter is a second Deployment from Studio's image with a ServiceAccount of its own, so Studio's process never holds a token that can read a database Secret. Its pods carry the name <name>-discovery, with the name cut to 53 characters before the suffix, and the component discovery-exporter, so Studio's Service, PodDisruptionBudget and NetworkPolicy, which select on Studio's name, never match them. Its Role and RoleBinding in each discovered namespace are named <namespace>.<fullname>-discovery, so two releases of one name may discover the same namespace.
  • With networkPolicy.enabled, the review opens egress on 6443 as well as 443: a policy is matched after kube-proxy rewrites kubernetes.default.svc to the API server's endpoint, which listens on 6443 on RKE2, k3s and kubeadm.
  • The export Secret is rendered with no data field: Helm's three-way merge then leaves what the exporter wrote alone on every upgrade, and the exporter, whose Role is limited to that name, never has to create it.
  • Studio mounts the whole export Secret rather than its export.json key, because the Secret starts with no key and a projection of a missing key would keep the pod from starting.

Render guards. rancherModeGuards in _helpers.tpl refuses every values set the access review's boot check refuses (src/lib/kubernetes-access/preflight.ts), the namespace's default ServiceAccount or the exporter's as the review's account, an accessReview.signIn Studio cannot parse, discovery with no namespace or under governance, an extraEnv entry that would override a variable the chart writes for either, and more than one replica or autoscaling with either on, since the groups record and the caches live in one process. With the review on the chart writes no ADMIN_PASSWORD, USER_EMAIL or USER_PASSWORD from secrets.existingSecret, as under governance.

Apps form. questions.yaml asks for the Rancher-mode values only; Rancher sets each answer at a dotted path, so a list entry such as the ingress host is set in the form's YAML view. tests/unit/helm-chart-rancher-questions.test.ts holds every question to a key of values.yaml with the same type and default.

OLM operator. Rancher mode is not offered through the operator in this release: its role grants no ui.cattle.io NavLink, no ClusterRole or ClusterRoleBinding and no read on CloudNativePG Clusters, so it cannot create the access review's binding, and the API server refuses the exporter's Roles as an escalation, since they grant a read the operator does not hold. DISTRIBUTION.md and the chart README send Rancher installs to Helm or Rancher's Apps page.

tests/unit/helm-chart-rancher-mode.test.ts, helm-chart-rancher-access-review.test.ts and helm-chart-rancher-discovery.test.ts pin these rules.

13. OpenShift sign-in mode

authProvider: openshift renders what Studio's openshift sign-in reads (src/lib/openshift-auth/config.ts), keyed on that one value rather than a separate switch (chart README).

  • Studio's ServiceAccount is the OAuth client: the chart annotates it with the redirect reference naming the Route and the callback path under config.basePath, and writes OPENSHIFT_OAUTH_CLIENT_ID as system:serviceaccount:<namespace>:<account>. So it must be an account the chart creates and never the namespace's default.
  • The client secret is a projected token of that account with the audience libredb-studio-oauth-client, which the API server does not accept, beside the cluster's CA from kube-root-ca.crt, which also signs the OAuth route's certificate. Both are mounted read-only at /var/run/secrets/libredb/openshift; the pod's own token mount stays off.
  • The admin target of the self review is Studio's Deployment for a Helm install and the LibreDBStudio resource, named after the release, under the operator; no ServiceAccount review variable, ClusterRole or system:auth-delegator binding renders, because every review runs with the signed-in user's own token.
  • The chart writes no session lifetime, review cache or SEED_CACHE_TTL_MS unless a value asks, so the app's defaults for the mode (28800 s, 15 s and a 10 s list refresh in the browser) stand and the chart never restates them.
  • With discovery on, ALLOW_CUSTOM_CONNECTIONS defaults to false and the NetworkPolicy renders unless networkPolicy.enabled is false, confining the database ports to the discovered namespaces, plus the release namespace while the bundled PostgreSQL holds the storage; 5353 and 6443 render whenever the mode is on, not only when the OpenShift API is detected. Only the six database ports carry a destination: DNS, 443 and 6443 stay open to every address, and a policy matches after the Service rewrite, so a principal who may write EndpointSlices in a discovered namespace could still send Studio elsewhere on those ports. The policy also confines ingress to TCP service.targetPort plus networkPolicy.additionalIngress, as it does in every mode. The default is unset (null), so an upgrade with --reuse-values from a chart that carried false keeps the opt-out; the install notes warn when the policy is off in this mode.

Render guards. openshiftModeGuards in _helpers.tpl, included in deployment.yaml right after authProviderGuard, refuses every values set the openshift preflight refuses or the mode cannot run, naming the value: no Route or no fixed Route host, an account the chart does not create, more than one replica, an identity provider role claim, MCP, the agent, the ServiceAccount review's own values, inline passwords, governance, a hand-wired openshift-oauth volume or mount, a mounted API token or, with discovery on, the exporter's account as Studio's, an OAuth redirect annotation beside the chart's two, under the operator an extraEnv admin target, and an effective image tag that is a version at or below 1.1.0, the last release without the mode. Three guards hold in every sign-in mode: authProviderGuard refuses an extraEnv NEXT_PUBLIC_AUTH_PROVIDER naming another mode than authProvider, which every guard keys on, discoveryOnOpenShiftGuard refuses discovery with oidc sign-in where config.openshift.io/v1 is served, and under operator.managed the discovery template, the ConsoleLink, the access review's ClusterRole and binding and the Rancher NavLink render a cross-namespace or cluster-scoped object only for the release in operator.clusterStudioNamespace (clusterStudioNamespaceGuard), which the operator sets to its own namespace, the only one it reconciles. Under operator.managed, operatorServiceAccountGuard also refuses serviceAccount.create=false, because Studio's pod shares the operator's namespace and must never run as another account there.

ConsoleLink. consolelink.yaml renders the Tier 0 link only where console.openshift.io/v1 is served or forced, and only for a host the release's Route or Ingress serves; with discovery on, the ApplicationMenu link is the locator, named libredb-studio with the label app.kubernetes.io/name: libredb-studio, and any other link takes the release-derived name <namespace>.<fullname> without that label. No reader role ships: the console already lets every authenticated user read ConsoleLinks.

tests/unit/helm-chart-openshift-mode.test.ts pins these rules.

14. OpenShift console plugin

openshift.consolePlugin renders the server of the OpenShift console plugin and its cluster-scoped ConsolePlugin from console-plugin.yaml (chart README). The plugin, built in its own repository, lists the database resources the signed-in user may list and opens one in Studio, which it finds through the locator ConsoleLink libredb-studio; it reads no Secret, and Studio reviews again with the user's own token.

Values. enabled (off), namespace (where the server runs, empty by default), image (a whole reference pinned by digest; the schema refuses a tag alone and admits the empty string so the guard can name the value), imagePullPolicy, replicas (1 to 5) and resources (requests and limits with cpu and memory only). The block is closed: any other key, an enable switch or a patcher image included, fails the schema. Under the operator, operator/watches.yaml pins namespace to the operator's own namespace ($WATCH_NAMESPACE), which is the cluster Studio namespace, and image to $RELATED_IMAGE_PLUGIN, so a LibreDBStudio can neither move the server nor swap its image.

Objects. A ServiceAccount, Deployment, Service and NetworkPolicy named libredb-studio-console-plugin in the plugin namespace, and the ConsolePlugin libredb-studio with a Service backend on 9443, i18n.loadType: Preload, and no proxy and no contentSecurityPolicy. The names are fixed whatever nameOverride or fullnameOverride say: the ConsolePlugin is a cluster-wide singleton, so a second release collides on it and Helm's ownership check refuses the install. The Deployment runs as its own ServiceAccount with no token, with the restricted security context, a read-only root, the service CA's serving certificate read-only at /var/cert and an emptyDir at /tmp, and no command, arguments or environment: the image bakes its nginx configuration in, which logs no request header.

Guards. consolePluginGuards in _helpers.tpl, in this order, each naming the value to change:

  1. console.openshift.io/v1 not served and global.compatibility.openshift.apis not force.
  2. Under the operator, a release outside operator.clusterStudioNamespace (clusterStudioNamespaceGuard).
  3. An empty namespace; under the operator an unset WATCH_NAMESPACE empties operator.clusterStudioNamespace too, so guard 2 answers first.
  4. On the Helm path, the release's own namespace. Under the operator, any namespace other than operator.clusterStudioNamespace, the operator's own namespace and, after guard 2, the release's: the server shares that namespace with the cluster Studio, which only cluster admins write.
  5. A namespace discovery.kubernetes.namespaces lists.
  6. On both paths, the plugin namespace, and on the Helm path also the release's namespace, when lookup finds it carrying openshift.io/requester, which the default project request template sets on every project made through a project request and whose requester administers it; skipped where lookup returns nothing (helm template, a client-side dry run, a namespace that does not exist yet), and blind to a requested project whose custom project request template drops that annotation. With the plugin on, the release's namespace falls under the cluster Studio namespace rule (docs/OPENSHIFT.md): only cluster admins may hold RoleBindings there. Under the operator the two are one namespace, the operator's own, and the operator's ClusterRole already holds get on namespaces.
  7. A release that renders no locator: discovery off, or no ApplicationMenu console link.
  8. An empty image (and under the operator, an unset RELATED_IMAGE_PLUGIN).
  9. A nameOverride, fullnameOverride or release name that gives Studio the server's name libredb-studio-console-plugin: Studio's selector labels would then match the server's pods, and under the operator its objects would take the server's names in the same namespace.

Guards 4 to 6 share one reason: the console sends every console user's token to the server and runs the plugin in every console session, so whoever may change that Deployment holds both, and the namespace must be one only cluster admins write. On the operator path that is the operator's namespace itself: whoever writes it already controls the operator, Studio's ServiceAccount there holds no right on the server's objects, Studio's pod mounts no API token by default, and, by guard 9, the server's selector labels match none of Studio's.

NetworkPolicy. The server's policy selects exactly its pods and admits only the openshift-console namespace on TCP 9443, with no egress rule: nginx needs nothing else, and while no other NetworkPolicy and no AdminNetworkPolicy allow rule selects those pods (policies add up), a compromised server or image cannot send what it receives anywhere.

No enable Job. The chart renders no Job, ClusterRole or ClusterRoleBinding for the plugin and offers no value that would. A hook Job that patches consoles.operator.openshift.io would need a hook ServiceAccount bound to patch on the console configuration, and the Helm the console embeds before v3.19.0 (v3.17 on 4.19, v3.18 on 4.20 and 4.21) deletes only the failed hook when a later hook fails, leaving that binding behind, where helm uninstall never removes it. Turning the plugin on is a one-time cluster-admin act that changes every console session, so a cluster admin does it on the console's Console plugins page or with the guarded patch the install notes print, and the operator path offers the install form's toggle.

tests/unit/helm-chart-openshift-plugin.test.ts pins these rules.

Release Pipeline

The chart never releases before the image it deploys exists (#161), and the whole flow is compatible with the repository's immutable-releases policy (draft-first, #154/#155/#158). For a product release, docker-build-push.yml dispatches helm-release.yml on the release tag ref after the image publish succeeds - the chart is deliberately published from the tag's snapshot (the same commit as the image), not from main HEAD; a chart-only fix merged in between publishes separately via its own charts/** push run.

Push to main (charts/** changed)   OR   dispatched by docker-build-push
  │                                     after a successful image publish
  ▼
┌─────────────────────────────────────────┐
│  helm-release.yml                       │
│                                         │
│  Job 0: preflight gates                 │
│    ghcr image <appVersion> published?   │
│    ├── yes → proceed                    │
│    ├── no + push event → green no-op    │
│    │   (the release chain re-dispatches │
│    │    after the image is published)   │
│    └── no + dispatch → fail fast        │
│                                         │
│    chart <version> already released     │
│    with its .tgz? → skip jobs 2 and 3   │
│                                         │
│  Job 1: lint-test                       │
│    ├── ct lint (chart-testing)          │
│    ├── Kind cluster create              │
│    └── ct install (real cluster test)   │
│                                         │
│  Job 2: release-github-pages            │
│    ├── helm dependency build            │
│    ├── draft release + .tgz upload,     │
│    │   publish last (immutable-safe)    │
│    └── helm repo index --merge → push   │
│        gh-pages index.yaml              │
│                                         │
│  Job 3: release-oci                     │
│    ├── helm dependency build            │
│    ├── helm package                     │
│    └── helm push → ghcr.io/libredb/charts│
└─────────────────────────────────────────┘
  │
  ▼
ArtifactHub auto-scan (~30 min)

Do not manually dispatch helm-release.yml for a new appVersion before its image is on GHCR - the gate fails fast by design. A failed image build dispatches nothing, so the chart correctly stays unpublished. The gate queries GHCR anonymously and relies on the image package being public.

The second preflight gate protects the published surfaces from re-publication (#167). Immutable releases freeze the release asset, but the gh-pages index and the OCI tag are mutable: both publish jobs re-package whatever charts/** currently holds, so a charts/** merge that changed chart content without bumping the chart version used to rewrite the released version's index digest and its OCI copy while the asset kept the original bytes. When the release for libredb-studio-<version> already exists, is published, and carries its .tgz, jobs 2 and 3 are skipped and the run is a full no-op after lint-test. A missing release, a leftover draft, or a published release without its asset all still publish - the last one so the release job's loud immutability error (#154) is what the run reports. Publishing chart changes therefore always means bumping version:; bun run chart:check now refuses the un-bumped state at PR time, so this gate is the backstop rather than the first line of defence.

The gate has one escape hatch, the force_republish dispatch input, for the single case a version bump cannot fix: a run whose asset upload succeeded but whose index or OCI push failed, leaving a released version missing from the index (this happened to chart 0.1.5 and was hand-patched at the time). Dispatch helm-release.yml with force_republish=true on the released version's ref and only while charts/** is byte-identical to what that version shipped - otherwise it does exactly the damage the gate exists to prevent. The release chain's automated dispatch passes no inputs, so it can never take this path.

Version Management

  • Chart.yaml version (e.g., 0.1.4): the chart's own SemVer. Chart-only fixes bump it alone; bun run chart:bump bumps its patch when tracking an app release.
  • Chart.yaml appVersion: the app image version this chart deploys. Always equals package.json version — enforced in CI.
  • Guard: scripts/sync-chart-version.mjs runs as bun run chart:check inside the required Lint, Typecheck and Build check (ci.yml), so a PR that bumps package.json cannot merge until bun run chart:bump is run and committed (issue #138). The guard also fails when appVersion changes without a chart version bump (chart-releaser's skip_existing would silently publish nothing) or when the new chart version was already released, and it keeps the artifacthub.io/images tag and the README --version example in step. The artifacthub.io/changes line is written by chart:bump but deliberately not checked, so hand-written changelog entries for chart-only releases never trip the guard.
  • Guard (#167): a PR that changes any packaged file under charts/libredb-studio/ while leaving the chart version at an already-released value fails the same check - re-publishing that version would mutate its gh-pages/OCI digest. Fix it by bumping version: (and the README --version examples) by hand: chart:bump only moves the chart version when appVersion is out of sync, so a chart-only content change needs the manual bump. Paths matched by the chart's .helmignore (ci/) are excluded because they never reach the packaged .tgz, and a version that has not been released yet stays freely editable.
  • Base comparisons read main's Chart.yaml at the merge-base of HEAD and origin/main, so a release merged to main after your branch point cannot false-positive the already-released check on a stale branch (issue #151); in shallow checkouts with no computable merge-base (CI's depth-1 fetch), the origin/main tip is used as before, which is effectively exact on PR merge refs. CI sets CHART_SYNC_STRICT=1 (ci.yml), which turns the guard's skip-and-warn paths (origin/main not resolvable, origin tags unreachable) into hard failures; unset locally, they stay warnings so offline runs still work.

Versioning policy: version and appVersion stay independent (researched 2026-07)

Lockstep (version == appVersion, the cert-manager model) was considered and rejected: it only works when chart-only releases never happen. This chart still has an active chart-only backlog, and under lockstep each such fix would either wait for the next product release or force an artificial one through the full distribution pipeline (npm, Docker, brew, deb/rpm, snap) — irreversible now that immutable releases are enabled. SemVer prerelease suffixes are not an escape hatch (they sort below the version they suffix and Helm hides prereleases by default), and kubernetes-sigs/kueue abandoned lockstep for the same reasons (kueue#3971). Consumers still see the app version everywhere it matters: the APP VERSION column in helm search repo, ArtifactHub, and the artifacthub.io/images annotation. Revisit lockstep if the chart reaches 1.0 and chart-only churn drops.

Two tag series means some third-party catalogs read the wrong version, and that is their bug rather than ours. helm-release.yml publishes every chart release with gh release edit --latest=false, so GitHub's own answer stays correct: the repository page, the REST releases/latest endpoint and the GraphQL latestRelease field all name the application release. A consumer that instead takes the first element of the unordered releases list gets whichever release was published most recently, which is a chart release about half the time. Measured on 2026-09-19: releases(first: 1) returned libredb-studio-0.1.65 while latestRelease returned 0.16.0, which is why awesome-selfhosted listed our current release as 0.1.65. Reported upstream as nodiscc/hecat#163, with the measurement that 51 of 435 sampled entries in that data set are wrong for the same reason, mostly prereleases and nightlies rather than second tag series. When a catalog shows a chart version as the application version, send the catalog's maintainer to latestRelease; do not "fix" it by marking chart releases as prereleases, which would be false and would hide them from helm search consumers.

Deployment Examples

Minimal (port-forward)

Any cluster works. For a local throwaway one, kind create cluster or k3d cluster create is enough, and kind delete cluster or k3d cluster delete removes it afterwards.

helm repo add libredb https://libredb.org/libredb-studio/
helm install libredb libredb/libredb-studio \
  --set secrets.jwtSecret=$(openssl rand -base64 32) \
  --set secrets.adminPassword=MyAdmin123
kubectl port-forward svc/libredb-libredb-studio 3000:80

Production (Ingress + PostgreSQL + HPA)

helm install libredb libredb/libredb-studio \
  --set secrets.jwtSecret=$(openssl rand -base64 32) \
  --set secrets.adminPassword=StrongPass123 \
  --set postgresql.enabled=true \
  --set postgresql.auth.password=pg-secret \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set "ingress.hosts[0].host=libredb.example.com" \
  --set "ingress.hosts[0].paths[0].path=/" \
  --set "ingress.hosts[0].paths[0].pathType=Prefix" \
  --set "ingress.tls[0].secretName=libredb-tls" \
  --set "ingress.tls[0].hosts[0]=libredb.example.com" \
  --set config.passkeyOrigin=https://libredb.example.com \
  --set autoscaling.enabled=true \
  --set podDisruptionBudget.enabled=true

Gateway API instead of an Ingress

helm install libredb libredb/libredb-studio \
  --set secrets.jwtSecret=$(openssl rand -base64 32) \
  --set secrets.adminPassword="$ADMIN_PASSWORD" \
  --set route.main.enabled=true \
  --set "route.main.parentRefs[0].name=traefik-gateway" \
  --set "route.main.parentRefs[0].namespace=traefik" \
  --set "route.main.parentRefs[0].sectionName=websecure" \
  --set "route.main.hostnames[0]=libredb.example.com"

route is a map of route names, so several routes can attach the same Service to different Gateways or listeners; main renders as <fullname>, any other name as <fullname>-<name>. Two constraints are enforced rather than documented:

  • An enabled route must set parentRefs or the render fails, naming the route at fault. The chart cannot default it - the Gateway to attach to is specific to each cluster's Gateway API install - and an HTTPRoute attached to no Gateway is accepted by the API server while routing nothing, so the failure replaces an install that succeeds with the app unreachable.
  • kind accepts only HTTPRoute (schema-enforced): only an HTTPRoute-shaped body is rendered, so any other kind would emit a manifest the API server rejects.

route.labels and route.annotations are shared across every enabled route, which makes labels and annotations reserved key names directly under route rather than route names. A per-route entry wins on a key collision; the chart's own labels win over both.

OpenShift

helm install libredb libredb/libredb-studio \
  --set secrets.jwtSecret=$(openssl rand -base64 32) \
  --set secrets.adminPassword="$ADMIN_PASSWORD" \
  --set openshiftRoute.enabled=true \
  --set openshift.trustedCABundle.enabled=true \
  --set openshift.topology.partOf=libredb \
  --set openshift.topology.runtime=nodejs

openshiftRoute.* renders an OpenShift Route with edge TLS and a 120s router timeout, openshift.trustedCABundle.enabled mounts the cluster's injected trusted CA bundle as NODE_EXTRA_CA_CERTS, and openshift.topology.* adds the console's Topology labels; every one is off by default, and the Route and the trusted CA bundle check that their OpenShift API is served. In a Helm install Topology groups the workloads by the Helm release, so openshift.topology.partOf shows as an application in the Application selector rather than as a group on the canvas. The chart README's "OpenShift" section covers the three exposure mechanisms, the timeout, the GitOps render with global.compatibility.openshift.apis=force, and helm test. A JSON parse error in the query UI after about 30 seconds means the router cut the request: openshiftRoute.timeout must exceed the query timeout, and an empty value leaves the router's 30s default in force.

With openshift sign-in, people sign in with their OpenShift account and no password is set:

helm install libredb libredb/libredb-studio \
  --set authProvider=openshift \
  --set openshiftRoute.enabled=true \
  --set openshiftRoute.host=studio-data.apps.example.com \
  --set openshift.consoleLink.enabled=true

The render refuses openshift mode while the effective image.tag is 1.1.0 or earlier, which the default appVersion, 1.2.0, is not; the chart README's "OpenShift sign-in" section lists what the mode renders and refuses, and section 13 above explains why.

External Secrets (Vault / ESO)

helm install libredb libredb/libredb-studio \
  --set secrets.existingSecret=my-vault-secret

Terraform (helm_release)

There is no Terraform module: the chart is the configuration surface, and the helm provider installs it directly. This uses the provider's 3.x syntax, where set and set_sensitive are lists.

terraform {
  required_providers {
    helm = {
      source  = "hashicorp/helm"
      version = "~> 3.0"
    }
  }
}

provider "helm" {
  kubernetes = {
    config_path = "~/.kube/config"
  }
}

variable "jwt_secret" {
  type      = string
  sensitive = true
}

variable "admin_password" {
  type      = string
  sensitive = true
}

resource "helm_release" "libredb_studio" {
  name             = "libredb"
  namespace        = "libredb"
  create_namespace = true
  repository       = "oci://ghcr.io/libredb/charts"
  chart            = "libredb-studio"

  set = [
    { name = "config.storageProvider", value = "sqlite" },
  ]

  set_sensitive = [
    { name = "secrets.jwtSecret", value = var.jwt_secret },
    { name = "secrets.adminPassword", value = var.admin_password },
  ]
}

Without version the release takes the latest chart; add version = "<chart version>" to pin it. storageProvider=sqlite creates the PVC on its own, so connections survive a pod restart without persistence.enabled. To source every secret from a Secret you manage (the Vault and External Secrets path), drop set_sensitive and set secrets.existingSecret instead. Any other value from values.yaml goes in set under the same name. terraform destroy removes the release and its PVC but leaves the namespace that create_namespace made.

Known Limitations

  1. SQLite + Multi-Replica: SQLite is single-writer. storageProvider=sqlite with replicaCount > 1 will cause write conflicts. Use postgres for multi-replica. With SQLite storage autoscaling.enabled is ignored: the HPA is not rendered (NOTES.txt warns) and the deployment falls back to replicaCount.
  2. ISR Cache: Next.js ISR cache is per-pod (emptyDir). Session-based app, so no impact.
  3. Chart appVersion: Not auto-bumped - a version-bump PR must run bun run chart:bump; the CI sync guard blocks the merge until appVersion equals package.json (see Version Management above).

Rate limiting, the Origin check, and the CSP escape hatch

Three Phase 1 security controls change what a multi-replica or reverse-proxied deployment has to configure, and all three are documented with copy-paste values in charts/libredb-studio/README.md.

The short version:

  • The built-in rate limiter is per process. The chart's default replicaCount: 1 makes that the whole deployment; anything above one replica must enforce the budgets at the ingress instead. This is a deliberate design decision, not a gap: the existing storage abstraction is a per-user blob store whose read-modify-write cycle cannot hold counters, and an ingress already has a rate limiter that works across replicas.
  • Studio refuses state-changing requests whose Origin host does not match its own. Set ALLOWED_ORIGINS (via the chart's extraEnv) to your public origin whenever the ingress rewrites Host without setting x-forwarded-host, or every action including login returns 403.
  • Studio's Content-Security-Policy is enforced, not report-only. If an upgrade breaks a resource served from a non-default origin, set CSP_REPORT_ONLY=true (also via extraEnv) to downgrade it without rebuilding the image while you identify the violated directive.