From 3a208408331357da9116775469b43cf12bf67768 Mon Sep 17 00:00:00 2001 From: Bill Guowei Yang Date: Thu, 9 Jul 2026 11:21:15 -0400 Subject: [PATCH 1/3] Run dev scenarios against isolated stacks --- .github/workflows/scenario-dev.yml | 133 +++++++++++++++--- docs/runbooks/scenario-dev.md | 60 +++++--- tests/scenario-dev/run.sh | 218 +++++++++++++++++++++++++++++ tests/scenario-dev/run_sh_test.go | 48 +++++++ tests/scenario/Dockerfile | 14 ++ tests/scenario/script_test.go | 29 ++-- 6 files changed, 459 insertions(+), 43 deletions(-) create mode 100755 tests/scenario-dev/run.sh create mode 100644 tests/scenario-dev/run_sh_test.go create mode 100644 tests/scenario/Dockerfile diff --git a/.github/workflows/scenario-dev.yml b/.github/workflows/scenario-dev.yml index 7963a17f..cb6fdf84 100644 --- a/.github/workflows/scenario-dev.yml +++ b/.github/workflows/scenario-dev.yml @@ -8,10 +8,21 @@ on: required: true default: false type: boolean + use_shared_dev: + description: Skip isolated deploy and run against the shared dev stack + required: true + default: false + type: boolean + duckgres_image: + description: Existing Duckgres image to deploy instead of building this SHA + required: false + default: "" + type: string schedule: - cron: "17 8 * * *" permissions: + id-token: write contents: read concurrency: @@ -19,53 +30,104 @@ concurrency: cancel-in-progress: false jobs: + scenario-runner-image: + uses: ./.github/workflows/_image-build.yml + with: + dockerfile: tests/scenario/Dockerfile + image-name: duckgres + tag: scenario-runner-${{ github.run_id }}-${{ github.run_attempt }}-arm64 + platform: linux/arm64 + cache-scope: scenario-runner-arm64 + secrets: + ecr-role: ${{ secrets.AWS_ECR_PUBLISH_IAM_ROLE }} + + duckgres-image: + if: ${{ github.event_name != 'workflow_dispatch' || (!inputs.use_shared_dev && inputs.duckgres_image == '') }} + uses: ./.github/workflows/_image-build.yml + with: + dockerfile: Dockerfile + image-name: duckgres + tag: scenario-duckgres-${{ github.run_id }}-${{ github.run_attempt }}-arm64 + platform: linux/arm64 + cache-scope: scenario-duckgres-arm64 + build-args: | + DUCKDB_EXTENSION_VERSION=1.5.3 + HTTPFS_EXTENSION_TAG=v1.5.3-cred-refresh-write-retry + DUCKLAKE_EXTENSION_TAG=v1.0-posthog.4 + DUCKDB_EXTENSION_REPOSITORY=https://extensions.duckdb.org + POSTGRES_SCANNER_REPOSITORY=https://extensions.duckdb.org + secrets: + ecr-role: ${{ secrets.AWS_ECR_PUBLISH_IAM_ROLE }} + scenarios: + needs: [scenario-runner-image, duckgres-image] + if: ${{ always() && needs.scenario-runner-image.result == 'success' && ((github.event_name == 'workflow_dispatch' && inputs.use_shared_dev) || needs.duckgres-image.result == 'success') }} runs-on: ubuntu-24.04 timeout-minutes: 270 strategy: fail-fast: false + max-parallel: 1 matrix: include: - name: provision-smoke + slot: 1 file: tests/scenario/scenarios/provision_smoke.yaml max_runtime: 30m go_test_timeout: 45m slow: false needs_dbt: false - name: provision-rejection + slot: 2 file: tests/scenario/scenarios/provision_rejection.yaml max_runtime: 30m go_test_timeout: 45m slow: false needs_dbt: false - name: frozen-metadata + slot: 3 file: tests/scenario/scenarios/posthog_frozen_metadata.yaml max_runtime: 45m go_test_timeout: 60m slow: false needs_dbt: false - name: frozen-perf + slot: 4 file: tests/scenario/scenarios/posthog_frozen_perf.yaml max_runtime: 45m go_test_timeout: 60m slow: false needs_dbt: false - name: frozen-dbt + slot: 5 file: tests/scenario/scenarios/posthog_frozen_dbt.yaml max_runtime: 4h go_test_timeout: 4h15m slow: true needs_dbt: true env: - DUCKGRES_SCENARIO_API_BASE: ${{ secrets.DUCKGRES_SCENARIO_API_BASE_DEV }} - DUCKGRES_SCENARIO_INTERNAL_SECRET: ${{ secrets.DUCKGRES_SCENARIO_INTERNAL_SECRET_DEV }} - DUCKGRES_SCENARIO_PG_HOST: ${{ secrets.DUCKGRES_SCENARIO_PG_HOST_DEV }} - DUCKGRES_SCENARIO_SNI_SUFFIX: ${{ secrets.DUCKGRES_SCENARIO_SNI_SUFFIX_DEV }} - DUCKGRES_SCENARIO_FROZEN_S3_URI: ${{ secrets.DUCKGRES_SCENARIO_FROZEN_S3_URI_DEV }} - DUCKGRES_SCENARIO_FLIGHT_ADDR: ${{ secrets.DUCKGRES_SCENARIO_FLIGHT_ADDR_DEV }} - DUCKGRES_SCENARIO_FLIGHT_INSECURE_SKIP_VERIFY: ${{ vars.DUCKGRES_SCENARIO_FLIGHT_INSECURE_SKIP_VERIFY_DEV || 'true' }} - DUCKGRES_SCENARIO_DBT_BIN: dbt - DUCKGRES_SCENARIO_OUTPUT_BASE: artifacts/scenario-dev + USE_SHARED_DEV: ${{ github.event_name == 'workflow_dispatch' && inputs.use_shared_dev }} + SCENARIO_RUNNER_IMAGE: ${{ needs.scenario-runner-image.outputs.image }} + WORKER_IMAGE: ${{ (github.event_name == 'workflow_dispatch' && inputs.duckgres_image) || needs.duckgres-image.outputs.image }} + CONTROLPLANE_IMAGE: ${{ (github.event_name == 'workflow_dispatch' && inputs.duckgres_image) || needs.duckgres-image.outputs.image }} + KUBE_CONTEXT: ${{ vars.DUCKGRES_SCENARIO_KUBE_CONTEXT }} + CLUSTER_NAME: ${{ vars.DUCKGRES_SCENARIO_EKS_CLUSTER_NAME }} + EKS_CLUSTER_NAME: ${{ vars.DUCKGRES_SCENARIO_EKS_CLUSTER_NAME }} + AWS_REGION: ${{ vars.DUCKGRES_SCENARIO_AWS_REGION || 'us-east-1' }} + CP_POD_IDENTITY_ROLE: ${{ secrets.DUCKGRES_SCENARIO_CP_POD_IDENTITY_ROLE }} + PR_NUMBER: ${{ github.run_id }}${{ matrix.slot }} + NAMESPACE: duckgres-ci-pr-${{ github.run_id }}${{ matrix.slot }} + SCENARIO_NAMESPACE: duckgres-ci-pr-${{ github.run_id }}${{ matrix.slot }} + SCENARIO_NAME: ${{ matrix.name }} + SCENARIO_FILE: ${{ matrix.file }} + SCENARIO_SHARED_NAMESPACE: ${{ vars.DUCKGRES_SCENARIO_SHARED_NAMESPACE }} + SCENARIO_SHARED_API_BASE: ${{ vars.DUCKGRES_SCENARIO_SHARED_API_BASE }} + SCENARIO_SHARED_PG_HOST: ${{ vars.DUCKGRES_SCENARIO_SHARED_PG_HOST }} + SCENARIO_SHARED_FLIGHT_ADDR: ${{ vars.DUCKGRES_SCENARIO_SHARED_FLIGHT_ADDR }} + SCENARIO_CONFIG_SECRET_NAMESPACE: ${{ vars.DUCKGRES_SCENARIO_CONFIG_SECRET_NAMESPACE }} + SCENARIO_CONFIG_SECRET_NAME: ${{ vars.DUCKGRES_SCENARIO_CONFIG_SECRET_NAME }} + SCENARIO_INTERNAL_SECRET_NAME: ${{ vars.DUCKGRES_SCENARIO_INTERNAL_SECRET_NAME }} + SCENARIO_INTERNAL_SECRET_KEY: ${{ vars.DUCKGRES_SCENARIO_INTERNAL_SECRET_KEY }} + SCENARIO_SNI_SUFFIX: ${{ vars.DUCKGRES_SCENARIO_SNI_SUFFIX || '.ci.duckgres.local' }} DUCKGRES_SCENARIO_MAX_RUNTIME: ${{ matrix.max_runtime }} DUCKGRES_SCENARIO_GO_TEST_TIMEOUT: ${{ matrix.go_test_timeout }} DUCKGRES_SCENARIO_RUN_ID: scenario-dev-${{ matrix.name }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -77,27 +139,62 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} + - name: Isolated dev target selected + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.use_shared_dev }} + run: echo "Deploying an isolated Duckgres stack for this scenario." + + - name: Shared dev target selected + if: ${{ github.event_name == 'workflow_dispatch' && inputs.use_shared_dev }} + run: echo "Skipping isolated deploy and targeting the shared dev stack." + - name: Set up Go if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 with: go-version-file: go.mod - - name: Install dbt - if: ${{ matrix.needs_dbt && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} - run: python -m pip install --user dbt-postgres + - name: Test scenario workflow scripts + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} + run: go test -count=1 ./tests/scenario ./tests/scenario-dev ./tests/e2e-mw-dev - - name: Add user Python bin to PATH - if: ${{ matrix.needs_dbt && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Configure AWS credentials (OIDC) + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 + with: + role-to-assume: arn:aws:iam::${{ secrets.MW_DEV_ACCOUNT_ID }}:role/github-duckgres-e2e + aws-region: us-east-1 - - name: Check scenario environment + - name: Connect to Tailscale if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} - run: scripts/scenario_run.sh --check-env "${{ matrix.file }}" + uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2 + with: + oauth-client-id: ${{ vars.TS_WIF_CLIENT_ID_MW_DEV }} + audience: ${{ vars.TS_WIF_AUDIENCE_MW_DEV }} + tags: tag:github-runner + + - name: Install kubectl + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} + uses: azure/setup-kubectl@776406bce94f63e41d621b960d78ee25c8b76ede # v4.0.1 + + - name: Update kubeconfig + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} + run: aws eks update-kubeconfig --name "$CLUSTER_NAME" --region "$AWS_REGION" --alias "$KUBE_CONTEXT" + + - name: Deploy isolated Duckgres stack + if: ${{ (github.event_name != 'workflow_dispatch' || !inputs.use_shared_dev) && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} + run: tests/scenario-dev/run.sh deploy - name: Run scenario if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }} - run: scripts/scenario_run.sh "${{ matrix.file }}" + run: tests/scenario-dev/run.sh test + + - name: Collect diagnostics + if: ${{ failure() && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} + run: tests/scenario-dev/run.sh diagnostics + + - name: Teardown + if: ${{ always() && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} + run: tests/scenario-dev/run.sh teardown - name: Upload scenario artifacts if: ${{ always() && (github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow) }} diff --git a/docs/runbooks/scenario-dev.md b/docs/runbooks/scenario-dev.md index 334b4de1..e984047d 100644 --- a/docs/runbooks/scenario-dev.md +++ b/docs/runbooks/scenario-dev.md @@ -1,9 +1,14 @@ # Dev Scenario Runner The `scenario-dev` GitHub Actions workflow runs scenario tests against the -configured dev environment. It is intentionally repo-local: the workflow checks -out this repository, calls `scripts/scenario_run.sh`, and uploads the scenario -artifacts generated by the Go scenario harness. +configured dev environment. By default it builds the current Duckgres SHA, +deploys an isolated Duckgres stack in dev infrastructure, runs each scenario in +an in-cluster Kubernetes Job, and uploads the scenario artifacts generated by the +Go scenario harness. + +Manual runs can set `use_shared_dev` to `true` to skip the isolated deployment +and target the shared dev stack instead. Use that mode for explicit deployed-dev +checks only; the default isolated mode is the reproducible CI path. ## Scheduled Runs @@ -20,26 +25,47 @@ to `true` when you need a faster run that skips the slow dbt scenario. ## Required Repository Configuration -Configure these repository secrets for the dev environment: +The workflow follows the e2e dev harness access pattern: GitHub OIDC assumes a +dev-scoped AWS role, Tailscale reaches the private cluster API, and the scenario +runner executes inside Kubernetes. + +Configure these repository secrets: + +- `AWS_ECR_PUBLISH_IAM_ROLE` +- `MW_DEV_ACCOUNT_ID` +- `DUCKGRES_SCENARIO_CP_POD_IDENTITY_ROLE` + +Configure these repository variables: -- `DUCKGRES_SCENARIO_API_BASE_DEV` -- `DUCKGRES_SCENARIO_INTERNAL_SECRET_DEV` -- `DUCKGRES_SCENARIO_PG_HOST_DEV` -- `DUCKGRES_SCENARIO_SNI_SUFFIX_DEV` -- `DUCKGRES_SCENARIO_FROZEN_S3_URI_DEV` -- `DUCKGRES_SCENARIO_FLIGHT_ADDR_DEV` +- `TS_WIF_CLIENT_ID_MW_DEV` +- `TS_WIF_AUDIENCE_MW_DEV` +- `DUCKGRES_SCENARIO_KUBE_CONTEXT` +- `DUCKGRES_SCENARIO_EKS_CLUSTER_NAME` +- `DUCKGRES_SCENARIO_AWS_REGION` +- `DUCKGRES_SCENARIO_CONFIG_SECRET_NAMESPACE` +- `DUCKGRES_SCENARIO_CONFIG_SECRET_NAME` +- `DUCKGRES_SCENARIO_INTERNAL_SECRET_NAME` +- `DUCKGRES_SCENARIO_INTERNAL_SECRET_KEY` +- `DUCKGRES_SCENARIO_SNI_SUFFIX` -Optional repository variables: +The Kubernetes scenario config secret must include `frozen-s3-uri` for frozen +dataset scenarios. Shared-dev mode additionally requires: -- `DUCKGRES_SCENARIO_FLIGHT_INSECURE_SKIP_VERIFY_DEV` +- `DUCKGRES_SCENARIO_SHARED_NAMESPACE` +- `DUCKGRES_SCENARIO_SHARED_API_BASE` +- `DUCKGRES_SCENARIO_SHARED_PG_HOST` +- `DUCKGRES_SCENARIO_SHARED_FLIGHT_ADDR` -Do not commit concrete dev endpoints, account ids, S3 bucket names, or internal -cluster names to this repository. Keep them in repository secrets or variables. +Do not commit concrete dev endpoints, account ids, S3 bucket names, internal +cluster names, or secret values to this repository. Keep them in repository +secrets, repository variables, or infra-managed Kubernetes secrets. ## Failure Recovery Every scenario provisions its own org or warehouse name and includes cleanup -steps. When a run fails, inspect the uploaded `scenario-dev-*` artifact first: +steps. Isolated-mode runs also tear down the temporary Duckgres namespace after +the scenario Job finishes. When a run fails, inspect the uploaded +`scenario-dev-*` artifact first: - `scenario_summary.json` contains the overall run verdict. - `step_results.csv` contains per-step duration, status, and error class. @@ -47,4 +73,6 @@ steps. When a run fails, inspect the uploaded `scenario-dev-*` artifact first: If cleanup did not complete, use the `run_id` in `scenario_summary.json` to find the scenario-created org name in the scenario YAML and deprovision it manually -through the dev control plane. +through the relevant dev control plane. For isolated-mode failures, also inspect +the workflow logs from `tests/scenario-dev/run.sh diagnostics`; the namespace is +named from the workflow run id and matrix slot. diff --git a/tests/scenario-dev/run.sh b/tests/scenario-dev/run.sh new file mode 100755 index 00000000..b5843f8e --- /dev/null +++ b/tests/scenario-dev/run.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Run scenario-dev against either a per-run isolated Duckgres stack or the +# shared dev stack. The isolated stack is delegated to tests/e2e-mw-dev/run.sh; +# this script owns only the scenario-runner Kubernetes Job. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CTX="${KUBE_CONTEXT:?KUBE_CONTEXT is required}" +KUBECTL=(kubectl --context "$CTX") +USE_SHARED_DEV="${USE_SHARED_DEV:-false}" +SCENARIO_NAMESPACE="${SCENARIO_NAMESPACE:-${NAMESPACE:-}}" +SCENARIO_SHARED_NAMESPACE="${SCENARIO_SHARED_NAMESPACE:-duckgres}" +SCENARIO_CONFIG_SECRET_NAME="${SCENARIO_CONFIG_SECRET_NAME:-duckgres-scenario-config}" +SCENARIO_CONFIG_SECRET_NAMESPACE="${SCENARIO_CONFIG_SECRET_NAMESPACE:-$SCENARIO_SHARED_NAMESPACE}" +SCENARIO_INTERNAL_SECRET_NAME="${SCENARIO_INTERNAL_SECRET_NAME:-duckgres-tokens}" +SCENARIO_INTERNAL_SECRET_KEY="${SCENARIO_INTERNAL_SECRET_KEY:-internal-secret}" +SCENARIO_SNI_SUFFIX="${SCENARIO_SNI_SUFFIX:-.ci.duckgres.local}" +SCENARIO_JOB_WATCH_TIMEOUT_SECONDS="${SCENARIO_JOB_WATCH_TIMEOUT_SECONDS:-16200}" + +target_namespace() { + if [ "$USE_SHARED_DEV" = "true" ]; then + printf '%s\n' "$SCENARIO_SHARED_NAMESPACE" + else + : "${SCENARIO_NAMESPACE:?SCENARIO_NAMESPACE or NAMESPACE is required}" + printf '%s\n' "$SCENARIO_NAMESPACE" + fi +} + +control_plane_api_base() { + local ns + ns="$(target_namespace)" + if [ "$USE_SHARED_DEV" = "true" ]; then + printf '%s\n' "${SCENARIO_SHARED_API_BASE:?SCENARIO_SHARED_API_BASE is required when USE_SHARED_DEV=true}" + else + printf 'http://duckgres-control-plane.%s.svc:8080\n' "$ns" + fi +} + +pg_host() { + local ns + ns="$(target_namespace)" + if [ "$USE_SHARED_DEV" = "true" ]; then + printf '%s\n' "${SCENARIO_SHARED_PG_HOST:?SCENARIO_SHARED_PG_HOST is required when USE_SHARED_DEV=true}" + else + printf 'duckgres-control-plane.%s.svc\n' "$ns" + fi +} + +flight_addr() { + local ns + ns="$(target_namespace)" + if [ "$USE_SHARED_DEV" = "true" ]; then + printf '%s\n' "${SCENARIO_SHARED_FLIGHT_ADDR:?SCENARIO_SHARED_FLIGHT_ADDR is required when USE_SHARED_DEV=true}" + else + printf 'duckgres-control-plane.%s.svc:8815\n' "$ns" + fi +} + +ensure_config_secret() { + local target_ns source_ns name + target_ns="$(target_namespace)" + source_ns="$SCENARIO_CONFIG_SECRET_NAMESPACE" + name="$SCENARIO_CONFIG_SECRET_NAME" + if [ "$target_ns" = "$source_ns" ]; then + return 0 + fi + "${KUBECTL[@]}" -n "$source_ns" get secret "$name" -o json \ + | jq --arg ns "$target_ns" 'del( + .metadata.annotations, + .metadata.creationTimestamp, + .metadata.managedFields, + .metadata.resourceVersion, + .metadata.uid + ) | .metadata.namespace = $ns' \ + | "${KUBECTL[@]}" apply -f - +} + +job_name() { + local name + name="${SCENARIO_NAME:?SCENARIO_NAME is required}" + printf 'duckgres-scenario-%s\n' "$name" | tr '_' '-' +} + +cmd_deploy() { + if [ "$USE_SHARED_DEV" = "true" ]; then + echo "Skipping isolated Duckgres deploy; using shared dev stack." + return 0 + fi + bash "$ROOT/tests/e2e-mw-dev/run.sh" deploy +} + +cmd_test() { + local ns job api_base pg flight rc + : "${SCENARIO_RUNNER_IMAGE:?SCENARIO_RUNNER_IMAGE is required}" + : "${SCENARIO_FILE:?SCENARIO_FILE is required}" + : "${DUCKGRES_SCENARIO_RUN_ID:?DUCKGRES_SCENARIO_RUN_ID is required}" + : "${DUCKGRES_SCENARIO_MAX_RUNTIME:?DUCKGRES_SCENARIO_MAX_RUNTIME is required}" + : "${DUCKGRES_SCENARIO_GO_TEST_TIMEOUT:?DUCKGRES_SCENARIO_GO_TEST_TIMEOUT is required}" + + ns="$(target_namespace)" + job="$(job_name)" + api_base="$(control_plane_api_base)" + pg="$(pg_host)" + flight="$(flight_addr)" + ensure_config_secret + + "${KUBECTL[@]}" -n "$ns" delete job "$job" --ignore-not-found + cat </dev/null)" = "True" ]; then + echo "scenario Job complete." + return 0 + fi + if [ "$("${KUBECTL[@]}" -n "$ns" get job "$job" -o jsonpath='{.status.conditions[?(@.type=="Failed")].status}' 2>/dev/null)" = "True" ]; then + echo "scenario Job failed." >&2 + return 1 + fi + sleep 10 + done + echo "scenario Job did not reach a terminal state in time." >&2 + return 1 +} + +copy_artifacts() { + local ns="$1" job="$2" pod dest + pod="$("${KUBECTL[@]}" -n "$ns" get pod -l "job-name=$job" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + [ -n "$pod" ] || return 0 + dest="$ROOT/artifacts/scenario-dev/${SCENARIO_NAME}" + mkdir -p "$dest" + "${KUBECTL[@]}" -n "$ns" cp "$pod:/artifacts/scenario-dev" "$dest" >/dev/null 2>&1 || true +} + +cmd_diagnostics() { + local ns job + ns="$(target_namespace)" + job="$(job_name)" + echo "::group::scenario job state" + "${KUBECTL[@]}" -n "$ns" get job,pod -l "job-name=$job" -o wide || true + "${KUBECTL[@]}" -n "$ns" describe job "$job" || true + "${KUBECTL[@]}" -n "$ns" describe pod -l "job-name=$job" || true + echo "::endgroup::" + if [ "$USE_SHARED_DEV" != "true" ]; then + bash "$ROOT/tests/e2e-mw-dev/run.sh" diagnostics || true + fi +} + +cmd_teardown() { + local ns job + ns="$(target_namespace)" + job="$(job_name)" + "${KUBECTL[@]}" -n "$ns" delete job "$job" --ignore-not-found --wait=false || true + if [ "$USE_SHARED_DEV" = "true" ]; then + return 0 + fi + bash "$ROOT/tests/e2e-mw-dev/run.sh" teardown +} + +case "${1:?usage: run.sh deploy|test|diagnostics|teardown}" in + deploy) cmd_deploy ;; + test) cmd_test ;; + diagnostics) cmd_diagnostics ;; + teardown) cmd_teardown ;; + *) echo "unknown: $1" >&2; exit 2 ;; +esac diff --git a/tests/scenario-dev/run_sh_test.go b/tests/scenario-dev/run_sh_test.go new file mode 100644 index 00000000..488a47d5 --- /dev/null +++ b/tests/scenario-dev/run_sh_test.go @@ -0,0 +1,48 @@ +package scenariodev_test + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestDeploySharedDevSkipsIsolatedStack(t *testing.T) { + cmd := exec.Command("bash", "run.sh", "deploy") + cmd.Dir = "." + cmd.Env = append(os.Environ(), + "KUBE_CONTEXT=test-context", + "USE_SHARED_DEV=true", + ) + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("shared deploy skip failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "Skipping isolated Duckgres deploy") { + t.Fatalf("shared deploy did not explain skip:\n%s", out) + } +} + +func TestSharedDevTestRequiresSharedConnectionConfig(t *testing.T) { + cmd := exec.Command("bash", "run.sh", "test") + cmd.Dir = "." + cmd.Env = append(os.Environ(), + "KUBE_CONTEXT=test-context", + "USE_SHARED_DEV=true", + "SCENARIO_RUNNER_IMAGE=example.invalid/duckgres:scenario", + "SCENARIO_NAME=provision-smoke", + "SCENARIO_FILE=tests/scenario/scenarios/provision_smoke.yaml", + "DUCKGRES_SCENARIO_RUN_ID=scenario-dev-test", + "DUCKGRES_SCENARIO_MAX_RUNTIME=30m", + "DUCKGRES_SCENARIO_GO_TEST_TIMEOUT=45m", + ) + + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("shared test succeeded without shared connection config:\n%s", out) + } + if !strings.Contains(string(out), "SCENARIO_SHARED_API_BASE is required") { + t.Fatalf("shared test did not report missing API base:\n%s", out) + } +} diff --git a/tests/scenario/Dockerfile b/tests/scenario/Dockerfile new file mode 100644 index 00000000..d5971eb9 --- /dev/null +++ b/tests/scenario/Dockerfile @@ -0,0 +1,14 @@ +FROM public.ecr.aws/docker/library/golang:1.25-bookworm + +WORKDIR /workspace + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3-pip \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m pip install --break-system-packages --no-cache-dir dbt-postgres + +COPY . . + +ENV DUCKGRES_SCENARIO_DBT_BIN=dbt + +ENTRYPOINT ["bash", "scripts/scenario_run.sh"] diff --git a/tests/scenario/script_test.go b/tests/scenario/script_test.go index 4cdd9523..0707b1d4 100644 --- a/tests/scenario/script_test.go +++ b/tests/scenario/script_test.go @@ -56,7 +56,7 @@ func TestScenarioRunScriptCheckEnvIncludesScenarioRequiredEnv(t *testing.T) { } } -func TestDevScenarioWorkflowRunsScheduledSecretBackedScenarios(t *testing.T) { +func TestDevScenarioWorkflowDefaultsToIsolatedDeploymentWithSharedDevOverride(t *testing.T) { workflowPath := filepath.Join("..", "..", ".github", "workflows", "scenario-dev.yml") raw, err := os.ReadFile(workflowPath) if err != nil { @@ -68,23 +68,28 @@ func TestDevScenarioWorkflowRunsScheduledSecretBackedScenarios(t *testing.T) { "name: scenario-dev", "workflow_dispatch:", "skip_slow:", + "use_shared_dev:", "default: false", "schedule:", - "scripts/scenario_run.sh", - "actions/upload-artifact@", + "id-token: write", + "uses: ./.github/workflows/_image-build.yml", + "image-name: duckgres", + "tag: scenario-runner-${{ github.run_id }}-${{ github.run_attempt }}-arm64", + "tag: scenario-duckgres-${{ github.run_id }}-${{ github.run_attempt }}-arm64", + "tests/scenario-dev/run.sh deploy", + "tests/scenario-dev/run.sh test", + "tests/scenario-dev/run.sh diagnostics", + "tests/scenario-dev/run.sh teardown", "tests/scenario/scenarios/provision_smoke.yaml", "tests/scenario/scenarios/provision_rejection.yaml", "tests/scenario/scenarios/posthog_frozen_metadata.yaml", "tests/scenario/scenarios/posthog_frozen_perf.yaml", "tests/scenario/scenarios/posthog_frozen_dbt.yaml", "if: ${{ github.event_name != 'workflow_dispatch' || !inputs.skip_slow || !matrix.slow }}", + "if: ${{ github.event_name != 'workflow_dispatch' || !inputs.use_shared_dev }}", + "if: ${{ github.event_name == 'workflow_dispatch' && inputs.use_shared_dev }}", "slow: true", - "DUCKGRES_SCENARIO_API_BASE: ${{ secrets.DUCKGRES_SCENARIO_API_BASE_DEV }}", - "DUCKGRES_SCENARIO_INTERNAL_SECRET: ${{ secrets.DUCKGRES_SCENARIO_INTERNAL_SECRET_DEV }}", - "DUCKGRES_SCENARIO_PG_HOST: ${{ secrets.DUCKGRES_SCENARIO_PG_HOST_DEV }}", - "DUCKGRES_SCENARIO_SNI_SUFFIX: ${{ secrets.DUCKGRES_SCENARIO_SNI_SUFFIX_DEV }}", - "DUCKGRES_SCENARIO_FROZEN_S3_URI: ${{ secrets.DUCKGRES_SCENARIO_FROZEN_S3_URI_DEV }}", - "DUCKGRES_SCENARIO_FLIGHT_ADDR: ${{ secrets.DUCKGRES_SCENARIO_FLIGHT_ADDR_DEV }}", + "go test -count=1 ./tests/scenario ./tests/scenario-dev ./tests/e2e-mw-dev", } { if !strings.Contains(workflow, required) { t.Fatalf("workflow missing %q", required) @@ -92,6 +97,12 @@ func TestDevScenarioWorkflowRunsScheduledSecretBackedScenarios(t *testing.T) { } for _, forbidden := range []string{ + "DUCKGRES_SCENARIO_API_BASE: ${{ secrets.", + "DUCKGRES_SCENARIO_INTERNAL_SECRET: ${{ secrets.", + "DUCKGRES_SCENARIO_PG_HOST: ${{ secrets.", + "DUCKGRES_SCENARIO_SNI_SUFFIX: ${{ secrets.", + "DUCKGRES_SCENARIO_FROZEN_S3_URI: ${{ secrets.", + "DUCKGRES_SCENARIO_FLIGHT_ADDR: ${{ secrets.", "posthog-mw-dev", "373313242555", "645773004826", From fbf4cb9b484007d74d5ebaec46a2e465a4d8a7e8 Mon Sep 17 00:00:00 2001 From: Bill Guowei Yang Date: Thu, 9 Jul 2026 11:30:55 -0400 Subject: [PATCH 2/3] Fix dev scenario runner workflow edge cases --- .github/workflows/scenario-dev.yml | 2 +- tests/scenario-dev/run.sh | 13 ++++- tests/scenario-dev/run_sh_test.go | 89 ++++++++++++++++++++++++++++++ tests/scenario/Dockerfile | 4 ++ tests/scenario/script_test.go | 19 +++++++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scenario-dev.yml b/.github/workflows/scenario-dev.yml index cb6fdf84..5eff7229 100644 --- a/.github/workflows/scenario-dev.yml +++ b/.github/workflows/scenario-dev.yml @@ -61,7 +61,7 @@ jobs: scenarios: needs: [scenario-runner-image, duckgres-image] - if: ${{ always() && needs.scenario-runner-image.result == 'success' && ((github.event_name == 'workflow_dispatch' && inputs.use_shared_dev) || needs.duckgres-image.result == 'success') }} + if: ${{ always() && needs.scenario-runner-image.result == 'success' && (needs.duckgres-image.result == 'success' || (github.event_name == 'workflow_dispatch' && (inputs.use_shared_dev || inputs.duckgres_image != ''))) }} runs-on: ubuntu-24.04 timeout-minutes: 270 strategy: diff --git a/tests/scenario-dev/run.sh b/tests/scenario-dev/run.sh index b5843f8e..b251ce74 100755 --- a/tests/scenario-dev/run.sh +++ b/tests/scenario-dev/run.sh @@ -75,6 +75,15 @@ ensure_config_secret() { | "${KUBECTL[@]}" apply -f - } +scenario_requires_frozen_config() { + local scenario_path + case "$SCENARIO_FILE" in + /*) scenario_path="$SCENARIO_FILE" ;; + *) scenario_path="$ROOT/$SCENARIO_FILE" ;; + esac + [ -f "$scenario_path" ] && grep -q 'DUCKGRES_SCENARIO_FROZEN_S3_URI' "$scenario_path" +} + job_name() { local name name="${SCENARIO_NAME:?SCENARIO_NAME is required}" @@ -102,7 +111,9 @@ cmd_test() { api_base="$(control_plane_api_base)" pg="$(pg_host)" flight="$(flight_addr)" - ensure_config_secret + if scenario_requires_frozen_config; then + ensure_config_secret + fi "${KUBECTL[@]}" -n "$ns" delete job "$job" --ignore-not-found cat <> "$SCENARIO_DEV_TEST_CALLS" +if [[ "$*" == *" get secret duckgres-scenario-config "* ]]; then + printf 'frozen config secret should not be required for this scenario\n' >&2 + exit 1 +fi +if [[ "$*" == *" apply -f -"* ]]; then + cat >/dev/null + exit 0 +fi +if [[ "$*" == *" get job duckgres-scenario-provision-smoke "* ]]; then + printf 'True' + exit 0 +fi +if [[ "$*" == *" get pod -l job-name=duckgres-scenario-provision-smoke "* ]]; then + printf 'duckgres-scenario-provision-smoke-pod' + exit 0 +fi +exit 0 +`) + writeFake(t, binDir, "date", `#!/usr/bin/env bash +printf 'date %s\n' "$*" >> "$SCENARIO_DEV_TEST_CALLS" +if [[ "$*" == "+%s" ]]; then + printf '2000000000' + exit 0 +fi +exec /bin/date "$@" +`) + writeFake(t, binDir, "sleep", `#!/usr/bin/env bash +printf 'sleep %s\n' "$*" >> "$SCENARIO_DEV_TEST_CALLS" +`) + + return binDir, callsPath +} + +func writeFake(t *testing.T, binDir, name, body string) { + t.Helper() + + path := filepath.Join(binDir, name) + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatalf("write fake %s: %v", name, err) + } +} diff --git a/tests/scenario/Dockerfile b/tests/scenario/Dockerfile index d5971eb9..d343946b 100644 --- a/tests/scenario/Dockerfile +++ b/tests/scenario/Dockerfile @@ -7,7 +7,11 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* \ && python3 -m pip install --break-system-packages --no-cache-dir dbt-postgres +COPY go.mod go.sum ./ +RUN go mod download + COPY . . +RUN go test -run '^$' ./tests/scenario ENV DUCKGRES_SCENARIO_DBT_BIN=dbt diff --git a/tests/scenario/script_test.go b/tests/scenario/script_test.go index 0707b1d4..bf03e86d 100644 --- a/tests/scenario/script_test.go +++ b/tests/scenario/script_test.go @@ -76,6 +76,7 @@ func TestDevScenarioWorkflowDefaultsToIsolatedDeploymentWithSharedDevOverride(t "image-name: duckgres", "tag: scenario-runner-${{ github.run_id }}-${{ github.run_attempt }}-arm64", "tag: scenario-duckgres-${{ github.run_id }}-${{ github.run_attempt }}-arm64", + "inputs.duckgres_image != ''", "tests/scenario-dev/run.sh deploy", "tests/scenario-dev/run.sh test", "tests/scenario-dev/run.sh diagnostics", @@ -114,3 +115,21 @@ func TestDevScenarioWorkflowDefaultsToIsolatedDeploymentWithSharedDevOverride(t } } } + +func TestScenarioRunnerImageCachesGoDependencies(t *testing.T) { + dockerfilePath := filepath.Join("Dockerfile") + raw, err := os.ReadFile(dockerfilePath) + if err != nil { + t.Fatalf("read scenario runner Dockerfile: %v", err) + } + dockerfile := string(raw) + + for _, required := range []string{ + "go mod download", + "go test -run '^$' ./tests/scenario", + } { + if !strings.Contains(dockerfile, required) { + t.Fatalf("scenario runner Dockerfile missing %q", required) + } + } +} From c65a95a6579fd87021eb91bc83fb44e5f3529551 Mon Sep 17 00:00:00 2001 From: Bill Guowei Yang Date: Thu, 9 Jul 2026 11:45:45 -0400 Subject: [PATCH 3/3] Harden dev scenario runner jobs --- .github/workflows/scenario-dev.yml | 3 ++- docs/runbooks/scenario-dev.md | 2 +- tests/scenario-dev/run.sh | 25 ++++++++++++----- tests/scenario-dev/run_sh_test.go | 43 ++++++++++++++++++++++++++++-- tests/scenario/dbt/runner_test.go | 43 +++++++++++++++++++++++++++--- tests/scenario/dbt/steps.go | 7 ++++- 6 files changed, 109 insertions(+), 14 deletions(-) diff --git a/.github/workflows/scenario-dev.yml b/.github/workflows/scenario-dev.yml index 5eff7229..aa2c609a 100644 --- a/.github/workflows/scenario-dev.yml +++ b/.github/workflows/scenario-dev.yml @@ -127,7 +127,8 @@ jobs: SCENARIO_CONFIG_SECRET_NAME: ${{ vars.DUCKGRES_SCENARIO_CONFIG_SECRET_NAME }} SCENARIO_INTERNAL_SECRET_NAME: ${{ vars.DUCKGRES_SCENARIO_INTERNAL_SECRET_NAME }} SCENARIO_INTERNAL_SECRET_KEY: ${{ vars.DUCKGRES_SCENARIO_INTERNAL_SECRET_KEY }} - SCENARIO_SNI_SUFFIX: ${{ vars.DUCKGRES_SCENARIO_SNI_SUFFIX || '.ci.duckgres.local' }} + SCENARIO_ISOLATED_SNI_SUFFIX: .ci.duckgres.local + SCENARIO_SHARED_SNI_SUFFIX: ${{ vars.DUCKGRES_SCENARIO_SHARED_SNI_SUFFIX || vars.DUCKGRES_SCENARIO_SNI_SUFFIX }} DUCKGRES_SCENARIO_MAX_RUNTIME: ${{ matrix.max_runtime }} DUCKGRES_SCENARIO_GO_TEST_TIMEOUT: ${{ matrix.go_test_timeout }} DUCKGRES_SCENARIO_RUN_ID: scenario-dev-${{ matrix.name }}-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/docs/runbooks/scenario-dev.md b/docs/runbooks/scenario-dev.md index e984047d..856f4164 100644 --- a/docs/runbooks/scenario-dev.md +++ b/docs/runbooks/scenario-dev.md @@ -46,7 +46,6 @@ Configure these repository variables: - `DUCKGRES_SCENARIO_CONFIG_SECRET_NAME` - `DUCKGRES_SCENARIO_INTERNAL_SECRET_NAME` - `DUCKGRES_SCENARIO_INTERNAL_SECRET_KEY` -- `DUCKGRES_SCENARIO_SNI_SUFFIX` The Kubernetes scenario config secret must include `frozen-s3-uri` for frozen dataset scenarios. Shared-dev mode additionally requires: @@ -55,6 +54,7 @@ dataset scenarios. Shared-dev mode additionally requires: - `DUCKGRES_SCENARIO_SHARED_API_BASE` - `DUCKGRES_SCENARIO_SHARED_PG_HOST` - `DUCKGRES_SCENARIO_SHARED_FLIGHT_ADDR` +- `DUCKGRES_SCENARIO_SHARED_SNI_SUFFIX` Do not commit concrete dev endpoints, account ids, S3 bucket names, internal cluster names, or secret values to this repository. Keep them in repository diff --git a/tests/scenario-dev/run.sh b/tests/scenario-dev/run.sh index b251ce74..d8dc63ff 100755 --- a/tests/scenario-dev/run.sh +++ b/tests/scenario-dev/run.sh @@ -14,7 +14,8 @@ SCENARIO_CONFIG_SECRET_NAME="${SCENARIO_CONFIG_SECRET_NAME:-duckgres-scenario-co SCENARIO_CONFIG_SECRET_NAMESPACE="${SCENARIO_CONFIG_SECRET_NAMESPACE:-$SCENARIO_SHARED_NAMESPACE}" SCENARIO_INTERNAL_SECRET_NAME="${SCENARIO_INTERNAL_SECRET_NAME:-duckgres-tokens}" SCENARIO_INTERNAL_SECRET_KEY="${SCENARIO_INTERNAL_SECRET_KEY:-internal-secret}" -SCENARIO_SNI_SUFFIX="${SCENARIO_SNI_SUFFIX:-.ci.duckgres.local}" +SCENARIO_ISOLATED_SNI_SUFFIX="${SCENARIO_ISOLATED_SNI_SUFFIX:-.ci.duckgres.local}" +SCENARIO_SHARED_SNI_SUFFIX="${SCENARIO_SHARED_SNI_SUFFIX:-${SCENARIO_SNI_SUFFIX:-}}" SCENARIO_JOB_WATCH_TIMEOUT_SECONDS="${SCENARIO_JOB_WATCH_TIMEOUT_SECONDS:-16200}" target_namespace() { @@ -42,7 +43,7 @@ pg_host() { if [ "$USE_SHARED_DEV" = "true" ]; then printf '%s\n' "${SCENARIO_SHARED_PG_HOST:?SCENARIO_SHARED_PG_HOST is required when USE_SHARED_DEV=true}" else - printf 'duckgres-control-plane.%s.svc\n' "$ns" + "${KUBECTL[@]}" -n "$ns" get svc duckgres-control-plane -o jsonpath='{.spec.clusterIP}' fi } @@ -56,6 +57,14 @@ flight_addr() { fi } +sni_suffix() { + if [ "$USE_SHARED_DEV" = "true" ]; then + printf '%s\n' "${SCENARIO_SHARED_SNI_SUFFIX:?SCENARIO_SHARED_SNI_SUFFIX is required when USE_SHARED_DEV=true}" + else + printf '%s\n' "$SCENARIO_ISOLATED_SNI_SUFFIX" + fi +} + ensure_config_secret() { local target_ns source_ns name target_ns="$(target_namespace)" @@ -85,9 +94,10 @@ scenario_requires_frozen_config() { } job_name() { - local name + local name run_hash name="${SCENARIO_NAME:?SCENARIO_NAME is required}" - printf 'duckgres-scenario-%s\n' "$name" | tr '_' '-' + run_hash="$(printf '%s' "${DUCKGRES_SCENARIO_RUN_ID:?DUCKGRES_SCENARIO_RUN_ID is required}" | cksum | awk '{print $1}')" + printf 'duckgres-scenario-%s-%s\n' "$name" "$run_hash" | tr '_' '-' } cmd_deploy() { @@ -99,7 +109,7 @@ cmd_deploy() { } cmd_test() { - local ns job api_base pg flight rc + local ns job api_base pg flight suffix rc : "${SCENARIO_RUNNER_IMAGE:?SCENARIO_RUNNER_IMAGE is required}" : "${SCENARIO_FILE:?SCENARIO_FILE is required}" : "${DUCKGRES_SCENARIO_RUN_ID:?DUCKGRES_SCENARIO_RUN_ID is required}" @@ -111,6 +121,7 @@ cmd_test() { api_base="$(control_plane_api_base)" pg="$(pg_host)" flight="$(flight_addr)" + suffix="$(sni_suffix)" if scenario_requires_frozen_config; then ensure_config_secret fi @@ -127,6 +138,8 @@ spec: template: spec: restartPolicy: Never + nodeSelector: + kubernetes.io/arch: arm64 containers: - name: scenario image: $SCENARIO_RUNNER_IMAGE @@ -140,7 +153,7 @@ spec: name: $SCENARIO_INTERNAL_SECRET_NAME key: $SCENARIO_INTERNAL_SECRET_KEY - { name: DUCKGRES_SCENARIO_PG_HOST, value: "$pg" } - - { name: DUCKGRES_SCENARIO_SNI_SUFFIX, value: "$SCENARIO_SNI_SUFFIX" } + - { name: DUCKGRES_SCENARIO_SNI_SUFFIX, value: "$suffix" } - { name: DUCKGRES_SCENARIO_FROZEN_S3_URI, valueFrom: { secretKeyRef: { name: $SCENARIO_CONFIG_SECRET_NAME, key: frozen-s3-uri, optional: true } } } - { name: DUCKGRES_SCENARIO_FLIGHT_ADDR, value: "$flight" } - { name: DUCKGRES_SCENARIO_FLIGHT_INSECURE_SKIP_VERIFY, value: "true" } diff --git a/tests/scenario-dev/run_sh_test.go b/tests/scenario-dev/run_sh_test.go index b35605d8..425dc83b 100644 --- a/tests/scenario-dev/run_sh_test.go +++ b/tests/scenario-dev/run_sh_test.go @@ -82,6 +82,41 @@ func TestProvisionScenarioDoesNotRequireFrozenConfigSecret(t *testing.T) { } } +func TestScenarioJobPinsArm64RunnerImage(t *testing.T) { + raw, err := os.ReadFile("run.sh") + if err != nil { + t.Fatalf("read run.sh: %v", err) + } + script := string(raw) + for _, required := range []string{ + "nodeSelector:", + "kubernetes.io/arch: arm64", + } { + if !strings.Contains(script, required) { + t.Fatalf("scenario job manifest missing %q", required) + } + } +} + +func TestScenarioDevScriptUsesRunSpecificJobNameAndClusterIPHostaddr(t *testing.T) { + raw, err := os.ReadFile("run.sh") + if err != nil { + t.Fatalf("read run.sh: %v", err) + } + script := string(raw) + for _, required := range []string{ + "DUCKGRES_SCENARIO_RUN_ID", + "cksum", + "jsonpath='{.spec.clusterIP}'", + "SCENARIO_ISOLATED_SNI_SUFFIX", + "SCENARIO_SHARED_SNI_SUFFIX", + } { + if !strings.Contains(script, required) { + t.Fatalf("scenario-dev script missing %q", required) + } + } +} + func newFakeBin(t *testing.T) (string, string) { t.Helper() @@ -98,15 +133,19 @@ if [[ "$*" == *" get secret duckgres-scenario-config "* ]]; then printf 'frozen config secret should not be required for this scenario\n' >&2 exit 1 fi +if [[ "$*" == *" get svc duckgres-control-plane "* ]]; then + printf '10.96.0.20' + exit 0 +fi if [[ "$*" == *" apply -f -"* ]]; then cat >/dev/null exit 0 fi -if [[ "$*" == *" get job duckgres-scenario-provision-smoke "* ]]; then +if [[ "$*" == *" get job duckgres-scenario-provision-smoke-"* ]]; then printf 'True' exit 0 fi -if [[ "$*" == *" get pod -l job-name=duckgres-scenario-provision-smoke "* ]]; then +if [[ "$*" == *" get pod -l job-name=duckgres-scenario-provision-smoke-"* ]]; then printf 'duckgres-scenario-provision-smoke-pod' exit 0 fi diff --git a/tests/scenario/dbt/runner_test.go b/tests/scenario/dbt/runner_test.go index 8b4e2ae3..2bae7a09 100644 --- a/tests/scenario/dbt/runner_test.go +++ b/tests/scenario/dbt/runner_test.go @@ -92,10 +92,10 @@ func TestExecutorRunsCommandsCapturesLogsAndCopiesTargetArtifacts(t *testing.T) t.Fatalf("DUCKGRES_DBT_HOST = %q", envValue(first.Env, "DUCKGRES_DBT_HOST")) } if envValue(first.Env, "DUCKGRES_DBT_HOSTADDR") != "" { - t.Fatal("expected command environment to avoid DUCKGRES_DBT_HOSTADDR because dbt-postgres expects hostaddr to be numeric") + t.Fatal("expected command environment to avoid unsupported dbt profile hostaddr") } - if envValue(first.Env, "PGHOSTADDR") != "" { - t.Fatal("expected command environment to avoid PGHOSTADDR because dbt-postgres expects hostaddr to be numeric") + if envValue(first.Env, "PGHOSTADDR") != "10.0.0.10" { + t.Fatalf("PGHOSTADDR = %q", envValue(first.Env, "PGHOSTADDR")) } if envValue(first.Env, "DBT_ENV_SECRET_DUCKGRES_PASSWORD") != "root-password" { t.Fatal("expected command environment to include provision password as a dbt secret") @@ -130,6 +130,43 @@ func TestExecutorRunsCommandsCapturesLogsAndCopiesTargetArtifacts(t *testing.T) } } +func TestExecutorOmitsPGHostAddrWhenConnectionHostAddrIsNotNumeric(t *testing.T) { + projectDir := writeDBTProject(t) + provisionState := provision.NewState() + provisionState.StoreProvisionResponse("scenario-org", provision.ProvisionResponse{ + Org: "scenario-org", + Username: "root", + Password: "root-password", + }) + runner := &fakeCommandRunner{} + executor := NewExecutor(ExecutorConfig{ + ProvisionState: provisionState, + Connection: scenariosql.ConnectionConfig{ + HostAddr: "duckgres-control-plane.test.svc", + SNISuffix: ".dev.example", + SSLMode: "require", + }, + OutputDir: t.TempDir(), + CommandRunner: runner, + }) + + err := executor.ExecuteStep(context.Background(), core.Step{ + ID: "dbt_models", + Type: StepTypeDBTRun, + With: map[string]any{ + "org_id": "scenario-org", + "project_dir": projectDir, + "commands": []any{"debug"}, + }, + }) + if err != nil { + t.Fatalf("ExecuteStep returned error: %v", err) + } + if envValue(runner.requests[0].Env, "PGHOSTADDR") != "" { + t.Fatalf("expected non-numeric hostaddr to be omitted, env=%#v", runner.requests[0].Env) + } +} + func TestExecutorStopsAfterFailedDBTCommand(t *testing.T) { projectDir := writeDBTProject(t) provisionState := provision.NewState() diff --git a/tests/scenario/dbt/steps.go b/tests/scenario/dbt/steps.go index 141ceb84..a4d23b78 100644 --- a/tests/scenario/dbt/steps.go +++ b/tests/scenario/dbt/steps.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "net/netip" "os" "os/exec" "path/filepath" @@ -350,7 +351,7 @@ func (e *Executor) commandEnv(spec stepSpec) []string { if connectTimeout == 0 { connectTimeout = 10 } - return []string{ + env := []string{ "DUCKGRES_DBT_HOST=" + spec.OrgID + e.connection.SNISuffix, "DUCKGRES_DBT_PORT=" + strconv.Itoa(port), "DUCKGRES_DBT_USER=" + spec.Username, @@ -360,6 +361,10 @@ func (e *Executor) commandEnv(spec stepSpec) []string { "DUCKGRES_DBT_SSLMODE=" + spec.SSLMode, "DUCKGRES_DBT_CONNECT_TIMEOUT=" + strconv.Itoa(connectTimeout), } + if _, err := netip.ParseAddr(e.connection.HostAddr); err == nil { + env = append(env, "PGHOSTADDR="+e.connection.HostAddr) + } + return env } func commandsFromStep(step core.Step) ([]commandSpec, error) {