Skip to content

Commit c68a82a

Browse files
committed
BREAKING: Rewrite functions:config:export command
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: $ 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 9903737 commit c68a82a

File tree

3 files changed

+111
-480
lines changed

3 files changed

+111
-480
lines changed
Lines changed: 111 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,142 @@
1-
import * as path from "path";
2-
31
import * as clc from "colorette";
42

5-
import requireInteractive from "../requireInteractive";
3+
import * as functionsConfig from "../functionsConfig";
64
import { Command } from "../command";
75
import { FirebaseError } from "../error";
8-
import { testIamPermissions } from "../gcp/iam";
9-
import { logger } from "../logger";
10-
import { input, confirm } from "../prompt";
6+
import { input } from "../prompt";
117
import { requirePermissions } from "../requirePermissions";
12-
import { logBullet, logWarning } from "../utils";
13-
import { zip } from "../functional";
14-
import * as configExport from "../functions/runtimeConfigExport";
8+
import { logBullet, logWarning, logSuccess } from "../utils";
159
import { requireConfig } from "../requireConfig";
10+
import { ensureValidKey, ensureSecret } from "../functions/secrets";
11+
import { addVersion, toSecretVersionResourceName } from "../gcp/secretManager";
12+
import { needProjectId } from "../projectUtils";
13+
import { requireAuth } from "../requireAuth";
14+
import { ensureApi } from "../gcp/secretManager";
1615

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

