ext:export improvements (smarter choice of where to write file to, automatic secret ejection) - #10932
ext:export improvements (smarter choice of where to write file to, automatic secret ejection)#10932Berlioz wants to merge 6 commits into
Conversation
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
There was a problem hiding this comment.
Code Review
This pull request updates the ext:export command to support exporting Extension instances to Function Kits, introducing new options (--kit and --outputDir) and logic to transfer control of secrets from Extensions to Kits. The review identified a few critical issues: a potential TypeError in ext-export.ts when functions is not defined in firebase.json, another potential TypeError in export.ts when an optional secret parameter is missing from liveParams, and a style guide violation where a user-facing error is logged instead of throwing a FirebaseError.
| const functionsConfig = firebaseConfig.get("functions"); | ||
| const functions = Array.isArray(functionsConfig) ? functionsConfig : [functionsConfig]; | ||
| for (const fn of functions) { | ||
| if (typeof fn.kit === "undefined" || typeof fn.instances === "undefined") { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
If functionsConfig is undefined (e.g., when functions is not defined in firebase.json), functions will be initialized as [undefined]. When iterating, fn will be undefined, causing a TypeError: Cannot read properties of undefined (reading 'kit') when evaluating typeof fn.kit. We should handle the case where functionsConfig is falsy or filter out undefined values.
const functionsConfig = firebaseConfig.get("functions");
const functions = Array.isArray(functionsConfig)
? functionsConfig
: functionsConfig
? [functionsConfig]
: [];
for (const fn of functions) {
if (!fn || typeof fn.kit === "undefined" || typeof fn.instances === "undefined") {
continue;
}There was a problem hiding this comment.
the config loader coerces the config to always contain a default functions config if one isn't present
| const secretName = specParam.param; | ||
| const resourceName = liveParams[secretName]; | ||
| const match = resourceName.match(SECRET_VERSION_NAME_REGEX); |
There was a problem hiding this comment.
If a secret parameter is optional or not set in liveParams, resourceName will be undefined. Calling resourceName.match(...) will result in a runtime crash (TypeError: Cannot read properties of undefined (reading 'match')). We should add a defensive check to ensure resourceName is defined before attempting to match it.
| const secretName = specParam.param; | |
| const resourceName = liveParams[secretName]; | |
| const match = resourceName.match(SECRET_VERSION_NAME_REGEX); | |
| const secretName = specParam.param; | |
| const resourceName = liveParams[secretName]; | |
| if (!resourceName) { | |
| continue; | |
| } | |
| const match = resourceName.match(SECRET_VERSION_NAME_REGEX); |
There was a problem hiding this comment.
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 (instance.state !== "ACTIVE" && !options.force) { | ||
| logger.error( | ||
| `Extension ${options.instance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
According to the repository style guide, expected user-facing errors should throw a FirebaseError instead of logging an error and returning. This ensures the CLI exits with a non-zero status code and handles the error consistently.
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
- Throw FirebaseError for expected, user-facing errors. (link)
ajperel
left a comment
There was a problem hiding this comment.
A need to review more closely, but here's some initial feedback.
| if (secretCount > 0) { | ||
| if ( | ||
| !(await confirm({ | ||
| message: `${secretCount} Cloud Secret Manager resources found in export. Transfer control of secrets from Extensions to Kits?`, |
There was a problem hiding this comment.
Nit: Kits aren't going to manage secrets. This sounds like something else is going to take it up for auto managing for them.
They must self-manage secrets after this.
Also doesn't help a user understand why this is important. If we're going to prompt we should probably explain why.
?.. in export. Stop Firebase Extensions management of secrets? This is necessary to prevent extension uninstall from deleting potentially migrated secrets."
I don't love this.
I still wonder if it should be a flag rather than a prompt. Maybe worth a quick discussion with @inlined
|
|
||
| /** | ||
| * PATCHes a Secret resource by removing any "firebase-extensions-managed" labels | ||
| * and replacing them with "firebase-managed": "functions" |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Yeah, we actually have a small collection of bugs because it used to be firebase-managed: true but now it's been split into firebase-managed: functions and firebase-managed: apphosting.
ajperel
left a comment
There was a problem hiding this comment.
Some mostly nitty comments the substantive thing being we need to make sure we handle secrets not being disassociated from Extensions well.
Also you should add a CHANGELOG and maybe some tests?
| ) | ||
| .option( | ||
| `--instance <instanceId>`, | ||
| `-e`, |
There was a problem hiding this comment.
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.
| if (experiments.isEnabled("extMigrationFeatures") && options.mode === "functions") { | ||
| if ( | ||
| experiments.isEnabled("extMigrationFeatures") && | ||
| (options.mode === "functions" || options.mode === "kits") |
There was a problem hiding this comment.
I think you took previous feedback and we no longer have "kits"?
| if (typeof fn.kit === "undefined" || typeof fn.instances === "undefined") { | ||
| continue; | ||
| } | ||
| for (const [kitId, kitPath] of Object.entries(fn.instances)) { |
There was a problem hiding this comment.
nit: probably better as instanceId, instanceConfigPath or something like that.
| } | ||
| const projectId = match.groups.project; | ||
| const secretId = match.groups.secret; | ||
| await transferSecretToKits(projectId, secretId); |
There was a problem hiding this comment.
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 .env file but fail to unmanage secrets. In that case if they move forward with the migration and the secrets are deleted they're just totally lost.
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 .env file so they have to repeat.
| newLabels[labelKey] = labelValue; | ||
| } | ||
| } | ||
| } catch (err: any) { |
There was a problem hiding this comment.
Joe's review bot says you should really use unknown instead of any.
inlined
left a comment
There was a problem hiding this comment.
I think it LGTM with the nit that I'm going to need to be able to call some of this code without it going to the file system (because I'll pass it to init instead)
| // 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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
If I'm understanding you correctly, this is already implemented in the functionsEnvFromInstance helper defined in src/extensions/export.ts
| writeUserEnvs(convertedEnv, writeLocation); | ||
| } | ||
|
|
||
| if (secretCount > 0) { |
There was a problem hiding this comment.
nit: I usually prefer either testing the opposite and returning early or having a helper alone. Helps you avoid pyramid code.
| const functionsConfig = firebaseConfig.get("functions"); | ||
| const functions = Array.isArray(functionsConfig) ? functionsConfig : [functionsConfig]; | ||
| for (const fn of functions) { | ||
| if (typeof fn.kit === "undefined" || typeof fn.instances === "undefined") { |
There was a problem hiding this comment.
Is it not valid to use this command to create a new/first instance?
There was a problem hiding this comment.
It should be. The if() on l:247 is more of a type narrowing clause, since the Config made available by the CLI doesn't validate the format by default. It looks like the .src getter on it does validate though, so I'm moving the code to rely on that for readability.
| const resourceName = liveParams[secretName]; | ||
| if (!resourceName) { | ||
| throw new FirebaseError( | ||
| `Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`, |
There was a problem hiding this comment.
This seems like something that should never happen. Should it have a different exit code?
There was a problem hiding this comment.
I'd forgotten about that. Good catch.
No description provided.