Skip to content

Add DuckLake round-trip + iceberg integration tests#569

Open
benben wants to merge 11 commits into
mainfrom
ben/k8s-ducklake-iceberg-tests
Open

Add DuckLake round-trip + iceberg integration tests#569
benben wants to merge 11 commits into
mainfrom
ben/k8s-ducklake-iceberg-tests

Conversation

@benben
Copy link
Copy Markdown
Member

@benben benben commented May 19, 2026

Summary

  • Extends the k8s integration suite (tests/k8s/ducklake_test.go) from "DuckLake catalog is attached" to round-trip writes through real MinIO, durability across worker pod restarts, and concurrent-writer correctness (exercising the PostHog DuckLake fork's conflict-retry path).
  • Adds the first end-to-end iceberg test against real AWS S3 Tables (tests/k8s/iceberg_test.go), hard-gated on env vars so PR CI is unaffected. A dedicated iceberg CI lane sets DUCKGRES_K8S_ICEBERG_TABLE_BUCKET_ARN against a persistent sandbox table bucket and gets real-AWS signal.
  • Adds the activation-layer regression net for fix(server): rotate iceberg secret alongside DuckLake on STS expiry #563 (duckdbservice/activation_test.go): on hot-idle reclaim with rotated STS credentials, RefreshIcebergSecret must fire alongside refreshS3Secret with the new creds. Inverse case (iceberg disabled) asserts the iceberg refresh is skipped. These run on every PR with no AWS dependency.

Why no LocalStack / moto / stub catalog for iceberg?

The DuckDB iceberg extension derives its endpoint from the table bucket ARN (ATTACH 'arn:aws:s3tables:<region>:...' (TYPE iceberg, ENDPOINT_TYPE 's3_tables')) and talks to s3tables.<region>.amazonaws.com directly. Every stub (LocalStack community, moto, REST-catalog substitutes) tests a different code path. The only way to gain high confidence in ENDPOINT_TYPE 's3_tables' is to point at real AWS.

LocalStack Pro emulates S3 Tables, but I avoided it per the task constraints.

Iceberg CI setup (one-time, per sandbox AWS account)

  1. Create one S3 Tables bucket in the sandbox account — persistent, reused across CI runs. Tests create tables with t_<unix_nano> suffixes and DROP TABLE in cleanup, so the bucket never accumulates state.
  2. Create one regular S3 bucket for DuckLake parquet (the worker attaches DuckLake alongside iceberg).
  3. Create an IAM role/user with s3tables:* on the table bucket and s3:* on the data bucket.
  4. Set CI secrets:
    • DUCKGRES_K8S_ICEBERG_TABLE_BUCKET_ARN
    • DUCKGRES_K8S_ICEBERG_REGION
    • DUCKGRES_K8S_ICEBERG_DATA_BUCKET
    • AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN)
  5. Run just test-k8s-integration in the iceberg lane. The test self-skips with a clear message in any job where these env vars are unset.

The persistent-bucket approach side-steps the 10-buckets-per-region quota, the ~30–60s per-run create/delete overhead, and the orphan-leak problem of bucket-per-run setups.

Test plan

  • go test ./duckdbservice/ — new activation tests pass locally (TestReuseExistingActivationRefreshesIcebergAlongsideS3 + TestReuseExistingActivationSkipsIcebergRefreshWhenDisabled).
  • go test -tags 'k8s_integration kubernetes' -c ./tests/k8s/ — k8s test package compiles with new files.
  • just test-k8s-integration against a fresh kind cluster — verify new DuckLake tests pass end-to-end.
  • Run TestK8sIcebergRoundTrip in the iceberg CI lane against the sandbox bucket — verify real-AWS round trip.
  • Confirm TestK8sIcebergRoundTrip skips cleanly with a clear message when AWS env vars are unset (PR CI behavior).

benben added 6 commits May 19, 2026 09:38
Extends the k8s integration suite from "DuckLake catalog is attached" to
"DuckLake actually serves writes/reads through real MinIO, survives a
worker restart, and the fork's conflict-retry path works under
concurrent writers."

Adds the first end-to-end iceberg test against real AWS S3 Tables, hard-
gated on env vars so PR CI stays fast; a dedicated iceberg CI lane sets
DUCKGRES_K8S_ICEBERG_TABLE_BUCKET_ARN (a persistent sandbox bucket,
reused across runs) and gets the real signal. Stub catalogs (LocalStack
community, moto, REST-catalog substitutes) all hit a different DuckDB
code path than ENDPOINT_TYPE 's3_tables', so the only way to gain real
confidence is to test against actual S3 Tables.

