diff --git a/CHANGELOG.md b/CHANGELOG.md index f70b35dfb9e..f2ca449cdb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ +- Add JSON format support for Cloud Functions secrets with `--format json` flag and auto-detection from file extensions (#1745) - `firebase dataconnect:sdk:generate` will run `init dataconnect:sdk` automatically if no SDKs are configured (#9325). diff --git a/src/commands/functions-secrets-set.ts b/src/commands/functions-secrets-set.ts index 193b49a0821..cd586d2724c 100644 --- a/src/commands/functions-secrets-set.ts +++ b/src/commands/functions-secrets-set.ts @@ -1,7 +1,9 @@ import * as clc from "colorette"; +import * as tty from "tty"; import { logger } from "../logger"; -import { ensureValidKey, ensureSecret } from "../functions/secrets"; +import { FirebaseError } from "../error"; +import { ensureValidKey, ensureSecret, validateJsonSecret } from "../functions/secrets"; import { Command } from "../command"; import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; @@ -36,15 +38,37 @@ export const command = new Command("functions:secrets:set ") "--data-file ", 'file path from which to read secret data. Set to "-" to read the secret data from stdin', ) + .option("--format ", "format of the secret value. 'string' (default) or 'json'") .action(async (unvalidatedKey: string, options: Options) => { const projectId = needProjectId(options); const projectNumber = await needProjectNumber(options); const key = await ensureValidKey(unvalidatedKey, options); const secret = await ensureSecret(projectId, key, options); - const secretValue = await readSecretValue( - `Enter a value for ${key}`, - options.dataFile as string | undefined, - ); + + // Determine format using priority order: explicit flag > file extension > default to string + let format = options.format as string | undefined; + const dataFile = options.dataFile as string | undefined; + if (!format && dataFile && dataFile !== "-") { + // Auto-detect from file extension + if (dataFile.endsWith(".json")) { + format = "json"; + } + } + + // Only error if there's no input source (no file and no piped stdin) + if (!dataFile && tty.isatty(0) && options.nonInteractive) { + throw new FirebaseError( + `Cannot prompt for secret value in non-interactive mode.\n` + + `Use --data-file to provide the secret value from a file.`, + ); + } + + const promptSuffix = format === "json" ? " (JSON format)" : ""; + const secretValue = await readSecretValue(`Enter a value for ${key}${promptSuffix}:`, dataFile); + + if (format === "json") { + validateJsonSecret(key, secretValue); + } const secretVersion = await addVersion(projectId, key, secretValue); logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`); diff --git a/src/deploy/functions/params.ts b/src/deploy/functions/params.ts index b16fae733e2..31a4a80c3e8 100644 --- a/src/deploy/functions/params.ts +++ b/src/deploy/functions/params.ts @@ -1,6 +1,7 @@ import { logger } from "../../logger"; import { FirebaseError } from "../../error"; import { checkbox, input, password, select } from "../../prompt"; +import { validateJsonSecret } from "../../functions/secrets"; import * as build from "./build"; import { assertExhaustive, partition } from "../../functional"; import * as secretManager from "../../gcp/secretManager"; @@ -224,6 +225,9 @@ interface SecretParam { // A long description of the parameter's purpose and allowed values. If omitted, UX will not // provide a description of the parameter description?: string; + + // The format of the secret, e.g. "json" + format?: string; } export type Param = StringParam | IntParam | BooleanParam | ListParam | SecretParam; @@ -390,6 +394,23 @@ export async function resolveParams( } const [needSecret, needPrompt] = partition(outstanding, (param) => param.type === "secret"); + + // Check for missing secrets in non-interactive mode + if (nonInteractive && needSecret.length > 0) { + const secretNames = needSecret.map((p) => p.name).join(", "); + const commands = needSecret + .map( + (p) => + `\tfirebase functions:secrets:set ${p.name}${(p as SecretParam).format === "json" ? " --format=json --data-file " : ""}`, + ) + .join("\n"); + throw new FirebaseError( + `In non-interactive mode but have no value for the following secrets: ${secretNames}\n\n` + + "Set these secrets before deploying:\n" + + commands, + ); + } + // The functions emulator will handle secrets if (!isEmulator) { for (const param of needSecret) { @@ -398,7 +419,7 @@ export async function resolveParams( } if (nonInteractive && needPrompt.length > 0) { - const envNames = outstanding.map((p) => p.name).join(", "); + const envNames = needPrompt.map((p) => p.name).join(", "); throw new FirebaseError( `In non-interactive mode but have no value for the following environment variables: ${envNames}\n` + "To continue, either run `firebase deploy` with an interactive terminal, or add values to a dotenv file. " + @@ -460,17 +481,24 @@ function populateDefaultParams(config: FirebaseConfig): Record { const metadata = await secretManager.getSecretMetadata(projectId, secretParam.name, "latest"); if (!metadata.secret) { + const promptMessage = `This secret will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${ + secretParam.name + }. Enter ${secretParam.format === "json" ? "a JSON value" : "a value"} for ${ + secretParam.label || secretParam.name + }:`; + const secretValue = await password({ - message: `This secret will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${ - secretParam.name - }. Enter a value for ${secretParam.label || secretParam.name}:`, + message: promptMessage, }); + if (secretParam.format === "json") { + validateJsonSecret(secretParam.name, secretValue); + } await secretManager.createSecret(projectId, secretParam.name, secretLabels()); await secretManager.addVersion(projectId, secretParam.name, secretValue); - return secretValue; + return; } else if (!metadata.secretVersion) { throw new FirebaseError( `Cloud Secret Manager has no latest version of the secret defined by param ${ diff --git a/src/functions/secrets.ts b/src/functions/secrets.ts index 9b58641ed8a..1749e72297f 100644 --- a/src/functions/secrets.ts +++ b/src/functions/secrets.ts @@ -87,6 +87,26 @@ export async function ensureValidKey(key: string, options: Options): Promise\n` + + `Or pipe from stdin:\n` + + ` cat | firebase functions:secrets:set ${secretName} --format=json`, + ); + } +} + /** * Ensure secret exists. Optionally prompt user to have non-Firebase managed keys be managed by Firebase. */ diff --git a/src/utils.ts b/src/utils.ts index 71802bbfe52..3df352cb11a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -890,7 +890,7 @@ export function generatePassword(n = 20): string { /** * Reads a secret value from either a file or a prompt. - * If dataFile is falsy and this is a tty, uses prompty. Otherwise reads from dataFile. + * If dataFile is falsy and this is a tty, uses prompt. Otherwise reads from dataFile. * If dataFile is - or falsy, this means reading from file descriptor 0 (e.g. pipe in) */ export function readSecretValue(prompt: string, dataFile?: string): Promise {