Skip to content

Commit 795975c

Browse files
authored
Improve secret parameter support for firebase functions. (#9335)
* Improve secret parameter support for firebase functions. Key changes: - Added --format flag to functions:secrets:set command - Auto-detect JSON format from .json file extensions - Added format field to SecretParam interface for deploy-time handling - Use visible input() prompt for JSON secrets vs password() for regular secrets - Validate JSON format before storing in Secret Manager - Improved error messages with actionable commands for developers - Added non-interactive mode check with helpful error for missing secrets Example usage: firebase functions:secrets:set STRIPE_CONFIG --format=json --data-file config.json cat config.json | firebase functions:secrets:set STRIPE_CONFIG --format=json * Address code review feedback for functions-secrets-set command - Include parse error message in JSON validation errors for better debugging - Remove redundant --format=json flag from error suggestions (auto-detected from .json extension) - Use consistent <file.json> placeholder instead of config.json in examples - Implement custom secret reading logic to handle file/stdin/interactive input - Keep all interactive secrets hidden using password() for security (including JSON) * Address code review feedback for deploy-time secret params - Include parse error message in JSON validation errors for better debugging - Remove redundant --format=json flag from error suggestions (auto-detected from .json extension) - Use consistent <file.json> placeholder instead of config.json in examples - Keep all interactive secrets hidden using password() for security (including JSON) * Formatting. * Add CHANGELOG entry for JSON secrets support * Extract JSON secret validation to shared function Move validateJsonSecret to src/functions/secrets.ts to adhere to DRY principle and improve maintainability. This avoids duplicating the same validation logic and error messages across multiple files. * formatting. * make better use of existing utiliyt. * nit: organize import.
1 parent b119293 commit 795975c

File tree

5 files changed

+85
-12
lines changed

5 files changed

+85
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
- Add JSON format support for Cloud Functions secrets with `--format json` flag and auto-detection from file extensions (#1745)
12
- `firebase dataconnect:sdk:generate` will run `init dataconnect:sdk` automatically if no SDKs are configured (#9325).

src/commands/functions-secrets-set.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as clc from "colorette";
2+
import * as tty from "tty";
23

34
import { logger } from "../logger";
4-
import { ensureValidKey, ensureSecret } from "../functions/secrets";
5+
import { FirebaseError } from "../error";
6+
import { ensureValidKey, ensureSecret, validateJsonSecret } from "../functions/secrets";
57
import { Command } from "../command";
68
import { requirePermissions } from "../requirePermissions";
79
import { Options } from "../options";
@@ -36,15 +38,37 @@ export const command = new Command("functions:secrets:set <KEY>")
3638
"--data-file <dataFile>",
3739
'file path from which to read secret data. Set to "-" to read the secret data from stdin',
3840
)
41+
.option("--format <format>", "format of the secret value. 'string' (default) or 'json'")
3942
.action(async (unvalidatedKey: string, options: Options) => {
4043
const projectId = needProjectId(options);
4144
const projectNumber = await needProjectNumber(options);
4245
const key = await ensureValidKey(unvalidatedKey, options);
4346
const secret = await ensureSecret(projectId, key, options);
44-
const secretValue = await readSecretValue(
45-
`Enter a value for ${key}`,
46-
options.dataFile as string | undefined,
47-
);
47+
48+
// Determine format using priority order: explicit flag > file extension > default to string
49+
let format = options.format as string | undefined;
50+
const dataFile = options.dataFile as string | undefined;
51+
if (!format && dataFile && dataFile !== "-") {
52+
// Auto-detect from file extension
53+
if (dataFile.endsWith(".json")) {
54+
format = "json";
55+
}
56+
}
57+
58+
// Only error if there's no input source (no file and no piped stdin)
59+
if (!dataFile && tty.isatty(0) && options.nonInteractive) {
60+
throw new FirebaseError(
61+
`Cannot prompt for secret value in non-interactive mode.\n` +
62+
`Use --data-file to provide the secret value from a file.`,
63+
);
64+
}
65+
66+
const promptSuffix = format === "json" ? " (JSON format)" : "";
67+
const secretValue = await readSecretValue(`Enter a value for ${key}${promptSuffix}:`, dataFile);
68+
69+
if (format === "json") {
70+
validateJsonSecret(key, secretValue);
71+
}
4872
const secretVersion = await addVersion(projectId, key, secretValue);
4973
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);
5074

src/deploy/functions/params.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { logger } from "../../logger";
22
import { FirebaseError } from "../../error";
33
import { checkbox, input, password, select } from "../../prompt";
4+
import { validateJsonSecret } from "../../functions/secrets";
45
import * as build from "./build";
56
import { assertExhaustive, partition } from "../../functional";
67
import * as secretManager from "../../gcp/secretManager";
@@ -224,6 +225,9 @@ interface SecretParam {
224225
// A long description of the parameter's purpose and allowed values. If omitted, UX will not
225226
// provide a description of the parameter
226227
description?: string;
228+
229+
// The format of the secret, e.g. "json"
230+
format?: string;
227231
}
228232

229233
export type Param = StringParam | IntParam | BooleanParam | ListParam | SecretParam;
@@ -390,6 +394,23 @@ export async function resolveParams(
390394
}
391395

392396
const [needSecret, needPrompt] = partition(outstanding, (param) => param.type === "secret");
397+
398+
// Check for missing secrets in non-interactive mode
399+
if (nonInteractive && needSecret.length > 0) {
400+
const secretNames = needSecret.map((p) => p.name).join(", ");
401+
const commands = needSecret
402+
.map(
403+
(p) =>
404+
`\tfirebase functions:secrets:set ${p.name}${(p as SecretParam).format === "json" ? " --format=json --data-file <file.json>" : ""}`,
405+
)
406+
.join("\n");
407+
throw new FirebaseError(
408+
`In non-interactive mode but have no value for the following secrets: ${secretNames}\n\n` +
409+
"Set these secrets before deploying:\n" +
410+
commands,
411+
);
412+
}
413+
393414
// The functions emulator will handle secrets
394415
if (!isEmulator) {
395416
for (const param of needSecret) {
@@ -398,7 +419,7 @@ export async function resolveParams(
398419
}
399420

400421
if (nonInteractive && needPrompt.length > 0) {
401-
const envNames = outstanding.map((p) => p.name).join(", ");
422+
const envNames = needPrompt.map((p) => p.name).join(", ");
402423
throw new FirebaseError(
403424
`In non-interactive mode but have no value for the following environment variables: ${envNames}\n` +
404425
"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<string, ParamValu
460481
* to read its environment variables. They are instead provided through GCF's own
461482
* Secret Manager integration.
462483
*/
463-
async function handleSecret(secretParam: SecretParam, projectId: string) {
484+
async function handleSecret(secretParam: SecretParam, projectId: string): Promise<void> {
464485
const metadata = await secretManager.getSecretMetadata(projectId, secretParam.name, "latest");
465486
if (!metadata.secret) {
487+
const promptMessage = `This secret will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${
488+
secretParam.name
489+
}. Enter ${secretParam.format === "json" ? "a JSON value" : "a value"} for ${
490+
secretParam.label || secretParam.name
491+
}:`;
492+
466493
const secretValue = await password({
467-
message: `This secret will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${
468-
secretParam.name
469-
}. Enter a value for ${secretParam.label || secretParam.name}:`,
494+
message: promptMessage,
470495
});
496+
if (secretParam.format === "json") {
497+
validateJsonSecret(secretParam.name, secretValue);
498+
}
471499
await secretManager.createSecret(projectId, secretParam.name, secretLabels());
472500
await secretManager.addVersion(projectId, secretParam.name, secretValue);
473-
return secretValue;
501+
return;
474502
} else if (!metadata.secretVersion) {
475503
throw new FirebaseError(
476504
`Cloud Secret Manager has no latest version of the secret defined by param ${

src/functions/secrets.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ export async function ensureValidKey(key: string, options: Options): Promise<str
8787
return transformedKey;
8888
}
8989

90+
/**
91+
* Validates that a secret value is valid JSON and throws a helpful error if not.
92+
* @param secretName The name of the secret being validated
93+
* @param secretValue The value to validate
94+
* @throws FirebaseError if the value is not valid JSON
95+
*/
96+
export function validateJsonSecret(secretName: string, secretValue: string): void {
97+
try {
98+
JSON.parse(secretValue);
99+
} catch (e: any) {
100+
throw new FirebaseError(
101+
`Provided value for ${secretName} is not valid JSON: ${e.message}\n\n` +
102+
`For complex JSON values, use:\n` +
103+
` firebase functions:secrets:set ${secretName} --data-file <file.json>\n` +
104+
`Or pipe from stdin:\n` +
105+
` cat <file.json> | firebase functions:secrets:set ${secretName} --format=json`,
106+
);
107+
}
108+
}
109+
90110
/**
91111
* Ensure secret exists. Optionally prompt user to have non-Firebase managed keys be managed by Firebase.
92112
*/

src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ export function generatePassword(n = 20): string {
890890

891891
/**
892892
* Reads a secret value from either a file or a prompt.
893-
* If dataFile is falsy and this is a tty, uses prompty. Otherwise reads from dataFile.
893+
* If dataFile is falsy and this is a tty, uses prompt. Otherwise reads from dataFile.
894894
* If dataFile is - or falsy, this means reading from file descriptor 0 (e.g. pipe in)
895895
*/
896896
export function readSecretValue(prompt: string, dataFile?: string): Promise<string> {

0 commit comments

Comments
 (0)