-
Notifications
You must be signed in to change notification settings - Fork 1.2k
ext:export improvements (smarter choice of where to write file to, automatic secret ejection) #10932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ext:export improvements (smarter choice of where to write file to, automatic secret ejection) #10932
Changes from all commits
f103e23
4b14150
9aabc10
552ae2f
062aad7
e164b47
93decfb
e227f48
3792f0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| parameterizeProject, | ||
| setSecretParamsToLatest, | ||
| functionsEnvFromInstance, | ||
| ejectSecretsFromInstance, | ||
| } from "../extensions/export"; | ||
| import { ensureExtensionsApiEnabled } from "../extensions/extensionsHelper"; | ||
| import * as manifest from "../extensions/manifest"; | ||
|
|
@@ -24,6 +25,9 @@ | |
| 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 @@ | |
| `experimental: controls the target system of the export (supports "extensions", "functions")`, | ||
| ) | ||
| .option( | ||
| `--instance <instanceId>`, | ||
| `-e`, | ||
| `--extension-instance <instanceId>`, | ||
| `scope the export to the single instance with the specified instance id`, | ||
| ) | ||
| .option( | ||
| `-k`, | ||
| "---kit-instance <kitId>", | ||
| `write the .env export from --mode functions to the config path for a kit instance currently defined in firebase.json`, | ||
| ) | ||
| .option( | ||
| `--outputDir <path>`, | ||
| `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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you took previous feedback and we no longer have "kits"? |
||
| ) { | ||
| // Functions handler: | ||
| // - writes to <instanceId>/.env-<projectId> | ||
| // - does not parametrize project number and ID (e.g "12345678" instead of "{param:PROJECT_NUMBER}") | ||
|
|
@@ -67,11 +84,11 @@ | |
| 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,58 +150,134 @@ | |
| } | ||
|
|
||
| async function fnHandler(options: Options): Promise<void> { | ||
| if (!options.instance) { | ||
| if (!options.extensionInstance) { | ||
| logger.info( | ||
| `ext:export must specify an --instance <instanceId> 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.`, | ||
| ); | ||
| } | ||
|
Comment on lines
+165
to
+169
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the repository style guide, expected user-facing errors should throw a if (instance.state !== "ACTIVE" && !options.force) {
throw new FirebaseError(
`Extension ${options.instance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`,
);
}References
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ack |
||
|
|
||
| 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 <currentDir>/<instanceId> | ||
| let writeLocationOpts: UserEnvsOpts; | ||
| const writeLocation: UserEnvsOpts = kitExportTarget(instanceId, projectId, options); | ||
| if (hasUserEnvs(writeLocation)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For me to use this in ext:migrate I'm going to need you to separate the code that generates environment variables from the code that saves them to disk. This is generally a good thing for testability anyway.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I'm understanding you correctly, this is already implemented in the |
||
| 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) <Firebase root>/<instanceId>/ if inside a Firebase directory | ||
| * 4) <cwd>/<instanceId>/ 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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: probably better as instanceId, instanceConfigPath or something like that. |
||
| 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, | ||
| isEmulator: false, | ||
| projectDir: options.projectRoot, | ||
| }; | ||
| } else { | ||
| writeLocationOpts = { | ||
| return { | ||
| functionsSource: instanceId, | ||
| configDir: instanceId, | ||
| projectId: projectId, | ||
| isEmulator: false, | ||
| 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); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ | |
| 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. | ||
|
|
@@ -24,7 +25,7 @@ | |
| * @param have The instances currently installed on the project. | ||
| * @param nonInteractive whether the user can be prompted to create secrets that are missing. | ||
| */ | ||
| export async function handleSecretParams( | ||
| payload: Payload, | ||
| have: DeploymentInstanceSpec[], | ||
| nonInteractive: boolean, | ||
|
|
@@ -39,12 +40,15 @@ | |
| for (const i of updates) { | ||
| if (await checkSpecForSecrets(i)) { | ||
| logLabeledBullet("extensions", `Verifying secret params for ${clc.bold(i.instanceId)}`); | ||
| const previousSpec = have.find((h) => h.instanceId === i.instanceId)!; | ||
| await handleSecretsUpdateInstance(i, previousSpec, nonInteractive); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
Berlioz marked this conversation as resolved.
|
||
| * @returns true if the InstanceSpec defines any Secret params | ||
| */ | ||
| export async function checkSpecForSecrets(i: InstanceSpec): Promise<boolean> { | ||
| const extensionSpec = await getExtensionSpec(i); | ||
| return secretUtils.usesSecrets(extensionSpec); | ||
|
|
@@ -54,7 +58,7 @@ | |
| return spec.params.filter((p) => p.type === ParamType.SECRET); | ||
| }; | ||
|
|
||
| async function handleSecretsCreateInstance(i: DeploymentInstanceSpec, nonInteractive: boolean) { | ||
| const spec = await getExtensionSpec(i); | ||
| const secretParams = secretsInSpec(spec); | ||
| for (const s of secretParams) { | ||
|
|
@@ -62,7 +66,7 @@ | |
| } | ||
| } | ||
|
|
||
| async function handleSecretsUpdateInstance( | ||
| i: DeploymentInstanceSpec, | ||
| prevSpec: DeploymentInstanceSpec, | ||
| nonInteractive: boolean, | ||
|
|
@@ -212,7 +216,7 @@ | |
| secret?: secretManager.Secret; | ||
| secretVersion?: secretManager.SecretVersion; | ||
| }> { | ||
| const secretInfo: any = {}; | ||
| try { | ||
| secretInfo.secret = await secretManager.getSecret(projectId, secretName); | ||
| secretInfo.secretVersion = await secretManager.getSecretVersion(projectId, secretName, version); | ||
|
|
@@ -225,6 +229,32 @@ | |
| return secretInfo; | ||
| } | ||
|
|
||
| /** | ||
| * PATCHes a Secret resource by removing any "firebase-extensions-managed" labels | ||
| * and replacing them with "firebase-managed": "functions" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ohh this is new to me. What's the implication of firebase-managed: functions? Do we do that for other secrets we right from the command line?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, we actually have a small collection of bugs because it used to be |
||
| */ | ||
| export async function transferSecretToKits( | ||
| projectId: string, | ||
| secretName: string, | ||
| ): Promise<secretManager.Secret> { | ||
| const newLabels: Record<string, string> = {}; | ||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Joe's review bot says you should really use |
||
| 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<st | |||||||||||||||||||
|
|
||||||||||||||||||||
| return envs; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Removes the Extensions label from all secrets in an ExtensionInstance and replaces them | ||||||||||||||||||||
| * them with the Functions label. | ||||||||||||||||||||
| * @return a list of all secrets that were modified | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| export async function ejectSecretsFromInstance(instance: ExtensionInstance): Promise<string[]> { | ||||||||||||||||||||
| 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.`, | ||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like something that should never happen. Should it have a different exit code?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd forgotten about that. Good catch. |
||||||||||||||||||||
| { exit: 1 }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const match = resourceName.match(SECRET_VERSION_NAME_REGEX); | ||||||||||||||||||||
|
Comment on lines
+170
to
+178
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a secret parameter is optional or not set in
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pretty sure that can't happen in an ACTIVE extension since there's no way to define a secret param as optional, but it's true that this could be a problem if run on an ERRORED extension for some reason. |
||||||||||||||||||||
| 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); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Somewhere around this we should have some really good error messaging to users. I really want to avoid the case where users successfully get a We should at the very least warn LOUDLY that secrets will be deleted and they should re-export to try and fix. But maybe we should go so far as to fail writing the |
||||||||||||||||||||
| secretsChanged.push(`${projectId}/${secretId}`); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return secretsChanged; | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As nice as this is, it's a breaking change and I don't want to break anything that currently scripts usage of --instance.
I'd just keep it as is, though you could alias and deprecate if we really wanted.