|
1 | | -import * as path from "path"; |
2 | | - |
3 | 1 | import * as clc from "colorette"; |
| 2 | +import * as semver from "semver"; |
4 | 3 |
|
5 | | -import requireInteractive from "../requireInteractive"; |
| 4 | +import * as functionsConfig from "../functionsConfig"; |
6 | 5 | import { Command } from "../command"; |
7 | 6 | import { FirebaseError } from "../error"; |
8 | | -import { testIamPermissions } from "../gcp/iam"; |
9 | | -import { logger } from "../logger"; |
10 | 7 | import { input, confirm } from "../prompt"; |
11 | 8 | 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"; |
15 | 10 | 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"; |
16 | 17 |
|
17 | 18 | import type { Options } from "../options"; |
18 | | -import { normalizeAndValidate, resolveConfigDir } from "../functions/projectConfig"; |
19 | 19 |
|
20 | | -const REQUIRED_PERMISSIONS = [ |
| 20 | +const RUNTIME_CONFIG_PERMISSIONS = [ |
21 | 21 | "runtimeconfig.configs.list", |
22 | 22 | "runtimeconfig.configs.get", |
23 | 23 | "runtimeconfig.variables.list", |
24 | 24 | "runtimeconfig.variables.get", |
25 | 25 | ]; |
26 | 26 |
|
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 | +]; |
29 | 33 |
|
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); |
38 | 41 | } |
| 42 | + return masked; |
39 | 43 | } |
| 44 | + return "******"; |
40 | 45 | } |
41 | 46 |
|
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) { |
52 | 71 | 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 }, |
54 | 76 | ); |
55 | 77 | } |
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 | 78 |
|
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(""); |
65 | 86 |
|
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(""); |
68 | 92 | } |
69 | | - } |
70 | | -} |
71 | 93 |
|
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 | + } |
80 | 106 |
|
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); |
88 | 109 |
|
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) { |
103 | 137 | 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.", |
105 | 140 | ); |
106 | 141 | } |
107 | 142 |
|
108 | | - let pInfos = configExport.getProjectInfos(options); |
109 | | - checkReservedAliases(pInfos); |
| 143 | + const secretVersion = await addVersion(projectId, key, secretValue); |
| 144 | + console.log(""); |
110 | 145 |
|
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'); |
116 | 153 |
|
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 | + }); |
120 | 158 |
|
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'); |
123 | 162 |
|
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}"); |
130 | 164 |
|
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); |
134 | 184 | } |
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 |
137 | 187 | } |
138 | 188 |
|
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 | + ); |
150 | 195 | } |
| 196 | + logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions")); |
| 197 | + |
| 198 | + return secretName; |
151 | 199 | }); |
0 commit comments