experimental ext:export support for exporting to functions .env format - #10895
experimental ext:export support for exporting to functions .env format#10895Berlioz wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for exporting Firebase Extension instances to a Functions environment (as .env files) via a new --mode option in the ext:export command. The code reviewer identified a critical runtime issue where instance.config?.source?.spec is undefined, which would cause a crash. To fix this, the reviewer recommended asynchronously fetching the extension spec using getSource or getExtensionVersion in the command handler and passing it to functionsEnvFromInstance. The reviewer also provided suggestions to reduce nesting and import necessary types and error classes.
| export function functionsEnvFromInstance(instance: ExtensionInstance): Record<string, string> { | ||
| const liveParams = instance.config?.params || {}; | ||
| const liveSystemParams = instance.config?.systemParams || {}; | ||
| const specParams = instance.config?.source?.spec?.params || {}; | ||
| const specSystemParams = instance.config?.source?.spec?.systemParams || {}; | ||
|
|
||
| const envs: Record<string, string> = {}; | ||
|
|
||
| // Every user param must be available, so we replicate the spec's default behavior if not present | ||
| specParams.forEach((specParam) => { | ||
| if (specParam.type === "SECRET") { | ||
| const renamed = "FIREBASE_SECRET_REF_" + specParam.param; | ||
| envs[renamed] = liveParams[specParam.param]; | ||
| } else if (specParam.param in liveParams) { | ||
| envs[specParam.param] = liveParams[specParam.param]; | ||
| } else { | ||
| envs[specParam.param] = specParam.default ?? ""; | ||
| } | ||
| }); | ||
|
|
||
| // System params aren't necessarily defined in the spec, but we do respect any defaults | ||
| Object.entries(liveSystemParams).forEach(([sysParamName, sysParamValue]) => { | ||
| const renamed = sysParamName | ||
| .replace("firebaseextensions.v1beta.function/", "FIREBASE_SYSTEM_") | ||
| .toUpperCase(); | ||
| envs[renamed] = sysParamValue; | ||
| }); | ||
| Object.entries(specSystemParams).forEach(([, specSystemParam]) => { | ||
| if (specSystemParam.param in liveSystemParams) { | ||
| return; | ||
| } | ||
| if ("default" in specSystemParam) { | ||
| const renamed = specSystemParam.param | ||
| .replace("firebaseextensions.v1beta.function/", "FIREBASE_SYSTEM_") | ||
| .toUpperCase(); | ||
| envs[renamed] = specSystemParam.default ?? ""; | ||
| } | ||
| }); | ||
|
|
||
| // Also pull in ALLOWED_EVENTS and EVENTARC_CHANNEL | ||
| if (typeof instance.config.allowedEventTypes !== "undefined") { | ||
| envs["EXT_SELECTED_EVENTS"] = instance.config.allowedEventTypes.toString(); | ||
| } | ||
| if (typeof instance.config.eventarcChannel !== "undefined") { | ||
| envs["EVENTARC_CHANNEL"] = instance.config.eventarcChannel; | ||
| } | ||
|
|
||
| return envs; | ||
| } |
There was a problem hiding this comment.
The ExtensionInstance returned by listInstances does not contain the full spec of the extension (i.e., instance.config?.source?.spec is undefined). Consequently, specParams evaluates to {} (an empty object), and calling specParams.forEach will throw a TypeError: specParams.forEach is not a function at runtime.
To fix this, we should pass the fetched ExtensionSpec as an argument to functionsEnvFromInstance and fetch it asynchronously in the caller (fnHandler).
export function functionsEnvFromInstance(
instance: ExtensionInstance,
spec: ExtensionSpec,
): Record<string, string> {
const liveParams = instance.config?.params || {};
const liveSystemParams = instance.config?.systemParams || {};
const specParams = spec.params || [];
const specSystemParams = spec.systemParams || [];
const envs: Record<string, string> = {};
// Every user param must be available, so we replicate the spec's default behavior if not present
specParams.forEach((specParam) => {
if (specParam.type === "SECRET") {
const renamed = "FIREBASE_SECRET_REF_" + specParam.param;
envs[renamed] = liveParams[specParam.param];
} else if (specParam.param in liveParams) {
envs[specParam.param] = liveParams[specParam.param];
} else {
envs[specParam.param] = specParam.default ?? "";
}
});
// System params aren't necessarily defined in the spec, but we do respect any defaults
Object.entries(liveSystemParams).forEach(([sysParamName, sysParamValue]) => {
const renamed = sysParamName
.replace("firebaseextensions.v1beta.function/", "FIREBASE_SYSTEM_")
.toUpperCase();
envs[renamed] = sysParamValue;
});
specSystemParams.forEach((specSystemParam) => {
if (specSystemParam.param in liveSystemParams) {
return;
}
if ("default" in specSystemParam) {
const renamed = specSystemParam.param
.replace("firebaseextensions.v1beta.function/", "FIREBASE_SYSTEM_")
.toUpperCase();
envs[renamed] = specSystemParam.default ?? "";
}
});
// Also pull in ALLOWED_EVENTS and EVENTARC_CHANNEL
if (typeof instance.config.allowedEventTypes !== "undefined") {
envs["EXT_SELECTED_EVENTS"] = instance.config.allowedEventTypes.toString();
}
if (typeof instance.config.eventarcChannel !== "undefined") {
envs["EVENTARC_CHANNEL"] = instance.config.eventarcChannel;
}
return envs;
}There was a problem hiding this comment.
What? Am I being gaslit by the bot here? It manifestly does
| let found = false; | ||
| instances.forEach((instance) => { | ||
| const instanceId = last(instance.name.split("/")) ?? ""; | ||
| if (instanceId !== options.instance) { | ||
| return; | ||
| } | ||
|
|
||
| found = true; | ||
| const convertedEnv = functionsEnvFromInstance(instance); | ||
| for (const key of Object.keys(convertedEnv)) { | ||
| logger.info(`${key}=${convertedEnv[key]}`); | ||
| } | ||
| const writeLocationOpts: UserEnvsOpts = { | ||
| functionsSource: instanceId, | ||
| configDir: instanceId, | ||
| projectId: projectId, | ||
| isEmulator: false, | ||
| }; | ||
| if (hasUserEnvs(writeLocationOpts)) { | ||
| logger.info( | ||
| `Exported extensions config appears to already exist in /${instanceId}, aborting write.`, | ||
| ); | ||
| return; | ||
| } | ||
| mkdirSync(instanceId, { recursive: true }); | ||
| writeUserEnvs(convertedEnv, writeLocationOpts); | ||
| logger.info(`Exported extensions config to /${instanceId}.${projectId}`); | ||
| }); |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
| import { logger } from "../logger"; | ||
| import { parseSecretVersionResourceName, toSecretVersionResourceName } from "../gcp/secretManager"; | ||
| import { getActiveSecrets } from "./secretsUtils"; | ||
| import { ExtensionInstance } from "./types"; |
There was a problem hiding this comment.
what's that even mean? the linter is very not happy with this suggestion
| import { listInstances } from "../extensions/extensionsApi"; | ||
| import { last } from "../utils"; | ||
| import { writeUserEnvs, UserEnvsOpts, hasUserEnvs } from "../functions/env"; | ||
| import { mkdirSync } from "fs"; | ||
| import * as experiments from "../experiments"; |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
| // System params aren't necessarily defined in the spec, but we do respect any defaults | ||
| Object.entries(liveSystemParams).forEach(([sysParamName, sysParamValue]) => { | ||
| const renamed = sysParamName | ||
| .replace("firebaseextensions.v1beta.function/", "FIREBASE_SYSTEM_") |
There was a problem hiding this comment.
I need to do more thorough review, which I will do tomorrow, but I started by just running this export with an instance of the firestore-to-bigquery extension and it had an error because that extension has a v2 function (one of the few).
Gemini says this and like 132 need to be updated to:
.replace(/^firebaseextensions\.v1beta\.(v2)?function\//, "FIREBASE_SYSTEM_")
There was a problem hiding this comment.
gah, yeah that make sense, didn't know v2 was on the cards
ajperel
left a comment
There was a problem hiding this comment.
Ok. Overall pretty good. I think the big things are:
(1) Handling v2 system params that I commented on yesterday
(2) We need to coordinate on export vs. kits index.js so everything "just works" after a migration.
(3) Simiarly we should talk more about where / controlling where this env file is written.
But a few other comments throughout
| // and strip project IDs from the param values. | ||
| // Note that this does not, nor should it include instances defined via SDK. | ||
| const have = await Promise.all(await planner.have(projectId)); | ||
| if (experiments.isEnabled("internaltesting") && options.mode === "functions") { |
There was a problem hiding this comment.
Should this be under it's own experiment? internal testing sounds fairly generic.
Or just grouped with kits CLI under "kits" experiment?
There was a problem hiding this comment.
Either would do, I think. Internaltesting is the "standard" choice when committing code before api review, but I think any non-listed experiement would be fine.
| // - writes to <instanceId>/.env-<projectId> | ||
| // - does not parametrize project number and ID (e.g "12345678" instead of "{param:PROJECT_NUMBER}") | ||
| // - explicitly sets unspecified user params to the empty string instead of leaving them out (and causing a prompt on first deploy) | ||
| // - coerces system param naming format to be valid .env keys (e.g FIREBASE_SYSTEM_MEMORY=256 instead of firebaseextensions.v1beta.function/memory=256) |
There was a problem hiding this comment.
Am I correct in understanding everything with FIREBASE_SYSTEM_* is something that should turn into a setGlobalOptions() option? If so few things here:
- Users aren't supposed to set reserved ENV variables though right and we reserve everything under FIREBASE_* so this is a little weird.
- Without some coordination nothing is going to pick this up and do anything with it as we try to preserve behavior.
We need too coordinate what you do here with our migration instructions and how function kits and ext:migrate and kits:install work.
I think the first piece is we all agree on the ENV variable names that will show up both in the export AND in the index.js of a kit. We must have them align so that kits automatically pick these up.
Then we need to make sure the parameters that are set are actually uncommented in index.js since by default we were going to comment out everything but location. Some things I think we could do here:
-
When
kits:installis called byext:migratewith this data, we know in advance that we want to set these. Thusinstallshould make sure those lines aren't commented out. -
When
kits:installis called before export when someone is doing a more manual migration thenindex.jswill be in a bad state. We could ask users to fix this or we could potentially add a 3rd mode for kits or maybe an additional flag --kit-instance. If you do
ext:export --mode functions --instance firestore-bigquery-export --kit-instance foo then we can modify the behavior in two important ways:
- We can know to put the
.env.<project-id>file in the kit config directory. - We could modify the existing index.js to set the right system params.
I'm open to other options, but we do have to at a minimum coordinate on parameter names between exports and kits.
There was a problem hiding this comment.
It would be ugly in some ways but a way to simplify this would be that we don't use parameters by default for global options in kits except for location since it always must be specified.
For all other system params we look at process.env.<SYSTEM VARIABLE NAME> if it exists we use it in our setGlobalOptions() call and if it doesn't we don't.
Thus we don't need any changes to index.js to handle the specific options that exist. We could proceed all this with a general comment that's like "if you want to force users to specify these options you can define them as parameters like is done with location".
| // - explicitly sets unspecified user params to the empty string instead of leaving them out (and causing a prompt on first deploy) | ||
| // - coerces system param naming format to be valid .env keys (e.g FIREBASE_SYSTEM_MEMORY=256 instead of firebaseextensions.v1beta.function/memory=256) | ||
| // - writes references to secrets in the Functions format (e.g FIREBASE_SECRET_REF_API_KEY=foo:latest instead of API_KEY=projects/${param:PROJECT_NUMBER}/secrets/API_KEY/versions/latest) | ||
| // - makes DeploymentInstanceSpec.eventarcChannel and allowedEventTypes available as FIREBASE_EVENTARC_CHANNEL and EXT_SELECTED_EVENTS |
There was a problem hiding this comment.
These just keeps them working as they did in Extensions if extesions code is migrated directly to kits right?
And in the future we can suggest people follow Thomas's suggestions to just write callbacks instead?
| const have = await Promise.all(await planner.have(projectId)); | ||
| if (experiments.isEnabled("internaltesting") && options.mode === "functions") { | ||
| // Functions handler: | ||
| // - writes to <instanceId>/.env-<projectId> |
There was a problem hiding this comment.
I think the two most common cases are:
(1) Thomas calls the guts of this in ext:migrate and we never write to file
(2) We want to write to a known location that isn't /.env-projectid but that's based on kit configDir.
And as a rarer (3) --- saving .env values for future use but not migrating to a kit now.
I wonder if we should handle this differently:
- I assume we can't use stout because we have other log lines so something like
firebase ext:export <flags> > <path/to/file>won't work? - Have a flag that just specifies the directory to write .env file in?
- Have a flag that specifies the kit instance and we use that to look up
configDir?
In the worst case I think instructions woudl be something like:
- Run
firebase ext:export mv <instance>/.env-<projectId> <path/to/configDir>rm -r <instance>
| // - explicitly sets unspecified user params to the empty string instead of leaving them out (and causing a prompt on first deploy) | ||
| // - coerces system param naming format to be valid .env keys (e.g FIREBASE_SYSTEM_MEMORY=256 instead of firebaseextensions.v1beta.function/memory=256) | ||
| // - writes references to secrets in the Functions format (e.g FIREBASE_SECRET_REF_API_KEY=foo:latest instead of API_KEY=projects/${param:PROJECT_NUMBER}/secrets/API_KEY/versions/latest) | ||
| // - makes DeploymentInstanceSpec.eventarcChannel and allowedEventTypes available as FIREBASE_EVENTARC_CHANNEL and EXT_SELECTED_EVENTS |
There was a problem hiding this comment.
And these will continue to just work for Extensions with these names right?
And if we want in the future we can suggest people stop this pattern and just do callbacks but it's not required for migration?
| }); | ||
| async function fnHandler(options: Options) { | ||
| if (!options.instance) { | ||
| logger.info(`ext:export must be scoped to a specific instance when exporting to Functions`); |
There was a problem hiding this comment.
Do you think we should suggest running ext:list to see possible instances?
There was a problem hiding this comment.
Also maybe be a bit more specific on how shoudl we note the --instance <instance id> flag explicitly?
| params: buildBindingOptionsWithBaseValue(paramCopy), | ||
| }; | ||
| }); | ||
| async function fnHandler(options: Options) { |
There was a problem hiding this comment.
Just to check.... will this handle both console installed extensions and CLI installed extensions?
|
|
||
| saveEtags(options.rc, projectId, have); | ||
| let found = false; | ||
| instances.forEach((instance) => { |
There was a problem hiding this comment.
Can you simplify this a bit and maybe reduce nesting by doing something like:
const instance = instances.find((i) => {
const instanceId = last(i.name.split("/")) ?? "";
return instanceId === options.instance;
});
if (!instance) {
logger.info(`No extensions instances found matching instance ID ${options.instance}`);
// exit
}
// else all the stuff you do?
| return { ...spec, params: newParams }; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
uh the linter did when asked to --fix, oops
| export function functionsEnvFromInstance(instance: ExtensionInstance): Record<string, string> { | ||
| const liveParams = instance.config?.params || {}; | ||
| const liveSystemParams = instance.config?.systemParams || {}; | ||
| const specParams = instance.config?.source?.spec?.params || {}; |
There was a problem hiding this comment.
(1) Gemini I think correctly finds for me that your fallback should be [] and not {} if you're going to call forEach on it?
(2) When will this stuff not exist and do we need to do more to fetch it in that case? It'd be bad if this ever silently writes a bad .env file because it couldn't find something? Would erroring be better?
No description provided.