Also adds the activation-layer regression net for #563: hot-idle
reclaim with rotated STS credentials must refresh the iceberg_sigv4
secret alongside DuckLake's S3 secret, with the new credentials. The
inverse case (iceberg disabled) asserts the refresh fn is not invoked.
These run on every PR with no AWS dependency.
setupMultiTenant() begins with `kubectl delete namespace duckgres
--ignore-not-found --wait=true` against whatever kubeconfig kubectl
picks up by default. requireLocalKindCluster — the guard documented to
prevent exactly this — was placed after that call, so it only protected
read operations on the already-deleted namespace. Today (2026-05-19)
this destroyed mw-dev's duckgres namespace for the second time; the
prior incident is what the safety guard was added for in the first
place.

Move the env-var check + kubeconfig load + requireLocalKindCluster
above the setupMultiTenant call so failing the safety check exits
fatal before any destructive kubectl runs. Anything destructive must
live below the guard block — added an inline comment to that effect so
this doesn't regress a third time.
The original "DUCKGRES_K8S_TEST_KUBECONFIG is required" message was
technically correct but easy to misread as a missing-config error
rather than a destructive-suite refusal. The two prior incidents both
involved engineers misreading the situation and trying to "set the
missing env var" — which is exactly the wrong fix.

Rewrite both guard messages (the kubeconfig-unset path and the
requireLocalKindCluster path) to lead with REFUSING / DESTRUCTIVE, name
the specific destructive action (kubectl delete namespace duckgres),
list the contexts where it must not run (local default, dev, shared,
production), and point at `just test-k8s-integration` as the only
supported way to run.
The earlier safety-ordering commit broke CI: BuildConfigFromFlags was
called BEFORE setupMultiTenant, but in CI the kubeconfig file is
created BY setupMultiTenant (via kind-cluster-reset). Cold runs hit
"stat /tmp/duckgres-kind-kubeconfig: no such file or directory" before
the test bodies could run.

Split the guard into two phases:

  Phase 1 (pre-setup, mandatory env-var check + conditional file check):
    - Always require DUCKGRES_K8S_TEST_KUBECONFIG to be set
    - If the file already exists (warm local rerun), validate it via
      requireLocalKindCluster BEFORE setupMultiTenant runs. This is
      what would have stopped the mw-dev incident in the
      env-pointed-at-real-cluster variant.
    - If the file doesn't exist yet (cold CI), skip the file load.
      setupMultiTenant's opening `kubectl delete namespace` runs
      against the missing path and fails inert ("no such file") — no
      damage possible because kubectl can't connect.

  Phase 2 (post-setup, mandatory):
    - File MUST exist now; load + requireLocalKindCluster. Final
      safety net for the cold-bootstrap path. Failure aborts before
      any test body runs.

The env-unset path (the actual mw-dev incident shape) still fail-fasts
in <1s with the existing REFUSING message, verified locally.
Per request: a skip-on-missing-env-vars path hides two failure modes
that matter more than the test itself running on a given PR.

  1. CI misconfiguration. A rotated secret, renamed bucket, or
     dropped env var renders as "missing env vars — skip". The job
     reports SUCCESS, nobody notices, and the test silently stops
     running on the iceberg lane until someone happens to look.
  2. A real iceberg regression that lands during an env-var gap is
     invisible — it hides behind the same "skipped" line that a
     misconfigured lane produces.

Replace t.Skipf with t.Fatalf. Diagnostic spells out the missing vars,
explains why the test refuses to skip, and walks through the sandbox
bucket setup. Empty env vars are treated as missing (a rotated CI
secret often renders as empty rather than absent).

Tradeoff worth noting: PR CI's k8s-integration-tests job will fail
until the iceberg env vars are wired into every lane that runs the
suite. If keeping default PR CI green matters more than uniform
coverage, the follow-up is to split this test behind a build tag
(`//go:build k8s_iceberg`) so it only compiles into a dedicated
iceberg lane.
Three additions to the k8s-integration-tests job, all of which start
working the moment cloud-infra PR #8124 applies:

  1. permissions: id-token: write — lets the job mint an OIDC token
     against GitHub's IdP. Falls back to the existing top-level
     contents: read since per-job permissions override.

  2. New "Configure AWS credentials via OIDC" step. Trades the OIDC
     token for STS credentials via aws-actions/configure-aws-credentials,
     assuming the new github-duckgres-iceberg-test-role in mw-dev (role
     trust policy: repo:PostHog/duckgres:*, scoped IAM policy on the
     two test buckets only). Action pinned to the same commit SHA
     cloud-infra workflows use.

  3. Three iceberg env vars hardcoded in the job's env block — the
     bucket ARN, region, and data bucket name. AWS_ACCESS_KEY_ID /
     AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are populated by
     configure-aws-credentials, picked up by iceberg_test.go via
     os.Getenv. Hardcoding the bucket coordinates matches the
     cloud-infra workflow convention; the buckets are persistent
     fixtures with stable names.

