diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..2668eb75f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Refactored `ext:install` to use the latest extension metadata. (#5997) diff --git a/src/commands/ext-configure.ts b/src/commands/ext-configure.ts index 994523de058..fd6ba7e6577 100644 --- a/src/commands/ext-configure.ts +++ b/src/commands/ext-configure.ts @@ -88,8 +88,6 @@ export const command = new Command("ext:configure ") projectId, paramSpecs: tbdParams, nonInteractive: false, - // TODO(b/230598656): Clean up paramsEnvPath after v11 launch. - paramsEnvPath: "", instanceId, reconfiguring: true, }); diff --git a/src/commands/ext-install.ts b/src/commands/ext-install.ts index 35e55d2d09c..4e722975a17 100644 --- a/src/commands/ext-install.ts +++ b/src/commands/ext-install.ts @@ -1,12 +1,14 @@ import * as clc from "colorette"; import { marked } from "marked"; +import * as semver from "semver"; import * as TerminalRenderer from "marked-terminal"; -import { displayExtInfo } from "../extensions/displayExtensionInfo"; +import { displayExtensionVersionInfo } from "../extensions/displayExtensionInfo"; import * as askUserForEventsConfig from "../extensions/askUserForEventsConfig"; import { checkMinRequiredVersion } from "../checkMinRequiredVersion"; import { Command } from "../command"; import { FirebaseError } from "../error"; +import { logger } from "../logger"; import { getProjectId, needProjectId } from "../projectUtils"; import * as extensionsApi from "../extensions/extensionsApi"; import { ExtensionVersion, ExtensionSource } from "../extensions/types"; @@ -17,13 +19,11 @@ import { createSourceFromLocation, ensureExtensionsApiEnabled, logPrefix, - promptForOfficialExtension, promptForValidInstanceId, diagnoseAndFixProject, - isUrlPath, isLocalPath, - canonicalizeRefInput, } from "../extensions/extensionsHelper"; +import { resolveVersion } from "../deploy/extensions/planner"; import { getRandomString } from "../extensions/utils"; import { requirePermissions } from "../requirePermissions"; import * as utils from "../utils"; @@ -40,7 +40,7 @@ marked.setOptions({ /** * Command for installing an extension */ -export const command = new Command("ext:install [extensionName]") +export const command = new Command("ext:install [extensionRef]") .description( "add an uploaded extension to firebase.json if [publisherId/extensionId] is provided;" + "or, add a local extension if [localPath] is provided" @@ -51,67 +51,80 @@ export const command = new Command("ext:install [extensionName]") .before(ensureExtensionsApiEnabled) .before(checkMinRequiredVersion, "extMinVersion") .before(diagnoseAndFixProject) - .action(async (extensionName: string, options: Options) => { - const projectId = getProjectId(options); - // TODO(b/230598656): Clean up paramsEnvPath after v11 launch. - const paramsEnvPath = ""; - let learnMore = false; - if (!extensionName) { - if (options.interactive) { - learnMore = true; - extensionName = await promptForOfficialExtension( - "Which official extension do you wish to install?\n" + - " Select an extension, then press Enter to learn more." - ); - } else { - throw new FirebaseError( - `Unable to find published extension '${clc.bold(extensionName)}'. ` + - `Run ${clc.bold( - "firebase ext:install -i" - )} to select from the list of all available published extensions.` - ); - } - } - let source; - let extensionVersion; - - // TODO(b/220900194): Remove when deprecating old install flow. - // --local doesn't support urlPath so this will become dead codepath. - if (isUrlPath(extensionName)) { - throw new FirebaseError( - `Installing with a source url is no longer supported in the CLI. Please use Firebase Console instead.` - ); - } + .action(async (extensionRef: string, options: Options) => { if (options.local) { utils.logLabeledWarning( logPrefix, "As of firebase-tools@11.0.0, the `--local` flag is no longer required, as it is the default behavior." ); } - + if (!extensionRef) { + throw new FirebaseError( + "Extension ref is required to install. To see a full list of available extensions, go to Extensions Hub (https://extensions.dev/extensions)." + ); + } + let source: ExtensionSource | undefined; + let extensionVersion: ExtensionVersion | undefined; + const projectId = getProjectId(options); // If the user types in a local path (prefixed with ~/, ../, or ./), install from local source. // Otherwise, treat the input as an extension reference and proceed with reference-based installation. - if (isLocalPath(extensionName)) { + if (isLocalPath(extensionRef)) { // TODO(b/228444119): Create source should happen at deploy time. // Should parse spec locally so we don't need project ID. - source = await createSourceFromLocation(needProjectId({ projectId }), extensionName); - await displayExtInfo(extensionName, "", source.spec); + source = await createSourceFromLocation(needProjectId({ projectId }), extensionRef); + await displayExtensionVersionInfo({ spec: source.spec }); void trackGA4("extension_added_to_manifest", { published: "local", interactive: options.nonInteractive ? "false" : "true", }); } else { - extensionName = await canonicalizeRefInput(extensionName); - extensionVersion = await extensionsApi.getExtensionVersion(extensionName); - + const extension = await extensionsApi.getExtension(extensionRef); + const ref = refs.parse(extensionRef); + ref.version = await resolveVersion(ref, extension); + const extensionVersionRef = refs.toExtensionVersionRef(ref); + extensionVersion = await extensionsApi.getExtensionVersion(extensionVersionRef); void trackGA4("extension_added_to_manifest", { published: extensionVersion.listing?.state === "APPROVED" ? "published" : "uploaded", interactive: options.nonInteractive ? "false" : "true", }); - await infoExtensionVersion({ - extensionName, + await displayExtensionVersionInfo({ + spec: extensionVersion.spec, extensionVersion, + latestApprovedVersion: extension.latestApprovedVersion, + latestVersion: extension.latestVersion, }); + if (extensionVersion.state === "DEPRECATED") { + throw new FirebaseError( + `Extension version ${clc.bold( + extensionVersionRef + )} is deprecated and cannot be installed. To install the latest non-deprecated version, omit the version in the extension ref.` + ); + } + logger.info(); + // Check if selected version is older than the latest approved version, or the latest version only if there is no approved version. + if ( + (extension.latestApprovedVersion && + semver.gt(extension.latestApprovedVersion, extensionVersion.spec.version)) || + (!extension.latestApprovedVersion && + extension.latestVersion && + semver.gt(extension.latestVersion, extensionVersion.spec.version)) + ) { + const version = extension.latestApprovedVersion || extension.latestVersion; + logger.info( + `You are about to install extension version ${clc.bold( + extensionVersion.spec.version + )} which is older than the latest ${ + extension.latestApprovedVersion ? "accepted version" : "version" + } ${clc.bold(version!)}.` + ); + } + } + if (!source && !extensionVersion) { + throw new FirebaseError( + `Failed to parse ${clc.bold( + extensionRef + )} as an extension version or a path to a local extension. Please specify a valid reference.` + ); } if ( !(await confirm({ @@ -122,33 +135,18 @@ export const command = new Command("ext:install [extensionName]") ) { return; } - if (!source && !extensionVersion) { - throw new FirebaseError( - "Could not find a source. Please specify a valid source to continue." - ); - } const spec = source?.spec ?? extensionVersion?.spec; if (!spec) { throw new FirebaseError( `Could not find the extension.yaml for extension '${clc.bold( - extensionName + extensionRef )}'. Please make sure this is a valid extension and try again.` ); } - if (learnMore) { - utils.logLabeledBullet( - logPrefix, - `You selected: ${clc.bold(spec.displayName || "")}.\n` + - `${spec.description}\n` + - `View details: https://firebase.google.com/products/extensions/${spec.name}\n` - ); - } - try { return installToManifest({ - paramsEnvPath, projectId, - extensionName, + extensionRef, source, extVersion: extensionVersion, nonInteractive: options.nonInteractive, @@ -164,18 +162,9 @@ export const command = new Command("ext:install [extensionName]") } }); -async function infoExtensionVersion(args: { - extensionName: string; - extensionVersion: ExtensionVersion; -}): Promise { - const ref = refs.parse(args.extensionName); - await displayExtInfo(args.extensionName, ref.publisherId, args.extensionVersion.spec, true); -} - interface InstallExtensionOptions { - paramsEnvPath?: string; projectId?: string; - extensionName: string; + extensionRef: string; source?: ExtensionSource; extVersion?: ExtensionVersion; nonInteractive: boolean; @@ -189,14 +178,13 @@ interface InstallExtensionOptions { * @param options */ async function installToManifest(options: InstallExtensionOptions): Promise { - const { projectId, extensionName, extVersion, source, paramsEnvPath, nonInteractive, force } = - options; - const isLocalSource = isLocalPath(extensionName); + const { projectId, extensionRef, extVersion, source, nonInteractive, force } = options; + const isLocalSource = isLocalPath(extensionRef); const spec = extVersion?.spec ?? source?.spec; if (!spec) { throw new FirebaseError( - `Could not find the extension.yaml for ${extensionName}. Please make sure this is a valid extension and try again.` + `Could not find the extension.yaml for ${extensionRef}. Please make sure this is a valid extension and try again.` ); } @@ -215,7 +203,6 @@ async function installToManifest(options: InstallExtensionOptions): Promise [updateSour newSpec: newExtensionVersion.spec, currentParams: oldParamValues, projectId, - // TODO(b/230598656): Clean up paramsEnvPath after v11 launch. - paramsEnvPath: "", nonInteractive: options.nonInteractive, instanceId, }); diff --git a/src/deploy/extensions/planner.ts b/src/deploy/extensions/planner.ts index 8a389e3e3dc..75c1778f2e9 100644 --- a/src/deploy/extensions/planner.ts +++ b/src/deploy/extensions/planner.ts @@ -207,32 +207,33 @@ export async function want(args: { } /** - * resolveVersion resolves a semver string to the max matching version. - * Exported for testing. - * @param publisherId - * @param extensionId - * @param version a semver or semver range + * Resolves a semver string to the max matching version. If no version is specified, + * it will default to the extension's latest approved version if set, otherwise to the latest version. + * + * @param ref the extension version ref + * @param extension the extension (optional) */ -export async function resolveVersion(ref: refs.Ref): Promise { +export async function resolveVersion(ref: refs.Ref, extension?: Extension): Promise { const extensionRef = refs.toExtensionRef(ref); - const extension = await extensionsApi.getExtension(extensionRef); - if (!ref.version || ref.version === "latest-approved") { - if (!extension.latestApprovedVersion) { + if (!ref.version && extension?.latestApprovedVersion) { + return extension.latestApprovedVersion; + } + if (ref.version === "latest-approved") { + if (!extension?.latestApprovedVersion) { throw new FirebaseError( `${extensionRef} has not been published to Extensions Hub (https://extensions.dev). To install it, you must specify the version you want to install.` ); } return extension.latestApprovedVersion; } - if (ref.version === "latest") { - if (!extension.latestVersion) { + if (!ref.version || ref.version === "latest") { + if (!extension?.latestVersion) { throw new FirebaseError( `${extensionRef} has no stable non-deprecated versions. If you wish to install a prerelease version, you must specify the version you want to install.` ); } return extension.latestVersion; } - const versions = await extensionsApi.listExtensionVersions(extensionRef, undefined, true); if (versions.length === 0) { throw new FirebaseError(`No versions found for ${extensionRef}`); diff --git a/src/extensions/displayExtensionInfo.ts b/src/extensions/displayExtensionInfo.ts index aef30fe192a..fc23c344f7b 100644 --- a/src/extensions/displayExtensionInfo.ts +++ b/src/extensions/displayExtensionInfo.ts @@ -1,12 +1,23 @@ import * as clc from "colorette"; import { marked } from "marked"; +import * as semver from "semver"; import * as TerminalRenderer from "marked-terminal"; +import * as path from "path"; -import * as utils from "../utils"; -import { logPrefix } from "./extensionsHelper"; +import * as refs from "../extensions/refs"; import { logger } from "../logger"; -import { FirebaseError } from "../error"; -import { Api, ExtensionSpec, Role, Resource, FUNCTIONS_RESOURCE_TYPE } from "./types"; +import { + Api, + ExtensionSpec, + ExtensionVersion, + LifecycleEvent, + ExternalService, + Role, + Param, + Resource, + FUNCTIONS_RESOURCE_TYPE, + EventDescriptor, +} from "./types"; import * as iam from "../gcp/iam"; import { SECRET_ROLE, usesSecrets } from "./secretsUtils"; @@ -18,33 +29,77 @@ const TASKS_ROLE = "cloudtasks.enqueuer"; const TASKS_API = "cloudtasks.googleapis.com"; /** - * displayExtInfo prints the extension info displayed when running ext:install. + * Displays info about an extension version, whether it is uploaded to the registry or a local spec. * - * @param extensionName name of the extension to display information about - * @param spec extension spec - * @param published whether or not the extension is a published extension - */ -export async function displayExtInfo( - extensionName: string, - publisher: string, - spec: ExtensionSpec, - published = false -): Promise { - const lines = []; - lines.push(`**Name**: ${spec.displayName}`); - if (publisher) { - lines.push(`**Publisher**: ${publisher}`); - } + * @param spec the extension spec + * @param extensionVersion the extension version + * */ +export async function displayExtensionVersionInfo(args: { + spec: ExtensionSpec; + extensionVersion?: ExtensionVersion; + latestApprovedVersion?: string; + latestVersion?: string; +}): Promise { + const { spec, extensionVersion, latestApprovedVersion, latestVersion } = args; + const lines: string[] = []; + const extensionRef = extensionVersion + ? refs.toExtensionRef(refs.parse(extensionVersion?.ref)) + : ""; + lines.push( + `${clc.bold("Extension:")} ${spec.displayName ?? "Unnamed extension"} ${ + extensionRef ? `(${extensionRef})` : "" + }` + ); if (spec.description) { - lines.push(`**Description**: ${spec.description}`); + lines.push(`${clc.bold("Description:")} ${spec.description}`); + } + let versionNote = ""; + const latestRelevantVersion = latestApprovedVersion || latestVersion; + if (latestRelevantVersion && semver.eq(spec.version, latestRelevantVersion)) { + versionNote = `- ${clc.green("Latest")}`; + } + if (extensionVersion?.state === "DEPRECATED") { + versionNote = `- ${clc.red("Deprecated")}`; } - if (published) { - if (spec.license) { - lines.push(`**License**: ${spec.license}`); + lines.push(`${clc.bold("Version:")} ${spec.version} ${versionNote}`); + if (extensionVersion) { + let reviewStatus: string; + switch (extensionVersion.listing?.state) { + case "APPROVED": + reviewStatus = clc.bold(clc.green("Accepted")); + break; + case "REJECTED": + reviewStatus = clc.bold(clc.red("Rejected")); + break; + default: + reviewStatus = clc.bold(clc.yellow("Unreviewed")); } - if (spec.sourceUrl) { - lines.push(`**Source code**: ${spec.sourceUrl}`); + lines.push(`${clc.bold("Review status:")} ${reviewStatus}`); + if (latestApprovedVersion) { + lines.push( + `${clc.bold("View in Extensions Hub:")} https://extensions.dev/extensions/${extensionRef}` + ); } + if (extensionVersion.buildSourceUri) { + const buildSourceUri = new URL(extensionVersion.buildSourceUri!); + buildSourceUri.pathname = path.join( + buildSourceUri.pathname, + extensionVersion.extensionRoot ?? "" + ); + lines.push(`${clc.bold("Source in GitHub:")} ${buildSourceUri}`); + } else { + lines.push( + `${clc.bold("Source download URI:")} ${extensionVersion.sourceDownloadUri ?? "-"}` + ); + } + } + lines.push(`${clc.bold("License:")} ${spec.license ?? "-"}`); + lines.push(displayResources(spec)); + if (spec.events?.length) { + lines.push(displayEvents(spec)); + } + if (spec.externalServices?.length) { + lines.push(displayExternalServices(spec)); } const apis = impliedApis(spec); if (apis.length) { @@ -54,33 +109,59 @@ export async function displayExtInfo( if (roles.length) { lines.push(await displayRoles(roles)); } - if (lines.length > 0) { - utils.logLabeledBullet(logPrefix, `information about '${clc.bold(extensionName)}':`); - const infoStr = lines.join("\n"); - // Convert to markdown and convert any trailing newlines to a single newline. - const formatted = marked(infoStr).replace(/\n+$/, "\n"); - logger.info(formatted); - // Return for testing purposes. - return lines; - } else { - throw new FirebaseError( - "Error occurred during installation: cannot parse info from source spec", - { - context: { - spec: spec, - extensionName: extensionName, - }, - } - ); - } + logger.info(`\n${lines.join("\n")}`); + return lines; } -/** - * Prints a clickable link where users can download the source code for an Extension Version. - */ -export function printSourceDownloadLink(sourceDownloadUri: string): void { - const sourceDownloadMsg = `Want to review the source code that will be installed? Download it here: ${sourceDownloadUri}`; - utils.logBullet(marked(sourceDownloadMsg)); +export function displayExternalServices(spec: ExtensionSpec) { + const lines = + spec.externalServices?.map((service: ExternalService) => { + return ` - ${clc.cyan(`${service.name} (${service.pricingUri})`)}`; + }) ?? []; + return clc.bold("External services used:\n") + lines.join("\n"); +} + +export function displayEvents(spec: ExtensionSpec) { + const lines = + spec.events?.map((event: EventDescriptor) => { + return ` - ${clc.magenta(event.type)}${event.description ? `: ${event.description}` : ""}`; + }) ?? []; + return clc.bold("Events emitted:\n") + lines.join("\n"); +} + +export function displayResources(spec: ExtensionSpec) { + const lines = spec.resources.map((resource: Resource) => { + let type: string = resource.type; + switch (resource.type) { + case "firebaseextensions.v1beta.function": + type = "Cloud Function (1st gen)"; + break; + case "firebaseextensions.v1beta.v2function": + type = "Cloud Function (2nd gen)"; + break; + default: + } + return ` - ${clc.blue(`${resource.name} (${type})`)}${ + resource.description ? `: ${resource.description}` : "" + }`; + }); + lines.push( + ...new Set( + spec.lifecycleEvents?.map((event: LifecycleEvent) => { + return ` - ${clc.blue(`${event.taskQueueTriggerFunction} (Cloud Task queue)`)}`; + }) + ) + ); + lines.push( + ...spec.params + .filter((param: Param) => { + return param.type === "SECRET"; + }) + .map((param: Param) => { + return ` - ${clc.blue(`${param.param} (Cloud Secret Manager secret)`)}`; + }) + ); + return clc.bold("Resources created:\n") + (lines.length ? lines.join("\n") : " - None"); } /** @@ -92,7 +173,7 @@ export function printSourceDownloadLink(sourceDownloadUri: string): void { */ export async function retrieveRoleInfo(role: string) { const res = await iam.getRole(role); - return ` ${res.title} (${res.description})`; + return ` - ${clc.yellow(res.title!)}${res.description ? `: ${res.description}` : ""}`; } async function displayRoles(roles: Role[]): Promise { @@ -101,14 +182,14 @@ async function displayRoles(roles: Role[]): Promise { return retrieveRoleInfo(role.role); }) ); - return clc.bold("**Roles granted to this Extension**:\n") + lines.join("\n"); + return clc.bold("Roles granted:\n") + lines.join("\n"); } function displayApis(apis: Api[]): string { const lines: string[] = apis.map((api: Api) => { - return ` ${api.apiName} (${api.reason})`; + return ` - ${clc.cyan(api.apiName!)}: ${api.reason}`; }); - return "**APIs used by this Extension**:\n" + lines.join("\n"); + return clc.bold("APIs used:\n") + lines.join("\n"); } function usesTasks(spec: ExtensionSpec): boolean { @@ -123,13 +204,13 @@ function impliedRoles(spec: ExtensionSpec): Role[] { if (usesSecrets(spec) && !spec.roles?.some((r: Role) => r.role === SECRET_ROLE)) { roles.push({ role: SECRET_ROLE, - reason: "Allows the extension to read secret values from Cloud Secret Manager", + reason: "Allows the extension to read secret values from Cloud Secret Manager.", }); } if (usesTasks(spec) && !spec.roles?.some((r: Role) => r.role === TASKS_ROLE)) { roles.push({ role: TASKS_ROLE, - reason: "Allows the extension to enqueue Cloud Tasks", + reason: "Allows the extension to enqueue Cloud Tasks.", }); } return roles.concat(spec.roles ?? []); @@ -140,7 +221,7 @@ function impliedApis(spec: ExtensionSpec): Api[] { if (usesTasks(spec) && !spec.apis?.some((a: Api) => a.apiName === TASKS_API)) { apis.push({ apiName: TASKS_API, - reason: "Allows the extension to enqueue Cloud Tasks", + reason: "Allows the extension to enqueue Cloud Tasks.", }); } diff --git a/src/extensions/extensionsHelper.ts b/src/extensions/extensionsHelper.ts index 063d526af9e..b61073423f5 100644 --- a/src/extensions/extensionsHelper.ts +++ b/src/extensions/extensionsHelper.ts @@ -42,7 +42,6 @@ import { envOverride } from "../utils"; import { getLocalChangelog } from "./change-log"; import { getProjectNumber } from "../getProjectNumber"; import { Constants } from "../emulator/constants"; -import { resolveVersion } from "../deploy/extensions/planner"; /** * SpecParamType represents the exact strings that the extensions @@ -1245,25 +1244,3 @@ export async function diagnoseAndFixProject(options: any): Promise { throw new FirebaseError("Unable to proceed until all issues are resolved."); } } - -/** - * Canonicalize a user-inputted ref string. - * 1. Infer firebase publisher if not provided - * 2. Infer "latest-approved" as the version if not provided - */ -export async function canonicalizeRefInput(refInput: string): Promise { - let inferredRef = refInput; - // TODO: Stop defaulting to 'firebase' publisher ID if none provided. - // Infer 'firebase' if publisher ID not provided. - if (refInput.split("/").length < 2) { - inferredRef = `firebase/${inferredRef}`; - } - // Infer 'latest-approved' if no version provided. - if (refInput.split("@").length < 2) { - inferredRef = `${inferredRef}@latest-approved`; - } - // Get the correct version for a given extension reference from the Registry API. - const ref = refs.parse(inferredRef); - ref.version = await resolveVersion(ref); - return refs.toExtensionVersionRef(ref); -} diff --git a/src/extensions/paramHelper.ts b/src/extensions/paramHelper.ts index aca46a91d4d..695410085b2 100644 --- a/src/extensions/paramHelper.ts +++ b/src/extensions/paramHelper.ts @@ -94,7 +94,6 @@ export async function getParams(args: { instanceId: string; paramSpecs: Param[]; nonInteractive?: boolean; - paramsEnvPath?: string; reconfiguring?: boolean; }): Promise> { let params: Record; @@ -118,7 +117,6 @@ export async function getParamsForUpdate(args: { newSpec: ExtensionSpec; currentParams: { [option: string]: string }; projectId?: string; - paramsEnvPath?: string; nonInteractive?: boolean; instanceId: string; }): Promise> { diff --git a/src/extensions/types.ts b/src/extensions/types.ts index a9c06cfe2f4..a2c29ce3137 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -122,6 +122,12 @@ export interface ExtensionSpec { readmeContent?: string; externalServices?: ExternalService[]; events?: EventDescriptor[]; + lifecycleEvents?: LifecycleEvent[]; +} + +export interface LifecycleEvent { + stage: "STAGE_UNSPECIFIED" | "ON_INSTALL" | "ON_UPDATE" | "ON_CONFIGURE"; + taskQueueTriggerFunction: string; } export interface EventDescriptor { diff --git a/src/extensions/updateHelper.ts b/src/extensions/updateHelper.ts index 521d91cb013..699107bdf3d 100644 --- a/src/extensions/updateHelper.ts +++ b/src/extensions/updateHelper.ts @@ -13,7 +13,7 @@ import { isLocalOrURLPath, } from "./extensionsHelper"; import * as utils from "../utils"; -import { displayExtInfo } from "./displayExtensionInfo"; +import { displayExtensionVersionInfo } from "./displayExtensionInfo"; function invalidSourceErrMsgTemplate(instanceId: string, source: string): string { return `Unable to update from the source \`${clc.bold( @@ -157,7 +157,7 @@ export async function updateFromLocalSource( localSource: string, existingSpec: ExtensionSpec ): Promise { - await displayExtInfo(instanceId, "", existingSpec, false); + await displayExtensionVersionInfo({ spec: existingSpec }); let source; try { source = await createSourceFromLocation(projectId, localSource); @@ -187,7 +187,7 @@ export async function updateFromUrlSource( urlSource: string, existingSpec: ExtensionSpec ): Promise { - await displayExtInfo(instanceId, "", existingSpec, false); + await displayExtensionVersionInfo({ spec: existingSpec }); let source; try { source = await createSourceFromLocation(projectId, urlSource); diff --git a/src/test/deploy/extensions/planner.spec.ts b/src/test/deploy/extensions/planner.spec.ts index b4a6ee757a6..6c082b5bbbb 100644 --- a/src/test/deploy/extensions/planner.spec.ts +++ b/src/test/deploy/extensions/planner.spec.ts @@ -35,7 +35,6 @@ function extensionVersion(version?: string): any { describe("Extensions Deployment Planner", () => { describe("resolveSemver", () => { let listExtensionVersionsStub: sinon.SinonStub; - let getExtensionStub: sinon.SinonStub; before(() => { listExtensionVersionsStub = sinon.stub(extensionsApi, "listExtensionVersions").resolves([ extensionVersion("0.1.0"), @@ -43,14 +42,10 @@ describe("Extensions Deployment Planner", () => { extensionVersion("0.2.0"), extensionVersion(), // Explicitly test that this doesn't break on bad data ]); - getExtensionStub = sinon - .stub(extensionsApi, "getExtension") - .resolves(extension("0.2.0", "0.1.1")); }); after(() => { listExtensionVersionsStub.restore(); - getExtensionStub.restore(); }); const cases = [ @@ -94,19 +89,25 @@ describe("Extensions Deployment Planner", () => { it(c.description, () => { if (!c.err) { expect( - planner.resolveVersion({ - publisherId: "test", - extensionId: "test", - version: c.in, - }) + planner.resolveVersion( + { + publisherId: "test", + extensionId: "test", + version: c.in, + }, + extension("0.2.0", "0.1.1") + ) ).to.eventually.equal(c.out); } else { expect( - planner.resolveVersion({ - publisherId: "test", - extensionId: "test", - version: c.in, - }) + planner.resolveVersion( + { + publisherId: "test", + extensionId: "test", + version: c.in, + }, + extension("0.2.0", "0.1.1") + ) ).to.eventually.be.rejected; } }); diff --git a/src/test/extensions/displayExtensionInfo.spec.ts b/src/test/extensions/displayExtensionInfo.spec.ts index 8057b149dba..7aa8dac1b10 100644 --- a/src/test/extensions/displayExtensionInfo.spec.ts +++ b/src/test/extensions/displayExtensionInfo.spec.ts @@ -3,18 +3,18 @@ import { expect } from "chai"; import * as iam from "../../gcp/iam"; import * as displayExtensionInfo from "../../extensions/displayExtensionInfo"; -import { ExtensionSpec, Param, Resource } from "../../extensions/types"; +import { ExtensionSpec, ExtensionVersion, Resource } from "../../extensions/types"; import { ParamType } from "../../extensions/types"; const SPEC: ExtensionSpec = { name: "test", - displayName: "Old", - description: "descriptive", - version: "0.1.0", + displayName: "My Extension", + description: "My extension's description", + version: "1.0.0", license: "MIT", apis: [ - { apiName: "api1", reason: "" }, - { apiName: "api2", reason: "" }, + { apiName: "api1.googleapis.com", reason: "" }, + { apiName: "api2.googleapis.com", reason: "" }, ], roles: [ { role: "role1", reason: "" }, @@ -23,29 +23,53 @@ const SPEC: ExtensionSpec = { resources: [ { name: "resource1", type: "firebaseextensions.v1beta.function", description: "desc" }, { name: "resource2", type: "other", description: "" } as unknown as Resource, + { + name: "taskResource", + type: "firebaseextensions.v1beta.function", + properties: { + taskQueueTrigger: {}, + }, + }, ], author: { authorName: "Tester", url: "firebase.google.com" }, contributors: [{ authorName: "Tester 2" }], billingRequired: true, sourceUrl: "test.com", - params: [], + params: [ + { + param: "secret", + label: "Secret", + type: ParamType.SECRET, + }, + ], systemParams: [], + events: [ + { + type: "abc.def.my-event", + description: "desc", + }, + ], + lifecycleEvents: [ + { + stage: "ON_INSTALL", + taskQueueTriggerFunction: "taskResource", + }, + ], }; -const TASK_FUNCTION_RESOURCE: Resource = { - name: "taskResource", - type: "firebaseextensions.v1beta.function", - properties: { - taskQueueTrigger: {}, +const EXT_VERSION: ExtensionVersion = { + name: "publishers/pub/extensions/my-ext/versions/1.0.0", + ref: "pub/my-ext@1.0.0", + state: "PUBLISHED", + spec: SPEC, + hash: "abc123", + sourceDownloadUri: "https://google.com", + buildSourceUri: "https://github.com/pub/extensions/my-ext", + listing: { + state: "APPROVED", }, }; -const SECRET_PARAM: Param = { - param: "secret", - label: "Secret", - type: ParamType.SECRET, -}; - describe("displayExtensionInfo", () => { describe("displayExtInfo", () => { let getRoleStub: sinon.SinonStub; @@ -74,91 +98,51 @@ describe("displayExtensionInfo", () => { }); it("should display info during install", async () => { - const loggedLines = await displayExtensionInfo.displayExtInfo(SPEC.name, "", SPEC); - const expected: string[] = [ - "**Name**: Old", - "**Description**: descriptive", - "**APIs used by this Extension**:\n api1 ()\n api2 ()", - "\u001b[1m**Roles granted to this Extension**:\n\u001b[22m Role 1 (a role)\n Role 2 (a role)", - ]; - expect(loggedLines.length).to.eql(expected.length); - expect(loggedLines[0]).to.include("Old"); - expect(loggedLines[1]).to.include("descriptive"); - expect(loggedLines[2]).to.include("api1"); - expect(loggedLines[2]).to.include("api2"); - expect(loggedLines[3]).to.include("Role 1"); - expect(loggedLines[3]).to.include("Role 2"); + const loggedLines = await displayExtensionInfo.displayExtensionVersionInfo({ spec: SPEC }); + expect(loggedLines[0]).to.include(SPEC.displayName); + expect(loggedLines[1]).to.include(SPEC.description); + expect(loggedLines[2]).to.include(SPEC.version); + expect(loggedLines[3]).to.include(SPEC.license); + expect(loggedLines[4]).to.include("resource1 (Cloud Function (1st gen))"); + expect(loggedLines[4]).to.include("resource2 (other)"); + expect(loggedLines[4]).to.include("taskResource (Cloud Function (1st gen))"); + expect(loggedLines[4]).to.include("taskResource (Cloud Task queue)"); + expect(loggedLines[4]).to.include("secret (Cloud Secret Manager secret)"); + expect(loggedLines[5]).to.include("abc.def.my-event"); + expect(loggedLines[6]).to.include("api1.googleapis.com"); + expect(loggedLines[6]).to.include("api1.googleapis.com"); + expect(loggedLines[6]).to.include("cloudtasks.googleapis.com"); + expect(loggedLines[7]).to.include("Role 1"); + expect(loggedLines[7]).to.include("Role 2"); + expect(loggedLines[7]).to.include("Cloud Task Enqueuer"); }); it("should display additional information for a published extension", async () => { - const loggedLines = await displayExtensionInfo.displayExtInfo( - SPEC.name, - "testpublisher", - SPEC, - true - ); - const expected: string[] = [ - "**Name**: Old", - "**Publisher**: testpublisher", - "**Description**: descriptive", - "**License**: MIT", - "**Source code**: test.com", - "**APIs used by this Extension**:\n api1 ()\n api2 ()", - "\u001b[1m**Roles granted to this Extension**:\n\u001b[22m Role 1 (a role)\n Role 2 (a role)", - ]; - expect(loggedLines.length).to.eql(expected.length); - expect(loggedLines[0]).to.include("Old"); - expect(loggedLines[1]).to.include("testpublisher"); - expect(loggedLines[2]).to.include("descriptive"); - expect(loggedLines[3]).to.include("MIT"); - expect(loggedLines[4]).to.include("test.com"); - expect(loggedLines[5]).to.include("api1"); - expect(loggedLines[5]).to.include("api2"); - expect(loggedLines[6]).to.include("Role 1"); - expect(loggedLines[6]).to.include("Role 2"); - }); - - it("should display role and api for Cloud Tasks during install", async () => { - const specWithTasks = JSON.parse(JSON.stringify(SPEC)) as ExtensionSpec; - specWithTasks.resources.push(TASK_FUNCTION_RESOURCE); - - const loggedLines = await displayExtensionInfo.displayExtInfo(SPEC.name, "", specWithTasks); - const expected: string[] = [ - "**Name**: Old", - "**Description**: descriptive", - "**APIs used by this Extension**:\n api1 ()\n api2 ()", - "\u001b[1m**Roles granted to this Extension**:\n\u001b[22m Role 1 (a role)\n Role 2 (a role)\n Cloud Task Enqueuer (Enqueue tasks)", - ]; - expect(loggedLines.length).to.eql(expected.length); - expect(loggedLines[0]).to.include("Old"); - expect(loggedLines[1]).to.include("descriptive"); - expect(loggedLines[2]).to.include("api1"); - expect(loggedLines[2]).to.include("api2"); - expect(loggedLines[2]).to.include("Cloud Tasks"); - expect(loggedLines[3]).to.include("Role 1"); - expect(loggedLines[3]).to.include("Role 2"); - expect(loggedLines[3]).to.include("Cloud Task Enqueuer"); - }); - - it("should display role for Cloud Secret Manager during install", async () => { - const specWithSecret = JSON.parse(JSON.stringify(SPEC)) as ExtensionSpec; - specWithSecret.params.push(SECRET_PARAM); - - const loggedLines = await displayExtensionInfo.displayExtInfo(SPEC.name, "", specWithSecret); - const expected: string[] = [ - "**Name**: Old", - "**Description**: descriptive", - "**APIs used by this Extension**:\n api1 ()\n api2 ()", - "\u001b[1m**Roles granted to this Extension**:\n\u001b[22m Role 1 (a role)\n Role 2 (a role)\n Secret Accessor (Access secrets)", - ]; - expect(loggedLines.length).to.eql(expected.length); - expect(loggedLines[0]).to.include("Old"); - expect(loggedLines[1]).to.include("descriptive"); - expect(loggedLines[2]).to.include("api1"); - expect(loggedLines[2]).to.include("api2"); - expect(loggedLines[3]).to.include("Role 1"); - expect(loggedLines[3]).to.include("Role 2"); - expect(loggedLines[3]).to.include("Secret Accessor"); + const loggedLines = await displayExtensionInfo.displayExtensionVersionInfo({ + spec: SPEC, + extensionVersion: EXT_VERSION, + latestApprovedVersion: "1.0.0", + latestVersion: "1.0.0", + }); + expect(loggedLines[0]).to.include(SPEC.displayName); + expect(loggedLines[1]).to.include(SPEC.description); + expect(loggedLines[2]).to.include(SPEC.version); + expect(loggedLines[3]).to.include("Accepted"); + expect(loggedLines[4]).to.include("View in Extensions Hub"); + expect(loggedLines[5]).to.include(EXT_VERSION.buildSourceUri); + expect(loggedLines[6]).to.include(SPEC.license); + expect(loggedLines[7]).to.include("resource1 (Cloud Function (1st gen))"); + expect(loggedLines[7]).to.include("resource2 (other)"); + expect(loggedLines[7]).to.include("taskResource (Cloud Function (1st gen))"); + expect(loggedLines[7]).to.include("taskResource (Cloud Task queue)"); + expect(loggedLines[7]).to.include("secret (Cloud Secret Manager secret)"); + expect(loggedLines[8]).to.include("abc.def.my-event"); + expect(loggedLines[9]).to.include("api1.googleapis.com"); + expect(loggedLines[9]).to.include("api1.googleapis.com"); + expect(loggedLines[9]).to.include("cloudtasks.googleapis.com"); + expect(loggedLines[10]).to.include("Role 1"); + expect(loggedLines[10]).to.include("Role 2"); + expect(loggedLines[10]).to.include("Cloud Task Enqueuer"); }); }); }); diff --git a/src/test/extensions/extensionsHelper.spec.ts b/src/test/extensions/extensionsHelper.spec.ts index 446890600c6..51c8fab7b99 100644 --- a/src/test/extensions/extensionsHelper.spec.ts +++ b/src/test/extensions/extensionsHelper.spec.ts @@ -22,8 +22,6 @@ import { } from "../../extensions/types"; import { Readable } from "stream"; import { ArchiveResult } from "../../archiveDirectory"; -import { canonicalizeRefInput } from "../../extensions/extensionsHelper"; -import * as planner from "../../deploy/extensions/planner"; describe("extensionsHelper", () => { describe("substituteParams", () => { @@ -1004,37 +1002,4 @@ describe("extensionsHelper", () => { ).to.eql("Prerelease"); }); }); - - describe(`${canonicalizeRefInput.name}`, () => { - let resolveVersionStub: sinon.SinonStub; - beforeEach(() => { - resolveVersionStub = sinon.stub(planner, "resolveVersion").resolves("10.1.1"); - }); - afterEach(() => { - resolveVersionStub.restore(); - }); - it("should do nothing to a valid ref", async () => { - expect(await canonicalizeRefInput("firebase/bigquery-export@10.1.1")).to.equal( - "firebase/bigquery-export@10.1.1" - ); - }); - - it("should infer latest version", async () => { - expect(await canonicalizeRefInput("firebase/bigquery-export")).to.equal( - "firebase/bigquery-export@10.1.1" - ); - }); - - it("should infer publisher name as firebase", async () => { - expect(await canonicalizeRefInput("firebase/bigquery-export")).to.equal( - "firebase/bigquery-export@10.1.1" - ); - }); - - it("should infer publisher name as firebase and also infer latest as version", async () => { - expect(await canonicalizeRefInput("bigquery-export")).to.equal( - "firebase/bigquery-export@10.1.1" - ); - }); - }); });