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
78 changes: 78 additions & 0 deletions .github/workflows/helm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Helm chart

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: helm-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
- name: Check Helm availability
run: helm version --short
- name: Lint and render contracts
run: |
helm lint charts/duckflight --strict --set image.tag=e2e --set auth.existingSecret=auth --set bootstrap.existingSecret=bootstrap
uv run --with ruff ruff check test/test_helm_chart.py
uv run --with ruff ruff format --check test/test_helm_chart.py
uv run test/test_helm_chart.py
uv run --with ruff ruff check test/kubernetes/*.py
uv run --with ruff ruff format --check test/kubernetes/*.py

e2e:
name: k3s / ${{ matrix.catalog }}
runs-on: ubuntu-24.04
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
catalog: [iceberg, ducklake]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
- name: Install checksum-verified k3d
env:
K3D_VERSION: v5.9.0
run: |
directory="$RUNNER_TEMP/k3d"
mkdir -p "$directory"
curl --fail --location --retry 3 "https://github.com/k3d-io/k3d/releases/download/$K3D_VERSION/k3d-linux-amd64" -o "$directory/k3d-linux-amd64"
curl --fail --location --retry 3 "https://github.com/k3d-io/k3d/releases/download/$K3D_VERSION/checksums.txt" -o "$directory/checksums.txt"
cd "$directory"
awk '$2 == "_dist/k3d-linux-amd64" {print $1 " k3d-linux-amd64"}' checksums.txt > selected-checksum.txt
test -s selected-checksum.txt
sha256sum --check selected-checksum.txt
chmod +x k3d-linux-amd64
ln -s k3d-linux-amd64 k3d
echo "$directory" >> "$GITHUB_PATH"
- name: Build checkout's server and locked E2E client
run: |
docker build --tag duckflight:e2e .
docker build --file test/kubernetes/Dockerfile --tag duckflight-e2e-client:local .
- name: Helm install and real lake queries on k3s
run: >-
uv run --no-project test/kubernetes/run.py
--catalog ${{ matrix.catalog }} --image duckflight:e2e
--artifacts "$RUNNER_TEMP/e2e-artifacts"
- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: k3s-${{ matrix.catalog }}-diagnostics
path: ${{ runner.temp }}/e2e-artifacts
if-no-files-found: ignore
retention-days: 7
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ RUN curl --fail --location --retry 3 https://raw.githubusercontent.com/duckdb/du
COPY docker/server.c docker/server.c
COPY LICENSE LICENSE

# Startup-loaded Iceberg needs librt, which is not a dependency of the CLI/core.
RUN mkdir -p /runtime/usr/local/bin /runtime/opt/duckflight /runtime/etc/ssl/certs \
/runtime/data /runtime/tmp \
&& cc -Os -s -Wall -Wextra -Werror docker/server.c -o /runtime/usr/local/bin/duckflight-server \
Expand All @@ -55,6 +56,7 @@ RUN mkdir -p /runtime/usr/local/bin /runtime/opt/duckflight /runtime/etc/ssl/cer
&& { ldd /build/cli/duckdb; ldd /build/duckflight.duckdb_extension; ldd /runtime/usr/local/bin/duckflight-server; } \
| awk '/=> \// {print $3} /^\t\// {print $1}' | sort -u \
| xargs -I '{}' cp --parents -L '{}' /runtime \
&& cp --parents -L /lib/"$(cc -dumpmachine)"/librt.so.1 /runtime \
&& printf 'duckflight:x:10001:10001::/data:/sbin/nologin\n' > /runtime/etc/passwd \
&& printf 'duckflight:x:10001:\n' > /runtime/etc/group \
&& chown 10001:10001 /runtime/data \
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ no data to export or copy.

- [Install and load](#install-and-load)
- [Docker](#docker)
- [Kubernetes](#kubernetes)
- [SQL API](#sql-api)
- [Authentication setup](#authentication-setup)
- [Observability](#observability)
Expand Down Expand Up @@ -215,6 +216,13 @@ To run the same real-client container checks locally:
uv run test/docker_smoke.py duckflight:local
```

## Kubernetes

For Iceberg or DuckLake, use the [DuckFlight Helm chart](charts/duckflight/README.md).
It deploys independent workers with in-memory DuckDB, external lake catalogs,
TLS/authentication and bootstrap Secrets, and ephemeral disk for spill. The chart
uses a Deployment with two replicas by default and creates no persistent volumes.

## SQL API

DuckFlight adds server-control table functions and, when the real core initializes, PostgreSQL
Expand Down
7 changes: 7 additions & 0 deletions charts/duckflight/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: v2
name: duckflight
description: Stateless DuckFlight query workers for external lake catalogs
type: application
version: 0.1.0
kubeVersion: ">=1.25.0-0"
home: https://github.com/sidequery/duckflight-extension
180 changes: 180 additions & 0 deletions charts/duckflight/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# DuckFlight on Kubernetes

This chart runs independent query workers: a Deployment, an internal TCP Service,
and a ServiceAccount. Each pod starts DuckDB in memory and bootstraps its lake
catalog before opening PostgreSQL and Flight SQL listeners. No PVC, StatefulSet,
local catalog, or shared DuckDB file is created. ZeroFS is out of scope.

Use an external Iceberg REST catalog, or a DuckLake PostgreSQL catalog with data in
object storage. Catalog provisioning, backups, and maintenance belong to those
systems. An operator is unnecessary for deploying these workers; reconsider one
if catalog lifecycle or lake maintenance needs reconciliation.

## Install

Prepare two existing Secrets in the deployment namespace:

* `duckflight-auth`: `duckflight.toml` and all relative certificate/key files.
Use the repository's authentication helper. Set certificate SANs to the actual
Service DNS names (for this example `lake-duckflight.analytics.svc` and
`lake-duckflight.analytics.svc.cluster.local`). TLS and authentication are
required even for internal cluster traffic.
* `duckflight-bootstrap`: `init.sql` with trusted startup configuration. It may
contain catalog credentials; keep it out of Git and Helm values. Mounting a
Secret instead of templating credentials also avoids Helm release storage.

Provision them through your secret manager, or from local protected files:

```sh
kubectl create namespace analytics
kubectl -n analytics create secret generic duckflight-auth \
--from-file=duckflight.toml=/absolute/path/duckflight.toml \
--from-file=server.crt=/absolute/path/server.crt \
--from-file=server.key=/absolute/path/server.key
kubectl -n analytics create secret generic duckflight-bootstrap \
--from-file=init.sql=/absolute/path/init.sql

helm upgrade --install lake ./charts/duckflight --namespace analytics \
--set image.tag="${DUCKFLIGHT_IMAGE_TAG:?Set a tested image commit tag}" \
--set auth.existingSecret=duckflight-auth \
--set bootstrap.existingSecret=duckflight-bootstrap
```

Set `image.tag` to a tested commit tag, or set `image.digest` to pin the manifest
digest. The chart requires an explicit image version. Iceberg requires
`librt.so.1`; the image built from this checkout includes it. Images built before
this fix may omit it. Override `image.repository` and
`image.tag` for an image with preinstalled lake extensions. The base image bundles
DuckFlight but not Iceberg, DuckLake, AWS, or PostgreSQL extensions. `INSTALL` in
startup SQL needs egress to the DuckDB extension repository and downloads them
into ephemeral `/data`; a custom image avoids those downloads on every cold start.

## Lake bootstrap

Load extensions, establish temporary DuckDB secrets, attach the catalog, and
allowlist object-storage prefixes in `init.sql`. The launcher subsequently locks
configuration and disables general external access. Loading an extension or
creating a view alone does not allow remote files: all required metadata/data
paths must remain accessible under that final policy. Never allowlist secret
mounts, `/`, or `/data` (which also holds installed extensions).

The following examples are templates; replace endpoints, catalog names, and
prefixes. Use the catalog's documented credential mechanism and validate it with
the DuckDB/extension versions in your chosen image. Add secrets before `ATTACH`.
These examples intentionally contain no reusable credentials.

### Iceberg

```sql
install httpfs;
load httpfs;
install iceberg;
load iceberg;

-- Create the catalog's temporary Iceberg secret and storage secrets here.
-- OAuth, vended credentials, and cloud-managed catalogs differ by provider.
attach 'warehouse' as lake (
type iceberg,
endpoint 'https://catalog.example.com',
read_only
);
set allowed_directories = ['s3://analytics-bucket/warehouse/'];
set max_temp_directory_size = '8GiB';
```

See [DuckDB Iceberg catalogs](https://duckdb.org/docs/stable/core_extensions/iceberg/iceberg_rest_catalogs).
For catalogs whose runtime requests are checked by DuckDB's filesystem policy,
allowlist their precise required URL prefixes too. Catalog API permissions and
object-store IAM remain authoritative; the path allowlist is not a read-only ACL.

### DuckLake

```sql
install httpfs;
load httpfs;
install postgres;
load postgres;
install ducklake;
load ducklake;

-- Create temporary PostgreSQL and scoped S3 secrets here.
attach 'ducklake:postgres:dbname=ducklake_catalog host=catalog.example.com sslmode=verify-full'
as lake (data_path 's3://analytics-bucket/ducklake/', read_only);
set allowed_directories = ['s3://analytics-bucket/ducklake/'];
set max_temp_directory_size = '8GiB';
```

Use a shared external PostgreSQL catalog, not a local DuckDB/SQLite metadata
file. See [DuckLake catalog choices](https://ducklake.select/docs/stable/duckdb/usage/choosing_a_catalog_database).
For workload identity, annotate the chart's ServiceAccount (or supply an existing
one), configure the provider's token projection, and load its DuckDB credential
provider during bootstrap. The chart does not create cloud roles or policies.

Read-only attachments are the initial recommendation for serving analytics.
Writes must target a catalog/table format that coordinates concurrent commits,
using matching catalog and storage permissions. Ordinary local tables are
pod-local and disappear on restart. More replicas increase concurrent query
capacity; they do not distribute one query across pods.

## Connections, scaling, and lifecycle

The Service balances connections. PostgreSQL sessions stay on their established
TCP connection. Flight tickets, transactions, and server-issued bearer sessions
belong to the originating pod: reuse the same gRPC channel for handshake, query,
and result fetches. Clients that open independent channels need routing affinity
at a suitable gateway. Do not treat the Service as a shared Flight session store.
After pod replacement clients must reconnect and create new sessions; queries
are not transparently migrated. Rolling upgrades have an overlap pod, so bootstrap
must be repeatable and must not run catalog migrations or destructive maintenance.

Tune `replicaCount`, `duckdb.memoryLimit`, `duckdb.threads`, `resources`, and
`spill.sizeLimit`. Spill is disk-backed `emptyDir`, not tmpfs; it counts toward
node ephemeral-storage pressure. Keep DuckDB's limit below the container limit
and set `max_temp_directory_size` below spill capacity in startup SQL. Schedule
workers onto nodes with enough local disk and object-store bandwidth. HPA is
deliberately not installed: scale-down must account for active sessions/queries.

Startup and readiness use a TCP probe of the last enabled listener. They detect
listener availability, not successful lake queries or catalog health. Kubernetes'
native gRPC probe does not support this listener's required TLS; the scratch image
has no shell or gRPC probe client. Use external TLS/authenticated synthetic queries
for end-to-end health. Liveness is omitted to avoid restarting workers solely
because an expensive query or catalog outage slows a probe.

The default 90-second termination window allows SIGTERM-driven listener shutdown.
Tune it above your configured listener drain time, allowing for both protocols;
queries exceeding the window may be interrupted. Secret contents are read on
startup. Rotate them using versioned Secret names in values, or explicitly roll
the Deployment after updating an existing Secret. The chart never reads Secret
contents into Helm templates.

Use TLS-aware PostgreSQL clients and Flight SQL at
`grpc+tls://lake-duckflight.analytics.svc:31337`. Keep the Service internal by
default. For external access use a TCP/TLS-capable gateway; PostgreSQL is not HTTP
and generic HTTP ingress is insufficient.

For the examples above, PostgreSQL clients should connect with database `lake`.
Use catalog-qualified names such as `lake.analytics.events` with both protocols;
this also avoids relying on the connection's default catalog/schema for Iceberg.
PostgreSQL's
database qualifier follows the client's selected database; an unrelated startup
database name such as `duckflight` is not an alias for qualified `lake.*` SQL.

## Validate

```sh
helm lint charts/duckflight --strict \
--set image.tag=e2e \
--set auth.existingSecret=auth --set bootstrap.existingSecret=bootstrap
uv run test/test_helm_chart.py
```

The Helm workflow also builds the checkout's server image and runs
[Kubernetes E2E tests](../../test/kubernetes/README.md) on k3s. Separate Iceberg and
DuckLake jobs deploy a real catalog and S3-compatible object store, install this
chart, and query catalog-backed Parquet through TLS PostgreSQL and Flight SQL.
They check both workers, visibility of new external commits, authentication and
secret-file isolation, and query/reconnect after worker replacement.

Before production, also confirm your provider-specific IAM credential refresh,
TLS names, ephemeral disk capacity, and drain behavior with your workload.
30 changes: 30 additions & 0 deletions charts/duckflight/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{- define "duckflight.name" -}}
{{- printf "%s-duckflight" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "duckflight.selectorLabels" -}}
app.kubernetes.io/name: duckflight
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{- define "duckflight.labels" -}}
{{ include "duckflight.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }}
{{- end -}}

{{- define "duckflight.serviceAccount" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "duckflight.name" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- required "serviceAccount.name is required when create=false" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}

{{- define "duckflight.image" -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:{{ required "image.tag or image.digest is required" .Values.image.tag }}
{{- end -}}
{{- end -}}
Loading
Loading