20-
const REQUIRED_PERMISSIONS = [
18+
const RUNTIME_CONFIG_PERMISSIONS = [
2119
"runtimeconfig.configs.list",
2220
"runtimeconfig.configs.get",
2321
"runtimeconfig.variables.list",
2422
"runtimeconfig.variables.get",
2523
];
2624

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

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;
38-
}
39-
}
40-
}
41-
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.
32+
export const command = new Command("functions:config:export")
33+
.description("export environment config as a JSON secret to store in Cloud Secret Manager")
34+
.option("--secret <name>", "name of the secret to create (default: RUNTIME_CONFIG)")
35+
.withForce("use default secret name without prompting")
36+
.before(requireAuth)
37+
.before(ensureApi)
38+
.before(requirePermissions, [...RUNTIME_CONFIG_PERMISSIONS, ...SECRET_MANAGER_PERMISSIONS])
39+
.before(requireConfig)
40+
.action(async (options: Options) => {
41+
const projectId = needProjectId(options);
42+
43+
logBullet(
44+
"This command retrieves your Runtime Config values (accessed via " +
45+
clc.bold("functions.config()") +
46+
") and exports them as a Secret Manager secret.",
47+
);
48+
console.log("");
49+
50+
logBullet(`Fetching your existing functions.config() from ${clc.bold(projectId)}...`);
51+
52+
let configJson: Record<string, unknown>;
53+
try {
54+
configJson = await functionsConfig.materializeAll(projectId);
55+
} catch (err: any) {
5256
throw new FirebaseError(
53-
`Unexpectedly failed to fetch runtime config for project ${pInfo.projectId}`,
57+
`Failed to fetch runtime config for project ${projectId}. ` +
58+
"Ensure you have the required permissions:\n\t" +
59+
RUNTIME_CONFIG_PERMISSIONS.join("\n\t"),
60+
{ original: err },
5461
);
5562
}
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-
);
60-
61-
const confirmed = await confirm({
62-
message: `Continue without importing configs from project ${pInfo.projectId}?`,
63-
default: true,
64-
});
6563

66-
if (!confirmed) {
67-
throw new FirebaseError("Command aborted!");
64+
if (Object.keys(configJson).length === 0) {
65+
logSuccess("Your functions.config() is empty. Nothing to do.");
66+
return;
6867
}
69-
}
70-
}
71-
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-
}
8068

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-
}
69+
logSuccess("Fetched your existing functions.config().");
70+
console.log("");
8871

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) {
72+
// Display config in interactive mode
73+
if (!options.nonInteractive) {
74+
logBullet(clc.bold("Configuration to be exported:"));
75+
logWarning("This may contain sensitive data. Do not share this output.");
76+
console.log("");
77+
console.log(JSON.stringify(configJson, null, 2));
78+
console.log("");
79+
}
80+
81+
const defaultSecretName = "RUNTIME_CONFIG";
82+
const secretName =
83+
(options.secret as string) ||
84+
(await input({
85+
message: "What would you like to name the new secret for your configuration?",
86+
default: defaultSecretName,
87+
nonInteractive: options.nonInteractive,
88+
force: options.force,
89+
}));
90+
91+
const key = await ensureValidKey(secretName, options);
92+
await ensureSecret(projectId, key, options);
93+
94+
const secretValue = JSON.stringify(configJson, null, 2);
95+
96+
// Check size limit (64KB)
97+
const sizeInBytes = Buffer.byteLength(secretValue, "utf8");
98+
const maxSize = 64 * 1024; // 64KB
99+
if (sizeInBytes > maxSize) {
103100
throw new FirebaseError(
104-
"functions:config:export requires a local env directory. Set functions[].configDir in firebase.json when using remoteSource.",
101+
`Configuration size (${sizeInBytes} bytes) exceeds the 64KB limit for JSON secrets. ` +
102+
"Please reduce the size of your configuration or split it into multiple secrets.",
105103
);
106104
}
107105

108-
let pInfos = configExport.getProjectInfos(options);
109-
checkReservedAliases(pInfos);
110-
106+
const secretVersion = await addVersion(projectId, key, secretValue);
107+
console.log("");
108+
109+
logSuccess(`Created new secret version ${toSecretVersionResourceName(secretVersion)}`);
110+
console.log("");
111+
logBullet(clc.bold("To complete the migration, update your code:"));
112+
console.log("");
113+
console.log(clc.gray(" // Before:"));
114+
console.log(clc.gray(` const functions = require('firebase-functions');`));
115+
console.log(clc.gray(` `));
116+
console.log(clc.gray(` exports.myFunction = functions.https.onRequest((req, res) => {`));
117+
console.log(clc.gray(` const apiKey = functions.config().service.key;`));
118+
console.log(clc.gray(` // ...`));
119+
console.log(clc.gray(` });`));
120+
console.log("");
121+
console.log(clc.gray(" // After:"));
122+
console.log(clc.gray(` const functions = require('firebase-functions');`));
123+
console.log(clc.gray(` const { defineJsonSecret } = require('firebase-functions/params');`));
124+
console.log(clc.gray(` `));
125+
console.log(clc.gray(` const config = defineJsonSecret("${key}");`));
126+
console.log(clc.gray(` `));
127+
console.log(clc.gray(` exports.myFunction = functions`));
128+
console.log(clc.gray(` .runWith({ secrets: [config] }) // Bind secret here`));
129+
console.log(clc.gray(` .https.onRequest((req, res) => {`));
130+
console.log(clc.gray(` const apiKey = config.value().service.key;`));
131+
console.log(clc.gray(` // ...`));
132+
console.log(clc.gray(` });`));
133+
console.log("");
111134
logBullet(
112-
"Importing functions configs from projects [" +
113-
pInfos.map(({ projectId }) => `${clc.bold(projectId)}`).join(", ") +
114-
"]",
135+
clc.bold("Note: ") +
136+
"defineJsonSecret requires firebase-functions v6.6.0 or later. " +
137+
"Update your package.json if needed.",
115138
);
139+
logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions"));
116140

117-
await configExport.hydrateConfigs(pInfos);
118-
await checkRequiredPermission(pInfos);
119-
pInfos = pInfos.filter((pInfo) => pInfo.config);
120-
121-
logger.debug(`Loaded function configs: ${JSON.stringify(pInfos)}`);
122-
logBullet(`Importing configs from projects: [${pInfos.map((p) => p.projectId).join(", ")}]`);
123-
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-
}
130-
131-
const errMsg = configExport.hydrateEnvs(pInfos, prefix);
132-
if (errMsg.length === 0) {
133-
break;
134-
}
135-
prefix = await promptForPrefix(errMsg);
136-
attempts += 1;
137-
}
138-
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);
150-
}
141+
return secretName;
151142
});

0 commit comments

Comments
 (0)