Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
34 changes: 29 additions & 5 deletions src/commands/functions-secrets-set.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,15 +38,37 @@ export const command = new Command("functions:secrets:set <KEY>")
"--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";
}
}

// 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)}`);

Expand Down
40 changes: 34 additions & 6 deletions src/deploy/functions/params.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -224,6 +225,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 +272,7 @@
return pv;
}

setDelimiter(delimiter: string) {

Check warning on line 275 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 +299,7 @@
if (this.rawValue.includes("[")) {
// Convert quotes to apostrophes
const unquoted = this.rawValue.replace(/'/g, '"');
return JSON.parse(unquoted);

Check warning on line 302 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 +394,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 +419,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 +434,7 @@
}
if (paramDefault && !canSatisfyParam(param, paramDefault)) {
throw new FirebaseError(
"Parameter " + param.name + " has default value " + paramDefault + " of wrong type",

Check warning on line 437 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 +481,24 @@
* 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") {
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 ${
Expand Down Expand Up @@ -579,7 +607,7 @@
const defaultToText: TextInput<string> = { text: {} };
param.input = defaultToText;
}
const isTruthyInput = (res: string) => ["true", "y", "yes", "1"].includes(res.toLowerCase());

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

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
let prompt: string;

if (isSelectInput(param.input)) {
Expand Down Expand Up @@ -695,11 +723,11 @@
const notFound = new FirebaseError(`No instances of ${input.resource.type} found.`);
switch (input.resource.type) {
case "storage.googleapis.com/Bucket":
const buckets = (await listBuckets(projectId)).map((b) => b.name);

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected lexical declaration in case block
if (buckets.length === 0) {
throw notFound;
}
const forgedInput: SelectInput<string> = {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected lexical declaration in case block
select: {
options: buckets.map((bucketName: string): SelectOptions<string> => {
return { label: bucketName, value: bucketName };
Expand All @@ -723,11 +751,11 @@
const notFound = new FirebaseError(`No instances of ${input.resource.type} found.`);
switch (input.resource.type) {
case "storage.googleapis.com/Bucket":
const buckets = (await listBuckets(projectId)).map((b) => b.name);

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected lexical declaration in case block
if (buckets.length === 0) {
throw notFound;
}
const forgedInput: MultiSelectInput = {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected lexical declaration in case block
multiSelect: {
options: buckets.map((bucketName: string): SelectOptions<string> => {
return { label: bucketName, value: bucketName };
Expand All @@ -744,7 +772,7 @@
}

type retryInput = { message: string };
function shouldRetry(obj: any): obj is retryInput {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
return typeof obj === "object" && (obj as retryInput).message !== undefined;
}

Expand Down
20 changes: 20 additions & 0 deletions src/functions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,32 @@
}
try {
validateKey(transformedKey);
} catch (err: any) {

Check warning on line 84 in src/functions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(`Invalid secret key ${transformedKey}`, { children: [err] });
}
return transformedKey;
}

/**
* Validates that a secret value is valid JSON and throws a helpful error if not.
* @param secretName The name of the secret being validated
* @param secretValue The value to validate
* @throws FirebaseError if the value is not valid JSON
*/
export function validateJsonSecret(secretName: string, secretValue: string): void {
try {
JSON.parse(secretValue);
} catch (e: any) {
throw new FirebaseError(
`Provided value for ${secretName} is not valid JSON: ${e.message}\n\n` +
`For complex JSON values, use:\n` +
` firebase functions:secrets:set ${secretName} --data-file <file.json>\n` +
`Or pipe from stdin:\n` +
` cat <file.json> | firebase functions:secrets:set ${secretName} --format=json`,
);
}
}

/**
* Ensure secret exists. Optionally prompt user to have non-Firebase managed keys be managed by Firebase.
*/
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