Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 118 additions & 25 deletions src/commands/ext-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
parameterizeProject,
setSecretParamsToLatest,
functionsEnvFromInstance,
ejectSecretsFromInstance,
} from "../extensions/export";
import { ensureExtensionsApiEnabled } from "../extensions/extensionsHelper";
import * as manifest from "../extensions/manifest";
Expand All @@ -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";

Expand All @@ -34,15 +38,28 @@
`experimental: controls the target system of the export (supports "extensions", "functions")`,
)
.option(
`--instance <instanceId>`,
`-e`,

Copy link
Copy Markdown
Contributor

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.

`--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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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}")
Expand All @@ -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}.`,

Check warning on line 91 in src/commands/ext-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "{}" of template literal expression
);
return;
}
Expand Down Expand Up @@ -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`);

Check warning on line 162 in src/commands/ext-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "{}" of template literal expression
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.`,

Check warning on line 167 in src/commands/ext-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "{}" of template literal expression
);
}
Comment on lines +165 to +169

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. Throw FirebaseError for expected, user-facing errors. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding you correctly, this is already implemented in the functionsEnvFromInstance helper defined in src/extensions/export.ts

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}`,

Check warning on line 217 in src/commands/ext-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "string[]" of template literal expression
);
}

/**
* @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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
}
30 changes: 30 additions & 0 deletions src/deploy/extensions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(

Check warning on line 28 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
payload: Payload,
have: DeploymentInstanceSpec[],
nonInteractive: boolean,
Expand All @@ -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)!;

Check warning on line 43 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
await handleSecretsUpdateInstance(i, previousSpec, nonInteractive);
}
}
}

/**
Comment thread
Berlioz marked this conversation as resolved.
* @returns true if the InstanceSpec defines any Secret params

Check warning on line 50 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
export async function checkSpecForSecrets(i: InstanceSpec): Promise<boolean> {
const extensionSpec = await getExtensionSpec(i);
return secretUtils.usesSecrets(extensionSpec);
Expand All @@ -54,7 +58,7 @@
return spec.params.filter((p) => p.type === ParamType.SECRET);
};

async function handleSecretsCreateInstance(i: DeploymentInstanceSpec, nonInteractive: boolean) {

Check warning on line 61 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
const spec = await getExtensionSpec(i);
const secretParams = secretsInSpec(spec);
for (const s of secretParams) {
Expand All @@ -62,7 +66,7 @@
}
}

async function handleSecretsUpdateInstance(

Check warning on line 69 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
i: DeploymentInstanceSpec,
prevSpec: DeploymentInstanceSpec,
nonInteractive: boolean,
Expand Down Expand Up @@ -212,7 +216,7 @@
secret?: secretManager.Secret;
secretVersion?: secretManager.SecretVersion;
}> {
const secretInfo: any = {};

Check warning on line 219 in src/deploy/extensions/secrets.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
try {
secretInfo.secret = await secretManager.getSecret(projectId, secretName);
secretInfo.secretVersion = await secretManager.getSecretVersion(projectId, secretName, version);
Expand All @@ -225,6 +229,32 @@
return secretInfo;
}

/**
* PATCHes a Secret resource by removing any "firebase-extensions-managed" labels
* and replacing them with "firebase-managed": "functions"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 firebase-managed: true but now it's been split into firebase-managed: functions and firebase-managed: apphosting.

*/
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Joe's review bot says you should really use unknown instead of 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;
Expand Down
40 changes: 39 additions & 1 deletion src/extensions/export.ts
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,
Expand Down Expand Up @@ -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.`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 .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.

secretsChanged.push(`${projectId}/${secretId}`);
}
return secretsChanged;
}
2 changes: 1 addition & 1 deletion src/gcp/secretManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\/" + "(?<version>latest|[0-9]+)",
);

Expand Down
Loading