Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add JSON format support for Cloud Functions secrets with `--format json` flag and auto-detection from file extensions (#1745)
60 changes: 54 additions & 6 deletions src/commands/functions-secrets-set.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as clc from "colorette";

import { logger } from "../logger";
import { FirebaseError } from "../error";
import { ensureValidKey, ensureSecret } from "../functions/secrets";
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { Options } from "../options";
import { confirm } from "../prompt";
import { logBullet, logSuccess, logWarning, readSecretValue } from "../utils";
import { confirm, password } from "../prompt";
import { logBullet, logSuccess, logWarning } from "../utils";
import * as tty from "tty";
import * as fs from "fs";
import { needProjectId, needProjectNumber } from "../projectUtils";
import {
addVersion,
Expand Down Expand Up @@ -36,15 +39,60 @@
"--data-file <dataFile>",
'file path from which to read secret data. Set to "-" to read the secret data from stdin',
)
.option("--format <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";
}
}

// Read secret value from file, stdin, or interactive prompt
let secretValue: string;
if (dataFile) {
// Read from file or stdin
const inputSource = dataFile === "-" ? 0 : dataFile;
try {
secretValue = fs.readFileSync(inputSource, "utf-8");
} catch (e: any) {

Check warning on line 66 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (e.code === "ENOENT") {

Check warning on line 67 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .code on an `any` value
throw new FirebaseError(`File not found: ${inputSource}`, { original: e });

Check warning on line 68 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
}
throw e;
}
} else if (!tty.isatty(0)) {
// Read from stdin (piped input)
secretValue = fs.readFileSync(0, "utf-8");
} else {
// Interactive prompt: use password for all secrets (hidden input)
const promptSuffix = format === "json" ? " (JSON format)" : "";
secretValue = await password({
message: `Enter a value for ${key}${promptSuffix}:`,
});
}

if (format === "json") {
try {
JSON.parse(secretValue);
} catch (e: any) {

Check warning on line 86 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(
`Provided value for ${key} is not valid JSON: ${e.message}\n\n` +

Check warning on line 88 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value

Check warning on line 88 in src/commands/functions-secrets-set.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "any" of template literal expression
`For complex JSON values, use:\n` +
` firebase functions:secrets:set ${key} --data-file <file.json>\n` +
`Or pipe from stdin:\n` +
` cat <file.json> | firebase functions:secrets:set ${key} --format=json`,
);
}
}
const secretVersion = await addVersion(projectId, key, secretValue);
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);

Expand Down
49 changes: 43 additions & 6 deletions src/deploy/functions/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@
// 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;
Expand Down Expand Up @@ -268,7 +271,7 @@
return pv;
}

setDelimiter(delimiter: string) {

Check warning on line 274 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
this.delimiter = delimiter;
}

Expand All @@ -295,7 +298,7 @@
if (this.rawValue.includes("[")) {
// Convert quotes to apostrophes
const unquoted = this.rawValue.replace(/'/g, '"');
return JSON.parse(unquoted);

Check warning on line 301 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

// Continue to handle something like "a,b,c"
Expand Down Expand Up @@ -390,6 +393,23 @@
}

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 <file.json>" : ""}`,
)
.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) {
Expand All @@ -398,7 +418,7 @@
}

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. " +
Expand All @@ -413,7 +433,7 @@
}
if (paramDefault && !canSatisfyParam(param, paramDefault)) {
throw new FirebaseError(
"Parameter " + param.name + " has default value " + paramDefault + " of wrong type",

Check warning on line 436 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Operands of '+' operation must either be both strings or both numbers. Consider using a template literal
);
}
paramValues[param.name] = await promptParam(param, firebaseConfig.projectId, paramDefault);
Expand Down Expand Up @@ -460,17 +480,34 @@
* to read its environment variables. They are instead provided through GCF's own
* Secret Manager integration.
*/
async function handleSecret(secretParam: SecretParam, projectId: string) {
async function handleSecret(secretParam: SecretParam, projectId: string): Promise<void> {
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") {
try {
JSON.parse(secretValue);
} catch (e: any) {

Check warning on line 498 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(
`Provided value for ${secretParam.name} is not valid JSON: ${e.message}\n\n` +
`For complex JSON values, use:\n` +
` firebase functions:secrets:set ${secretParam.name} --data-file <file.json>\n` +
`Or pipe from stdin:\n` +
` cat <file.json> | firebase functions:secrets:set ${secretParam.name} --format=json`,
);
}
}
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 ${
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down
Loading