diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index f00ac117c0d..e0f4059bc7b 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -7,6 +7,7 @@ import { parameterizeProject, setSecretParamsToLatest, functionsEnvFromInstance, + ejectSecretsFromInstance, } from "../extensions/export"; import { ensureExtensionsApiEnabled } from "../extensions/extensionsHelper"; import * as manifest from "../extensions/manifest"; @@ -24,6 +25,9 @@ import { writeUserEnvs, UserEnvsOpts, hasUserEnvs } from "../functions/env"; import { mkdirSync } from "fs"; import { join } from "path"; import { logBullet } from "../utils"; +import { Config } from "../config"; +import { normalizeAndValidate, isKitConfig } from "../functions/projectConfig"; +import { FirebaseError } from "../error"; import * as clc from "colorette"; import * as experiments from "../experiments"; @@ -34,15 +38,28 @@ export const command = new Command("ext:export") `experimental: controls the target system of the export (supports "extensions", "functions")`, ) .option( - `--instance `, + `-e`, + `--extension-instance `, `scope the export to the single instance with the specified instance id`, ) + .option( + `-k`, + "---kit-instance ", + `write the .env export from --mode functions to the config path for a kit instance currently defined in firebase.json`, + ) + .option( + `--outputDir `, + `override the .env export from --mode functions to a specific arbitrary path`, + ) .before(requirePermissions, ["firebaseextensions.instances.list"]) .before(ensureExtensionsApiEnabled) .before(checkMinRequiredVersion, "extMinVersion") .withForce() .action(async (options: Options) => { - if (experiments.isEnabled("extMigrationFeatures") && options.mode === "functions") { + if ( + experiments.isEnabled("extMigrationFeatures") && + (options.mode === "functions" || options.mode === "kits") + ) { // Functions handler: // - writes to /.env- // - does not parametrize project number and ID (e.g "12345678" instead of "{param:PROJECT_NUMBER}") @@ -67,11 +84,11 @@ async function extHandler(options: Options): Promise { logger.info(`No extension instances installed on ${projectId}, so there is nothing to export.`); return; } - if (options.instance) { - have = have.filter((s) => s.instanceId === options.instance); + if (options.extensionInstance) { + have = have.filter((s) => s.instanceId === options.extensionInstance); if (have.length === 0) { logger.info( - `No extension instances installed on ${projectId} match specified instance ID ${options.instance}.`, + `No extension instances installed on ${projectId} match specified instance ID ${options.extensionInstance}.`, ); return; } @@ -133,33 +150,121 @@ async function extHandler(options: Options): Promise { } async function fnHandler(options: Options): Promise { - if (!options.instance) { + if (!options.extensionInstance) { logger.info( `ext:export must specify an --instance option when exporting to Functions. Use ext:list to find your instance IDs.`, ); return; } const projectId = needProjectId(options); - const instance = await getInstance(projectId, options.instance as string); + const instance = await getInstance(projectId, options.extensionInstance as string); if (typeof instance === "undefined") { - logger.info(`No extension matching instance ID ${options.instance} found`); + logger.info(`No extension matching instance ID ${options.extensionInstance} found`); return; } + if (instance.state !== "ACTIVE" && !options.force) { + throw new FirebaseError( + `Extension ${options.extensionInstance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, + ); + } const instanceId = last(instance.name.split("/")) ?? ""; - if (instanceId !== options.instance) { + if (instanceId !== options.extensionInstance) { return; } + let secretCount = 0; const convertedEnv = functionsEnvFromInstance(instance); for (const key of Object.keys(convertedEnv)) { + if (key.startsWith("FIREBASE_SECRET_REF_")) { + secretCount += 1; + } console.log(`${key}=${convertedEnv[key]}`); } - // Write to firebase root if inside a firebase project dir, otherwise / - let writeLocationOpts: UserEnvsOpts; + const writeLocation: UserEnvsOpts = kitExportTarget(instanceId, projectId, options); + if (hasUserEnvs(writeLocation)) { + logger.info( + `Exported extensions config appears to already exist in /${instanceId}, aborting write.`, + ); + } else { + logBullet( + clc.cyan(clc.bold("functions: ")) + + `Saving exported extensions config as a Function Kits .env file`, + ); + mkdirSync(join(writeLocation.projectDir, writeLocation.configDir ?? instanceId), { + recursive: true, + }); + writeUserEnvs(convertedEnv, writeLocation); + } + + if (secretCount === 0) { + return; + } + if ( + !(await confirm({ + message: `${secretCount} Cloud Secret Manager resources found in export. Remove from Extensions lifecycle management?\nThis is necessary to prevent extension uninstall from deleting potentially migrated secrets.`, + nonInteractive: options.nonInteractive, + force: options.force, + default: true, + })) + ) { + return; + } + const secretsChanged = await ejectSecretsFromInstance(instance); + logBullet( + clc.cyan(clc.bold("functions: ")) + + `Added functions-managed label to secrets: ${secretsChanged}`, + ); +} + +/** + * @return A forged UserEnvsOpts that will cause writeUserEnvs to write an + * exported Kits environment to a sensible location: + * 1) --outputDir command line argument if provided + * 2) the corresponding kit instance directory from firebase.json if --kit-instance is provided + * 3) // if inside a Firebase directory + * 4) // as a fallback + */ +function kitExportTarget(instanceId: string, projectId: string, options: Options): UserEnvsOpts { + const firebaseConfig = Config.load(options, true); + if (typeof options.outputDir !== "undefined") { + return { + functionsSource: instanceId, + configDir: String(options.outputDir), + projectId: projectId, + isEmulator: false, + projectDir: options.cwd ?? process.cwd(), + }; + } + if (typeof options.kitInstance !== "undefined") { + if (!firebaseConfig) { + throw new FirebaseError( + "--kit-instance option was provided but no firebase.json available to look in for kit definitions.", + ); + } + const functionsConfig = firebaseConfig.src.functions ?? []; + const validatedFunctions = normalizeAndValidate(functionsConfig); + + for (const fn of validatedFunctions) { + if (!isKitConfig(fn)) { + continue; + } + for (const [kitId, kitPath] of Object.entries(fn.instances)) { + if (kitId === options.kitInstance) { + return { + functionsSource: instanceId, + configDir: String(kitPath), + projectId: projectId, + isEmulator: false, + projectDir: options.projectRoot ?? process.cwd(), + }; + } + } + } + } if (typeof options.projectRoot !== "undefined") { - writeLocationOpts = { + return { functionsSource: instanceId, configDir: join(options.projectRoot, instanceId), projectId: projectId, @@ -167,7 +272,7 @@ async function fnHandler(options: Options): Promise { projectDir: options.projectRoot, }; } else { - writeLocationOpts = { + return { functionsSource: instanceId, configDir: instanceId, projectId: projectId, @@ -175,16 +280,4 @@ async function fnHandler(options: Options): Promise { projectDir: options.cwd ?? process.cwd(), }; } - if (hasUserEnvs(writeLocationOpts)) { - logger.info( - `Exported extensions config appears to already exist in /${instanceId}, aborting write.`, - ); - return; - } - logBullet( - clc.cyan(clc.bold("functions: ")) + - `Saving exported extensions config as a Function Kits .env file`, - ); - mkdirSync(instanceId, { recursive: true }); - writeUserEnvs(convertedEnv, writeLocationOpts); } diff --git a/src/deploy/extensions/secrets.ts b/src/deploy/extensions/secrets.ts index 00a41f57959..e2ad9d70482 100644 --- a/src/deploy/extensions/secrets.ts +++ b/src/deploy/extensions/secrets.ts @@ -15,6 +15,7 @@ import { ExtensionSpec, Param, ParamType } from "../../extensions/types"; import { FirebaseError } from "../../error"; import { logger } from "../../logger"; import { logLabeledBullet } from "../../utils"; +import { FIREBASE_MANAGED } from "../../gcp/secretManager"; /** * handleSecretParams checks each spec for secret params, and validates that the secrets in the configuration exist. @@ -45,6 +46,9 @@ export async function handleSecretParams( } } +/** + * @returns true if the InstanceSpec defines any Secret params + */ export async function checkSpecForSecrets(i: InstanceSpec): Promise { const extensionSpec = await getExtensionSpec(i); return secretUtils.usesSecrets(extensionSpec); @@ -225,6 +229,32 @@ async function getSecretInfo( return secretInfo; } +/** + * PATCHes a Secret resource by removing any "firebase-extensions-managed" labels + * and replacing them with "firebase-managed": "functions" + */ +export async function transferSecretToKits( + projectId: string, + secretName: string, +): Promise { + const newLabels: Record = {}; + try { + const have = await secretManager.getSecret(projectId, secretName); + for (const [labelKey, labelValue] of Object.entries(have.labels)) { + if (labelKey !== secretUtils.SECRET_LABEL) { + newLabels[labelKey] = labelValue; + } + } + } catch (err: any) { + throw new FirebaseError( + `Error when retrieving current state of migrating secret ${projectId}/${secretName}: ${err instanceof Error ? err.message : String(err)}`, + { original: err instanceof Error ? err : undefined }, + ); + } + newLabels[FIREBASE_MANAGED] = "functions"; + return secretManager.patchSecret(projectId, secretName, newLabels); +} + async function promptForCreateSecret(args: { projectId: string; secretName: string; diff --git a/src/extensions/export.ts b/src/extensions/export.ts index af07dcd9aca..6c72c602669 100644 --- a/src/extensions/export.ts +++ b/src/extensions/export.ts @@ -1,9 +1,15 @@ import { getExtensionVersion, DeploymentInstanceSpec } from "../deploy/extensions/planner"; import { humanReadable } from "../deploy/extensions/deploymentSummary"; import { logger } from "../logger"; -import { parseSecretVersionResourceName, toSecretVersionResourceName } from "../gcp/secretManager"; +import { + parseSecretVersionResourceName, + toSecretVersionResourceName, + SECRET_VERSION_NAME_REGEX, +} from "../gcp/secretManager"; import { getActiveSecrets } from "./secretsUtils"; import { ExtensionInstance } from "./types"; +import { transferSecretToKits } from "../deploy/extensions/secrets"; +import { FirebaseError } from "../error"; /** * parameterizeProject searchs spec.params for any param that include projectId or projectNumber, @@ -148,3 +154,35 @@ export function functionsEnvFromInstance(instance: ExtensionInstance): Record { + const secretsChanged: string[] = []; + const liveParams = instance.config?.params || {}; + for (const specParam of instance.config?.source?.spec?.params ?? []) { + if (specParam.type !== "SECRET") { + continue; + } + const secretName = specParam.param; + const resourceName = liveParams[secretName]; + if (!resourceName) { + throw new FirebaseError( + `Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`, + { exit: 1 }, + ); + } + const match = resourceName.match(SECRET_VERSION_NAME_REGEX); + if (!match?.groups) { + throw new FirebaseError(`Invalid secret version resource name [${resourceName}].`); + } + const projectId = match.groups.project; + const secretId = match.groups.secret; + await transferSecretToKits(projectId, secretId); + secretsChanged.push(`${projectId}/${secretId}`); + } + return secretsChanged; +} diff --git a/src/gcp/secretManager.ts b/src/gcp/secretManager.ts index ac9c13fefd8..23290f1d18a 100644 --- a/src/gcp/secretManager.ts +++ b/src/gcp/secretManager.ts @@ -16,7 +16,7 @@ const SECRET_NAME_REGEX = new RegExp( ); // Matches projects/{PROJECT}/secrets/{SECRET}/versions/{latest|VERSION} -const SECRET_VERSION_NAME_REGEX = new RegExp( +export const SECRET_VERSION_NAME_REGEX = new RegExp( SECRET_NAME_REGEX.source + "\\/versions\\/" + "(?latest|[0-9]+)", );