Skip to content

Commit 014f323

Browse files
authored
BREAKING: Rewrite functions:config:export command (#9341)
Target the new defineJsonSecret API as migration target for functions.config() usage. The new API is a simpler migration target for existing functions.config() use cases. Example flow: ```shell $ firebase functions:config:export i This command retrieves your Runtime Config values (accessed via functions.config()) and exports them as a Secret Manager secret. i Fetching your existing functions.config() from danielylee-90... ✔ Fetched your existing functions.config(). i Configuration to be exported: ⚠ This may contain sensitive data. Do not share this output. { <CONFIG> } ✔ What would you like to name the new secret for your configuration? RUNTIME_CONFIG ✔ Created new secret version projects/XXX/secrets/RUNTIME_CONFIG/versions/1 i To complete the migration, update your code: // Before: const functions = require('firebase-functions'); exports.myFunction = functions.https.onRequest((req, res) => { const apiKey = functions.config().service.key; // ... }); // After: const functions = require('firebase-functions'); const { defineJsonSecret } = require('firebase-functions/params'); const config = defineJsonSecret("RUNTIME_CONFIG"); exports.myFunction = functions .runWith({ secrets: [config] }) // Bind secret here .https.onRequest((req, res) => { const apiKey = config.value().service.key; // ... }); i Note: defineJsonSecret requires firebase-functions v6.6.0 or later. Update your package.json if needed. i Then deploy your functions: firebase deploy --only functions ```
1 parent 184d210 commit 014f323

File tree

3 files changed

+155
-467
lines changed

3 files changed

+155
-467
lines changed
Lines changed: 155 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,199 @@
1-
import * as path from "path";
2-
31
import * as clc from "colorette";
2+
import * as semver from "semver";
43

5-
import requireInteractive from "../requireInteractive";
4+
import * as functionsConfig from "../functionsConfig";
65
import { Command } from "../command";
76
import { FirebaseError } from "../error";
8-
import { testIamPermissions } from "../gcp/iam";
9-
import { logger } from "../logger";
107
import { input, confirm } from "../prompt";
118
import { requirePermissions } from "../requirePermissions";
12-
import { logBullet, logWarning } from "../utils";
13-
import { zip } from "../functional";
14-
import * as configExport from "../functions/runtimeConfigExport";
9+
import { logBullet, logSuccess } from "../utils";
1510
import { requireConfig } from "../requireConfig";
11+
import { ensureValidKey, ensureSecret } from "../functions/secrets";
12+
import { addVersion, listSecretVersions, toSecretVersionResourceName } from "../gcp/secretManager";
13+
import { needProjectId } from "../projectUtils";
14+
import { requireAuth } from "../requireAuth";
15+
import { ensureApi } from "../gcp/secretManager";
16+
import { getFunctionsSDKVersion } from "../deploy/functions/runtimes/node/versioning";
1617

1718
import type { Options } from "../options";
18-
import { normalizeAndValidate, resolveConfigDir } from "../functions/projectConfig";
1919

20-
const REQUIRED_PERMISSIONS = [
20+
const RUNTIME_CONFIG_PERMISSIONS = [
2121
"runtimeconfig.configs.list",
2222
"runtimeconfig.configs.get",
2323
"runtimeconfig.variables.list",
2424
"runtimeconfig.variables.get",
2525
];
2626

27-
const RESERVED_PROJECT_ALIAS = ["local"];
28-
const MAX_ATTEMPTS = 3;
27+
const SECRET_MANAGER_PERMISSIONS = [
28+
"secretmanager.secrets.create",
29+
"secretmanager.secrets.get",
30+
"secretmanager.secrets.update",
31+
"secretmanager.versions.add",
32+
];
2933

30-
function checkReservedAliases(pInfos: configExport.ProjectConfigInfo[]): void {
31-
for (const pInfo of pInfos) {
32-
if (pInfo.alias && RESERVED_PROJECT_ALIAS.includes(pInfo.alias)) {
33-
logWarning(
34-
`Project alias (${clc.bold(pInfo.alias)}) is reserved for internal use. ` +
35-
`Saving exported config in .env.${pInfo.projectId} instead.`,
36-
);
37-
delete pInfo.alias;
34+
const DEFAULT_SECRET_NAME = "FUNCTIONS_CONFIG_EXPORT";
35+
36+
function maskConfigValues(obj: any): any {
37+
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
38+
const masked: Record<string, any> = {};
39+
for (const [key, value] of Object.entries(obj)) {
40+
masked[key] = maskConfigValues(value);
3841
}
42+
return masked;
3943
}
44+
return "******";
4045
}
4146

42-
/* For projects where we failed to fetch the runtime config, find out what permissions are missing in the project. */
43-
async function checkRequiredPermission(pInfos: configExport.ProjectConfigInfo[]): Promise<void> {
44-
pInfos = pInfos.filter((pInfo) => !pInfo.config);
45-
const testPermissions = pInfos.map((pInfo) =>
46-
testIamPermissions(pInfo.projectId, REQUIRED_PERMISSIONS),
47-
);
48-
const results = await Promise.all(testPermissions);
49-
for (const [pInfo, result] of zip(pInfos, results)) {
50-
if (result.passed) {
51-
// We should've been able to fetch the config but couldn't. Ask the user to try export command again.
47+
export const command = new Command("functions:config:export")
48+
.description("export environment config as a JSON secret to store in Cloud Secret Manager")
49+
.option("--secret <name>", `name of the secret to create (default: ${DEFAULT_SECRET_NAME})`)
50+
.withForce("use default secret name without prompting")
51+
.before(requireAuth)
52+
.before(ensureApi)
53+
.before(requirePermissions, [...RUNTIME_CONFIG_PERMISSIONS, ...SECRET_MANAGER_PERMISSIONS])
54+
.before(requireConfig)
55+
.action(async (options: Options) => {
56+
const projectId = needProjectId(options);
57+
58+
logBullet(
59+
"This command retrieves your Runtime Config values (accessed via " +
60+
clc.bold("functions.config()") +
61+
") and exports them as a Secret Manager secret.",
62+
);
63+
console.log("");
64+
65+
logBullet(`Fetching your existing functions.config() from ${clc.bold(projectId)}...`);
66+
67+
let configJson: Record<string, unknown>;
68+
try {
69+
configJson = await functionsConfig.materializeAll(projectId);
70+
} catch (err: unknown) {
5271
throw new FirebaseError(
53-
`Unexpectedly failed to fetch runtime config for project ${pInfo.projectId}`,
72+
`Failed to fetch runtime config for project ${projectId}. ` +
73+
"Ensure you have the required permissions:\n\t" +
74+
RUNTIME_CONFIG_PERMISSIONS.join("\n\t"),
75+
{ original: err as Error },
5476
);
5577
}
56-
logWarning(
57-
"You are missing the following permissions to read functions config on project " +
58-
`${clc.bold(pInfo.projectId)}:\n\t${result.missing.join("\n\t")}`,
59-
);
6078

61-
const confirmed = await confirm({
62-
message: `Continue without importing configs from project ${pInfo.projectId}?`,
63-
default: true,
64-
});
79+
if (Object.keys(configJson).length === 0) {
80+
logSuccess("Your functions.config() is empty. Nothing to do.");
81+
return;
82+
}
83+
84+
logSuccess("Fetched your existing functions.config().");
85+
console.log("");
6586

66-
if (!confirmed) {
67-
throw new FirebaseError("Command aborted!");
87+
// Display config in interactive mode
88+
if (!options.nonInteractive) {
89+
logBullet(clc.bold("Configuration to be exported:"));
90+
console.log(JSON.stringify(maskConfigValues(configJson), null, 2));
91+
console.log("");
6892
}
69-
}
70-
}
7193

72-
async function promptForPrefix(errMsg: string): Promise<string> {
73-
logWarning("The following configs keys could not be exported as environment variables:\n");
74-
logWarning(errMsg);
75-
return await input({
76-
default: "CONFIG_",
77-
message: "Enter a PREFIX to rename invalid environment variable keys:",
78-
});
79-
}
94+
let secretName = options.secret as string;
95+
if (!secretName) {
96+
if (options.force) {
97+
secretName = DEFAULT_SECRET_NAME;
98+
} else {
99+
secretName = await input({
100+
message: "What would you like to name the new secret for your configuration?",
101+
default: DEFAULT_SECRET_NAME,
102+
nonInteractive: options.nonInteractive,
103+
});
104+
}
105+
}
80106

81-
function fromEntries<V>(itr: Iterable<[string, V]>): Record<string, V> {
82-
const obj: Record<string, V> = {};
83-
for (const [k, v] of itr) {
84-
obj[k] = v;
85-
}
86-
return obj;
87-
}
107+
const key = await ensureValidKey(secretName, options);
108+
await ensureSecret(projectId, key, options);
88109

89-
export const command = new Command("functions:config:export")
90-
.description("export environment config as environment variables in dotenv format")
91-
.before(requirePermissions, [
92-
"runtimeconfig.configs.list",
93-
"runtimeconfig.configs.get",
94-
"runtimeconfig.variables.list",
95-
"runtimeconfig.variables.get",
96-
])
97-
.before(requireConfig)
98-
.before(requireInteractive)
99-
.action(async (options: Options) => {
100-
const config = normalizeAndValidate(options.config.src.functions)[0];
101-
const configDir = resolveConfigDir(config);
102-
if (!configDir) {
110+
const versions = await listSecretVersions(projectId, key);
111+
const enabledVersions = versions.filter((v) => v.state === "ENABLED");
112+
enabledVersions.sort((a, b) => (b.createTime || "").localeCompare(a.createTime || ""));
113+
const latest = enabledVersions[0];
114+
115+
if (latest) {
116+
logBullet(
117+
`Secret ${clc.bold(key)} already exists (latest version: ${clc.bold(latest.versionId)}, created: ${latest.createTime}).`,
118+
);
119+
const proceed = await confirm({
120+
message: "Do you want to add a new version to this secret?",
121+
default: false,
122+
nonInteractive: options.nonInteractive,
123+
force: options.force,
124+
});
125+
if (!proceed) {
126+
return;
127+
}
128+
console.log("");
129+
}
130+
131+
const secretValue = JSON.stringify(configJson, null, 2);
132+
133+
// Check size limit (64KB)
134+
const sizeInBytes = Buffer.byteLength(secretValue, "utf8");
135+
const maxSize = 64 * 1024; // 64KB
136+
if (sizeInBytes > maxSize) {
103137
throw new FirebaseError(
104-
"functions:config:export requires a local env directory. Set functions[].configDir in firebase.json when using remoteSource.",
138+
`Configuration size (${sizeInBytes} bytes) exceeds the 64KB limit for JSON secrets. ` +
139+
"Please reduce the size of your configuration or split it into multiple secrets.",
105140
);
106141
}
107142

108-
let pInfos = configExport.getProjectInfos(options);
109-
checkReservedAliases(pInfos);
143+
const secretVersion = await addVersion(projectId, key, secretValue);
144+
console.log("");
110145

111-
logBullet(
112-
"Importing functions configs from projects [" +
113-
pInfos.map(({ projectId }) => `${clc.bold(projectId)}`).join(", ") +
114-
"]",
115-
);
146+
logSuccess(`Created new secret version ${toSecretVersionResourceName(secretVersion)}`);
147+
console.log("");
148+
logBullet(clc.bold("To complete the migration, update your code:"));
149+
console.log("");
150+
console.log(
151+
clc.gray(` // Before:
152+
const functions = require('firebase-functions');
116153
117-
await configExport.hydrateConfigs(pInfos);
118-
await checkRequiredPermission(pInfos);
119-
pInfos = pInfos.filter((pInfo) => pInfo.config);
154+
exports.myFunction = functions.https.onRequest((req, res) => {
155+
const apiKey = functions.config().service.key;
156+
// ...
157+
});
120158
121-
logger.debug(`Loaded function configs: ${JSON.stringify(pInfos)}`);
122-
logBullet(`Importing configs from projects: [${pInfos.map((p) => p.projectId).join(", ")}]`);
159+
// After:
160+
const functions = require('firebase-functions');
161+
const { defineJsonSecret } = require('firebase-functions/params');
123162
124-
let attempts = 0;
125-
let prefix = "";
126-
while (true) {
127-
if (attempts >= MAX_ATTEMPTS) {
128-
throw new FirebaseError("Exceeded max attempts to fix invalid config keys.");
129-
}
163+
const config = defineJsonSecret("${key}");
130164
131-
const errMsg = configExport.hydrateEnvs(pInfos, prefix);
132-
if (errMsg.length === 0) {
133-
break;
165+
exports.myFunction = functions
166+
.runWith({ secrets: [config] }) // Bind secret here
167+
.https.onRequest((req, res) => {
168+
const apiKey = config.value().service.key;
169+
// ...
170+
});`),
171+
);
172+
console.log("");
173+
174+
// Try to detect the firebase-functions version to see if we need to warn about defineJsonSecret
175+
let sdkVersion: string | undefined;
176+
try {
177+
const functionsConfig = options.config.get("functions");
178+
const source = Array.isArray(functionsConfig)
179+
? functionsConfig[0]?.source
180+
: functionsConfig?.source;
181+
if (source) {
182+
const sourceDir = options.config.path(source);
183+
sdkVersion = getFunctionsSDKVersion(sourceDir);
134184
}
135-
prefix = await promptForPrefix(errMsg);
136-
attempts += 1;
185+
} catch (e) {
186+
// ignore error, just show the warning if we can't detect the version
137187
}
138188

139-
const header = `# Exported firebase functions:config:export command on ${new Date().toLocaleDateString()}`;
140-
const dotEnvs = pInfos.map((pInfo) => configExport.toDotenvFormat(pInfo.envs!, header));
141-
const filenames = pInfos.map(configExport.generateDotenvFilename);
142-
const filesToWrite = fromEntries(zip(filenames, dotEnvs));
143-
filesToWrite[".env.local"] =
144-
`${header}\n# .env.local file contains environment variables for the Functions Emulator.\n`;
145-
filesToWrite[".env"] =
146-
`${header}# .env file contains environment variables that applies to all projects.\n`;
147-
148-
for (const [filename, content] of Object.entries(filesToWrite)) {
149-
await options.config.askWriteProjectFile(path.join(configDir, filename), content);
189+
if (!sdkVersion || semver.lt(sdkVersion, "6.6.0")) {
190+
logBullet(
191+
clc.bold("Note: ") +
192+
"defineJsonSecret requires firebase-functions v6.6.0 or later. " +
193+
`Update to a newer version with ${clc.bold("npm i firebase-functions @latest")}${!sdkVersion ? " if needed" : ""}.`,
194+
);
150195
}
196+
logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions"));
197+
198+
return secretName;
151199
});

0 commit comments

Comments
 (0)