Until cloud-infra #8124 applies the role and buckets don't exist yet,
so this job will fail on every PR until then. That's the fail-openly
behavior we just landed for iceberg_test.go — when the role appears,
the next run goes green automatically.
@benben benben requested a review from a team May 19, 2026 09:36
benben added 5 commits May 19, 2026 11:58
Coordinated with PostHog/posthog-cloud-infra#8124 which renames
github-duckgres-iceberg-test-role to
github-duckgres-iceberg-ci-testing-role (Michael's review nit — the
old "test-role" suffix invited "can we delete that?" janitor risk).

Comments updated to match.
TestK8sIcebergRoundTrip's tenant activation fails with a 403 during
the Delta catalog probe:

  delta catalog configured but attachment failed: failed to probe
  Delta catalog: ... 403 Forbidden ...
  for path orgs/delta/_delta_log/_last_checkpoint
  https://s3.us-east-1.amazonaws.com/orgs/delta/_delta_log/_last_checkpoint

ManagedWarehouseS3.DeltaCatalogEnabled defaults to true in the
configstore (GORM `default:true` on the column), so every newly
seeded tenant gets Delta attached at startup whether or not it
intends to use it. The Delta extension's probe URL on this tenant
ends up pointing at a bucket segment that isn't the tenant's data
bucket — the URL the request lands at is
`https://s3.us-east-1.amazonaws.com/orgs/delta/_delta_log/_last_checkpoint`
(bucket=`orgs` in path-style), despite IAM granting s3:GetObject on
`arn:aws:s3:::posthog-duckgres-iceberg-test-data-mw-dev`.

The iceberg integration test exercises iceberg-on-S3-Tables +
DuckLake only and doesn't need Delta. Setting
s3_delta_catalog_enabled = false on the test fixture sidesteps the
probe entirely. Inline comment in the seed builder flags the
underlying URL-construction bug as a separate item to chase if/when
this test grows a Delta scenario.

Also added s3_delta_catalog_enabled to the ON CONFLICT DO UPDATE set
so re-runs against an already-seeded configstore pick up the flag.
The S3-credentials secret payload schema in shared_worker_activator's
buildDuckLakeConfigFromConfigStore only extracts access_key_id and
secret_access_key. The companion STS-broker path (a few lines below)
correctly sets dl.S3SessionToken. The static-secret path silently
drops it.

This works for production today because production uses long-term IAM
user keys (no session token needed). It breaks any setup that sources
credentials from STS — STS-vended creds (AccessKeyId prefixed `ASIA…`)
are rejected by AWS without the matching session token, and the
iceberg REST endpoint returns 403 (`Forbidden`) without naming the
specific cause.

Spent several hours chasing this as an iceberg IAM/Lake-Formation
configuration problem. Direct signed curl from the CI runner with
all three header values returned 200; the activator-built DuckLake
config (missing the token) returned 403 from the worker. The fix is
one extra field on the JSON payload schema, plumbed through.

Also drops the temporary debug step from ci.yml that uncovered this.
3-part identifiers (`iceberg.main.t_<id>`) fail through duckgres'
flight-update path with `Catalog Error: Schema with name "" not found`,
even though plain DuckDB inside the cluster accepts the same SQL
verbatim against the same bucket (verified from an in-cluster debug
pod). pg_query's parse+deparse produces clean
`CREATE TABLE iceberg.main.t_x (id int)`; the duckgres transpiler
output is identical; the failure is somewhere downstream in the
flight-update pipeline.

Sidestep with `USE iceberg.<ns>` set on the connection before each
CREATE/INSERT/SELECT/DROP, then use unqualified table names. Same
end state in S3 Tables, just expressed in the form that survives the
pipeline. MaxOpenConns=1 on the test connection (set by
openDBConnAs) keeps the USE + DDL/DML on one underlying connection.

The 3-part bug deserves its own investigation — file follow-up once
this PR lands; the test should switch back to 3-part once duckgres
treats both forms uniformly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant