From e84253193220b28526cb54937c5955b303ff8e62 Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Tue, 11 Aug 2026 17:49:56 +0100 Subject: [PATCH 1/2] SDKQE-3975: carry Capella v4 API key credentials through fit-cli - Resolve a v4 organization API key, secret, and v4 endpoint per Capella environment, from environments.json5 and the same AWS secret, with personal overrides via config, CAPELLA_API_KEY / CAPELLA_API_SECRET, or --capella-api-key / --capella-api-secret - Forward CAPELLA_V4_ENDPOINT, CAPELLA_API_KEY, and CAPELLA_API_SECRET to the remote box so cbdinocluster init --auto enables the cloud deployer, which now requires the API secret - Keep the v2 username/password: custom image deploys, server version changes, and columnar operations still need them --- README.md | 1 + environments.json5 | 20 +++-- specs/credentials-and-secrets.md | 1 + .../default-cbdinocluster-init-config.ts | 6 +- src/fit/config/config.ts | 9 +++ src/fit/config/edit.ts | 50 ++++++++++--- src/fit/config/tests/auto-edit.test.ts | 15 ++++ .../run-from-definition.ts | 2 +- src/fit/shared/util/remote-fit-run.ts | 9 ++- src/fit/util/config.ts | 73 ++++++++++++------ src/fit/util/environments.ts | 10 ++- src/fit/util/tests/config.test.ts | 74 ++++++++++++++++--- 12 files changed, 208 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index e873921..72946bc 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ than templated). When running a preset group, an override only has to apply to a ## Capella When running locally, we use Capella creds from your fit-cli config. Generally you just need to provide your email address. We default to using Capella's production environment. When running on CI, the user chooses what Capella environment to use (stage, dev, etc.) and we use previously-setup accounts for those. +cbdinocluster's cloud deployer authenticates with a Capella v4 organization API key. By default the shared per-environment key is read from AWS Secrets Manager; override it with CAPELLA_API_KEY / CAPELLA_API_SECRET (or `config edit`). The v2 username/password are still used for custom image deploys, server version changes, and columnar operations. ## Capabilities Each performer declares what it supports — a set of "caps" — over the `performerCapsFetch` gRPC call. diff --git a/environments.json5 b/environments.json5 index b8df893..3cf6391 100644 --- a/environments.json5 +++ b/environments.json5 @@ -7,21 +7,24 @@ // capella → instances[*].setup.capellaEnvironment (default 'prod') // results → runs[*].situational.database.resultsEnvironment (default 'prod'; used to be 'dev') // - // Only the passwords live in AWS Secrets Manager now (us-west-2); everything else - // (endpoint, oid, the shared Capella username, results host) is non-secret and lives here. + // Only the passwords and API keys live in AWS Secrets Manager now (us-west-2); everything else + // (endpoints, oid, the shared Capella username, results host) is non-secret and lives here. // Secrets are fetched at run time using the same AWS credentials fit-cli already needs to // create instances — so CI and laptops resolve identically. Access is controlled purely by // IAM on the secrets (CI's fit-cli-role + developers' SSO role need secretsmanager:GetSecretValue). - // capella secret JSON → { "password": "..." } + // capella secret JSON → { "password": "...", "apiKey": "...", "apiSecret": "..." } // results secret JSON → { "password": "..." } (the user is the non-secret `username` on the // results block below; a "username" in the secret overrides it, for emergency role changes) // - // Capella password: a developer's own (from `config edit` / CAPELLA_PASS env) takes precedence - // locally; with none configured we fall back to the shared account password in the secret (and - // warn). The username is the shared account below unless overridden. Results creds always from the secret. + // Capella credentials: a developer's own (from `config edit` / the CAPELLA_PASS, CAPELLA_API_KEY, + // CAPELLA_API_SECRET env vars) take precedence locally; with none configured we fall back to the + // shared values in the secret. The apiKey/apiSecret pair is a v4 organization API key; + // cbdinocluster's cloud deployer authenticates with it (against v4Endpoint), while the v2 + // username/password stay needed for custom image deploys, server version changes, and columnar. + // The username is the shared account below unless overridden. Results creds always from the secret. // // A null non-secret value means "not yet provided" — selecting that block fails fast. - // bun run secrets set capella/dev password='' + // bun run secrets set capella/dev password='' apiKey='' apiSecret='' // bun run secrets set results/dev password='' // Global defaults — not per-environment. Version strings passed to cbdinocluster @@ -146,18 +149,21 @@ capella: { prod: { endpoint: "https://api.cloud.couchbase.com", + v4Endpoint: "https://cloudapi.cloud.couchbase.com", oid: "62488bdd-d416-467e-84f7-fc7c1583a083", username: "sdk_qe@couchbase.com", secretId: "fit-cli/capella/prod", }, dev: { endpoint: "https://api.dev.nonprod-project-avengers.com", + v4Endpoint: "https://cloudapi.dev.nonprod-project-avengers.com", oid: "6af08c0a-8cab-4c1c-b257-b521575c16d0", username: "sdk_qe@couchbase.com", secretId: "fit-cli/capella/dev", }, stage: { endpoint: "https://api.stage.nonprod-project-avengers.com", + v4Endpoint: "https://cloudapi.stage.nonprod-project-avengers.com", oid: "5ae82d34-5da8-4313-af5f-90ea4142a4c6", username: "sdk_qe@couchbase.com", secretId: "fit-cli/capella/stage", diff --git a/specs/credentials-and-secrets.md b/specs/credentials-and-secrets.md index 77063e1..8678b6c 100644 --- a/specs/credentials-and-secrets.md +++ b/specs/credentials-and-secrets.md @@ -21,6 +21,7 @@ So nothing __too__ secret should be stored - we are talking GHA PATs, Gerrit cre We use the known sdkqe@couchbase.com accounts, which are setup in all Capella envs, for all Capella testing by default. These are stored in environments.json5 and AWS Secrets [SECRETS1]. [CAPELLA1] The user can provide a different acount in their fit-cli. This is used both for localhost testing and clean cloud instance testing, an exception to the [CONFIG1] rule. +[CAPELLA2] cbdinocluster's cloud deployer authenticates with a v4 organization API key and secret. These live per environment in the same AWS secret (apiKey/apiSecret keys) and can be overridden personally, like the password [CAPELLA1]. The v2 username/password are kept alongside: custom or unreleased image deploys, server version changes, and columnar operations still need them. ## AWS [SECRETS2] After encountering various problems when using user's localhost credentials in the clean EC2 testing, have decided to settle on EC2 testing exclusively using info from AWS secrets (Github PAT, Gerrit creds, etc). diff --git a/src/cluster/cluster-create/default-cbdinocluster-init-config.ts b/src/cluster/cluster-create/default-cbdinocluster-init-config.ts index 8a6884d..618f4bd 100644 --- a/src/cluster/cluster-create/default-cbdinocluster-init-config.ts +++ b/src/cluster/cluster-create/default-cbdinocluster-init-config.ts @@ -46,9 +46,9 @@ export function defaultCbdinoclusterInitArgs( * Like {@link defaultCbdinoclusterInitArgs} but leaves Capella *enabled* so that * `cbdinocluster init --auto` populates the `capella` block from the `CAPELLA_*` * environment variables fit-cli forwards to the box (see - * `uploadRemoteCapellaConfig` / cbdinocluster's `cmd/init.go`, which reads - * `CAPELLA_ENDPOINT/USER/PASS/OID`). With a `CAPELLA_USER` present, `--auto` - * enables and fills in Capella; without one it leaves Capella disabled. + * `uploadRemoteCapellaConfig` / cbdinocluster's `cmd/init.go`). With a + * `CAPELLA_API_SECRET` present, `--auto` enables and fills in Capella; without + * one it leaves Capella disabled. * * `cloudProvider` picks which direct cloud-infra block cbdinocluster also needs: * situational PE (`private-endpoints setup-link`) calls the CSP's API directly, so diff --git a/src/fit/config/config.ts b/src/fit/config/config.ts index 889c0f6..c950495 100644 --- a/src/fit/config/config.ts +++ b/src/fit/config/config.ts @@ -58,6 +58,9 @@ Edit options: (AWS secret "fit-cli/gerrit/ssh-key" used as fallback on EC2). --capella-username Your Capella username for situational/SIT runs (env: CAPELLA_USER / CAP_USER). --capella-password

