diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ebdd03e..f4f1a26 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -33,7 +33,7 @@ export default defineConfig({ { text: "Examples", link: "/examples/" }, { text: "Overlay Testing", link: "/overlay/" }, { - text: "v2.2.0", + text: "v2.3.0", items: [{ text: "Changelog", link: "/changelog" }], }, ], @@ -277,6 +277,11 @@ export default defineConfig({ { text: "Plugin Metadata", link: "/api/utils/plugin-metadata" }, ], }, + { + text: "Secrets", + collapsed: true, + items: [{ text: "Secrets API", link: "/api/secrets" }], + }, { text: "ESLint", collapsed: true, diff --git a/docs/api/helpers/login-helper.md b/docs/api/helpers/login-helper.md index fc2da2d..bd98cf6 100644 --- a/docs/api/helpers/login-helper.md +++ b/docs/api/helpers/login-helper.md @@ -46,8 +46,8 @@ async loginAsGithubUser(): Promise Login using GitHub OAuth. **Required environment variables:** -- `VAULT_GH_USER_NAME` -- `VAULT_GH_USER_PASSWORD` +- `VAULT_GH_USER_ID` +- `VAULT_GH_USER_PASS` - `VAULT_GH_2FA_SECRET` ### `signOut()` diff --git a/docs/api/index.md b/docs/api/index.md index 70de4de..bbb5c3a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,6 +14,7 @@ Complete API documentation for all exports from `@red-hat-developer-hub/e2e-test | [`/helpers`](/api/helpers/ui-helper) | Helper classes | | [`/pages`](/api/pages/catalog-page) | Page object classes | | [`/eslint`](/api/eslint/create-eslint-config) | ESLint configuration | +| [`/secrets`](/api/secrets) | Bitwarden local secret execution | ## Categories @@ -57,6 +58,10 @@ Complete API documentation for all exports from `@red-hat-developer-hub/e2e-test - [envsubst](/api/utils/common) - Environment substitution - [Plugin Metadata](/api/utils/plugin-metadata) - Plugin metadata injection +### Secrets + +- [Secrets](/api/secrets) - Bitwarden-backed local execution APIs + ### ESLint - [createEslintConfig](/api/eslint/create-eslint-config) - ESLint factory diff --git a/docs/api/secrets.md b/docs/api/secrets.md new file mode 100644 index 0000000..b8a496e --- /dev/null +++ b/docs/api/secrets.md @@ -0,0 +1,39 @@ +# Secrets API + +The `@red-hat-developer-hub/e2e-test-utils/secrets` export provides provider +access and child-process execution for local tests. It does not run from +Playwright global setup. + +## Local Command + +```bash +export BW_SESSION="" +rhdh-e2e-secrets exec \ + --profile e2e-secrets.profile.json \ + --workspace tech-radar \ + -- yarn playwright test +``` + +The `bw` executable must be installed locally and available on `PATH`. The +tool does not log in, unlock, lock, or persist the Bitwarden session. + +## Public Functions + +```typescript +parseProfile(value: unknown): SecretProfile +expandProfile(profile: SecretProfile, workspaces?: readonly string[]): ExpandedSecretProfile +getCollectionMapping(collection: string): CollectionMapping +new BitwardenClient(options?: BitwardenClientOptions) +executeCommand(options: ExecuteCommandOptions): Promise +materializeEnvironment(secrets, selectors, parent?): NodeJS.ProcessEnv +``` + +Profiles contain collection and prefix selectors but never secret values. Only +the approved readable collections are accepted; `rhdh-aws-credentials` is +explicitly denied. Environment destinations preserve legacy `VAULT_*` names +when the profile requests the `legacy-env` transformation. + +## Related Pages + +- [Global Setup](/guide/core-concepts/global-setup) - Provider-neutral Playwright setup +- [Package Exports](/guide/core-concepts/package-exports) - All package entry points diff --git a/docs/changelog.md b/docs/changelog.md index d9f4f1b..4039379 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,18 @@ All notable changes to this project will be documented in this file. -## [2.1.13] - Current +## [2.3.0] - Current + +### Added + +- **Bitwarden local secret execution**: Added the `./secrets` export and `rhdh-e2e-secrets exec` command for scoped, value-free profile loading into a child test process. + +### Changed + +- **Playwright global setup**: Secret-provider access is now external to global setup, and `.env` values fill only missing environment variables. +- **Overlay local workflows**: Secret-backed tests use the `test:secrets` command and preserve the existing `VAULT_*` payload variable names. + +## [2.1.13] ### Added @@ -68,6 +79,7 @@ All notable changes to this project will be documented in this file. ### Changed - **Trace retention on all test runs**: Changed Playwright trace setting from `"retain-on-failure"` to `"on"` so traces are always retained, including on passed tests. This enables the fullsend e2e-triage agent to compare passing and failing traces for more accurate root cause analysis. + ## [2.1.4] ### Changed @@ -123,7 +135,6 @@ All notable changes to this project will be documented in this file. - Starting CSV version for OSL operator was removed so latest stable will now be installed directly. - ## [1.1.43] ### Fixed diff --git a/docs/examples/custom-deployment.md b/docs/examples/custom-deployment.md index c328341..4a7b28a 100644 --- a/docs/examples/custom-deployment.md +++ b/docs/examples/custom-deployment.md @@ -74,15 +74,15 @@ import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; test.beforeAll(async ({ rhdh }) => { // Set secrets at runtime - process.env.GITHUB_TOKEN = await getSecretFromVault("github-token"); - process.env.API_KEY = await getSecretFromVault("api-key"); + process.env.GITHUB_TOKEN = await getSecret("github-token"); + process.env.API_KEY = await getSecret("api-key"); await rhdh.configure({ auth: "keycloak" }); await rhdh.deploy(); }); -async function getSecretFromVault(name: string): Promise { - // Your vault integration +async function getSecret(name: string): Promise { + // Use the secret provider approved for your environment. return "secret-value"; } ``` diff --git a/docs/guide/configuration/environment-variables.md b/docs/guide/configuration/environment-variables.md index ae60024..be4f5c3 100644 --- a/docs/guide/configuration/environment-variables.md +++ b/docs/guide/configuration/environment-variables.md @@ -108,8 +108,8 @@ For GitHub integration: | Variable | Description | Required | | ------------------------- | ---------------------------- | ------------ | | `VAULT_GITHUB_USER_TOKEN` | GitHub personal access token | For API/auth | -| `VAULT_GH_USER_NAME` | GitHub username | For login | -| `VAULT_GH_USER_PASSWORD` | GitHub password | For login | +| `VAULT_GH_USER_ID` | GitHub username | For login | +| `VAULT_GH_USER_PASS` | GitHub password | For login | | `VAULT_GH_2FA_SECRET` | 2FA secret for OTP | For login | ## Custom Variables @@ -145,7 +145,7 @@ GITHUB_TOKEN=ghp_xxxxx MY_API_KEY=secret-value ``` -The `.env` file is automatically loaded by global setup. Variables defined here take priority over Vault secrets. +The `.env` file is automatically loaded by global setup. Variables already supplied by the local secret wrapper are preserved; `.env` fills only missing values. ### CI/CD diff --git a/docs/guide/core-concepts/global-setup.md b/docs/guide/core-concepts/global-setup.md index d85e43c..3a755e0 100644 --- a/docs/guide/core-concepts/global-setup.md +++ b/docs/guide/core-concepts/global-setup.md @@ -4,31 +4,20 @@ The package includes a global setup function that runs once before all tests. Th ## What Global Setup Does -### 1. Vault Secret Loading (Local Development) +### 1. Provider-Neutral Setup -When `VAULT=1` or `VAULT=true` is set, global setup fetches secrets from HashiCorp Vault before anything else runs: - -- Checks that the `vault` CLI is installed -- Logs in via OIDC if not already authenticated (opens browser) -- Fetches global secrets and per-workspace secrets -- Injects all `VAULT_*` keys into `process.env` -- Only logs key names, never secret values +Global setup does not access a secret provider. For local runs, invoke the +standalone `rhdh-e2e-secrets` command before Playwright so selected values are +available before configuration files, imports, and global setup execute: ```bash -# From workspace -VAULT=1 yarn test - -# From repo root -VAULT=1 ./run-e2e.sh -w argocd +export BW_SESSION="" +rhdh-e2e-secrets exec --profile e2e-secrets.profile.json --workspace argocd -- yarn playwright test ``` -If you don't have Vault access, request it in Slack: `#rhdh-e2e-tests`. - -| Variable | Description | Default | -|----------|-------------|---------| -| `VAULT` | Enable Vault secret loading (`1` or `true`) | - | -| `VAULT_ADDR` | Vault server URL | `https://vault.ci.openshift.org` | -| `VAULT_BASE_PATH` | Base path in Vault | `selfservice/rhdh-plugin-export-overlays` | +The wrapper reads only the prefixes declared by the profile and passes the +selected values to the child process. Existing environment values take +priority over `.env` values loaded by global setup. ### 2. Binary Validation diff --git a/docs/guide/core-concepts/package-exports.md b/docs/guide/core-concepts/package-exports.md index 32762ec..d8467b3 100644 --- a/docs/guide/core-concepts/package-exports.md +++ b/docs/guide/core-concepts/package-exports.md @@ -15,6 +15,7 @@ The package provides multiple entry points for different use cases. Each export | `@red-hat-developer-hub/e2e-test-utils/pages` | Page object classes for common RHDH pages | | `@red-hat-developer-hub/e2e-test-utils/eslint` | ESLint configuration factory | | `@red-hat-developer-hub/e2e-test-utils/tsconfig` | Base TypeScript configuration | +| `@red-hat-developer-hub/e2e-test-utils/secrets` | Bitwarden local secret execution APIs | ## Detailed Exports @@ -113,6 +114,21 @@ Factory function for creating ESLint flat config with Playwright and TypeScript Base TypeScript configuration to extend in your project. +### Secrets (`/secrets`) + +```typescript +import { + BitwardenClient, + executeCommand, + parseProfile, +} from "@red-hat-developer-hub/e2e-test-utils/secrets"; +``` + +The secrets export provides profile validation, scoped Bitwarden reads, safe +child-environment materialization, and programmatic command execution. The +`rhdh-e2e-secrets` CLI is a separate executable and is not imported by +Playwright global setup. + ## Usage Patterns ### Minimal Test Setup diff --git a/docs/guide/deployment/authentication.md b/docs/guide/deployment/authentication.md index 3628a11..9a203ba 100644 --- a/docs/guide/deployment/authentication.md +++ b/docs/guide/deployment/authentication.md @@ -140,7 +140,7 @@ test.beforeEach(async ({ loginHelper }) => { await loginHelper.loginAsGithubUser(); }); ``` -By default, test user credentials will be pulled from the global workspace in vault. +When supplied by a local secret wrapper or CI, test user credentials are read from the global secret set. ::: warning GitHub authentication requires 2FA secret for automated logins. This is more complex to set up than guest or Keycloak auth. @@ -154,7 +154,7 @@ No additional environment variables required. ### Keycloak Auth -These are automatically set by `KeycloakHelper.configureForRHDH()` or populated from global workspace in the vault: +These are automatically set by `KeycloakHelper.configureForRHDH()` or populated from the caller's environment: | Variable | Description | | ------------------------------- | --------------------- | @@ -169,7 +169,7 @@ These are automatically set by `KeycloakHelper.configureForRHDH()` or populated ### GitHub Auth -Configuring github auth provider will populate the following variables from global workspace in the vault: +Configuring the GitHub auth provider consumes the following variables from the caller's environment: | Variable | Description | |----------|-------------| diff --git a/docs/guide/helpers/login-helper.md b/docs/guide/helpers/login-helper.md index 78ca42b..4df4c7f 100644 --- a/docs/guide/helpers/login-helper.md +++ b/docs/guide/helpers/login-helper.md @@ -62,8 +62,8 @@ await loginHelper.loginAsGithubUser(); ``` Required environment variables: -- `VAULT_GH_USER_NAME` - GitHub username -- `VAULT_GH_USER_PASSWORD` - GitHub password +- `VAULT_GH_USER_ID` - GitHub username +- `VAULT_GH_USER_PASS` - GitHub password - `VAULT_GH_2FA_SECRET` - GitHub 2FA secret (for OTP generation) ::: warning @@ -192,8 +192,8 @@ test("login flow", async ({ page, loginHelper }) => { | Variable | Description | Required | |----------|-------------|----------| -| `VAULT_GH_USER_NAME` | GitHub username | Yes | -| `VAULT_GH_USER_PASSWORD` | GitHub password | Yes | +| `VAULT_GH_USER_ID` | GitHub username | Yes | +| `VAULT_GH_USER_PASS` | GitHub password | Yes | | `VAULT_GH_2FA_SECRET` | 2FA secret for OTP | Yes | ## Troubleshooting diff --git a/docs/guide/index.md b/docs/guide/index.md index b7b4e1a..daceb3b 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -74,5 +74,5 @@ The package simplifies end-to-end testing for RHDH plugins by providing: 4. [Core Concepts](/guide/core-concepts/) - Understand the key concepts ::: tip For Overlay Repository Contributors -If you're writing tests in the **rhdh-plugin-export-overlays** repository, see the [Overlay Testing](/overlay/) documentation for repository-specific guidance including CI/CD integration, Vault secrets, and workspace structure. +If you're writing tests in the **rhdh-plugin-export-overlays** repository, see the [Overlay Testing](/overlay/) documentation for repository-specific guidance including CI/CD integration, local Bitwarden secrets, and workspace structure. ::: diff --git a/docs/overlay/examples/basic-plugin.md b/docs/overlay/examples/basic-plugin.md index 7ee1566..97eb93f 100644 --- a/docs/overlay/examples/basic-plugin.md +++ b/docs/overlay/examples/basic-plugin.md @@ -10,6 +10,7 @@ This is a minimal example of E2E tests for a simple plugin that doesn't require ## Overview This example shows the simplest possible E2E test setup for a plugin in the overlay repository. Use this as a starting point for plugins that: + - Don't require external data providers - Don't need custom Kubernetes resources - Have straightforward UI interactions @@ -49,7 +50,7 @@ workspaces//e2e-tests/ "description": "E2E tests for ", "scripts": { "test": "playwright test", - "test:vault": "VAULT=1 playwright test", + "test:secrets": "rhdh-e2e-secrets exec --profile ../../../e2e-secrets.profile.json --workspace -- playwright test", "report": "playwright show-report", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", @@ -63,7 +64,7 @@ workspaces//e2e-tests/ "devDependencies": { "@eslint/js": "10.0.1", "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "1.1.33", + "@red-hat-developer-hub/e2e-test-utils": "2.3.0", "@types/node": "25.5.2", "eslint": "10.2.0", "eslint-plugin-check-file": "3.3.1", @@ -209,15 +210,15 @@ test.describe("Test ", () => { ## Common UIhelper Methods -| Method | Description | -|--------|-------------| -| `openSidebar(name)` | Click sidebar navigation item | -| `verifyHeading(text)` | Verify heading text is visible | -| `verifyText(text)` | Verify text is visible | -| `clickButton(name)` | Click button by name | -| `clickLink(text)` | Click link by text | -| `fillTextInputByLabel(label, value)` | Fill input field | -| `waitForLoad()` | Wait for page to finish loading | +| Method | Description | +| ------------------------------------ | ------------------------------- | +| `openSidebar(name)` | Click sidebar navigation item | +| `verifyHeading(text)` | Verify heading text is visible | +| `verifyText(text)` | Verify text is visible | +| `clickButton(name)` | Click button by name | +| `clickLink(text)` | Click link by text | +| `fillTextInputByLabel(label, value)` | Fill input field | +| `waitForLoad()` | Wait for page to finish loading | See [UIhelper API](/api/helpers/ui-helper) for the full API reference. diff --git a/docs/overlay/examples/tech-radar.md b/docs/overlay/examples/tech-radar.md index a18b8b8..81b8497 100644 --- a/docs/overlay/examples/tech-radar.md +++ b/docs/overlay/examples/tech-radar.md @@ -10,6 +10,7 @@ This is a complete annotated example of E2E tests for the Tech Radar plugin in t ## Overview The Tech Radar plugin displays technology choices in a radar visualization. The E2E tests verify that: + - The plugin loads correctly - The radar displays expected sections - Specific technologies appear in the correct sections @@ -52,7 +53,7 @@ workspaces/tech-radar/e2e-tests/ "description": "E2E tests for Tech Radar plugin", "scripts": { "test": "playwright test", - "test:vault": "VAULT=1 playwright test", + "test:secrets": "rhdh-e2e-secrets exec --profile ../../../e2e-secrets.profile.json --workspace tech-radar -- playwright test", "report": "playwright show-report", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", @@ -66,7 +67,7 @@ workspaces/tech-radar/e2e-tests/ "devDependencies": { "@eslint/js": "10.0.1", "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "1.1.33", + "@red-hat-developer-hub/e2e-test-utils": "2.3.0", "@types/node": "25.5.2", "eslint": "10.2.0", "eslint-plugin-check-file": "3.3.1", @@ -142,6 +143,7 @@ techRadar: ``` **Key points:** + - `app.title` - Custom title for the test instance - `backend.reading.allow` - Allows RHDH to fetch from the data provider - `techRadar.url` - URL to the Tech Radar JSON data @@ -196,6 +198,7 @@ deploy_test_backstage_customization_provider "$1" ``` **Key points:** + - Idempotent - checks if resources exist before creating - Dynamic Node.js version detection from cluster - Fallback to known working version @@ -222,27 +225,30 @@ test.describe("Test tech-radar plugin", () => { // Wrap in runOnce — the external service deployment is expensive // and should not re-run when Playwright restarts the worker after a test failure test.beforeAll(async ({ rhdh }) => { - await test.runOnce(`tech-radar-setup-${rhdh.deploymentConfig.namespace}`, async () => { - const project = rhdh.deploymentConfig.namespace; - - // Configure RHDH with Keycloak authentication - await rhdh.configure({ auth: "keycloak" }); - - // Deploy the external data provider service - await $`bash ${setupScript} ${project}`; - - // Get the route URL and set as environment variable - // Remove http:// prefix as the config expects just the host - process.env.TECH_RADAR_DATA_URL = ( - await rhdh.k8sClient.getRouteLocation( - project, - "test-backstage-customization-provider", - ) - ).replace("http://", ""); - - // Deploy RHDH (will use the TECH_RADAR_DATA_URL env var) - await rhdh.deploy(); - }); + await test.runOnce( + `tech-radar-setup-${rhdh.deploymentConfig.namespace}`, + async () => { + const project = rhdh.deploymentConfig.namespace; + + // Configure RHDH with Keycloak authentication + await rhdh.configure({ auth: "keycloak" }); + + // Deploy the external data provider service + await $`bash ${setupScript} ${project}`; + + // Get the route URL and set as environment variable + // Remove http:// prefix as the config expects just the host + process.env.TECH_RADAR_DATA_URL = ( + await rhdh.k8sClient.getRouteLocation( + project, + "test-backstage-customization-provider", + ) + ).replace("http://", ""); + + // Deploy RHDH (will use the TECH_RADAR_DATA_URL env var) + await rhdh.deploy(); + }, + ); }); // beforeEach runs before each test @@ -282,6 +288,7 @@ async function verifyRadarDetails(page: Page, section: string, text: string) { ``` **Key points:** + - Uses `rhdh` fixture for deployment management - Uses `$` utility for bash command execution - Gets route URL via `k8sClient.getRouteLocation()` diff --git a/docs/overlay/index.md b/docs/overlay/index.md index df01976..a4c9fc9 100644 --- a/docs/overlay/index.md +++ b/docs/overlay/index.md @@ -66,10 +66,10 @@ metadata/*.yaml -> deploy RHDH ``` -### Vault → Secrets → Config +### Local Secrets → Config ```text -Vault / .env +Bitwarden wrapper / .env -> rhdh-secrets.yaml -> app-config-rhdh.yaml -> deploy RHDH diff --git a/docs/overlay/reference/environment-variables.md b/docs/overlay/reference/environment-variables.md index 85c5056..44bfa87 100644 --- a/docs/overlay/reference/environment-variables.md +++ b/docs/overlay/reference/environment-variables.md @@ -7,30 +7,30 @@ For using @red-hat-developer-hub/e2e-test-utils in external projects, see the [G This page documents all environment variables used in overlay E2E tests. -## Vault Secrets (VAULT\_\*) +## Secret Variables (VAULT\_\*) -In OpenShift CI, secrets are managed through [HashiCorp Vault](https://vault.ci.openshift.org) and automatically exported as environment variables. +OpenShift CI and the local Bitwarden wrapper expose selected secret values as +environment variables. The `VAULT_` prefix is a retained secret-name +convention for these legacy payload names; it does not select a provider. All secrets **must** start with the `VAULT_` prefix (e.g., `VAULT_API_KEY`, `VAULT_GITHUB_TOKEN`). -For complete Vault setup instructions including paths, annotations, and access requests, see [OpenShift CI Pipeline - Vault Secrets](/overlay/tutorials/ci-pipeline#vault-secrets). +For local access, use the `e2e-secrets.profile.json` profile and the +`rhdh-e2e-secrets exec` command. -## Vault Auto-Loading (Local Development) +## Bitwarden Access (Local Development) -Set `VAULT=1` or `VAULT=true` to automatically fetch secrets from Vault during global setup. This replaces the need to manually copy secrets into `.env` files. - -| Variable | Description | Default | -| ----------------- | ------------------------------------- | ----------------------------------------- | -| `VAULT` | Enable automatic Vault secret loading | - | -| `VAULT_ADDR` | Vault server URL | `https://vault.ci.openshift.org` | -| `VAULT_BASE_PATH` | Base path in Vault KV store | `selfservice/rhdh-plugin-export-overlays` | +The `--secrets` runner flag or `test:secrets` script invokes the standalone +wrapper. It requires `BW_SESSION` from an already unlocked `bw` session and +does not create a persistent secret file. ```bash -VAULT=1 yarn test -VAULT=1 ./run-e2e.sh -w argocd +export BW_SESSION="" +yarn test:secrets +./run-e2e.sh --secrets -w argocd ``` -See [Running Locally - Secrets from Vault](/overlay/tutorials/running-locally#secrets-from-vault) for full details. +See [Running Locally - Secrets from Bitwarden](/overlay/tutorials/running-locally#secrets-from-bitwarden) for full details. ## Core Variables @@ -183,7 +183,7 @@ RHDH_VERSION=1.5 INSTALLATION_METHOD=helm SKIP_KEYCLOAK_DEPLOYMENT=false -# Vault secrets for local testing +# Secret values for local testing may be supplied by Bitwarden. VAULT_MY_SECRET=local-test-value VAULT_GITHUB_TOKEN=ghp_xxx ``` @@ -202,9 +202,9 @@ test.beforeAll(async ({ rhdh }) => { }); ``` -### In Vault (CI) +### In CI secret storage -Add secrets to the appropriate Vault path with `VAULT_` prefix: +Add secrets to the approved CI collection with the `VAULT_` prefix: ``` VAULT_MY_SECRET: secret-value diff --git a/docs/overlay/reference/run-e2e.md b/docs/overlay/reference/run-e2e.md index 17c68b0..27fa193 100644 --- a/docs/overlay/reference/run-e2e.md +++ b/docs/overlay/reference/run-e2e.md @@ -60,29 +60,29 @@ A workspace is discovered when it has a `workspaces//e2e-tests/` directory ### RHDH Deployment -| Variable | Description | Default | -|----------|-------------|---------| -| `RHDH_VERSION` | RHDH version to deploy (e.g., `1.10`, `next`) | `1.10` | -| `INSTALLATION_METHOD` | Deployment method: `helm` or `operator` | `helm` | -| `SKIP_KEYCLOAK_DEPLOYMENT` | Set `true` to skip Keycloak deployment | - | -| `CATALOG_INDEX_IMAGE` | Override the default catalog index image baked into the RHDH chart | - | +| Variable | Description | Default | +| -------------------------- | ------------------------------------------------------------------ | ------- | +| `RHDH_VERSION` | RHDH version to deploy (e.g., `1.10`, `next`) | `1.10` | +| `INSTALLATION_METHOD` | Deployment method: `helm` or `operator` | `helm` | +| `SKIP_KEYCLOAK_DEPLOYMENT` | Set `true` to skip Keycloak deployment | - | +| `CATALOG_INDEX_IMAGE` | Override the default catalog index image baked into the RHDH chart | - | ### Test Framework -| Variable | Description | Default | -|----------|-------------|---------| -| `CI` | Enables CI mode (forbidOnly, namespace teardown) | `true` | -| `PLAYWRIGHT_VERSION` | Pin `@playwright/test` version | `1.59.1` | -| `E2E_TEST_UTILS_PATH` | Absolute path to a local `e2e-test-utils` build (for testing unpublished changes) | - | -| `E2E_TEST_UTILS_VERSION` | Pin `@red-hat-developer-hub/e2e-test-utils` npm version | `latest` (nightly), empty otherwise | +| Variable | Description | Default | +| ------------------------ | --------------------------------------------------------------------------------- | ----------------------------------- | +| `CI` | Enables CI mode (forbidOnly, namespace teardown) | `true` | +| `PLAYWRIGHT_VERSION` | Pin `@playwright/test` version | `1.59.1` | +| `E2E_TEST_UTILS_PATH` | Absolute path to a local `e2e-test-utils` build (for testing unpublished changes) | - | +| `E2E_TEST_UTILS_VERSION` | Pin `@red-hat-developer-hub/e2e-test-utils` npm version | `latest` (nightly), empty otherwise | ### Plugin Resolution -| Variable | Description | Default | -|----------|-------------|---------| -| `E2E_NIGHTLY_MODE` | When `true`, uses released OCI images from metadata; defaults `E2E_TEST_UTILS_VERSION` to `latest` | `false` | -| `GIT_PR_NUMBER` | PR number for OCI URL generation (uses PR-built images) | - | -| `JOB_NAME` | CI job name; if contains `periodic-`, disables metadata injection. Also used to [auto-derive skip tags](#skip-tags). | - | +| Variable | Description | Default | +| ------------------ | -------------------------------------------------------------------------------------------------------------------- | ------- | +| `E2E_NIGHTLY_MODE` | When `true`, uses released OCI images from metadata; defaults `E2E_TEST_UTILS_VERSION` to `latest` | `false` | +| `GIT_PR_NUMBER` | PR number for OCI URL generation (uses PR-built images) | - | +| `JOB_NAME` | CI job name; if contains `periodic-`, disables metadata injection. Also used to [auto-derive skip tags](#skip-tags). | - | ## Skip Tags @@ -92,11 +92,11 @@ When `JOB_NAME` is set (by OpenShift CI), the script auto-derives a Playwright t The job suffix is extracted from `JOB_NAME` by stripping everything up to and including `-e2e-`. A negative lookahead `(?!-)` is appended so each tag matches exactly — `@skip-ocp-helm` won't accidentally filter `@skip-ocp-helm-nightly`: -| JOB_NAME (suffix shown) | `--grep-invert` pattern | -|--------------------------|-------------------------| -| `...-e2e-ocp-helm` | `@skip-ocp-helm(?!-)` | -| `...-e2e-ocp-helm-nightly` | `@skip-ocp-helm-nightly(?!-)` | -| `...-e2e-ocp-operator` | `@skip-ocp-operator(?!-)` | +| JOB_NAME (suffix shown) | `--grep-invert` pattern | +| ------------------------------ | --------------------------------- | +| `...-e2e-ocp-helm` | `@skip-ocp-helm(?!-)` | +| `...-e2e-ocp-helm-nightly` | `@skip-ocp-helm-nightly(?!-)` | +| `...-e2e-ocp-operator` | `@skip-ocp-operator(?!-)` | | `...-e2e-ocp-operator-nightly` | `@skip-ocp-operator-nightly(?!-)` | If `JOB_NAME` doesn't contain `-e2e-`, no tag is derived and no filtering is applied. @@ -146,15 +146,19 @@ Scans `workspaces/*/e2e-tests/` for directories containing both `package.json` a ### 3. Generate Root `package.json` Creates a root `package.json` with: + - **Yarn workspaces** pointing to selected `workspaces/*/e2e-tests` directories - **Resolutions** to pin `@playwright/test` and optionally `@red-hat-developer-hub/e2e-test-utils` ```json { - "workspaces": ["workspaces/tech-radar/e2e-tests", "workspaces/keycloak/e2e-tests"], + "workspaces": [ + "workspaces/tech-radar/e2e-tests", + "workspaces/keycloak/e2e-tests" + ], "resolutions": { "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "1.1.30" + "@red-hat-developer-hub/e2e-test-utils": "2.3.0" } } ``` @@ -180,6 +184,7 @@ Runs `npx playwright test` with any additional arguments passed through. All arg ### 7. Display Summary Parses `playwright-report/results.json` and displays: + - Duration, passed/failed/flaky/skipped counts - Overall status (PASSED/FAILED) - Report file location @@ -245,12 +250,12 @@ All arguments not recognized as `-w`/`--workspace` are forwarded directly to Pla The script generates these temporary files in the repository root: -| File | Purpose | -|------|---------| -| `package.json` | Root workspace config with resolutions | -| `.yarnrc.yml` | Yarn node-modules linker config | -| `playwright.config.ts` | Combined Playwright config with all workspace projects | -| `playwright.list.config.ts` | Lightweight config for `--list` mode (when used) | +| File | Purpose | +| --------------------------- | ------------------------------------------------------ | +| `package.json` | Root workspace config with resolutions | +| `.yarnrc.yml` | Yarn node-modules linker config | +| `playwright.config.ts` | Combined Playwright config with all workspace projects | +| `playwright.list.config.ts` | Lightweight config for `--list` mode (when used) | These files are generated fresh on each run. @@ -269,13 +274,13 @@ No changes to test code are needed. The same spec files work both with `yarn tes Two strategies were evaluated for running all workspace tests in CI: -| | Single root Playwright | Per-workspace shell parallel | -|---|---|---| -| **Parallelism** | Worker-level — Playwright auto-balances across all projects | Workspace-level — a large workspace bottlenecks while small ones sit idle | -| **Keycloak** | `globalSetup` runs once, no races | Multiple processes deploy simultaneously, causing races | -| **Reporting** | Single report with traces/screenshots/videos | Blob merge step needed, adds a failure point | -| **Dependency validation** | Yarn resolutions validates upgrades across all workspaces in one run | No way to test a dependency upgrade across all workspaces at once | -| **CLI** | Standard Playwright flags work (`--project`, `--grep`, `--shard`) | Flags must be forwarded per-process | +| | Single root Playwright | Per-workspace shell parallel | +| ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| **Parallelism** | Worker-level — Playwright auto-balances across all projects | Workspace-level — a large workspace bottlenecks while small ones sit idle | +| **Keycloak** | `globalSetup` runs once, no races | Multiple processes deploy simultaneously, causing races | +| **Reporting** | Single report with traces/screenshots/videos | Blob merge step needed, adds a failure point | +| **Dependency validation** | Yarn resolutions validates upgrades across all workspaces in one run | No way to test a dependency upgrade across all workspaces at once | +| **CLI** | Standard Playwright flags work (`--project`, `--grep`, `--shard`) | Flags must be forwarded per-process | The single root approach requires [WorkspacePaths](#path-resolution) to resolve config paths correctly, but this change is backward-compatible and benefits all execution modes. diff --git a/docs/overlay/reference/scripts.md b/docs/overlay/reference/scripts.md index 01cc543..fd27a04 100644 --- a/docs/overlay/reference/scripts.md +++ b/docs/overlay/reference/scripts.md @@ -22,15 +22,16 @@ Equivalent to: playwright test ``` -### yarn test:vault +### yarn test:secrets -Run tests with secrets automatically fetched from Vault: +Run tests with selected secrets fetched from Bitwarden: ```bash -yarn test:vault +yarn test:secrets ``` -Equivalent to `VAULT=1 yarn test`. Handles OIDC login, fetches global and per-workspace secrets. See [Running Locally - Secrets from Vault](/overlay/tutorials/running-locally#secrets-from-vault) for details. +The script runs `rhdh-e2e-secrets exec` with the workspace profile. Export an +unlocked `BW_SESSION` first. See [Running Locally - Secrets from Bitwarden](/overlay/tutorials/running-locally#secrets-from-bitwarden) for details. ### yarn test:headed @@ -149,7 +150,7 @@ Standard `package.json` scripts section: { "scripts": { "test": "playwright test", - "test:vault": "VAULT=1 playwright test", + "test:secrets": "rhdh-e2e-secrets exec --profile ../../../e2e-secrets.profile.json --workspace -- playwright test", "report": "playwright show-report", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", diff --git a/docs/overlay/reference/troubleshooting.md b/docs/overlay/reference/troubleshooting.md index 7ac0d04..35d5286 100644 --- a/docs/overlay/reference/troubleshooting.md +++ b/docs/overlay/reference/troubleshooting.md @@ -238,31 +238,24 @@ oc login --token= --server= ### "Tests pass locally but fail in CI" **Common causes:** -- Missing Vault secrets +- Missing secret values - Secrets not prefixed with `VAULT_` -- Missing Vault annotations +- Missing CI secret configuration - Different cluster configuration **Solutions:** - Check CI logs for specific error -- Verify secrets in Vault have `VAULT_` prefix -- Check Vault path has correct annotations: - ```json - { - "secretsync/target-name": "rhdh-plugin-export-overlays", - "secretsync/target-namespace": "test-credentials" - } - ``` +- Verify the selected secret item has the `VAULT_` prefix +- Check the CI secret collection or local Bitwarden profile -### "Vault secret not available" +### "Secret not available" -**Problem:** Environment variable from Vault is undefined. +**Problem:** A required environment variable is undefined. **Solutions:** - Verify secret name starts with `VAULT_` -- Check secret is in correct Vault path (global or workspace-specific) -- Verify Vault path has required annotations -- Request Vault access in team-rhdh channel if needed +- Check the item is under the correct `global/` or `workspaces//` prefix +- For local runs, verify `BW_SESSION` is set and `bw status` reports `unlocked` ### "Resource quota exceeded" diff --git a/docs/overlay/test-structure/configuration-files.md b/docs/overlay/test-structure/configuration-files.md index fc33d1f..64411d6 100644 --- a/docs/overlay/test-structure/configuration-files.md +++ b/docs/overlay/test-structure/configuration-files.md @@ -135,13 +135,13 @@ When this file is processed, any `$VAR_NAME` references are replaced with actual ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ -│ Vault / .env │────▶│ rhdh-secrets.yaml│────▶│ app-config-rhdh.yaml│ +│ Secrets / .env │────▶│ rhdh-secrets.yaml│────▶│ app-config-rhdh.yaml│ │ MY_SECRET=value │ │ MY_SECRET: $VAR │ │ ${MY_SECRET} │ │ │ │ (substituted) │ │ (references secret) │ └─────────────────┘ └──────────────────┘ └─────────────────────┘ ``` -1. Environment variable exists (from Vault in CI, or `.env` locally) +1. Environment variable exists (from CI, the Bitwarden wrapper, or `.env` locally) 2. `rhdh-secrets.yaml` references it with `$VAR_NAME` - **substituted with actual value** 3. RHDH configs reference the secret with `${VAR_NAME}` @@ -368,9 +368,9 @@ For local development: TECH_RADAR_DATA_URL=my-service.example.com ``` -### 3. In Vault (CI) +### 3. In CI secret storage -Add secrets to the Vault with `VAULT_` prefix. They are automatically exported during OpenShift CI execution: +Add secrets to the CI secret collection with the `VAULT_` prefix. They are automatically exported during OpenShift CI execution: ``` VAULT_TECH_RADAR_DATA_URL: my-service.apps.cluster.example.com @@ -384,7 +384,7 @@ techRadar: ``` ::: warning Secret Naming -All secrets in Vault **must** start with `VAULT_` prefix for automatic export. +All locally readable secrets **must** start with the `VAULT_` prefix for automatic environment mapping. ::: ## Common Configuration Patterns diff --git a/docs/overlay/test-structure/directory-layout.md b/docs/overlay/test-structure/directory-layout.md index 7e333a7..b2ecc88 100644 --- a/docs/overlay/test-structure/directory-layout.md +++ b/docs/overlay/test-structure/directory-layout.md @@ -29,7 +29,6 @@ workspaces//e2e-tests/ The `tests/config/` directory can be empty. Configuration is auto-generated from plugin metadata. See [Configuration Files](./configuration-files) for when to create specific files. ::: - ## Root Files ### package.json @@ -49,7 +48,7 @@ Defines the test package with dependencies and scripts: "packageManager": "yarn@4.12.0", "scripts": { "test": "playwright test", - "test:vault": "VAULT=1 playwright test", + "test:secrets": "rhdh-e2e-secrets exec --profile ../../../e2e-secrets.profile.json --workspace -- playwright test", "report": "playwright show-report", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", @@ -63,7 +62,7 @@ Defines the test package with dependencies and scripts: "devDependencies": { "@eslint/js": "10.0.1", "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "1.1.33", + "@red-hat-developer-hub/e2e-test-utils": "2.3.0", "@types/node": "25.5.2", "eslint": "10.2.0", "eslint-plugin-check-file": "3.3.1", @@ -141,13 +140,13 @@ Contains YAML configuration files that are merged with defaults when deploying R **All configuration files in this directory are optional.** The package provides sensible defaults. Only create files when you need to override or extend defaults. ::: -| File | Purpose | When to Create | -|------|---------|----------------| -| `app-config-rhdh.yaml` | RHDH configuration | Plugin-specific settings needed | -| `rhdh-secrets.yaml` | Kubernetes secrets | Using env vars in RHDH configs | +| File | Purpose | When to Create | +| ---------------------- | -------------------- | --------------------------------------- | +| `app-config-rhdh.yaml` | RHDH configuration | Plugin-specific settings needed | +| `rhdh-secrets.yaml` | Kubernetes secrets | Using env vars in RHDH configs | | `dynamic-plugins.yaml` | Plugin configuration | **Usually not needed** - auto-generated | -| `value_file.yaml` | Helm values | Override Helm defaults | -| `subscription.yaml` | Operator config | Override Operator defaults | +| `value_file.yaml` | Helm values | Override Helm defaults | +| `subscription.yaml` | Operator config | Override Operator defaults | See [Configuration Files](./configuration-files) for complete details on each file. @@ -197,20 +196,20 @@ deploy_external_service "$1" ## File Naming Conventions -| File Type | Convention | Example | -|-----------|-----------|---------| -| Spec files | `.spec.ts` | `tech-radar.spec.ts` | -| Deployment scripts | `deploy-.sh` | `deploy-customization-provider.sh` | -| Config files | Standard names | `app-config-rhdh.yaml` | +| File Type | Convention | Example | +| ------------------ | ----------------------- | ---------------------------------- | +| Spec files | `.spec.ts` | `tech-radar.spec.ts` | +| Deployment scripts | `deploy-.sh` | `deploy-customization-provider.sh` | +| Config files | Standard names | `app-config-rhdh.yaml` | ## Path Resolution (WorkspacePaths) Tests in this repo can run from two different working directories: -| Context | CWD | How | -|---------|-----|-----| -| Individual workspace | `workspaces//e2e-tests/` | `cd workspaces/tech-radar/e2e-tests && yarn test` | -| Repo root (unified runner) | Repository root | `./run-e2e.sh -w tech-radar` | +| Context | CWD | How | +| -------------------------- | -------------------------------- | ------------------------------------------------- | +| Individual workspace | `workspaces//e2e-tests/` | `cd workspaces/tech-radar/e2e-tests && yarn test` | +| Repo root (unified runner) | Repository root | `./run-e2e.sh -w tech-radar` | Configuration files live under `tests/config/` relative to each workspace's `e2e-tests/` directory. If paths were resolved from `process.cwd()`, they would break when running from the repo root. diff --git a/docs/overlay/tutorials/ci-pipeline.md b/docs/overlay/tutorials/ci-pipeline.md index 360966b..dd2494f 100644 --- a/docs/overlay/tutorials/ci-pipeline.md +++ b/docs/overlay/tutorials/ci-pipeline.md @@ -187,7 +187,7 @@ OCI URL generation is strict - deployment will fail if required files are missin ## Secrets in CI -Vault setup and usage details are documented here: [Using Secrets](/overlay/tutorials/using-secrets). +Secret setup and usage details are documented here: [Using Secrets](/overlay/tutorials/using-secrets). See [Configuration Files - rhdh-secrets.yaml](/overlay/test-structure/configuration-files#rhdh-secrets-yaml-optional) for more details on the secrets flow. @@ -207,7 +207,7 @@ The following environment variables are available during CI execution: | `JOB_MODE` | `nightly` or `pr-check` — set by step registry | | `E2E_NIGHTLY_MODE` | `true` for nightly jobs | | `E2E_TEST_UTILS_VERSION` | Pinned e2e-test-utils version (nightly only) | -| `VAULT_*` | All Vault secrets with this prefix | +| `VAULT_*` | All CI secret values with this legacy prefix | ### Plugin Metadata Variables @@ -245,9 +245,9 @@ CI logs are available on the PR. Look for: ### Common CI Issues **Secrets not available:** -- Verify the secret has `VAULT_` prefix -- Check Vault path has correct annotations -- Ensure you have access to the Vault path +- Verify the secret has the `VAULT_` prefix +- Check the CI secret collection and mounted file configuration +- For local runs, verify `BW_SESSION` is set and unlocked **Deployment timeout:** - Check cluster resources @@ -256,7 +256,7 @@ CI logs are available on the PR. Look for: **Tests pass locally but fail in CI:** - Check for hardcoded values that work locally -- Verify all required secrets are in Vault +- Verify all required secrets are in the CI secret collection - Ensure env vars are properly prefixed with `VAULT_` ## Local Testing Before CI @@ -266,11 +266,11 @@ Before pushing to CI, test locally: ```bash cd workspaces//e2e-tests -# Set required Vault secrets locally -export VAULT_MY_SECRET="local-value" +# Set required local secret values through Bitwarden. +export BW_SESSION="" # Run tests -yarn test +yarn test:secrets ``` ## Related Pages diff --git a/docs/overlay/tutorials/new-workspace.md b/docs/overlay/tutorials/new-workspace.md index 54926aa..85da584 100644 --- a/docs/overlay/tutorials/new-workspace.md +++ b/docs/overlay/tutorials/new-workspace.md @@ -45,7 +45,7 @@ Create `package.json` with the following content: "description": "E2E tests for ", "scripts": { "test": "playwright test", - "test:vault": "VAULT=1 playwright test", + "test:secrets": "rhdh-e2e-secrets exec --profile ../../../e2e-secrets.profile.json --workspace -- playwright test", "report": "playwright show-report", "test:ui": "playwright test --ui", "test:headed": "playwright test --headed", @@ -59,7 +59,7 @@ Create `package.json` with the following content: "devDependencies": { "@eslint/js": "10.0.1", "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "", + "@red-hat-developer-hub/e2e-test-utils": "2.3.0", "@types/node": "25.5.2", "eslint": "10.2.0", "eslint-plugin-check-file": "3.3.1", diff --git a/docs/overlay/tutorials/plugin-config.md b/docs/overlay/tutorials/plugin-config.md index c28aa7c..fca5292 100644 --- a/docs/overlay/tutorials/plugin-config.md +++ b/docs/overlay/tutorials/plugin-config.md @@ -89,7 +89,7 @@ If you only need a secret in your test code, just use `process.env.VAULT_*` dire ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ -│ Vault / .env │────▶│ rhdh-secrets.yaml│────▶│ app-config-rhdh.yaml│ +│ Secrets / .env │────▶│ rhdh-secrets.yaml│────▶│ app-config-rhdh.yaml│ │ VAULT_MY_SECRET │ │ MY_SECRET: $VAR │ │ ${MY_SECRET} │ │ │ │ (substituted) │ │ (references secret) │ └─────────────────┘ └──────────────────┘ └─────────────────────┘ @@ -105,7 +105,7 @@ metadata: type: Opaque stringData: # Left side: name to use in app-config (with ${...}) - # Right side: reference to env var from Vault/.env (with $) + # Right side: reference to a supplied env var (with $) SECRET_NAME: $VAULT_SECRET_NAME ``` @@ -165,9 +165,9 @@ TECH_RADAR_DATA_URL=my-service.apps.cluster.example.com GITHUB_TOKEN=ghp_xxxxxxxxxxxx ``` -### In Vault (CI) +### In CI secret storage -Add secrets to Vault with `VAULT_` prefix. They are automatically exported during OpenShift CI execution: +Add secrets to the approved CI collection with the `VAULT_` prefix. They are automatically exported during OpenShift CI execution: ``` VAULT_TECH_RADAR_DATA_URL: my-service.apps.cluster.example.com diff --git a/docs/overlay/tutorials/running-locally.md b/docs/overlay/tutorials/running-locally.md index 60aab62..4f6ced3 100644 --- a/docs/overlay/tutorials/running-locally.md +++ b/docs/overlay/tutorials/running-locally.md @@ -196,62 +196,49 @@ If you see odd module-resolution issues while testing a locally linked `e2e-test NODE_PRESERVE_SYMLINKS=1 yarn test:headed ``` -## Secrets from Vault +## Secrets from Bitwarden -Instead of manually copying secrets from the Vault UI into `.env` files, you can fetch them automatically by setting `VAULT=1`: +Local secret-backed tests use the standalone `rhdh-e2e-secrets` executable and +an unlocked Bitwarden Password Manager CLI session. The wrapper runs outside +Playwright, so secrets are available before configuration files and global +setup are evaluated. ```bash -# From workspace -cd workspaces/argocd/e2e-tests -yarn test:vault +# Unlock Bitwarden and export the session key in the current shell. +export BW_SESSION="" -# Or equivalently -VAULT=1 yarn test +# From a workspace +cd workspaces/argocd/e2e-tests +yarn test:secrets -# From repo root -VAULT=1 ./run-e2e.sh -w argocd +# From the repository root +./run-e2e.sh --secrets -w argocd ``` -This will: - -1. Check that the `vault` CLI is installed -2. Log you in via OIDC if needed (opens a browser) -3. Fetch global secrets and all per-workspace secrets from Vault -4. Inject `VAULT_*` keys into `process.env` for the test run - -::: tip -If you don't have Vault access, request it in Slack: `#rhdh-e2e-tests`. -::: +The overlay profile requests `global/*` and the selected +`workspaces//*` prefix. Workspace-specific values are optional, while +the global prefix is required. Secret values are passed only to the child +test process; no secret `.env` file is generated. -**Prerequisites:** Install the [Vault CLI](https://developer.hashicorp.com/vault/downloads). - -You can also override the Vault server or base path: - -```bash -VAULT=1 VAULT_ADDR=https://my-vault.example.com VAULT_BASE_PATH=my/path yarn test -``` +**Prerequisites:** Install the [Bitwarden Password Manager CLI](https://bitwarden.com/help/cli/), unlock it, and make `bw` available on `PATH`. ## Environment Variables ### Using .env File -Create `.env` for local configuration (alternative to Vault): +Create `.env` for non-secret local configuration: ```bash # .env RHDH_VERSION=1.5 INSTALLATION_METHOD=helm SKIP_KEYCLOAK_DEPLOYMENT=false - -# Secrets (or use VAULT=1 instead) -VAULT_GITHUB_TOKEN=ghp_xxx ``` ### Common Variables | Variable | Description | Default | | -------------------------- | -------------------------------------------------- | --------------- | -| `VAULT` | Fetch secrets from Vault automatically (`1` or `true`) | - | | `RHDH_VERSION` | RHDH version to deploy | `next` (latest) | | `INSTALLATION_METHOD` | `helm` or `operator` | `helm` | | `SKIP_KEYCLOAK_DEPLOYMENT` | Skip Keycloak deployment entirely (for guest auth) | `false` | diff --git a/docs/overlay/tutorials/using-secrets.md b/docs/overlay/tutorials/using-secrets.md index 513fc77..1d04ac1 100644 --- a/docs/overlay/tutorials/using-secrets.md +++ b/docs/overlay/tutorials/using-secrets.md @@ -1,20 +1,24 @@ # Using Secrets -This page explains how to consume Vault secrets in overlay E2E tests. +This page explains how to consume secret values in overlay E2E tests. ## Where Secrets Come From -In OpenShift CI, Vault secrets are exported as environment variables with the `VAULT_` prefix. +In OpenShift CI, mounted secret files are exported as environment variables +with the `VAULT_` prefix. Local runs use the same variable names through the +Bitwarden wrapper. -For **local development**, set `VAULT=1` to automatically fetch secrets from Vault instead of manually copying them into `.env` files: +For **local development**, unlock Bitwarden, export `BW_SESSION`, and run the +secret-backed script: ```bash -VAULT=1 yarn test +export BW_SESSION="" +yarn test:secrets ``` -See [Running Locally](/overlay/tutorials/running-locally#secrets-from-vault) for details. +See [Running Locally](/overlay/tutorials/running-locally#secrets-from-bitwarden) for details. -## Vault Setup (CI) +## Secret Collections ### Secret Naming Convention @@ -24,32 +28,27 @@ All secrets must start with the `VAULT_` prefix (e.g., `VAULT_API_KEY`). Global secrets are available to **all** workspace tests. Use these for shared values. -**Vault Path:** [Global Secrets](https://vault.ci.openshift.org/ui/vault/secrets/kv/kv/selfservice%2Frhdh-plugin-export-overlays%2Fglobal/details) +The local profile selects the `global/` prefix from the approved Bitwarden +collection. ### Workspace-Specific Secrets -Secrets for a specific workspace should be stored here: +Secrets for a specific workspace use this item-name prefix: ``` -selfservice/rhdh-plugin-export-overlays/workspaces/ +workspaces// ``` -**Example (tech-radar):** [Tech Radar Secrets](https://vault.ci.openshift.org/ui/vault/secrets/kv/kv/selfservice%2Frhdh-plugin-export-overlays%2Fworkspaces%2Ftech-radar/details) +For example, Tech Radar uses `workspaces/tech-radar/`. -### Required Vault Annotations +The workspace selector is optional so global-only workspaces can run. The +global selector remains required. -Each workspace-specific secret path must include: +## CI Secret Delivery -```json -{ - "secretsync/target-name": "rhdh-plugin-export-overlays", - "secretsync/target-namespace": "test-credentials" -} -``` - -### Requesting Vault Access - -If you don't have access, request it in the team-rhdh channel. +CI continues to provide secrets through its existing mounted-file and +environment contracts. This package does not change CI secret mounts or read +CI secret-manager values. ## Use in Test Code (Direct Access) @@ -71,7 +70,8 @@ test.beforeAll(async ({ rhdh }) => { ## Use in RHDH Configuration Files -To use Vault secrets in `app-config-rhdh.yaml` or `dynamic-plugins.yaml`, you must first add them to `rhdh-secrets.yaml`. +To use secret values in `app-config-rhdh.yaml` or `dynamic-plugins.yaml`, you +must first add them to `rhdh-secrets.yaml`. ### Step 1: Add to rhdh-secrets.yaml @@ -84,7 +84,7 @@ metadata: type: Opaque stringData: # Left side: name to use in app-config - # Right side: reference to Vault secret (with $) + # Right side: reference to a supplied secret environment variable (with $) EXTERNAL_HOST: $VAULT_EXTERNAL_HOST MY_PLUGIN_API_KEY: $VAULT_MY_PLUGIN_API_KEY ``` @@ -110,29 +110,21 @@ myPlugin: ## Related Pages -- [CI Pipeline](/overlay/tutorials/ci-pipeline) - CI and Vault setup +- [CI Pipeline](/overlay/tutorials/ci-pipeline) - CI secret delivery - [Configuration Files](/overlay/test-structure/configuration-files) - YAML config flow ## Adding a New Workspace to CI When adding E2E tests to a new workspace: -1. **Create workspace-specific secret path in Vault:** - ``` - selfservice/rhdh-plugin-export-overlays/workspaces/ - ``` - -2. **Add required annotations:** - ```json - { - "secretsync/target-name": "rhdh-plugin-export-overlays", - "secretsync/target-namespace": "test-credentials" - } - ``` - -3. **Add secrets with `VAULT_` prefix:** - ``` - VAULT_YOUR_SECRET: - ``` - -4. **Reference secrets in your configuration files** +1. **Add workspace-specific secure notes to the approved collection:** + ``` + workspaces// + ``` + +2. **Add secure notes with the `VAULT_` prefix:** + ``` + VAULT_YOUR_SECRET: + ``` + +3. **Reference secrets in your configuration files.** diff --git a/docs/superpowers/plans/2026-09-03-bitwarden-local-secrets.md b/docs/superpowers/plans/2026-09-03-bitwarden-local-secrets.md new file mode 100644 index 0000000..51d2861 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-bitwarden-local-secrets.md @@ -0,0 +1,43 @@ +# Bitwarden Local Secrets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace active Vault-backed local execution in `rhdh-e2e-test-utils` and `rhdh-plugin-export-overlays` with Bitwarden-backed execution. + +**Architecture:** A standalone `rhdh-e2e-secrets exec` process validates a value-free profile, reads only selected secure notes through a globally installed `bw` CLI, materializes a curated child environment, and spawns the test command. Playwright global setup remains provider-neutral. Directory materialization, rotation/GSM, and the `rhdh` consumer are deferred to separate work. + +**Tech Stack:** TypeScript, Node.js built-in test runner, `node:child_process`, JSON profiles, Bitwarden Password Manager CLI, Bash, Playwright. + +**Spec:** `/Users/zdrapela/Repos/rhdh-ci-secrets-management/docs/superpowers/specs/2026-09-03-rhdh-ci-secrets-tooling-design.md` + +## Global Constraints + +- Scope is `rhdh-e2e-test-utils` plus `rhdh-plugin-export-overlays`; rotation, directory destinations, and `rhdh` are separate work. +- Use a globally installed `bw`; do not add `@bitwarden/cli` as a package dependency. +- Require an existing unlocked `BW_SESSION`; never print or pass it as a command argument. +- Preserve legacy `VAULT_*` secret payload variable names; remove Vault provider code and active Vault workflow terminology. +- Query only the exact Bitwarden collection and selected prefixes. +- Use synthetic values and fake CLIs in automated tests. +- Keep historical changelog entries. + +## Tasks + +### Task 1: Profile and Bitwarden validation + +Create `src/secrets/config.ts`, `src/secrets/command.ts`, and tests. Implement collection inventory, denied `rhdh-aws-credentials`, profile schema validation, workspace expansion, exact collection resolution, `bw` status/sync, scoped item listing, secure-note parsing, prefix filtering, duplicate detection, and value-free errors. + +### Task 2: Child environment and executor + +Create `src/secrets/environment.ts`, `src/secrets/exec.ts`, `src/secrets/cli.ts`, `src/secrets/index.ts`, and tests. Implement legacy environment-name transformation, collisions, provider-variable removal, argument-array spawning, signal forwarding, exit-code propagation, CLI parsing, and `exec --profile ... -- command`. + +### Task 3: Package integration and Vault removal + +Remove `src/utils/vault.ts`; make global setup provider-neutral and dotenv fill-only; add the `./secrets` export and `rhdh-e2e-secrets` binary; bump to `2.2.0`; update relevant tests and remove credential logging. + +### Task 4: Overlay migration + +Add `e2e-secrets.profile.json`, pin all 24 workspace dependencies to `2.2.0`, replace all `test:vault` scripts with `test:secrets`, add the `extensions` command in a separate commit, and add `--secrets` handling to `run-e2e.sh`. Workspace selectors are optional so global-only workspaces work. + +### Task 5: Documentation and verification + +Replace active Vault local-run documentation with Bitwarden setup and commands, retain historical changelog text, validate package and docs builds, run all tests, and audit active references. diff --git a/package.json b/package.json index 70ec53f..57ac0d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "2.1.13", + "version": "2.3.0", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { @@ -56,8 +56,15 @@ "./openldap": { "types": "./dist/deployment/openldap/index.d.ts", "default": "./dist/deployment/openldap/index.js" + }, + "./secrets": { + "types": "./dist/secrets/index.d.ts", + "default": "./dist/secrets/index.js" } }, + "bin": { + "rhdh-e2e-secrets": "./dist/secrets/cli.js" + }, "publishConfig": { "access": "public" }, @@ -66,7 +73,7 @@ "tsconfig.base.json" ], "scripts": { - "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp -r src/deployment/openldap/config dist/deployment/openldap/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/", + "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp -r src/deployment/openldap/config dist/deployment/openldap/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/ && chmod +x dist/secrets/cli.js", "prepare": "husky", "check": "yarn typecheck && yarn lint:check && yarn prettier:check", "clean": "rm -rf dist", diff --git a/src/playwright/global-setup.test.ts b/src/playwright/global-setup.test.ts new file mode 100644 index 0000000..5790d2c --- /dev/null +++ b/src/playwright/global-setup.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable playwright/expect-expect, playwright/no-conditional-in-test -- this is a node:test regression suite */ + +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { FullConfig } from "@playwright/test"; +import { loadDotenvFromProjects } from "./global-setup.js"; + +test("dotenv values do not override secrets supplied by the parent process", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "global-setup-test-")); + const e2eRoot = path.join(root, "e2e-tests"); + const testDir = path.join(e2eRoot, "tests"); + await mkdir(testDir, { recursive: true }); + await writeFile( + path.join(e2eRoot, ".env"), + "VAULT_GITHUB_TOKEN=dotenv-value\nBW_SESSION=dotenv-session\nVAULT_TOKEN=dotenv-token\nLOCAL_ONLY=dotenv-only\n", + ); + + const previousToken = process.env.VAULT_GITHUB_TOKEN; + const previousSession = process.env.BW_SESSION; + const previousProviderToken = process.env.VAULT_TOKEN; + const previousLocal = process.env.LOCAL_ONLY; + process.env.VAULT_GITHUB_TOKEN = "bitwarden-value"; + delete process.env.BW_SESSION; + delete process.env.VAULT_TOKEN; + delete process.env.LOCAL_ONLY; + try { + loadDotenvFromProjects({ projects: [{ testDir }] } as FullConfig); + assert.equal(process.env.VAULT_GITHUB_TOKEN, "bitwarden-value"); + assert.equal(process.env.BW_SESSION, undefined); + assert.equal(process.env.VAULT_TOKEN, undefined); + assert.equal(process.env.LOCAL_ONLY, "dotenv-only"); + } finally { + if (previousToken === undefined) delete process.env.VAULT_GITHUB_TOKEN; + else process.env.VAULT_GITHUB_TOKEN = previousToken; + if (previousSession === undefined) delete process.env.BW_SESSION; + else process.env.BW_SESSION = previousSession; + if (previousProviderToken === undefined) delete process.env.VAULT_TOKEN; + else process.env.VAULT_TOKEN = previousProviderToken; + if (previousLocal === undefined) delete process.env.LOCAL_ONLY; + else process.env.LOCAL_ONLY = previousLocal; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/playwright/global-setup.ts b/src/playwright/global-setup.ts index 792485b..8989398 100644 --- a/src/playwright/global-setup.ts +++ b/src/playwright/global-setup.ts @@ -10,12 +10,11 @@ import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; import { $ } from "../utils/bash.js"; import { KeycloakHelper } from "../deployment/keycloak/index.js"; import { installRHDHOperator } from "../deployment/rhdh/operator-setup.js"; +import { removeProviderEnvironmentVariables } from "../secrets/environment.js"; import { DEFAULT_KEYCLOAK_CONFIG, DEFAULT_RHDH_CLIENT, - DEFAULT_USERS, } from "../deployment/keycloak/constants.js"; -import { loadLocalVaultSecrets } from "../utils/vault.js"; const REQUIRED_BINARIES = ["oc", "kubectl", "helm"] as const; @@ -72,20 +71,14 @@ async function deployKeycloak(): Promise { process.env.KEYCLOAK_METADATA_URL = `${keycloak.keycloakUrl}/realms/${realm}`; process.env.KEYCLOAK_BASE_URL = keycloak.keycloakUrl; - console.table({ - keycloakURL: keycloak.keycloakUrl, - adminUser: keycloak.deploymentConfig.adminUser, - adminPassword: keycloak.deploymentConfig.adminPassword, - testUsername: DEFAULT_USERS[0].username, - testPassword: DEFAULT_USERS[0].password, - }); + console.log(`Keycloak URL: ${keycloak.keycloakUrl}`); } export default async function globalSetup(config: FullConfig): Promise { console.log("Running global setup..."); await checkRequiredBinaries(); - await loadLocalVaultSecrets(); loadDotenvFromProjects(config); + removeProviderEnvironmentVariables(process.env); await setClusterRouterBaseEnv(); await Promise.all([installRHDHOperator(), deployKeycloak()]); console.log("Global setup completed successfully"); @@ -93,15 +86,16 @@ export default async function globalSetup(config: FullConfig): Promise { /** * Loads .env files from each project's e2e-tests directory. - * Uses `override: true` so local .env values take priority over Vault secrets. + * Existing values supplied by the caller take priority over local .env values. */ -function loadDotenvFromProjects(config: FullConfig): void { +export function loadDotenvFromProjects(config: FullConfig): void { const seen = new Set(); for (const project of config.projects) { // testDir points to e2e-tests/tests, go up one level to e2e-tests/ const e2eRoot = resolve(project.testDir, ".."); if (seen.has(e2eRoot)) continue; seen.add(e2eRoot); - dotenv.config({ path: resolve(e2eRoot, ".env"), override: true }); + dotenv.config({ path: resolve(e2eRoot, ".env"), override: false }); } + removeProviderEnvironmentVariables(process.env); } diff --git a/src/playwright/helpers/github-session.test.ts b/src/playwright/helpers/github-session.test.ts index 9c7a8dc..db3a688 100644 --- a/src/playwright/helpers/github-session.test.ts +++ b/src/playwright/helpers/github-session.test.ts @@ -46,7 +46,7 @@ describe("github session file naming", () => { ); }); - it("does not throw when the vault user id is unset", () => { + it("does not throw when the GitHub user id is unset", () => { // loginAsGithubUser defaults to `process.env.VAULT_GH_USER_ID as string`, and the // cast hides the undefined. Building the path must not be where that surfaces — // a TypeError here points nowhere near the missing variable. diff --git a/src/secrets/bitwarden.integration.test.ts b/src/secrets/bitwarden.integration.test.ts new file mode 100644 index 0000000..67f903c --- /dev/null +++ b/src/secrets/bitwarden.integration.test.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixture models CLI environment keys */ + +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { BitwardenClient } from "./bitwarden.js"; +import type { ExpandedSecretSelector } from "./config.js"; + +const selector: ExpandedSecretSelector = { + prefix: "global/", + optional: false, + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, +}; + +test("reads secure notes through the real command runner and a fake bw executable", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "bitwarden-cli-test-"), + ); + const command = path.join(directory, "bw"); + await writeFile( + command, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "--version") process.stdout.write("2026.5.0\\n"); +else if (args[0] === "status") process.stdout.write(JSON.stringify({ status: "unlocked" })); +else if (args[0] === "sync") {} +else if (args[0] === "list" && args[1] === "collections") process.stdout.write(JSON.stringify([{ id: "collection-id", name: "Rhdh Qe Ci Secrets", organizationId: "organization-id" }])); +else if (args[0] === "list" && args[1] === "items") process.stdout.write(JSON.stringify([{ id: "item-id" }])); +else if (args[0] === "get" && args[1] === "item") process.stdout.write(JSON.stringify({ id: args[2], name: "global/VAULT_TOKEN", notes: "synthetic-value", type: 2, collectionIds: ["collection-id"], organizationId: "organization-id" })); +else process.exitCode = 1; +`, + ); + await chmod(command, 0o755); + + try { + const secrets = await new BitwardenClient({ + command, + env: { BW_SESSION: "synthetic-session" }, + }).read("rhdh-qe", [selector]); + assert.equal(secrets[0]?.name, "global/VAULT_TOKEN"); + assert.equal(secrets[0]?.value, "synthetic-value"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/secrets/bitwarden.test.ts b/src/secrets/bitwarden.test.ts new file mode 100644 index 0000000..bcd88ef --- /dev/null +++ b/src/secrets/bitwarden.test.ts @@ -0,0 +1,295 @@ +/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect, playwright/no-conditional-in-test -- node:test fixtures model CLI and environment keys */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { BitwardenClient, type BitwardenCommandRunner } from "./bitwarden.js"; +import type { ExpandedSecretSelector } from "./config.js"; + +const selectors: ExpandedSecretSelector[] = [ + { + prefix: "global/", + optional: false, + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, + { + prefix: "workspaces/backstage/", + optional: true, + destination: { + kind: "environment", + stripPrefix: "workspaces/backstage/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, +]; + +function result(stdout = "", status = 0) { + return { status, stdout, stderr: status === 0 ? "" : "synthetic failure" }; +} + +test("requires an unlocked BW_SESSION before any Bitwarden command", async () => { + let called = false; + const runner: BitwardenCommandRunner = async () => { + called = true; + return result(); + }; + + await assert.rejects( + () => + new BitwardenClient({ + env: { PATH: "/bin" }, + runner, + }).read("rhdh-plugin-export-overlays", selectors), + /BW_SESSION.*unlocked/i, + ); + assert.equal(called, false); +}); + +test("rejects the denied AWS collection before any Bitwarden command", async () => { + let called = false; + const runner: BitwardenCommandRunner = async () => { + called = true; + return result(); + }; + + await assert.rejects( + () => + new BitwardenClient({ + env: { BW_SESSION: "synthetic-session" }, + runner, + }).read("rhdh-aws-credentials" as never, [selectors[0]!]), + /rhdh-aws-credentials.*Bitwarden/i, + ); + assert.equal(called, false); +}); + +test("syncs and reads only exact-prefix items from the selected collection", async () => { + const calls: Array<{ args: readonly string[]; env?: NodeJS.ProcessEnv }> = []; + const runner: BitwardenCommandRunner = async (_command, args, options) => { + calls.push({ args, env: options?.env }); + if (args[0] === "--version") return result("2026.5.0\n"); + if (args[0] === "status") + return result(JSON.stringify({ status: "unlocked" })); + if (args[0] === "sync") return result(); + if (args[0] === "list" && args[1] === "collections") { + return result( + JSON.stringify([ + { + id: "other-collection", + name: "Other", + organizationId: "other-org", + }, + { + id: "collection-id", + name: "Rhdh Plugin Export Overlays Ci Secrets", + organizationId: "org-id", + }, + ]), + ); + } + if (args[0] === "list" && args[1] === "items") { + assert.deepEqual(args.slice(0, 4), [ + "list", + "items", + "--collectionid", + "collection-id", + ]); + return result( + JSON.stringify( + args.at(-1) === "global/" + ? [{ id: "global-id" }, { id: "false-positive" }] + : [{ id: "workspace-id" }], + ), + ); + } + if (args[0] === "get" && args[1] === "item" && args[2] === "global-id") { + return result( + JSON.stringify({ + id: "global-id", + name: "global/VAULT_GITHUB_TOKEN", + notes: "synthetic-token", + type: 2, + collectionIds: ["collection-id"], + organizationId: "org-id", + }), + ); + } + if ( + args[0] === "get" && + args[1] === "item" && + args[2] === "false-positive" + ) { + return result( + JSON.stringify({ + id: "false-positive", + name: "global-other/VAULT_NOT_SELECTED", + notes: "not-selected", + type: 2, + collectionIds: ["collection-id"], + organizationId: "org-id", + }), + ); + } + if (args[0] === "get" && args[1] === "item" && args[2] === "workspace-id") { + return result( + JSON.stringify({ + id: "workspace-id", + name: "workspaces/backstage/VAULT_GH_USER_ID", + notes: "synthetic-user", + type: 2, + collectionIds: ["collection-id"], + organizationId: "org-id", + }), + ); + } + throw new Error(`Unexpected command: ${args.join(" ")}`); + }; + + const secrets = await new BitwardenClient({ + env: { BW_SESSION: "synthetic-session", PATH: "/bin" }, + runner, + }).read("rhdh-plugin-export-overlays", selectors); + + assert.deepEqual(secrets, [ + { + id: "global-id", + name: "global/VAULT_GITHUB_TOKEN", + value: "synthetic-token", + selector: selectors[0], + }, + { + id: "workspace-id", + name: "workspaces/backstage/VAULT_GH_USER_ID", + value: "synthetic-user", + selector: selectors[1], + }, + ]); + assert.equal(calls[0]?.args[0], "--version"); + assert.equal( + calls.every((call) => call.env?.BW_SESSION === "synthetic-session"), + true, + ); +}); + +test("rejects duplicate exact item names before returning secrets", async () => { + const runner: BitwardenCommandRunner = async (_command, args) => { + if (args[0] === "--version") return result("2026.5.0"); + if (args[0] === "status") + return result(JSON.stringify({ status: "unlocked" })); + if (args[0] === "sync") return result(); + if (args[1] === "collections") { + return result( + JSON.stringify([ + { + id: "collection-id", + name: "Rhdh Qe Ci Secrets", + organizationId: "org-id", + }, + ]), + ); + } + if (args[1] === "items") { + return result(JSON.stringify([{ id: "one" }, { id: "two" }])); + } + return result( + JSON.stringify({ + id: args[2], + name: "global/VAULT_DUPLICATE", + notes: "synthetic", + type: 2, + collectionIds: ["collection-id"], + organizationId: "org-id", + }), + ); + }; + + await assert.rejects( + () => + new BitwardenClient({ + env: { BW_SESSION: "synthetic-session" }, + runner, + }).read("rhdh-qe", [selectors[0]!]), + /duplicate.*item name/i, + ); +}); + +test("rejects non-note items and items assigned to multiple collections", async () => { + const runner: BitwardenCommandRunner = async (_command, args) => { + if (args[0] === "--version") return result("2026.5.0"); + if (args[0] === "status") + return result(JSON.stringify({ status: "unlocked" })); + if (args[0] === "sync") return result(); + if (args[1] === "collections") { + return result( + JSON.stringify([ + { + id: "collection-id", + name: "Rhdh Qe Ci Secrets", + organizationId: "org-id", + }, + ]), + ); + } + if (args[1] === "items") return result(JSON.stringify([{ id: "item-id" }])); + return result( + JSON.stringify({ + id: "item-id", + name: "global/VAULT_TOKEN", + notes: "synthetic", + type: 1, + collectionIds: ["collection-id", "other-id"], + organizationId: "org-id", + }), + ); + }; + + await assert.rejects( + () => + new BitwardenClient({ + env: { BW_SESSION: "synthetic-session" }, + runner, + }).read("rhdh-qe", [selectors[0]!]), + /secure note.*selected collection/i, + ); +}); + +test("fails required selectors and permits empty optional selectors", async () => { + const runner: BitwardenCommandRunner = async (_command, args) => { + if (args[0] === "--version") return result("2026.5.0"); + if (args[0] === "status") + return result(JSON.stringify({ status: "unlocked" })); + if (args[0] === "sync") return result(); + if (args[1] === "collections") { + return result( + JSON.stringify([ + { + id: "collection-id", + name: "Rhdh Qe Ci Secrets", + organizationId: "org-id", + }, + ]), + ); + } + return result("[]"); + }; + + await assert.rejects( + () => + new BitwardenClient({ + env: { BW_SESSION: "synthetic-session" }, + runner, + }).read("rhdh-qe", [selectors[0]!]), + /required prefix.*global\//i, + ); + + const secrets = await new BitwardenClient({ + env: { BW_SESSION: "synthetic-session" }, + runner, + }).read("rhdh-qe", [selectors[1]!]); + assert.deepEqual(secrets, []); +}); diff --git a/src/secrets/bitwarden.ts b/src/secrets/bitwarden.ts new file mode 100644 index 0000000..b138b71 --- /dev/null +++ b/src/secrets/bitwarden.ts @@ -0,0 +1,251 @@ +import { runCommand, type CommandResult } from "./command.js"; +import { + getCollectionMapping, + type ExpandedSecretSelector, + type ReadableCollectionId, +} from "./config.js"; + +export interface BitwardenCommandRunner { + ( + command: string, + args: readonly string[], + options?: { + env?: NodeJS.ProcessEnv; + input?: string; + }, + ): Promise; +} + +export interface BitwardenSecret { + id: string; + name: string; + value: string; + selector: ExpandedSecretSelector; +} + +interface BitwardenCollection { + id: string; + name: string; + organizationId: string; +} + +export interface BitwardenClientOptions { + command?: string; + env?: NodeJS.ProcessEnv; + runner?: BitwardenCommandRunner; +} + +export class BitwardenClient { + private readonly command: string; + private readonly env: NodeJS.ProcessEnv; + private readonly runner: BitwardenCommandRunner; + + constructor(options: BitwardenClientOptions = {}) { + this.command = options.command ?? "bw"; + this.env = { ...process.env, ...options.env }; + this.runner = options.runner ?? runCommand; + } + + async read( + collectionId: ReadableCollectionId, + selectors: readonly ExpandedSecretSelector[], + ): Promise { + const mapping = getCollectionMapping(collectionId); + if (!this.env.BW_SESSION?.trim()) { + throw new Error( + "BW_SESSION is required and must contain an unlocked Bitwarden session", + ); + } + + await this.runOrThrow(["--version"], "Bitwarden CLI is unavailable"); + const status = await this.runOrThrow( + ["status"], + "Bitwarden session status could not be checked", + ); + const statusJson = parseJson(status, "Bitwarden status"); + if (!isRecord(statusJson) || statusJson.status !== "unlocked") { + throw new Error("BW_SESSION is missing or Bitwarden is not unlocked"); + } + + await this.runOrThrow(["sync"], "Bitwarden sync failed"); + const collection = await this.resolveCollection( + mapping.bitwardenCollection, + ); + const result: BitwardenSecret[] = []; + const names = new Set(); + + for (const selector of selectors) { + const items = await this.readSelector(collection, selector); + if (items.length === 0 && !selector.optional) { + throw new Error( + `Required prefix has no matching item: ${selector.prefix}`, + ); + } + for (const item of items) { + if (names.has(item.name)) { + throw new Error(`Duplicate Bitwarden item name: ${item.name}`); + } + names.add(item.name); + result.push(item); + } + } + + return result; + } + + private async resolveCollection( + collectionName: string, + ): Promise { + const result = await this.runOrThrow( + ["list", "collections"], + "Bitwarden collection listing failed", + ); + const values = parseJson(result, "Bitwarden collection list"); + if (!Array.isArray(values)) { + throw new Error("Bitwarden returned an invalid collection list"); + } + + const matches = values.filter( + (value): value is Record => + isRecord(value) && value.name === collectionName, + ); + if (matches.length === 0) { + throw new Error(`Bitwarden collection not found: ${collectionName}`); + } + if (matches.length > 1) { + throw new Error( + `Bitwarden collection name is ambiguous: ${collectionName}`, + ); + } + + const match = matches[0]; + if ( + typeof match.id !== "string" || + typeof match.name !== "string" || + typeof match.organizationId !== "string" + ) { + throw new Error( + `Bitwarden collection has invalid metadata: ${collectionName}`, + ); + } + return { + id: match.id, + name: match.name, + organizationId: match.organizationId, + }; + } + + private async readSelector( + collection: BitwardenCollection, + selector: ExpandedSecretSelector, + ): Promise { + const result = await this.runOrThrow( + [ + "list", + "items", + "--collectionid", + collection.id, + "--search", + selector.prefix, + ], + `Bitwarden item listing failed for ${selector.prefix}`, + ); + const values = parseJson( + result, + `Bitwarden item list for ${selector.prefix}`, + ); + if (!Array.isArray(values)) { + throw new Error( + `Bitwarden returned an invalid item list for ${selector.prefix}`, + ); + } + + const ids = values.flatMap((value) => { + if ( + !isRecord(value) || + typeof value.id !== "string" || + value.id.length === 0 + ) { + throw new Error( + `Bitwarden returned an item without an id for ${selector.prefix}`, + ); + } + if ( + typeof value.name === "string" && + !value.name.startsWith(selector.prefix) + ) { + return []; + } + return [value.id]; + }); + if (new Set(ids).size !== ids.length) { + throw new Error(`Duplicate Bitwarden item id for ${selector.prefix}`); + } + + const items: BitwardenSecret[] = []; + for (const id of ids) { + const itemResult = await this.runOrThrow( + ["get", "item", id], + `Bitwarden item read failed for ${selector.prefix}`, + ); + const value = parseJson(itemResult, `Bitwarden item ${id}`); + if (!isRecord(value)) { + throw new Error( + `Bitwarden returned an invalid item for ${selector.prefix}`, + ); + } + if ( + typeof value.name !== "string" || + !value.name.startsWith(selector.prefix) + ) { + continue; + } + if ( + value.id !== id || + value.type !== 2 || + typeof value.notes !== "string" || + !Array.isArray(value.collectionIds) || + value.collectionIds.length !== 1 || + value.collectionIds[0] !== collection.id || + value.organizationId !== collection.organizationId + ) { + throw new Error( + `Bitwarden item ${typeof value.name === "string" ? value.name : id} is not a secure note in the selected collection`, + ); + } + items.push({ + id, + name: value.name, + value: value.notes, + selector, + }); + } + return items; + } + + private async runOrThrow( + args: readonly string[], + message: string, + ): Promise { + let result: CommandResult; + try { + result = await this.runner(this.command, args, { env: this.env }); + } catch { + throw new Error(message); + } + if (result.status !== 0) throw new Error(message); + return result; + } +} + +function parseJson(result: CommandResult, label: string): unknown { + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`${label} returned invalid JSON`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/secrets/cli.integration.test.ts b/src/secrets/cli.integration.test.ts new file mode 100644 index 0000000..ecda656 --- /dev/null +++ b/src/secrets/cli.integration.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixture models CLI environment keys */ + +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +test("runs the package bin with a fake bw executable and redacted child auth", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "secrets-cli-test-")); + const command = path.join(directory, "bw"); + const profile = path.join(directory, "profile.json"); + const entrypoint = path.join(directory, "rhdh-e2e-secrets"); + await symlink(path.resolve("dist/secrets/cli.js"), entrypoint); + await writeFile( + command, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "--version") process.stdout.write("2026.5.0\\n"); +else if (args[0] === "status") process.stdout.write(JSON.stringify({ status: "unlocked" })); +else if (args[0] === "sync") {} +else if (args[0] === "list" && args[1] === "collections") process.stdout.write(JSON.stringify([{ id: "collection-id", name: "Rhdh Qe Ci Secrets", organizationId: "organization-id" }])); +else if (args[0] === "list" && args[1] === "items") process.stdout.write(JSON.stringify([{ id: "item-id" }])); +else if (args[0] === "get" && args[1] === "item") process.stdout.write(JSON.stringify({ id: args[2], name: "global/VAULT_TOKEN", notes: "synthetic-value", type: 2, collectionIds: ["collection-id"], organizationId: "organization-id" })); +else process.exitCode = 1; +`, + ); + await chmod(command, 0o755); + await writeFile( + profile, + JSON.stringify({ + schemaVersion: 1, + collection: "rhdh-qe", + selectors: [ + { + prefix: "global/", + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, + ], + }), + ); + + try { + const result = spawnSync( + entrypoint, + [ + "exec", + "--profile", + profile, + "--", + process.execPath, + "-e", + "if (process.env.VAULT_TOKEN === 'synthetic-value' && !process.env.BW_SESSION) process.stdout.write('child-ran'); else process.exit(1)", + ], + { + cwd: path.resolve("."), + encoding: "utf8", + env: { + ...process.env, + PATH: `${directory}${path.delimiter}${process.env.PATH ?? ""}`, + BW_SESSION: "synthetic-session", + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /child-ran/); + assert.doesNotMatch(result.stdout, /synthetic-value/); + assert.doesNotMatch(result.stderr, /synthetic-value/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/secrets/cli.test.ts b/src/secrets/cli.test.ts new file mode 100644 index 0000000..663df9b --- /dev/null +++ b/src/secrets/cli.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable playwright/expect-expect -- this file uses node:test assertions */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCliArguments, type ExecCliArguments } from "./cli.js"; + +test("parses the exec profile, repeated workspaces, and command after --", () => { + const parsed = parseCliArguments([ + "exec", + "--profile", + "e2e-secrets.profile.json", + "--workspace", + "backstage", + "--workspace=extensions", + "--", + "playwright", + "test", + "--headed", + ]); + + assert.deepEqual(parsed, { + command: "exec", + profilePath: "e2e-secrets.profile.json", + workspaces: ["backstage", "extensions"], + executable: "playwright", + args: ["test", "--headed"], + } satisfies ExecCliArguments); +}); + +test("requires a profile and a command after --", () => { + assert.throws( + () => parseCliArguments(["exec", "--", "playwright"]), + /profile/i, + ); + assert.throws( + () => parseCliArguments(["exec", "--profile", "profile.json"]), + /command.*--/i, + ); + assert.throws( + () => parseCliArguments(["exec", "--profile", "profile.json", "--", ""]), + /command/i, + ); +}); + +test("rejects unknown commands and malformed options", () => { + assert.throws(() => parseCliArguments(["rotate"]), /unsupported command/i); + assert.throws( + () => parseCliArguments(["exec", "--profile", "--", "playwright"]), + /requires a value/i, + ); + assert.throws( + () => + parseCliArguments([ + "exec", + "--profile", + "profile.json", + "--workspace", + "--", + "playwright", + ]), + /requires a value/i, + ); +}); diff --git a/src/secrets/cli.ts b/src/secrets/cli.ts new file mode 100644 index 0000000..4abebbb --- /dev/null +++ b/src/secrets/cli.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { executeCommand } from "./exec.js"; +import { parseProfile } from "./config.js"; + +export interface ExecCliArguments { + command: "exec"; + profilePath: string; + workspaces: string[]; + executable: string; + args: string[]; +} + +const HELP = `Usage: + rhdh-e2e-secrets exec --profile [--workspace ...] -- [args...] + +Requirements: + BW_SESSION must contain an already unlocked Bitwarden CLI session. + The bw CLI must be installed and available on PATH. +`; + +export function parseCliArguments(argv: readonly string[]): ExecCliArguments { + if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + throw new Error(HELP); + } + if (argv[0] !== "exec") { + throw new Error(`Unsupported command: ${argv[0]}`); + } + + const delimiter = argv.indexOf("--"); + if (delimiter === -1) { + throw new Error("A command after -- is required"); + } + const options = argv.slice(1, delimiter); + const command = argv[delimiter + 1]; + const args = argv.slice(delimiter + 2); + let profilePath: string | undefined; + const workspaces: string[] = []; + + for (let index = 0; index < options.length; index++) { + const option = options[index]!; + if (option === "--profile" || option === "--workspace") { + const value = options[++index]; + if (!value) throw new Error(`${option} requires a value`); + if (option === "--profile") profilePath = value; + else workspaces.push(value); + continue; + } + if (option.startsWith("--profile=")) { + profilePath = option.slice("--profile=".length); + if (!profilePath) throw new Error("--profile requires a value"); + continue; + } + if (option.startsWith("--workspace=")) { + const workspace = option.slice("--workspace=".length); + if (!workspace) throw new Error("--workspace requires a value"); + workspaces.push(workspace); + continue; + } + throw new Error(`Unknown option: ${option}`); + } + + if (!profilePath) throw new Error("--profile is required"); + if (!command) throw new Error("A non-empty command after -- is required"); + return { + command: "exec", + profilePath, + workspaces, + executable: command, + args, + }; +} + +export async function main( + argv: readonly string[] = process.argv.slice(2), +): Promise { + if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + console.log(HELP); + return 0; + } + + try { + const parsed = parseCliArguments(argv); + const profileValue = JSON.parse( + await readFile(resolve(parsed.profilePath), "utf8"), + ) as unknown; + const profile = parseProfile(profileValue); + return await executeCommand({ + profile, + workspaces: parsed.workspaces, + command: parsed.executable, + args: parsed.args, + }); + } catch (error) { + console.error( + error instanceof Error ? error.message : "Secret execution failed", + ); + return 1; + } +} + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) +) { + void main().then( + (exitCode) => { + process.exitCode = exitCode; + }, + () => { + process.exitCode = 1; + }, + ); +} diff --git a/src/secrets/command.ts b/src/secrets/command.ts new file mode 100644 index 0000000..609c769 --- /dev/null +++ b/src/secrets/command.ts @@ -0,0 +1,52 @@ +import { spawn } from "node:child_process"; + +export interface CommandResult { + status: number | null; + stdout: string; + stderr: string; + error?: Error; +} + +export interface CommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + input?: string; + stdio?: "pipe" | "inherit"; +} + +export type CommandRunner = ( + command: string, + args: readonly string[], + options?: CommandOptions, +) => Promise; + +export const runCommand: CommandRunner = (command, args, options = {}) => + new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: options.stdio === "inherit" ? "inherit" : "pipe", + }); + let stdout = ""; + let stderr = ""; + + if (options.stdio !== "inherit") { + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + if (options.input !== undefined) child.stdin?.end(options.input); + else child.stdin?.end(); + } + + child.once("error", (error) => { + resolve({ status: null, stdout, stderr, error }); + }); + child.once("close", (status) => { + resolve({ status, stdout, stderr }); + }); + }); diff --git a/src/secrets/config.test.ts b/src/secrets/config.test.ts new file mode 100644 index 0000000..3ae35b6 --- /dev/null +++ b/src/secrets/config.test.ts @@ -0,0 +1,151 @@ +/* eslint-disable playwright/expect-expect -- this file uses node:test assertions */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + expandProfile, + getCollectionMapping, + parseProfile, + type SecretProfile, +} from "./config.js"; + +const overlayProfile: SecretProfile = { + schemaVersion: 1, + collection: "rhdh-plugin-export-overlays", + selectors: [ + { + prefix: "global/", + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, + { + prefix: "workspaces/${workspace}/", + optional: true, + destination: { + kind: "environment", + stripPrefix: "workspaces/${workspace}/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, + ], +}; + +test("maps each approved collection to its exact Bitwarden collection name", () => { + assert.deepEqual(getCollectionMapping("rhdh-qe"), { + id: "rhdh-qe", + bitwardenCollection: "Rhdh Qe Ci Secrets", + }); +}); + +test("rejects the GSM-only AWS collection before Bitwarden access", () => { + assert.throws( + () => + parseProfile({ ...overlayProfile, collection: "rhdh-aws-credentials" }), + /rhdh-aws-credentials.*Bitwarden/i, + ); +}); + +test("expands workspace selectors once for every requested workspace", () => { + assert.deepEqual( + expandProfile(overlayProfile, ["backstage", "extensions"]).selectors.map( + (selector) => selector.prefix, + ), + ["global/", "workspaces/backstage/", "workspaces/extensions/"], + ); +}); + +test("allows an optional workspace selector to have no matching items", () => { + const expanded = expandProfile(overlayProfile, ["extensions"]); + assert.equal(expanded.selectors[1]?.optional, true); +}); + +test("rejects workspace arguments for a profile without a workspace token", () => { + assert.throws( + () => + expandProfile( + parseProfile({ + schemaVersion: 1, + collection: "rhdh-qe", + selectors: [ + { + prefix: "rhdh/", + destination: { + kind: "environment", + stripPrefix: "rhdh/", + keyTransform: "identity", + }, + }, + ], + }), + ["backstage"], + ), + /does not accept workspace arguments/, + ); +}); + +test("rejects invalid workspace names and duplicate transformed selectors", () => { + assert.throws( + () => expandProfile(overlayProfile, ["../secrets"]), + /invalid workspace/i, + ); + + assert.throws( + () => + parseProfile({ + schemaVersion: 1, + collection: "rhdh-qe", + selectors: [ + { + prefix: "rhdh/", + destination: { + kind: "environment", + stripPrefix: "rhdh/", + keyTransform: "identity", + }, + }, + { + prefix: "rhdh/", + destination: { + kind: "environment", + stripPrefix: "rhdh/", + keyTransform: "identity", + }, + }, + ], + }), + /duplicate selector/i, + ); +}); + +test("rejects a strip prefix that cannot apply to the selected item prefix", () => { + assert.throws( + () => + parseProfile({ + schemaVersion: 1, + collection: "rhdh-qe", + selectors: [ + { + prefix: "global/", + destination: { + kind: "environment", + stripPrefix: "workspaces/", + keyTransform: "identity", + }, + }, + ], + }), + /stripPrefix.*prefix/i, + ); +}); + +test("rejects duplicate workspace arguments", () => { + assert.throws( + () => expandProfile(overlayProfile, ["backstage", "backstage"]), + /duplicate workspace/i, + ); +}); diff --git a/src/secrets/config.ts b/src/secrets/config.ts new file mode 100644 index 0000000..25ce7f3 --- /dev/null +++ b/src/secrets/config.ts @@ -0,0 +1,252 @@ +export type ReadableCollectionId = + | "rhdh-qe" + | "rhdh-test-instance" + | "rhdh-plugin-export-overlays"; + +export interface CollectionMapping { + id: ReadableCollectionId; + bitwardenCollection: string; +} + +export interface SecretProfile { + schemaVersion: 1; + collection: ReadableCollectionId; + selectors: readonly SecretSelector[]; +} + +export interface SecretSelector { + prefix: string; + destination: EnvironmentDestination; + optional?: boolean; +} + +export interface EnvironmentDestination { + kind: "environment"; + stripPrefix: string; + requirePrefix?: string; + keyTransform: "identity" | "legacy-env"; +} + +export interface ExpandedSecretSelector extends SecretSelector { + optional: boolean; +} + +export interface ExpandedSecretProfile { + schemaVersion: 1; + collection: ReadableCollectionId; + selectors: readonly ExpandedSecretSelector[]; +} + +const COLLECTIONS: readonly CollectionMapping[] = [ + { + id: "rhdh-qe", + bitwardenCollection: "Rhdh Qe Ci Secrets", + }, + { + id: "rhdh-test-instance", + bitwardenCollection: "Rhdh Test Instance Ci Secrets", + }, + { + id: "rhdh-plugin-export-overlays", + bitwardenCollection: "Rhdh Plugin Export Overlays Ci Secrets", + }, +]; + +const DENIED_COLLECTION = "rhdh-aws-credentials"; +const WORKSPACE_TOKEN = "${workspace}"; +const WORKSPACE_NAME = /^[a-z0-9][a-z0-9-]*$/; + +export function getCollectionMapping(collection: string): CollectionMapping { + if (collection === DENIED_COLLECTION) { + throw new Error( + `Collection ${DENIED_COLLECTION} is GSM-only and cannot be read from Bitwarden`, + ); + } + + if (!isReadableCollectionId(collection)) { + throw new Error(`Unknown Bitwarden collection: ${collection}`); + } + + return COLLECTIONS.find((mapping) => mapping.id === collection)!; +} + +export function parseProfile(value: unknown): SecretProfile { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new Error("Invalid secret profile: schemaVersion must be 1"); + } + + if (typeof value.collection !== "string") { + throw new Error("Invalid secret profile: collection is required"); + } + const collection = getCollectionMapping(value.collection).id; + + if (!Array.isArray(value.selectors) || value.selectors.length === 0) { + throw new Error("Invalid secret profile: selectors must not be empty"); + } + + const selectors = value.selectors.map((selector, index) => + parseSelector(selector, index), + ); + const selectorKeys = selectors.map((selector) => selector.prefix); + if (new Set(selectorKeys).size !== selectorKeys.length) { + throw new Error("Invalid secret profile: duplicate selector"); + } + + return { schemaVersion: 1, collection, selectors }; +} + +export function expandProfile( + profile: SecretProfile, + workspaces: readonly string[] = [], +): ExpandedSecretProfile { + const hasWorkspaceToken = profile.selectors.some((selector) => + selector.prefix.includes(WORKSPACE_TOKEN), + ); + + if (!hasWorkspaceToken && workspaces.length > 0) { + throw new Error("Secret profile does not accept workspace arguments"); + } + if (hasWorkspaceToken && workspaces.length === 0) { + throw new Error("Secret profile requires at least one workspace argument"); + } + + for (const workspace of workspaces) { + if (!WORKSPACE_NAME.test(workspace)) { + throw new Error(`Invalid workspace name: ${workspace}`); + } + } + if (new Set(workspaces).size !== workspaces.length) { + throw new Error("Duplicate workspace argument"); + } + + const selectors: ExpandedSecretSelector[] = []; + for (const selector of profile.selectors) { + const values = selector.prefix.includes(WORKSPACE_TOKEN) + ? workspaces + : [undefined]; + for (const workspace of values) { + selectors.push({ + ...selector, + prefix: expandToken(selector.prefix, workspace), + destination: { + ...selector.destination, + stripPrefix: expandToken(selector.destination.stripPrefix, workspace), + }, + optional: selector.optional === true, + }); + } + } + + return { + schemaVersion: 1, + collection: profile.collection, + selectors, + }; +} + +function parseSelector(value: unknown, index: number): SecretSelector { + if (!isRecord(value)) { + throw new Error(`Invalid secret selector at index ${index}`); + } + if (typeof value.prefix !== "string" || value.prefix.length === 0) { + throw new Error( + `Invalid secret selector at index ${index}: prefix is required`, + ); + } + validateTokenCount(value.prefix, `selector ${index} prefix`); + if (!value.prefix.endsWith("/")) { + throw new Error( + `Invalid secret selector at index ${index}: prefix must end with /`, + ); + } + + if ( + !isRecord(value.destination) || + value.destination.kind !== "environment" + ) { + throw new Error( + `Invalid secret selector at index ${index}: destination must be an environment`, + ); + } + const destination = value.destination; + if ( + typeof destination.stripPrefix !== "string" || + destination.stripPrefix.length === 0 + ) { + throw new Error( + `Invalid secret selector at index ${index}: stripPrefix is required`, + ); + } + validateTokenCount(destination.stripPrefix, `selector ${index} stripPrefix`); + if (!destination.stripPrefix.endsWith("/")) { + throw new Error( + `Invalid secret selector at index ${index}: stripPrefix must end with /`, + ); + } + if (!value.prefix.startsWith(destination.stripPrefix)) { + throw new Error( + `Invalid secret selector at index ${index}: stripPrefix must be a prefix of prefix`, + ); + } + if ( + typeof destination.requirePrefix !== "undefined" && + (typeof destination.requirePrefix !== "string" || + destination.requirePrefix.length === 0) + ) { + throw new Error( + `Invalid secret selector at index ${index}: requirePrefix must be non-empty`, + ); + } + if ( + destination.keyTransform !== "identity" && + destination.keyTransform !== "legacy-env" + ) { + throw new Error( + `Invalid secret selector at index ${index}: unsupported keyTransform`, + ); + } + if ( + typeof value.optional !== "undefined" && + typeof value.optional !== "boolean" + ) { + throw new Error( + `Invalid secret selector at index ${index}: optional must be boolean`, + ); + } + + return { + prefix: value.prefix, + optional: value.optional === true, + destination: { + kind: "environment", + stripPrefix: destination.stripPrefix, + ...(typeof destination.requirePrefix === "string" + ? { requirePrefix: destination.requirePrefix } + : {}), + keyTransform: destination.keyTransform, + }, + }; +} + +function expandToken(value: string, workspace: string | undefined): string { + if (!value.includes(WORKSPACE_TOKEN)) return value; + if (workspace === undefined) { + throw new Error(`Missing workspace for selector: ${value}`); + } + return value.replace(WORKSPACE_TOKEN, workspace); +} + +function validateTokenCount(value: string, label: string): void { + const count = value.split(WORKSPACE_TOKEN).length - 1; + if (count > 1) { + throw new Error(`${label} may contain ${WORKSPACE_TOKEN} at most once`); + } +} + +function isReadableCollectionId(value: string): value is ReadableCollectionId { + return COLLECTIONS.some((mapping) => mapping.id === value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/secrets/environment.test.ts b/src/secrets/environment.test.ts new file mode 100644 index 0000000..042fd75 --- /dev/null +++ b/src/secrets/environment.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixtures model process environment keys */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + materializeEnvironment, + type EnvironmentSecret, +} from "./environment.js"; +import type { ExpandedSecretSelector } from "./config.js"; + +const selector: ExpandedSecretSelector = { + prefix: "global/", + optional: false, + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, +}; + +test("maps selected notes into a child environment without mutating the parent", () => { + const parent = { + PATH: "/usr/bin", + VAULT_GITHUB_TOKEN: "old-value", + BW_SESSION: "session-value", + BW_CLIENTID: "client-id", + BW_CLIENTSECRET: "client-secret", + BW_PASSWORD: "password", + VAULT_TOKEN: "legacy-provider-token", + VAULT_ADDR: "legacy-provider-address", + VAULT_BASE_PATH: "legacy-provider-path", + VAULT: "legacy-provider-setting", + }; + const secrets: EnvironmentSecret[] = [ + { + id: "token-id", + name: "global/VAULT_GITHUB_TOKEN", + value: "line one\nline two\n", + selector, + }, + ]; + + const child = materializeEnvironment(secrets, [selector], parent); + + assert.equal(child.VAULT_GITHUB_TOKEN, "line one\nline two\n"); + assert.equal(child.PATH, "/usr/bin"); + assert.equal(child.BW_SESSION, undefined); + assert.equal(child.BW_CLIENTID, undefined); + assert.equal(child.BW_CLIENTSECRET, undefined); + assert.equal(child.BW_PASSWORD, undefined); + assert.equal(child.VAULT_TOKEN, undefined); + assert.equal(child.VAULT_ADDR, undefined); + assert.equal(child.VAULT_BASE_PATH, undefined); + assert.equal(child.VAULT, undefined); + assert.equal(parent.VAULT_GITHUB_TOKEN, "old-value"); +}); + +test("filters item names that do not satisfy requirePrefix", () => { + const secrets: EnvironmentSecret[] = [ + { + id: "ignored-id", + name: "global/OTHER_SETTING", + value: "ignored", + selector, + }, + ]; + + const child = materializeEnvironment(secrets, [selector], {}); + assert.deepEqual(child, {}); +}); + +test("rejects transformed environment-name collisions", () => { + const secrets: EnvironmentSecret[] = [ + { + id: "first-id", + name: "global/VAULT_A-B", + value: "first", + selector, + }, + { + id: "second-id", + name: "global/VAULT_A.B", + value: "second", + selector, + }, + ]; + + assert.throws( + () => materializeEnvironment(secrets, [selector], {}), + /environment variable collision.*VAULT_A_B/i, + ); +}); + +test("rejects invalid identity environment names", () => { + const identitySelector: ExpandedSecretSelector = { + ...selector, + destination: { + ...selector.destination, + requirePrefix: undefined, + keyTransform: "identity", + }, + }; + + assert.throws( + () => + materializeEnvironment( + [ + { + id: "invalid-id", + name: "global/NOT-VALID", + value: "synthetic", + selector: identitySelector, + }, + ], + [identitySelector], + {}, + ), + /invalid environment variable name/i, + ); +}); + +test("rejects secrets that are not covered by their selector", () => { + const wrongSelector: ExpandedSecretSelector = { + ...selector, + prefix: "workspaces/backstage/", + }; + const secret: EnvironmentSecret = { + id: "wrong-id", + name: "global/VAULT_TOKEN", + value: "synthetic", + selector: wrongSelector, + }; + + assert.throws( + () => materializeEnvironment([secret], [selector], {}), + /does not match selector/i, + ); +}); diff --git a/src/secrets/environment.ts b/src/secrets/environment.ts new file mode 100644 index 0000000..e2bb4a8 --- /dev/null +++ b/src/secrets/environment.ts @@ -0,0 +1,91 @@ +import type { ExpandedSecretSelector } from "./config.js"; + +export interface EnvironmentSecret { + id: string; + name: string; + value: string; + selector: ExpandedSecretSelector; +} + +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PROVIDER_ENVIRONMENT_KEYS = new Set([ + "VAULT", + "VAULT_TOKEN", + "VAULT_ADDR", + "VAULT_BASE_PATH", +]); + +export function removeProviderEnvironmentVariables( + environment: NodeJS.ProcessEnv, +): void { + for (const key of Object.keys(environment)) { + if (key.startsWith("BW_") || PROVIDER_ENVIRONMENT_KEYS.has(key)) { + delete environment[key]; + } + } +} + +export function materializeEnvironment( + secrets: readonly EnvironmentSecret[], + selectors: readonly ExpandedSecretSelector[], + parent: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const child = { ...parent }; + removeProviderEnvironmentVariables(child); + + const mapped = new Map(); + for (const secret of secrets) { + const selector = selectors.find((candidate) => + sameSelector(candidate, secret.selector), + ); + if (!selector) { + throw new Error(`Secret ${secret.name} does not match selector`); + } + if (!secret.name.startsWith(selector.prefix)) { + throw new Error( + `Secret ${secret.name} does not match selector ${selector.prefix}`, + ); + } + + const relativeName = secret.name.slice( + selector.destination.stripPrefix.length, + ); + if (selector.destination.requirePrefix !== undefined) { + if (!relativeName.startsWith(selector.destination.requirePrefix)) + continue; + } + const key = transformEnvironmentName( + relativeName, + selector.destination.keyTransform, + ); + if (!ENVIRONMENT_NAME.test(key)) { + throw new Error(`Invalid environment variable name: ${key}`); + } + if (mapped.has(key)) { + throw new Error(`Environment variable collision: ${key}`); + } + mapped.set(key, secret.value); + } + + for (const [key, value] of mapped) child[key] = value; + return child; +} + +function transformEnvironmentName( + value: string, + transform: ExpandedSecretSelector["destination"]["keyTransform"], +): string { + return transform === "legacy-env" ? value.replace(/[.\-/]/g, "_") : value; +} + +function sameSelector( + left: ExpandedSecretSelector, + right: ExpandedSecretSelector, +): boolean { + return ( + left.prefix === right.prefix && + left.destination.stripPrefix === right.destination.stripPrefix && + left.destination.requirePrefix === right.destination.requirePrefix && + left.destination.keyTransform === right.destination.keyTransform + ); +} diff --git a/src/secrets/exec.test.ts b/src/secrets/exec.test.ts new file mode 100644 index 0000000..2e3cee8 --- /dev/null +++ b/src/secrets/exec.test.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixtures model process environment keys */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { executeCommand, runChild, type ChildRunner } from "./exec.js"; +import type { BitwardenSecret } from "./bitwarden.js"; +import type { SecretProfile } from "./config.js"; + +const profile: SecretProfile = { + schemaVersion: 1, + collection: "rhdh-plugin-export-overlays", + selectors: [ + { + prefix: "global/", + destination: { + kind: "environment", + stripPrefix: "global/", + requirePrefix: "VAULT_", + keyTransform: "legacy-env", + }, + }, + ], +}; + +const secret: BitwardenSecret = { + id: "secret-id", + name: "global/VAULT_TOKEN", + value: "synthetic-value", + selector: { + ...profile.selectors[0]!, + optional: false, + }, +}; + +test("executes a command with only selected secrets in its child environment", async () => { + let received: + | { command: string; args: readonly string[]; env: NodeJS.ProcessEnv } + | undefined; + const childRunner: ChildRunner = async (command, args, env) => { + received = { command, args, env }; + return 0; + }; + const client = { read: async () => [secret] }; + + const exitCode = await executeCommand({ + profile, + workspaces: [], + command: "playwright", + args: ["test", "--project", "github"], + env: { + PATH: "/usr/bin", + BW_SESSION: "synthetic-session", + EXISTING_VALUE: "preserved", + }, + client, + childRunner, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(received?.args, ["test", "--project", "github"]); + assert.equal(received?.command, "playwright"); + assert.equal(received?.env.VAULT_TOKEN, "synthetic-value"); + assert.equal(received?.env.EXISTING_VALUE, "preserved"); + assert.equal(received?.env.BW_SESSION, undefined); +}); + +test("validates all selected mappings before starting the child", async () => { + let childStarted = false; + const childRunner: ChildRunner = async () => { + childStarted = true; + return 0; + }; + const client = { + read: async () => { + throw new Error("required prefix has no matching item"); + }, + }; + + await assert.rejects( + () => + executeCommand({ + profile, + workspaces: [], + command: "playwright", + args: [], + env: { BW_SESSION: "synthetic-session" }, + client, + childRunner, + }), + /required prefix/i, + ); + assert.equal(childStarted, false); +}); + +test("propagates the real child exit code", async () => { + const exitCode = await runChild( + process.execPath, + ["-e", "process.exit(17)"], + { + ...process.env, + }, + ); + assert.equal(exitCode, 17); +}); + +test("returns the shell-compatible status for a signal-terminated child", async () => { + const exitCode = await runChild( + process.execPath, + ["-e", "process.kill(process.pid, 'SIGTERM')"], + { ...process.env }, + ); + assert.equal(exitCode, 143); +}); diff --git a/src/secrets/exec.ts b/src/secrets/exec.ts new file mode 100644 index 0000000..109f8bc --- /dev/null +++ b/src/secrets/exec.ts @@ -0,0 +1,89 @@ +import { spawn } from "node:child_process"; +import { BitwardenClient, type BitwardenSecret } from "./bitwarden.js"; +import { + expandProfile, + type ExpandedSecretSelector, + type SecretProfile, +} from "./config.js"; +import { materializeEnvironment } from "./environment.js"; + +export interface SecretReader { + read( + collection: SecretProfile["collection"], + selectors: readonly ExpandedSecretSelector[], + ): Promise; +} + +export type ChildRunner = ( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +) => Promise; + +export interface ExecuteCommandOptions { + profile: SecretProfile; + workspaces: readonly string[]; + command: string; + args: readonly string[]; + env?: NodeJS.ProcessEnv; + client?: SecretReader; + childRunner?: ChildRunner; +} + +export async function executeCommand( + options: ExecuteCommandOptions, +): Promise { + const expanded = expandProfile(options.profile, options.workspaces); + const client = + options.client ?? new BitwardenClient({ env: options.env ?? process.env }); + const secrets = await client.read( + options.profile.collection, + expanded.selectors, + ); + const childEnvironment = materializeEnvironment( + secrets, + expanded.selectors, + options.env ?? process.env, + ); + const childRunner = options.childRunner ?? runChild; + return childRunner(options.command, options.args, childEnvironment); +} + +export const runChild: ChildRunner = (command, args, env) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: "inherit" }); + let settled = false; + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + const signalExitCodes = new Map<(typeof signals)[number], number>([ + ["SIGINT", 130], + ["SIGTERM", 143], + ["SIGHUP", 129], + ]); + const forwarders = new Map<(typeof signals)[number], () => void>(); + for (const signal of signals) { + const forwardSignal = () => child.kill(signal); + forwarders.set(signal, forwardSignal); + process.on(signal, forwardSignal); + } + const cleanup = () => { + for (const [signal, forwardSignal] of forwarders) { + process.removeListener(signal, forwardSignal); + } + }; + + child.once("error", () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Unable to start command: ${command}`)); + }); + child.once("close", (code, signal) => { + if (settled) return; + settled = true; + cleanup(); + const signalExitCode = signal + ? signalExitCodes.get(signal as (typeof signals)[number]) + : undefined; + resolve(code ?? signalExitCode ?? 1); + }); + }); diff --git a/src/secrets/index.ts b/src/secrets/index.ts new file mode 100644 index 0000000..b63f3ec --- /dev/null +++ b/src/secrets/index.ts @@ -0,0 +1,29 @@ +export { + expandProfile, + getCollectionMapping, + parseProfile, + type CollectionMapping, + type EnvironmentDestination, + type ExpandedSecretProfile, + type ExpandedSecretSelector, + type ReadableCollectionId, + type SecretProfile, + type SecretSelector, +} from "./config.js"; +export { + BitwardenClient, + type BitwardenClientOptions, + type BitwardenSecret, +} from "./bitwarden.js"; +export { + executeCommand, + runChild, + type ChildRunner, + type ExecuteCommandOptions, + type SecretReader, +} from "./exec.js"; +export { + materializeEnvironment, + removeProviderEnvironmentVariables, + type EnvironmentSecret, +} from "./environment.js"; diff --git a/src/utils/vault.ts b/src/utils/vault.ts deleted file mode 100644 index a4c1b26..0000000 --- a/src/utils/vault.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { $ } from "./bash.js"; - -const VAULT_ADDR_DEFAULT = "https://vault.ci.openshift.org"; -const VAULT_BASE_PATH_DEFAULT = "selfservice/rhdh-plugin-export-overlays"; - -/** - * Loads secrets from HashiCorp Vault into process.env. - * Only runs when `VAULT=1` or `VAULT=true` is set. Handles OIDC login automatically. - * - * Fetches secrets from: - * - Global path: `/global` - * - Per-workspace paths: `/workspaces/` - * - * Configure via env vars: - * - `VAULT_ADDR` — Vault server URL (default: https://vault.ci.openshift.org) - * - `VAULT_BASE_PATH` — Base path in Vault (default: selfservice/rhdh-plugin-export-overlays) - * - * Security: Only key names are logged, never secret values. - */ -export async function loadLocalVaultSecrets(): Promise { - if (process.env.VAULT !== "1" && process.env.VAULT !== "true") return; - - const vaultAddr = process.env.VAULT_ADDR || VAULT_ADDR_DEFAULT; - const basePath = process.env.VAULT_BASE_PATH || VAULT_BASE_PATH_DEFAULT; - process.env.VAULT_ADDR = vaultAddr; - - // Check vault CLI is installed - const whichResult = await vaultCmd`command -v vault`; - if (whichResult.exitCode !== 0) { - throw new Error( - "vault CLI not found. Install from https://developer.hashicorp.com/vault/downloads", - ); - } - - // Check if already logged in - const tokenCheck = await vaultCmd`vault token lookup`; - if (tokenCheck.exitCode !== 0) { - console.log("Vault: not logged in, starting OIDC login..."); - // vault login needs inherited stdio for browser-based OIDC flow - await $`vault login -no-print -method=oidc`; - - const retryCheck = await vaultCmd`vault token lookup`; - if (retryCheck.exitCode !== 0) { - throw new Error( - "Vault login failed. Run manually:\n export VAULT_ADDR='" + - vaultAddr + - "'\n vault login -method=oidc", - ); - } - } - - // Check access by fetching global secrets first - const globalResult = - await vaultCmd`vault kv get -format=json -mount=kv ${basePath}/global`; - - if (globalResult.stderr.includes("permission denied")) { - console.log( - "Vault: permission denied. Request access in Slack: #rhdh-e2e-tests", - ); - return; - } - - console.log("Loading secrets from vault..."); - - // Load global secrets - loadSecretsFromResult(globalResult, "global"); - - // List and fetch per-workspace secrets - const listResult = - await vaultCmd`vault kv list -format=json -mount=kv ${basePath}/workspaces`; - - if (listResult.exitCode === 0) { - const workspaces: string[] = JSON.parse(listResult.stdout); - await Promise.all( - workspaces.map((ws) => { - const name = ws.replace(/\/$/, ""); - return exportSecretsFromPath(`${basePath}/workspaces/${name}`, name); - }), - ); - } else { - console.log(" No workspace-specific secrets found"); - } - - console.log("Vault secrets loaded successfully."); -} - -/** Runs a shell command with piped stdio and nothrow, for capturing vault CLI output. */ -const vaultCmd = $({ - stdio: ["pipe", "pipe", "pipe"], - nothrow: true, -}); - -async function exportSecretsFromPath( - vaultPath: string, - label: string, -): Promise { - const result = - await vaultCmd`vault kv get -format=json -mount=kv ${vaultPath}`; - loadSecretsFromResult(result, label); -} - -interface VaultResult { - exitCode: number | null; - stdout: string; -} - -function loadSecretsFromResult(result: VaultResult, label: string): void { - if (result.exitCode !== 0) { - console.log(` No secrets at: ${label}`); - return; - } - - const json = JSON.parse(result.stdout) as { - data?: { data?: Record }; - }; - const secrets = json?.data?.data; - if (!secrets) { - console.log(` No secrets at: ${label}`); - return; - } - - console.log(` From: ${label}`); - for (const [key, value] of Object.entries(secrets)) { - if (key.startsWith("secretsync/")) continue; - if (!key.startsWith("VAULT_")) continue; - const safeKey = key.replace(/[.\-/]/g, "_"); - process.env[safeKey] = value; - } -} diff --git a/yarn.lock b/yarn.lock index e008a03..751d5c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,6 +404,8 @@ __metadata: zx: "npm:8.8.5" peerDependencies: "@playwright/test": ^1.57.0 + bin: + rhdh-e2e-secrets: ./dist/secrets/cli.js languageName: unknown linkType: soft