# End-to-end (e2e) tests and CI This document explains how the e2e suite is built and run in CI after the fan-out change: what the moving parts are, how a run flows, how credentials are scoped per provider, and how to add or enable a provider. ## Goals - Run the core controller behaviour and each provider as **separate CI legs**, so a flaky or broken addon in one provider fails only its own leg. - Give CI a **single, stable required status** even though the set of legs changes over time. - Give each leg **only the credentials it needs**, so a leg that tests one provider never has another provider's secrets in its environment. ## The pieces | File | Role | | ---- | ---- | | `e2e/suites//` | Ginkgo suites, compiled to `.test` binaries: `provider`, `generator`, `flux`, `argocd`. | | `e2e/suites/provider/cases/import.go` | Blank-imports every provider case into the single `provider.test` binary. Providers are told apart at run time by Ginkgo label. | | `e2e/matrix.yaml` | Source of truth for the fan-out: one `area` (leg) per provider, with its suite, label filter, secret groups, and trigger paths. | | `e2e/matrix.py` | Validates the matrix (`check`), emits the CI matrix JSON (`json`), and prints the per-leg credential plan (`plan`). | | `e2e/run.sh` | Host-side launcher. Runs `kubectl run` to start the e2e pod, forwarding `TEST_SUITES`, `GINKGO_LABELS`, `E2E_SKIP_GLOBAL_TEARDOWN`, and the (scoped) credentials as pod env. | | `e2e/entrypoint.sh` | In-pod entry (image `CMD`). Loops over `TEST_SUITES` and runs `ginkgo -label-filter="$GINKGO_LABELS"` against each `.test`. | | `.github/workflows/e2e.yml` | Non-managed e2e. Fans out into per-provider legs. Owns the `e2e-required` gate. | | `.github/workflows/e2e-reusable.yml` | The reusable build + matrix-test pipeline that `e2e.yml` calls. | | `.github/workflows/e2e-managed.yml` | Managed e2e (real cloud IRSA / workload-identity), run on demand via `/ok-to-test-managed`. Already per-provider. | ## How a run flows ```mermaid flowchart TD T[pull_request or /ok-to-test] --> P[prepare-matrix: matrix.py check + plan + json] T --> B[build: compile controller + e2e images once, upload tarball] P --> M{fan out over enabled areas} B --> M M --> L1[test core-smoke] M --> L2[test vault] M --> L3[test aws] M --> Ln[test ...] L1 --> R[e2e-required] L2 --> R L3 --> R Ln --> R R --> G[single stable green/red status] ``` 1. **prepare-matrix** runs `matrix.py check` (fail early if the matrix is inconsistent), prints the credential `plan`, and emits the enabled-areas matrix as JSON. This job has no secrets in scope. 2. **build** compiles the controller and e2e images once and uploads them as a tarball artifact. The test legs load that tarball; they need no Go toolchain. 3. **test** is a `fail-fast: false` matrix over the enabled areas. Each leg gets its own runner and its own kind cluster, loads the shared image tarball, and runs one suite under one label filter (`TEST_SUITES` + `GINKGO_LABELS`). 4. **e2e-required** aggregates the result into one status (see below). ## The matrix (`e2e/matrix.yaml`) Each `area` is one leg: ```yaml - name: aws # leg id, shown as "test (aws)" suite: provider # which suite binary (TEST_SUITES) labels: "aws && !managed" # Ginkgo -label-filter (GINKGO_LABELS) providers: [aws] # for the coverage check only secret_groups: [aws] # which credential groups this leg receives needs_secrets: true # mirror of "secret_groups is non-empty" paths: # phase 2 seed (affected-only), unused today - "providers/v1/aws/**" - "e2e/suites/provider/cases/aws/**" enabled: true # whether the phase 1 matrix runs it now ``` Notes: - **One suite per leg.** A label filter never has to span binaries that lack the labels. Provider legs use `suite: provider`; `generator`, `flux`, and `argocd` are their own suites. - **`!managed` everywhere.** This workflow runs only the non-managed specs; the managed IRSA / workload-identity specs run in `e2e-managed.yml`. - **`enabled`** lets the matrix grow gradually. Disabled areas still count for coverage and document the intended full matrix; flip to `true` to run them. ## Credential scoping (no secret spread) A leg receives a provider's secrets only when its `secret_groups` lists that group. In `e2e-reusable.yml` every secret is gated: ```yaml GCP_SERVICE_ACCOUNT_KEY: ${{ contains(matrix.secret_groups, 'gcp') && secrets.GCP_SERVICE_ACCOUNT_KEY || '' }} ``` So a `vault` or `core-smoke` leg (`secret_groups: []`) gets empty strings for every cloud credential, and the `aws` leg gets only the `aws` group. The group -> variable mapping lives in `e2e-reusable.yml`. Scoping is proven **without reading any secret**: `matrix.py plan` runs in `prepare-matrix` (which has no secrets in scope) and derives each leg's credential list from `matrix.yaml` plus the group mapping parsed out of the workflow text. It never references the `secrets` context, so nothing depends on GitHub's log masking. Example: ``` core-smoke: groups=[] -> (none: in-cluster only) vault: groups=[] -> (none: in-cluster only) aws: groups=['aws'] -> AWS_OIDC_ROLE_ARN, AWS_SA_NAME, AWS_SA_NAMESPACE ``` Which providers actually need external credentials: `fake`, `kubernetes`, `template`, `crd`, `vault`, `openbao`, `conjur`, and `infisical` run against in-cluster addons (or, for `crd` and `kubernetes`, the cluster's own API) and need none. The rest hit real APIs and are scoped to their group. The `generator` suite is split across two legs by label. The `generator` leg runs every generator except grafana (`!managed && !grafana`) and is scoped to `aws`, because the ecr and sts generators mint tokens against real AWS. The `grafana` leg (`grafana && !managed`, scoped to `grafana`) is isolated on its own because the grafana generator depends on a live external Grafana Cloud instance; keeping it separate means its external flakiness is attributable and never masks the other generators. ## The `e2e-required` gate The individual leg names change as the matrix grows, which makes them a poor target for branch protection. `e2e-required` (in `e2e.yml`) is one job that `needs` the trusted and fork callers and reports a single status: - passes when the e2e path that ran for this event succeeded, - treats the other (skipped) path as a non-failure, - fails if any leg failed or was cancelled (a failed leg propagates up through its caller job). Point branch protection at `e2e-required` and it stays stable regardless of how many legs exist. ## Trusted vs fork runs - **Same-repo PR** (`integration-trusted`): runs automatically with secrets. - **Fork PR**: a maintainer comments `/ok-to-test sha=<40-char-sha>` (or submits a review whose body contains `/ok-to-test`, which pins the reviewed commit). `guard-fork` rejects a bare command without a pinned SHA; `integration-fork` then runs. Note that the fork path runs the workflow from `main`, not from the PR branch. - **Managed** (`e2e-managed.yml`): `/ok-to-test-managed`, one job per cloud provider, `GINKGO_LABELS=" && managed"`. ## Local usage ```bash # validate the matrix (coverage, secret-scoping consistency, wiring) make -C e2e matrix.check # show which credentials each enabled leg will receive (reads no secrets) make -C e2e matrix.plan # run a single provider locally (overrides the Makefile defaults) make -C e2e test.run TEST_SUITES=provider GINKGO_LABELS="vault && !managed" # leave the global addons installed, for a cluster you are about to delete. # Saves about a minute; the kind legs set it, e2e-managed.yml does not. # Refused (stderr) when TEST_SUITES names several suites, and that guard sees # only its own process, so two single-suite runs on one cluster still collide. make -C e2e test.run TEST_SUITES=provider GINKGO_LABELS="vault && !managed" \ E2E_SKIP_GLOBAL_TEARDOWN=true ``` ## Adding or enabling a provider 1. Add the provider case under `e2e/suites/provider/cases//` and blank import it in `import.go`. 2. Add an `area` for it in `matrix.yaml` (its label, `providers: []`, and `paths`). 3. If it needs external credentials, add its secret group to `secret_groups` and wire that group's env vars in `e2e-reusable.yml`. 4. Set `enabled: true` when you want CI to run it. `matrix.py check` (run in `prepare-matrix`) enforces steps 1-3: it fails the build if a provider is compiled into the suite but not covered by an area, if `needs_secrets` disagrees with `secret_groups`, or if an area names a secret group that the workflow does not wire. ## Oracle: local only, by design The oracle suite runs against a real OCI tenancy and takes eight variables: `ORACLE_TENANCY_OCID`, `ORACLE_USER_OCID`, `ORACLE_REGION`, `ORACLE_FINGERPRINT`, `ORACLE_KEY` (the PEM signing key itself, which must have no passphrase), plus `ORACLE_VAULT_OCID`, `ORACLE_COMPARTMENT_OCID` and `ORACLE_ENCRYPTION_KEY_OCID`. The last three identify where secrets are created; the Vault API requires all of them and the `SecretStore` requires the vault. Note that `ORACLE_KEY` and `ORACLE_ENCRYPTION_KEY_OCID` are unrelated. The first is an RSA key that authenticates the caller; the second is the OCID of a symmetric AES key inside the vault that encrypts secrets at rest. **This leg is not enabled in CI, and that is a decision rather than a gap.** An always-free OCI tenancy caps secrets at 150, and a deleted secret cannot return to that pool for 24 hours; the floor is enforced, so values below it are rejected outright. One run of this suite creates 15 secrets, which allows about ten runs a rolling day before creates start failing for reasons unrelated to the change under test. `--flake-attempts=2` and `-p -nodes=5` in `entrypoint.sh` lower that further, the latter also being enough concurrency for the Vaults service to return `429`. Enabling this leg needs a tenancy without that cap. Until then the suite is a pre-submit tool for contributors with their own tenancy: ```bash make -C e2e test.run TEST_SUITES=provider GINKGO_LABELS="oracle && !managed" ```