Your Capella password (env: CAPELLA_PASS / CAP_PASS). + --capella-api-key Your Capella v4 organization API key (env: CAPELLA_API_KEY). + Optional: falls back to the shared per-environment key in AWS Secrets Manager. + --capella-api-secret Your Capella v4 organization API secret (env: CAPELLA_API_SECRET). --fit-performer-dir Path to your local transactions-fit-performer checkout, for localhost runs (env: FIT_PERFORMER_DIR). Stored under localhost.repos. --cbdinocluster-path Absolute path to the cbdinocluster binary, for non-PATH installs (localhost runs) @@ -85,6 +88,8 @@ export interface AutoInitCliArgs { gerritUser?: string; capellaUsername?: string; capellaPassword?: string; + capellaApiKey?: string; + capellaApiSecret?: string; /** Local transactions-fit-performer checkout dir (localhost runs). */ fitPerformerDir?: string; cbdinoclusterPath?: string; @@ -135,6 +140,8 @@ export function parseEditArgs(argv: string[]): AutoInitCliArgs { const gerritUser = consumeValue(args, "--gerrit-user"); const capellaUsername = consumeValue(args, "--capella-username"); const capellaPassword = consumeValue(args, "--capella-password"); + const capellaApiKey = consumeValue(args, "--capella-api-key"); + const capellaApiSecret = consumeValue(args, "--capella-api-secret"); const fitPerformerDir = consumeValue(args, "--fit-performer-dir"); const cbdinoclusterPath = consumeValue(args, "--cbdinocluster-path"); const configPath = consumeValue(args, "--config-path") ?? defaultFitCliConfigPath(); @@ -163,6 +170,8 @@ export function parseEditArgs(argv: string[]): AutoInitCliArgs { gerritUser, capellaUsername, capellaPassword, + capellaApiKey, + capellaApiSecret, fitPerformerDir, cbdinoclusterPath, configPath, diff --git a/src/fit/config/edit.ts b/src/fit/config/edit.ts index 9f24ac8..7401567 100644 --- a/src/fit/config/edit.ts +++ b/src/fit/config/edit.ts @@ -120,12 +120,14 @@ function awsAnswersToConfig(answers: AwsInitAnswers, existingInstanceTypes?: Fit return instanceTypes ? { instanceTypes } : undefined; } -/** Capella prompt defaults from a saved config: username from config, the rest defaulting to the hardcoded values. */ +/** Capella prompt defaults from a saved config; blank means "no personal override". */ function capellaDefaultsFromConfig(config?: FitCliConfig): CapellaInitAnswers { const c = config?.capella; return { username: c?.username ?? "", password: c?.password ?? "", + apiKey: c?.apiKey ?? "", + apiSecret: c?.apiSecret ?? "", }; } @@ -393,7 +395,7 @@ async function promptForConfig(existing?: FitCliConfig, configPath?: string): Pr /** * Ask whether to configure Capella (situational/SIT only), and if so, the - * username plus the five fields that default to the hardcoded values. + * personal username/password and v4 organization API key/secret overrides. */ async function promptForCapella( existing?: FitCliConfig, @@ -423,8 +425,8 @@ async function promptForCapella( }); console.warn( - `\nWarning: Capella password will be saved in plaintext in ${configPath ?? "~/.fit-cli/config.json5"}.\n` + - `Set CAPELLA_PASS in your environment to avoid storing it on disk.\n`, + `\nWarning: Capella secrets will be saved in plaintext in ${configPath ?? "~/.fit-cli/config.json5"}.\n` + + `Set CAPELLA_PASS / CAPELLA_API_SECRET in your environment to avoid storing them on disk.\n`, ); const capellaPassword = await password({ promptId: "init.capella.password", @@ -434,11 +436,28 @@ async function promptForCapella( mask: "*", }); + const apiKey = await input({ + promptId: "init.capella.api-key", + message: defaults.apiKey + ? `Capella v4 organization API key (leave blank to keep "${defaults.apiKey}"):` + : "Capella v4 organization API key (leave blank to use the shared one from AWS Secrets Manager):", + default: defaults.apiKey, + }); + const apiSecret = await password({ + promptId: "init.capella.api-secret", + message: defaults.apiSecret + ? "Capella v4 organization API secret (leave blank to keep the current one):" + : "Capella v4 organization API secret (leave blank to skip; set CAPELLA_API_SECRET env var instead):", + mask: "*", + }); + return { configureCapella: true, capella: { username, password: trimOptional(capellaPassword) ?? defaults.password, + apiKey, + apiSecret: trimOptional(apiSecret) ?? defaults.apiSecret, }, }; } @@ -483,6 +502,7 @@ export function formatConfigForDisplay(config: FitCliConfig): string { capella: { ...config.capella, ...(config.capella.password ? { password: ELIDED } : {}), + ...(config.capella.apiSecret ? { apiSecret: ELIDED } : {}), }, } : {}), @@ -567,6 +587,7 @@ function mask(value: string | undefined): string { const SECRET_FIELDS = new Set([ "github.token", "capella.password", + "capella.apiSecret", ]); /** Check whether a diagnostic-only env var is present, appending an entry to the log. */ @@ -692,9 +713,10 @@ export function buildAutoConfig( : undefined; const output: FitCliOutputConfig | undefined = format ? { format } : undefined; - // Capella section: PERSONAL credentials only (username/password). The endpoint and - // org id are per-environment and come from environments.json5 at run time. Anchored - // on the username: with none there's nothing to log in as, so we skip the section. + // Capella section: PERSONAL credentials only (username/password and the v4 + // organization API key/secret). The endpoints and org id are per-environment and + // come from environments.json5 at run time. With neither a username nor an API + // key there is nothing personal to store, so we skip the section. let capella: FitCliConfig["capella"] | undefined; if (args.disableCapella) { log.push({ field: "capella.*", source: "--disable-capella", found: false }); @@ -703,17 +725,25 @@ export function buildAutoConfig( { name: "CAPELLA_USER", value: env.CAPELLA_USER }, { name: "CAP_USER", value: env.CAP_USER }, ]); - if (!username) { - log.push({ field: "capella.*", source: "(no username)", found: false }); + const apiKey = resolveField(log, "capella.apiKey", args.capellaApiKey, "--capella-api-key", [ + { name: "CAPELLA_API_KEY", value: env.CAPELLA_API_KEY }, + ]); + if (!username && !apiKey) { + log.push({ field: "capella.*", source: "(no username or API key)", found: false }); } else { const capellaPassword = resolveField(log, "capella.password", args.capellaPassword, "--capella-password", [ { name: "CAPELLA_PASS", value: env.CAPELLA_PASS }, { name: "CAP_PASS", value: env.CAP_PASS }, ]); + const apiSecret = resolveField(log, "capella.apiSecret", args.capellaApiSecret, "--capella-api-secret", [ + { name: "CAPELLA_API_SECRET", value: env.CAPELLA_API_SECRET }, + ]); capella = { - username, + ...(username ? { username } : {}), ...(capellaPassword ? { password: capellaPassword } : {}), + ...(apiKey ? { apiKey } : {}), + ...(apiSecret ? { apiSecret } : {}), }; } } diff --git a/src/fit/config/tests/auto-edit.test.ts b/src/fit/config/tests/auto-edit.test.ts index ce60a3b..d9be7e9 100644 --- a/src/fit/config/tests/auto-edit.test.ts +++ b/src/fit/config/tests/auto-edit.test.ts @@ -115,6 +115,21 @@ test("buildAutoConfig: CAP_* env (fit-app-deployment names) populates the person }); }); +test("buildAutoConfig: CAPELLA_API_KEY/SECRET populate the personal v4 credentials", () => { + const env = { + CAPELLA_API_KEY: "org-key", + CAPELLA_API_SECRET: "org-secret", + }; + + const { config } = buildAutoConfig({ args: baseArgs(), env }); + + // A v4 API key alone is a valid personal credential; no username is needed. + assert.deepEqual(config.capella, { + apiKey: "org-key", + apiSecret: "org-secret", + }); +}); + test("buildAutoConfig: CAPELLA_* takes precedence over CAP_* aliases", () => { const env = { CAPELLA_USER: "primary@cb.com", CAP_USER: "alias@cb.com" }; diff --git a/src/fit/functional/run-from-definition/run-from-definition.ts b/src/fit/functional/run-from-definition/run-from-definition.ts index 0c8f87b..872f5ec 100644 --- a/src/fit/functional/run-from-definition/run-from-definition.ts +++ b/src/fit/functional/run-from-definition/run-from-definition.ts @@ -2261,7 +2261,7 @@ export async function runFromDefinition( } // Forward Capella and AWS settings before init so `cbdinocluster init --auto` // (run via a login shell sourcing ~/.profile) picks them up and writes the - // capella and aws blocks. Without a username it can't enable Capella, so fail + // capella and aws blocks. Without an API secret it can't enable Capella, so fail // clearly rather than letting `cbdinocluster allocate` later fail with "no deployers". capellaEndpoint = await uploadCapellaCredsForCloudDeployer( execution, diff --git a/src/fit/shared/util/remote-fit-run.ts b/src/fit/shared/util/remote-fit-run.ts index e03830a..97df1b5 100644 --- a/src/fit/shared/util/remote-fit-run.ts +++ b/src/fit/shared/util/remote-fit-run.ts @@ -229,13 +229,18 @@ function remoteCapellaConfigPath(rootDir: string): string { function capellaConfigScript(capella: ResolvedCapellaConfig): string { // The env var names cbdinocluster's `init` reads (see its cmd/init.go). With - // CAPELLA_USER present, `init --auto` enables Capella and fills the block from - // these; the situational init args leave Capella enabled for exactly this. + // CAPELLA_API_SECRET present, `init --auto` enables Capella and fills the block + // from these; without it the cloud deployer is silently left disabled. The v2 + // user/pass stay forwarded: custom image deploys, server version changes, and + // columnar operations still authenticate with them. const lines = [ `export CAPELLA_USER=${posixQuote(capella.username ?? "")}`, `export CAPELLA_ENDPOINT=${posixQuote(capella.endpoint)}`, `export CAPELLA_OID=${posixQuote(capella.organizationId)}`, `export CAPELLA_PASS=${posixQuote(capella.password)}`, + `export CAPELLA_V4_ENDPOINT=${posixQuote(capella.v4Endpoint)}`, + `export CAPELLA_API_KEY=${posixQuote(capella.apiKey)}`, + `export CAPELLA_API_SECRET=${posixQuote(capella.apiSecret)}`, ]; // Optional: only present for environments the Capella team has issued them for // (currently just "dev"). Both have env-var fallbacks in cbdinocluster's `init`, diff --git a/src/fit/util/config.ts b/src/fit/util/config.ts index 31ba23d..3cd16c3 100644 --- a/src/fit/util/config.ts +++ b/src/fit/util/config.ts @@ -195,14 +195,18 @@ export interface FitCliLocalhostConfig { * environment variables so `cbdinocluster init --auto` bakes them into * `~/.cbdinocluster` (see resolveCapellaConfig / uploadRemoteCapellaConfig). * - * Only `username` is per-user and has no default; the rest default to the - * well-known values in {@link DEFAULT_CAPELLA_SETTINGS} and rarely need changing. + * All fields are personal overrides. With none set, the shared account and the + * v4 organization API key come from environments.json5 plus AWS Secrets Manager. */ export interface FitCliCapellaConfig { /** Your Capella account (env: CAPELLA_USER / CAP_USER). */ username?: string; /** Your Capella account password (env: CAPELLA_PASS / CAP_PASS). */ password?: string; + /** Your Capella v4 organization API key (env: CAPELLA_API_KEY). */ + apiKey?: string; + /** Your Capella v4 organization API secret (env: CAPELLA_API_SECRET). */ + apiSecret?: string; } export interface FitCliConfig { @@ -343,6 +347,8 @@ export function validateFitCliConfig(raw: unknown): FitCliConfig { ? compactRecord({ username: readOptionalString(capellaValue, "username", "capella.username"), password: readOptionalString(capellaValue, "password", "capella.password"), + apiKey: readOptionalString(capellaValue, "apiKey", "capella.apiKey"), + apiSecret: readOptionalString(capellaValue, "apiSecret", "capella.apiSecret"), }) : undefined; @@ -860,11 +866,15 @@ export function resolveOutputFormat( /** The Capella settings forwarded to a remote box, with every field resolved. */ export interface ResolvedCapellaConfig { - /** Undefined when no username is configured (situational runs then can't enable Capella). */ username?: string; endpoint: string; organizationId: string; password: string; + /** Capella Management API v4 endpoint. */ + v4Endpoint: string; + /** v4 organization API key; cbdinocluster's cloud deployer authenticates with it. */ + apiKey: string; + apiSecret: string; /** Only set for environments the Capella team has issued one for (currently just "dev"). */ internalSupportToken?: string; overrideToken?: string; @@ -883,12 +893,15 @@ function firstEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined { * Resolve the Capella control-plane settings for a Capella environment (a key * under `capella` in environments.json5; default "prod"). * - * endpoint/organizationId/username are the non-secret registry values (username is - * the shared account for the environment). Only the password is a secret: a personal - * one (capella.password in config, or CAPELLA_PASS / CAP_PASS) takes precedence, else - * we fall back to the shared account password in AWS Secrets Manager (and warn). A - * personal username can still be set to override the shared one. Throws when the - * environment is unknown/unprovisioned or no password can be resolved. + * endpoint/v4Endpoint/organizationId/username are the non-secret registry values + * (username is the shared account for the environment). The password and the v4 + * organization API key/secret are secrets: personal ones (capella.* in config, or + * the CAPELLA_* env vars) take precedence, else we fall back to the shared values + * in AWS Secrets Manager. cbdinocluster's `init --auto` refuses to enable the + * cloud deployer without an API secret; the v2 password stays required because + * custom image deploys, server version changes, and columnar operations still use + * it. Throws when the environment is unknown/unprovisioned or any credential + * can't be resolved. */ export async function resolveCapellaConfig( options: { @@ -911,9 +924,12 @@ export async function resolveCapellaConfig( throw new InvalidFitCliConfigError(`Unknown Capella environment "${block}" — not defined in environments.json5.`); } const endpoint = entry.endpoint?.trim(); + const v4Endpoint = entry.v4Endpoint?.trim(); const organizationId = entry.oid?.trim(); - if (!endpoint || !organizationId) { - const missing = [!endpoint && "endpoint", !organizationId && "oid"].filter(Boolean).join(", "); + if (!endpoint || !v4Endpoint || !organizationId) { + const missing = [!endpoint && "endpoint", !v4Endpoint && "v4Endpoint", !organizationId && "oid"] + .filter(Boolean) + .join(", "); throw new InvalidFitCliConfigError( `Capella environment "${block}" isn't fully provisioned in environments.json5 (missing ${missing}).`, ); @@ -922,32 +938,40 @@ export async function resolveCapellaConfig( const c = config?.capella; // Username is the shared, non-secret account from the registry, overridable by personal config/env. let username = c?.username ?? firstEnv(env, ["CAPELLA_USER", "CAP_USER"]) ?? entry.username?.trim(); - // Password: personal first, else the shared account password from the secret. + // Secrets: personal first, else the shared values from the AWS secret. let password = c?.password ?? firstEnv(env, ["CAPELLA_PASS", "CAP_PASS"]); + let apiKey = c?.apiKey ?? firstEnv(env, ["CAPELLA_API_KEY"]); + let apiSecret = c?.apiSecret ?? firstEnv(env, ["CAPELLA_API_SECRET"]); let secret: Record | undefined; - if (!password) { + if (!password || !apiKey || !apiSecret) { if (!entry.secretId) { throw new InvalidFitCliConfigError( - `Capella environment "${block}" has no secretId in environments.json5 and no personal password is configured.`, + `Capella environment "${block}" has no secretId in environments.json5 and no personal credentials are configured.`, + ); + } + if (!password) { + console.info( + `No personal Capella password configured; using the shared "${block}" account password from AWS Secrets Manager.\n` + + ` Set CAPELLA_PASS (or run \`${runScriptPrefix("config")} edit\`) to use your own.`, ); } - console.info( - `No personal Capella password configured — using the shared "${block}" account password from AWS Secrets Manager.\n` + - ` Set CAPELLA_PASS (or run \`${runScriptPrefix("config")} edit\`) to use your own.`, - ); secret = await fetchSecret(entry.secretId); - password = secret.password?.trim(); - username = username ?? secret.username?.trim(); + password ??= secret.password?.trim(); + username ??= secret.username?.trim(); + apiKey ??= secret.apiKey?.trim(); + apiSecret ??= secret.apiSecret?.trim(); } - if (!username || !password) { - const missing = [!username && "username", !password && "password"].filter(Boolean).join(", "); + if (!username || !password || !apiKey || !apiSecret) { + const missing = [!username && "username", !password && "password", !apiKey && "apiKey", !apiSecret && "apiSecret"] + .filter(Boolean) + .join(", "); throw new InvalidFitCliConfigError(`Could not resolve Capella ${missing} for "${block}".`); } // internalSupportToken/overrideToken live only on the shared account's secret — they // belong to the shared "dev" account, not to whatever personal creds might override // username/password above. Only read them from `secret` when it was already fetched - // (i.e. no personal password was configured); per [CONFIG2] we don't touch AWS Secrets + // (i.e. some shared credential was needed); per [CONFIG2] we don't touch AWS Secrets // Manager at all when personal creds satisfy everything. const internalSupportToken = firstEnv(env, ["CAPELLA_INTERNAL_SUPPORT_TOKEN"]) ?? secret?.internalSupportToken?.trim() ?? undefined; @@ -956,8 +980,11 @@ export async function resolveCapellaConfig( return { username, endpoint, + v4Endpoint, organizationId, password, + apiKey, + apiSecret, ...(internalSupportToken ? { internalSupportToken } : {}), ...(overrideToken ? { overrideToken } : {}), }; diff --git a/src/fit/util/environments.ts b/src/fit/util/environments.ts index 034f4e8..28c6796 100644 --- a/src/fit/util/environments.ts +++ b/src/fit/util/environments.ts @@ -17,14 +17,16 @@ import JSON5 from "json5"; export interface CapellaEnvironment { endpoint?: string | null; + /** Capella Management API v4 endpoint for this environment. */ + v4Endpoint?: string | null; oid?: string | null; /** The (shared, non-secret) Capella account username for this environment. */ username?: string | null; /** - * AWS Secrets Manager id/ARN holding { password, internalSupportToken?, - * overrideToken? } for this Capella environment. The two tokens are optional — - * only "dev" currently has them, which is what makes cbcollect's dev-only - * support available. + * AWS Secrets Manager id/ARN holding { password, apiKey, apiSecret, + * internalSupportToken?, overrideToken? } for this Capella environment. The two + * tokens are optional — only "dev" currently has them, which is what makes + * cbcollect's dev-only support available. */ secretId?: string | null; } diff --git a/src/fit/util/tests/config.test.ts b/src/fit/util/tests/config.test.ts index 7d7f6a9..55707c6 100644 --- a/src/fit/util/tests/config.test.ts +++ b/src/fit/util/tests/config.test.ts @@ -224,7 +224,15 @@ const STUB_TEST_SETS = { const TEST_ENVIRONMENTS = { defaults: STUB_DEFAULTS, testSets: STUB_TEST_SETS, - capella: { dev: { endpoint: "https://dev.example", oid: "oid-dev", username: "sdk_qe@couchbase.com", secretId: "cap/dev" } }, + capella: { + dev: { + endpoint: "https://dev.example", + v4Endpoint: "https://cloudapi.dev.example", + oid: "oid-dev", + username: "sdk_qe@couchbase.com", + secretId: "cap/dev", + }, + }, results: { dev: { host: "dev.db.example", secretId: "res/dev" }, prod: { host: "prod.db.example", username: "results_writer", secretId: "res/prod" }, @@ -326,15 +334,25 @@ test("resolveRosaCredentials returns an error string when the secret can't be re }); test("parses a stored capella section (personal credentials only)", () => { - const parsed = parseFitCliConfig(`{ version: 1, capella: { username: "graham.pople@couchbase.com", password: "pw" } }`); - assert.deepEqual(parsed.capella, { username: "graham.pople@couchbase.com", password: "pw" }); + const parsed = parseFitCliConfig( + `{ version: 1, capella: { username: "graham.pople@couchbase.com", password: "pw", apiKey: "key", apiSecret: "sec" } }`, + ); + assert.deepEqual(parsed.capella, { + username: "graham.pople@couchbase.com", + password: "pw", + apiKey: "key", + apiSecret: "sec", + }); }); -test("resolveCapellaConfig prefers personal config credentials, with registry endpoint/oid", async () => { +test("resolveCapellaConfig prefers personal config credentials, with registry endpoints/oid", async () => { const resolved = await resolveCapellaConfig({ block: "dev", environments: TEST_ENVIRONMENTS, - config: { version: FIT_CLI_CONFIG_VERSION, capella: { username: "me@cb.com", password: "pw" } }, + config: { + version: FIT_CLI_CONFIG_VERSION, + capella: { username: "me@cb.com", password: "pw", apiKey: "my-key", apiSecret: "my-secret" }, + }, env: {}, fetchSecret: noFetch, }); @@ -342,7 +360,10 @@ test("resolveCapellaConfig prefers personal config credentials, with registry en username: "me@cb.com", password: "pw", endpoint: "https://dev.example", + v4Endpoint: "https://cloudapi.dev.example", organizationId: "oid-dev", + apiKey: "my-key", + apiSecret: "my-secret", }); }); @@ -351,36 +372,56 @@ test("resolveCapellaConfig prefers CAPELLA_*/CAP_* env over the shared account", block: "dev", environments: TEST_ENVIRONMENTS, config: { version: FIT_CLI_CONFIG_VERSION }, - env: { CAPELLA_USER: "envuser", CAPELLA_PASS: "envpass" }, + env: { CAPELLA_USER: "envuser", CAPELLA_PASS: "envpass", CAPELLA_API_KEY: "envkey", CAPELLA_API_SECRET: "envsec" }, fetchSecret: noFetch, }); assert.equal(resolved.username, "envuser"); assert.equal(resolved.password, "envpass"); + assert.equal(resolved.apiKey, "envkey"); + assert.equal(resolved.apiSecret, "envsec"); assert.equal(resolved.endpoint, "https://dev.example"); + assert.equal(resolved.v4Endpoint, "https://cloudapi.dev.example"); }); -test("resolveCapellaConfig uses the shared registry username + the secret's password when no personal creds", async () => { +test("resolveCapellaConfig uses the shared registry username + the secret's credentials when no personal creds", async () => { const resolved = await resolveCapellaConfig({ block: "dev", environments: TEST_ENVIRONMENTS, config: { version: FIT_CLI_CONFIG_VERSION }, env: {}, - fetchSecret: () => Promise.resolve({ password: "svc-pw" }), + fetchSecret: () => Promise.resolve({ password: "svc-pw", apiKey: "svc-key", apiSecret: "svc-sec" }), }); assert.equal(resolved.username, "sdk_qe@couchbase.com"); // from environments.json5, not the secret assert.equal(resolved.password, "svc-pw"); + assert.equal(resolved.apiKey, "svc-key"); + assert.equal(resolved.apiSecret, "svc-sec"); }); -test("resolveCapellaConfig keeps the registry username even when a personal password is set", async () => { +test("resolveCapellaConfig keeps personal values and fills the rest from the secret", async () => { const resolved = await resolveCapellaConfig({ block: "dev", environments: TEST_ENVIRONMENTS, config: { version: FIT_CLI_CONFIG_VERSION, capella: { password: "my-pw" } }, env: {}, - fetchSecret: noFetch, + fetchSecret: () => Promise.resolve({ password: "svc-pw", apiKey: "svc-key", apiSecret: "svc-sec" }), }); assert.equal(resolved.username, "sdk_qe@couchbase.com"); assert.equal(resolved.password, "my-pw"); + assert.equal(resolved.apiKey, "svc-key"); + assert.equal(resolved.apiSecret, "svc-sec"); +}); + +test("resolveCapellaConfig throws when the secret has no v4 API key", async () => { + await assert.rejects( + resolveCapellaConfig({ + block: "dev", + environments: TEST_ENVIRONMENTS, + config: { version: FIT_CLI_CONFIG_VERSION }, + env: {}, + fetchSecret: () => Promise.resolve({ password: "svc-pw" }), + }), + /Could not resolve Capella apiKey, apiSecret/, + ); }); test("resolveCapellaConfig picks up internalSupportToken/overrideToken from the shared secret", async () => { @@ -390,7 +431,13 @@ test("resolveCapellaConfig picks up internalSupportToken/overrideToken from the config: { version: FIT_CLI_CONFIG_VERSION }, env: {}, fetchSecret: () => - Promise.resolve({ password: "svc-pw", internalSupportToken: "support-tok", overrideToken: "override-tok" }), + Promise.resolve({ + password: "svc-pw", + apiKey: "svc-key", + apiSecret: "svc-sec", + internalSupportToken: "support-tok", + overrideToken: "override-tok", + }), }); assert.equal(resolved.internalSupportToken, "support-tok"); assert.equal(resolved.overrideToken, "override-tok"); @@ -400,7 +447,10 @@ test("resolveCapellaConfig omits internalSupportToken/overrideToken and never to const resolved = await resolveCapellaConfig({ block: "dev", environments: TEST_ENVIRONMENTS, - config: { version: FIT_CLI_CONFIG_VERSION, capella: { username: "me@cb.com", password: "pw" } }, + config: { + version: FIT_CLI_CONFIG_VERSION, + capella: { username: "me@cb.com", password: "pw", apiKey: "my-key", apiSecret: "my-sec" }, + }, env: {}, fetchSecret: noFetch, }); From 08c58b9c86a5125f40885731df9ef0446ee1479c Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Fri, 14 Aug 2026 12:54:48 +0100 Subject: [PATCH 2/2] SDKQE-3975: Stop clear-text logging of the Capella API key - CodeQL reported clear-text logging of the API key at the prompt replay log sites in src/util/non-fit/replay.ts. - Use a masked password prompt for the API key and do not put the saved key in the prompt message. The replay log masks password prompts, so neither the message nor the answer shows the key. - A blank answer keeps the saved key, the same as the API secret. - Add capella.apiKey to SECRET_FIELDS so the init --auto resolution log masks it too. --- src/fit/config/edit.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/fit/config/edit.ts b/src/fit/config/edit.ts index 7401567..a1bec14 100644 --- a/src/fit/config/edit.ts +++ b/src/fit/config/edit.ts @@ -436,12 +436,12 @@ async function promptForCapella( mask: "*", }); - const apiKey = await input({ + const apiKey = await password({ promptId: "init.capella.api-key", message: defaults.apiKey - ? `Capella v4 organization API key (leave blank to keep "${defaults.apiKey}"):` + ? "Capella v4 organization API key (leave blank to keep the current one):" : "Capella v4 organization API key (leave blank to use the shared one from AWS Secrets Manager):", - default: defaults.apiKey, + mask: "*", }); const apiSecret = await password({ promptId: "init.capella.api-secret", @@ -456,7 +456,7 @@ async function promptForCapella( capella: { username, password: trimOptional(capellaPassword) ?? defaults.password, - apiKey, + apiKey: trimOptional(apiKey) ?? defaults.apiKey, apiSecret: trimOptional(apiSecret) ?? defaults.apiSecret, }, }; @@ -587,6 +587,7 @@ function mask(value: string | undefined): string { const SECRET_FIELDS = new Set([ "github.token", "capella.password", + "capella.apiKey", "capella.apiSecret", ]);