From 700f7a2a64785e2e9bd7e56a9fe58bdd65b9c7bd Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Mon, 27 Nov 2023 20:25:48 +0900 Subject: [PATCH 01/12] Ensure API is enabled for frameworks commands. --- src/commands/frameworks-backends-create.ts | 2 ++ src/commands/frameworks-backends-delete.ts | 3 +++ src/commands/frameworks-backends-get.ts | 3 +++ src/commands/frameworks-backends-list.ts | 2 ++ src/gcp/frameworks.ts | 11 +++++++++++ 5 files changed, 21 insertions(+) diff --git a/src/commands/frameworks-backends-create.ts b/src/commands/frameworks-backends-create.ts index b31b5033820..a0e869a4181 100644 --- a/src/commands/frameworks-backends-create.ts +++ b/src/commands/frameworks-backends-create.ts @@ -3,9 +3,11 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import requireInteractive from "../requireInteractive"; import { doSetup } from "../init/features/frameworks"; +import { ensureApiEnabled } from "../gcp/frameworks"; export const command = new Command("backends:create") .description("Create a backend in a Firebase project") + .before(ensureApiEnabled) .before(requireInteractive) .action(async (options: Options) => { const projectId = needProjectId(options); diff --git a/src/commands/frameworks-backends-delete.ts b/src/commands/frameworks-backends-delete.ts index 1b579dbb03b..ef4a76dcd47 100644 --- a/src/commands/frameworks-backends-delete.ts +++ b/src/commands/frameworks-backends-delete.ts @@ -7,6 +7,8 @@ import { promptOnce } from "../prompt"; import * as utils from "../utils"; import { logger } from "../logger"; import { DEFAULT_REGION, ALLOWED_REGIONS } from "../init/features/frameworks/constants"; +import { ensureApiEnabled } from "../gcp/frameworks"; + const Table = require("cli-table"); const COLUMN_LENGTH = 20; @@ -24,6 +26,7 @@ export const command = new Command("backends:delete") .option("-l, --location ", "App Backend location", "") .option("-s, --backend ", "Backend Id", "") .withForce() + .before(ensureApiEnabled) .action(async (options: Options) => { const projectId = needProjectId(options); let location = options.location as string; diff --git a/src/commands/frameworks-backends-get.ts b/src/commands/frameworks-backends-get.ts index 152b80acb16..ac059ff009d 100644 --- a/src/commands/frameworks-backends-get.ts +++ b/src/commands/frameworks-backends-get.ts @@ -4,6 +4,8 @@ import { needProjectId } from "../projectUtils"; import * as gcp from "../gcp/frameworks"; import { FirebaseError } from "../error"; import { logger } from "../logger"; +import { ensureApiEnabled } from "../gcp/frameworks"; + const Table = require("cli-table"); const COLUMN_LENGTH = 20; const TABLE_HEAD = [ @@ -18,6 +20,7 @@ export const command = new Command("backends:get") .description("Get backend details of a Firebase project") .option("-l, --location ", "App Backend location", "-") .option("-b, --backend ", "Backend Id", "") + .before(ensureApiEnabled) .action(async (options: Options) => { const projectId = needProjectId(options); const location = options.location as string; diff --git a/src/commands/frameworks-backends-list.ts b/src/commands/frameworks-backends-list.ts index 253c7d00dad..6f15c9219c9 100644 --- a/src/commands/frameworks-backends-list.ts +++ b/src/commands/frameworks-backends-list.ts @@ -5,6 +5,7 @@ import * as gcp from "../gcp/frameworks"; import { FirebaseError } from "../error"; import { logger } from "../logger"; import { bold } from "colorette"; +import { ensureApiEnabled } from "../gcp/frameworks"; const Table = require("cli-table"); const COLUMN_LENGTH = 20; @@ -12,6 +13,7 @@ const TABLE_HEAD = ["Backend Id", "Repository", "Location", "URL", "Created Date export const command = new Command("backends:list") .description("List backends of a Firebase project.") .option("-l, --location ", "App Backend location", "-") + .before(ensureApiEnabled) .action(async (options: Options) => { const projectId = needProjectId(options); const location = options.location as string; diff --git a/src/gcp/frameworks.ts b/src/gcp/frameworks.ts index edc404150f1..af4ce113eee 100644 --- a/src/gcp/frameworks.ts +++ b/src/gcp/frameworks.ts @@ -1,6 +1,9 @@ import { Client } from "../apiv2"; +import { needProjectId } from "../projectUtils"; import { frameworksOrigin } from "../api"; +import { ensure } from "../ensureApiEnabled"; +export const API_HOST = new URL(frameworksOrigin).host; export const API_VERSION = "v1alpha"; const client = new Client({ @@ -162,3 +165,11 @@ export async function createBuild( return res.body; } + +/** + * Ensure that Frameworks API is enabled on the project. + */ +export async function ensureApiEnabled(options: any): Promise { + const projectId = needProjectId(options); + return await ensure(projectId, API_HOST, "frameworks", true); +} From 467302eb5b8ec43613699eb5bdd1cfe7c8a99d8b Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 13:15:35 +0900 Subject: [PATCH 02/12] Allow GCB repos to be reused in backend. --- src/init/features/frameworks/repo.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 92ea3efc502..617d3c9f5b2 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -240,10 +240,6 @@ export async function getOrCreateRepository( let repo: gcb.Repository; try { repo = await gcb.getRepository(projectId, location, connectionId, repositoryId); - const repoSlug = extractRepoSlugFromUri(repo.remoteUri); - if (repoSlug) { - throw new FirebaseError(`${repoSlug} has already been linked.`); - } } catch (err: unknown) { if ((err as FirebaseError).status === 404) { const op = await gcb.createRepository( From 2f9447cd8573438b7a023b3433b94e8dbbbb0e2d Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 7 Dec 2023 05:07:01 +0900 Subject: [PATCH 03/12] Add test case. --- src/test/init/frameworks/repo.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/init/frameworks/repo.spec.ts b/src/test/init/frameworks/repo.spec.ts index 6302a9933a0..4cb48726b44 100644 --- a/src/test/init/frameworks/repo.spec.ts +++ b/src/test/init/frameworks/repo.spec.ts @@ -128,6 +128,21 @@ describe("composer", () => { ); }); + it("re-uses existing repository it already exists", async () => { + getConnectionStub.resolves(completeConn); + fetchLinkableRepositoriesStub.resolves(repos); + promptOnceStub.onFirstCall().resolves(repos.repositories[0].remoteUri); + getRepositoryStub.resolves(repos.repositories[0]); + + const r = await repo.getOrCreateRepository( + projectId, + location, + connectionId, + repos.repositories[0].remoteUri + ); + expect(r).to.be.deep.equal(repos.repositories[0]); + }); + it("throws error if no linkable repositories are available", async () => { getConnectionStub.resolves(pendingConn); fetchLinkableRepositoriesStub.resolves({ repositories: [] }); From 2042e2bd87fe549c8b3c5e960b1603b1928b1ebb Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 12:41:17 +0900 Subject: [PATCH 04/12] Include serving locality in create backend requests. --- src/gcp/frameworks.ts | 3 +++ src/init/features/frameworks/index.ts | 1 + src/test/init/frameworks/index.spec.ts | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gcp/frameworks.ts b/src/gcp/frameworks.ts index af4ce113eee..089479bf3d9 100644 --- a/src/gcp/frameworks.ts +++ b/src/gcp/frameworks.ts @@ -19,11 +19,14 @@ interface Codebase { rootDirectory: string; } +export type ServingLocality = "GLOBAL_ACCESS" | "REGIONAL_STRICT"; + /** A Backend, the primary resource of Frameworks. */ export interface Backend { name: string; mode?: string; codebase: Codebase; + servingLocality: ServingLocality; labels: Record; createTime: string; updateTime: string; diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index 0f778f16091..c3749671b31 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -69,6 +69,7 @@ export async function doSetup(setup: any, projectId: string): Promise { function toBackend(cloudBuildConnRepo: Repository): Omit { return { + servingLocality: "GLOBAL_ACCESS", codebase: { repository: `${cloudBuildConnRepo.name}`, rootDirectory: "/", diff --git a/src/test/init/frameworks/index.spec.ts b/src/test/init/frameworks/index.spec.ts index af678372607..84ecadd34c4 100644 --- a/src/test/init/frameworks/index.spec.ts +++ b/src/test/init/frameworks/index.spec.ts @@ -60,7 +60,8 @@ describe("operationsConverter", () => { createTime: "0", updateTime: "1", }; - const backendInput = { + const backendInput: Omit = { + servingLocality: "GLOBAL_ACCESS", codebase: { repository: cloudBuildConnRepo.name, rootDirectory: "/", From e194ce832c556ebbcb76e0569b0cd39d7cc72426 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 7 Dec 2023 05:09:56 +0900 Subject: [PATCH 05/12] Add doc comment. --- src/gcp/frameworks.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/gcp/frameworks.ts b/src/gcp/frameworks.ts index 089479bf3d9..0904f44bce1 100644 --- a/src/gcp/frameworks.ts +++ b/src/gcp/frameworks.ts @@ -19,6 +19,12 @@ interface Codebase { rootDirectory: string; } +/** + * Specifies how Backend's data is replicated and served. + * GLOBAL_ACCESS: Stores and serves content from multiple points-of-presence (POP) + * REGIONAL_STRICT: Restricts data and serving infrastructure in Backend's region + * + */ export type ServingLocality = "GLOBAL_ACCESS" | "REGIONAL_STRICT"; /** A Backend, the primary resource of Frameworks. */ From a5769f050ec9db3829bb627780e011dadac22a71 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 12:17:36 +0900 Subject: [PATCH 06/12] Prompt required IAM permission during frameworks onboarding. --- src/gcp/cloudbuild.ts | 7 +++++ src/init/features/frameworks/repo.ts | 38 +++++++++++++++++++++++++--- src/utils.ts | 13 +++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/gcp/cloudbuild.ts b/src/gcp/cloudbuild.ts index 24b16f52ffa..0281ef04184 100644 --- a/src/gcp/cloudbuild.ts +++ b/src/gcp/cloudbuild.ts @@ -209,3 +209,10 @@ export async function deleteRepository( const res = await client.delete(name); return res.body; } + +/** + * Returns email associated with the Cloud Build Service Agent. + */ +export function serviceAgentEmail(projectNumber: string): string { + return `service-${projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com`; +} diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 617d3c9f5b2..288dcd05b37 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -1,12 +1,14 @@ import * as clc from "colorette"; import * as gcb from "../../../gcp/cloudbuild"; +import * as rm from "../../../gcp/resourceManager"; import * as poller from "../../../operation-poller"; import * as utils from "../../../utils"; import { cloudbuildOrigin } from "../../../api"; import { FirebaseError } from "../../../error"; import { logger } from "../../../logger"; import { promptOnce } from "../../../prompt"; +import { getProjectNumber } from "../../../getProjectNumber"; export interface ConnectionNameParts { projectId: string; @@ -81,6 +83,10 @@ export async function linkGitHubRepository( logger.info(clc.bold(`\n${clc.yellow("===")} Connect a GitHub repository`)); const existingConns = await listFrameworksConnections(projectId); if (existingConns.length < 1) { + const grantSuccess = await promptSecretManagerAdminGrant(projectId); + if (!grantSuccess) { + throw new FirebaseError("Insufficient IAM permissions to create a new connection to GitHub"); + } let oauthConn = await getOrCreateConnection(projectId, location, FRAMEWORKS_OAUTH_CONN_NAME); while (oauthConn.installationState.stage === "PENDING_USER_OAUTH") { oauthConn = await promptConnectionAuth(oauthConn); @@ -156,14 +162,40 @@ async function promptRepositoryUri( return { remoteUri, connection: remoteUriToConnection[remoteUri] }; } +async function promptSecretManagerAdminGrant(projectId: string): Promise { + const projectNumber = await getProjectNumber({ projectId }); + const cbsaEmail = gcb.serviceAgentEmail(projectNumber); + logger.info( + "To create a new GitHub connection, Secret Manager Admin role (roles/secretmanager.admin) is required on the Cloud Build Service Agent." + ); + const grant = await promptOnce({ + type: "confirm", + message: "Grant the required role to the Cloud Build Service Agent?", + }); + if (!grant) { + logger.info( + "You, or your project administrator, should run the following command to grant the required role:\n\n" + + `\tgcloud projects add-iam-policy-binding ${projectId} \\\n` + + `\t --member="serviceAccount:${cbsaEmail} \\\n` + + `\t --role="roles/secretmanager.admin\n` + ); + return false; + } + await rm.addServiceAccountToRoles(projectId, cbsaEmail, ["roles/secretmanager.admin"], true); + logger.info("Successfully granted the required role to the Cloud Build Service Agent!"); + return true; +} + async function promptConnectionAuth(conn: gcb.Connection): Promise { logger.info("You must authorize the Cloud Build GitHub app."); logger.info(); - logger.info("First, sign in to GitHub and authorize Cloud Build GitHub app:"); - const cleanup = await utils.openInBrowserPopup( + logger.info("Sign in to GitHub and authorize Cloud Build GitHub app:"); + const { url, cleanup } = await utils.openInBrowserPopup( conn.installationState.actionUri, "Authorize the GitHub app" ); + logger.info(`\t${url}`); + logger.info(); await promptOnce({ type: "input", message: "Press Enter once you have authorized the app", @@ -215,7 +247,7 @@ export async function getOrCreateConnection( try { conn = await gcb.getConnection(projectId, location, connectionId); } catch (err: unknown) { - if ((err as FirebaseError).status === 404) { + if ((err as any).status === 404) { conn = await createConnection(projectId, location, connectionId, githubConfig); } else { throw err; diff --git a/src/utils.ts b/src/utils.ts index e2932f50e28..cf33dd3e9ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -767,7 +767,10 @@ export async function openInBrowser(url: string): Promise { /** * Like openInBrowser but opens the url in a popup. */ -export async function openInBrowserPopup(url: string, buttonText: string): Promise<() => void> { +export async function openInBrowserPopup( + url: string, + buttonText: string +): Promise<{ url: string; cleanup: () => void }> { const popupPage = fs .readFileSync(path.join(__dirname, "../templates/popup.html"), { encoding: "utf-8" }) .replace("${url}", url) @@ -787,10 +790,12 @@ export async function openInBrowserPopup(url: string, buttonText: string): Promi server.listen(port); const popupPageUri = `http://localhost:${port}`; - logger.info(popupPageUri); await openInBrowser(popupPageUri); - return () => { - server.close(); + return { + url: popupPageUri, + cleanup: () => { + server.close(); + }, }; } From f1c6f76e6d068027f390bb9d2096a51ed624cfa3 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 13:14:23 +0900 Subject: [PATCH 07/12] Update use of log to be consistent in framework init. --- src/init/features/frameworks/index.ts | 24 +++++++++--------------- src/init/features/frameworks/repo.ts | 9 +++++---- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index c3749671b31..21ae156425a 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -1,14 +1,13 @@ import * as clc from "colorette"; -import * as utils from "../../../utils"; import * as repo from "./repo"; import * as poller from "../../../operation-poller"; import * as gcp from "../../../gcp/frameworks"; +import { logBullet, logSuccess } from "../../../utils"; import { frameworksOrigin } from "../../../api"; import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks"; import { Repository } from "../../../gcp/cloudbuild"; import { API_VERSION } from "../../../gcp/frameworks"; import { FirebaseError } from "../../../error"; -import { logger } from "../../../logger"; import { promptOnce } from "../../../prompt"; import { DEFAULT_REGION, ALLOWED_REGIONS } from "./constants"; @@ -25,7 +24,7 @@ const frameworksPollerOptions: Omit { setup.frameworks = {}; - utils.logBullet("First we need a few details to create your backend."); + logBullet("First we need a few details to create your backend."); await promptOnce( { @@ -50,20 +49,16 @@ export async function doSetup(setup: any, projectId: string): Promise { setup.frameworks ); - utils.logSuccess(`Region set to ${setup.frameworks.region}.`); + logSuccess(`Region set to ${setup.frameworks.region}.\n`); const backend: Backend | undefined = await getOrCreateBackend(projectId, setup); if (backend) { - logger.info(); - utils.logSuccess(`Successfully created backend:\n ${backend.name}`); - logger.info(); - utils.logSuccess(`Your site is being deployed at:\n https://${backend.uri}`); - logger.info(); - utils.logSuccess( - `View the rollout status by running:\n firebase backends:get --backend=${backend.name}` + logSuccess(`Successfully created backend:\n ${backend.name}`); + logSuccess(`Your site is being deployed at:\n https://${backend.uri}\n`); + logSuccess( + `View the rollout status by running:\n firebase backends:get --backend=${backend.name}\n` ); - logger.info(); } } @@ -91,7 +86,6 @@ export async function getOrCreateBackend( } catch (err: unknown) { if ((err as FirebaseError).status === 404) { const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location); - logger.info(); await promptOnce( { name: "branchName", @@ -102,7 +96,7 @@ export async function getOrCreateBackend( setup.frameworks ); const backendDetails = toBackend(cloudBuildConnRepo); - logger.info(clc.bold(`\n${clc.white("===")} Creating your backend`)); + logBullet(clc.bold(`${clc.white("===")} Creating your backend`)); return await createBackend(projectId, location, backendDetails, setup.frameworks.serviceName); } else { throw new FirebaseError( @@ -133,7 +127,7 @@ async function getExistingBackend( setup.frameworks ); if (setup.frameworks.existingBackend) { - logger.info("Using the existing backend."); + logBullet("Using the existing backend."); return backend; } await promptOnce( diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 288dcd05b37..1db075bcc62 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -80,7 +80,7 @@ export async function linkGitHubRepository( projectId: string, location: string ): Promise { - logger.info(clc.bold(`\n${clc.yellow("===")} Connect a GitHub repository`)); + utils.logBullet(clc.bold(`${clc.yellow("===")} Connect a GitHub repository`)); const existingConns = await listFrameworksConnections(projectId); if (existingConns.length < 1) { const grantSuccess = await promptSecretManagerAdminGrant(projectId); @@ -165,7 +165,7 @@ async function promptRepositoryUri( async function promptSecretManagerAdminGrant(projectId: string): Promise { const projectNumber = await getProjectNumber({ projectId }); const cbsaEmail = gcb.serviceAgentEmail(projectNumber); - logger.info( + utils.logBullet( "To create a new GitHub connection, Secret Manager Admin role (roles/secretmanager.admin) is required on the Cloud Build Service Agent." ); const grant = await promptOnce({ @@ -173,8 +173,9 @@ async function promptSecretManagerAdminGrant(projectId: string): Promise Date: Wed, 6 Dec 2023 17:22:43 +0900 Subject: [PATCH 08/12] More consitent use of logger. --- src/init/features/frameworks/repo.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 1db075bcc62..989337c58a8 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -6,7 +6,6 @@ import * as poller from "../../../operation-poller"; import * as utils from "../../../utils"; import { cloudbuildOrigin } from "../../../api"; import { FirebaseError } from "../../../error"; -import { logger } from "../../../logger"; import { promptOnce } from "../../../prompt"; import { getProjectNumber } from "../../../getProjectNumber"; @@ -124,7 +123,6 @@ export async function linkGitHubRepository( appInstallationId: connection.githubConfig?.appInstallationId, }); const repo = await getOrCreateRepository(projectId, location, connectionId, remoteUri); - logger.info(); utils.logSuccess(`Successfully linked GitHub repository at remote URI:\n ${remoteUri}`); return repo; } @@ -188,15 +186,13 @@ async function promptSecretManagerAdminGrant(projectId: string): Promise { - logger.info("You must authorize the Cloud Build GitHub app."); - logger.info(); - logger.info("Sign in to GitHub and authorize Cloud Build GitHub app:"); + utils.logBullet("You must authorize the Cloud Build GitHub app.\n"); + utils.logBullet("Sign in to GitHub and authorize Cloud Build GitHub app:"); const { url, cleanup } = await utils.openInBrowserPopup( conn.installationState.actionUri, "Authorize the GitHub app" ); - logger.info(`\t${url}`); - logger.info(); + utils.logBullet(`\t${url}\n`); await promptOnce({ type: "input", message: "Press Enter once you have authorized the app", @@ -207,9 +203,9 @@ async function promptConnectionAuth(conn: gcb.Connection): Promise { - logger.info("Now, install the Cloud Build GitHub app:"); + utils.logBullet("Now, install the Cloud Build GitHub app:"); const targetUri = conn.installationState.actionUri.replace("install_v2", "direct_install_v2"); - logger.info(targetUri); + utils.logBullet(targetUri); await utils.openInBrowser(targetUri); await promptOnce({ type: "input", From f0f8daac7e489615d40375e9c6456222bcf951b0 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 17:30:35 +0900 Subject: [PATCH 09/12] Missed a spot. --- src/init/features/frameworks/repo.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 989337c58a8..c792a6e1793 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -163,6 +163,17 @@ async function promptRepositoryUri( async function promptSecretManagerAdminGrant(projectId: string): Promise { const projectNumber = await getProjectNumber({ projectId }); const cbsaEmail = gcb.serviceAgentEmail(projectNumber); + + const alreadyGranted = await rm.serviceAccountHasRoles( + projectId, + cbsaEmail, + ["roles/secretmanager.admin"], + true + ); + if (alreadyGranted) { + return true; + } + utils.logBullet( "To create a new GitHub connection, Secret Manager Admin role (roles/secretmanager.admin) is required on the Cloud Build Service Agent." ); @@ -205,7 +216,7 @@ async function promptConnectionAuth(conn: gcb.Connection): Promise { utils.logBullet("Now, install the Cloud Build GitHub app:"); const targetUri = conn.installationState.actionUri.replace("install_v2", "direct_install_v2"); - utils.logBullet(targetUri); + utils.logBullet(`\t${targetUri}`); await utils.openInBrowser(targetUri); await promptOnce({ type: "input", From 1fe80a0a32440db50f992bd01de9e3df664847e2 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 17:42:10 +0900 Subject: [PATCH 10/12] Finishing touches on the logged messages. --- src/init/features/frameworks/index.ts | 7 +++---- src/init/features/frameworks/repo.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index 21ae156425a..1a42ae475d0 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -54,10 +54,10 @@ export async function doSetup(setup: any, projectId: string): Promise { const backend: Backend | undefined = await getOrCreateBackend(projectId, setup); if (backend) { - logSuccess(`Successfully created backend:\n ${backend.name}`); - logSuccess(`Your site is being deployed at:\n https://${backend.uri}\n`); + logSuccess(`Successfully created backend:\n\t${backend.name}`); + logSuccess(`Your site is being deployed at:\n\thttps://${backend.uri}`); logSuccess( - `View the rollout status by running:\n firebase backends:get --backend=${backend.name}\n` + `View the rollout status by running:\n\tfirebase backends:get --backend=${backend.name}` ); } } @@ -96,7 +96,6 @@ export async function getOrCreateBackend( setup.frameworks ); const backendDetails = toBackend(cloudBuildConnRepo); - logBullet(clc.bold(`${clc.white("===")} Creating your backend`)); return await createBackend(projectId, location, backendDetails, setup.frameworks.serviceName); } else { throw new FirebaseError( diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index c792a6e1793..8cc346c418c 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -79,7 +79,7 @@ export async function linkGitHubRepository( projectId: string, location: string ): Promise { - utils.logBullet(clc.bold(`${clc.yellow("===")} Connect a GitHub repository`)); + utils.logBullet(clc.bold(`${clc.yellow("===")} Set up a GitHub connection`)); const existingConns = await listFrameworksConnections(projectId); if (existingConns.length < 1) { const grantSuccess = await promptSecretManagerAdminGrant(projectId); @@ -123,7 +123,8 @@ export async function linkGitHubRepository( appInstallationId: connection.githubConfig?.appInstallationId, }); const repo = await getOrCreateRepository(projectId, location, connectionId, remoteUri); - utils.logSuccess(`Successfully linked GitHub repository at remote URI:\n ${remoteUri}`); + utils.logSuccess(`Successfully linked GitHub repository at remote URI`); + utils.logSuccess(`\t${remoteUri}`); return repo; } @@ -197,13 +198,13 @@ async function promptSecretManagerAdminGrant(projectId: string): Promise { - utils.logBullet("You must authorize the Cloud Build GitHub app.\n"); + utils.logBullet("You must authorize the Cloud Build GitHub app."); utils.logBullet("Sign in to GitHub and authorize Cloud Build GitHub app:"); const { url, cleanup } = await utils.openInBrowserPopup( conn.installationState.actionUri, "Authorize the GitHub app" ); - utils.logBullet(`\t${url}\n`); + utils.logBullet(`\t${url}`); await promptOnce({ type: "input", message: "Press Enter once you have authorized the app", @@ -214,9 +215,9 @@ async function promptConnectionAuth(conn: gcb.Connection): Promise { - utils.logBullet("Now, install the Cloud Build GitHub app:"); + utils.logBullet("Install the Cloud Build GitHub app to enable access to GitHub repositories"); const targetUri = conn.installationState.actionUri.replace("install_v2", "direct_install_v2"); - utils.logBullet(`\t${targetUri}`); + utils.logBullet(targetUri); await utils.openInBrowser(targetUri); await promptOnce({ type: "input", From 997aa47f75accfcf803f0b380b0fcc6a1736f5a9 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 18:37:06 +0900 Subject: [PATCH 11/12] Refactor createBackend and other utility functions. --- src/init/features/frameworks/index.ts | 137 +++++++++---------------- src/test/init/frameworks/index.spec.ts | 36 +++---- 2 files changed, 60 insertions(+), 113 deletions(-) diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index 1a42ae475d0..8897481e600 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -2,7 +2,7 @@ import * as clc from "colorette"; import * as repo from "./repo"; import * as poller from "../../../operation-poller"; import * as gcp from "../../../gcp/frameworks"; -import { logBullet, logSuccess } from "../../../utils"; +import { logBullet, logSuccess, logWarning } from "../../../utils"; import { frameworksOrigin } from "../../../api"; import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks"; import { Repository } from "../../../gcp/cloudbuild"; @@ -26,32 +26,40 @@ export async function doSetup(setup: any, projectId: string): Promise { logBullet("First we need a few details to create your backend."); - await promptOnce( - { - name: "serviceName", + const location = await promptOnce({ + name: "region", + type: "list", + default: DEFAULT_REGION, + message: + "Please select a region " + + `(${clc.yellow("info")}: Your region determines where your backend is located):\n`, + choices: ALLOWED_REGIONS, + }); + + logSuccess(`Region set to ${location}.\n`); + + let backendId: string; + while (true) { + backendId = await promptOnce({ + name: "backendId", type: "input", default: "acme-inc-web", message: "Create a name for your backend [1-30 characters]", - }, - setup.frameworks - ); - - await promptOnce( - { - name: "region", - type: "list", - default: DEFAULT_REGION, - message: - "Please select a region " + - `(${clc.yellow("info")}: Your region determines where your backend is located):\n`, - choices: ALLOWED_REGIONS, - }, - setup.frameworks - ); - - logSuccess(`Region set to ${setup.frameworks.region}.\n`); - - const backend: Backend | undefined = await getOrCreateBackend(projectId, setup); + }); + try { + await gcp.getBackend(projectId, location, backendId); + } catch (err: any) { + if (err.status === 404) { + break; + } + throw new FirebaseError( + `Failed to check if backend with id ${backendId} already exists in ${location}`, + { original: err } + ); + } + logWarning(`Backend with id ${backendId} already exists in ${location}`); + } + const backend: Backend = await onboardBackend(projectId, location, backendId); if (backend) { logSuccess(`Successfully created backend:\n\t${backend.name}`); @@ -74,75 +82,24 @@ function toBackend(cloudBuildConnRepo: Repository): Omit { - const location: string = setup.frameworks.region; - try { - return await getExistingBackend(projectId, setup, location); - } catch (err: unknown) { - if ((err as FirebaseError).status === 404) { - const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location); - await promptOnce( - { - name: "branchName", - type: "input", - default: "main", - message: "Which branch do you want to deploy?", - }, - setup.frameworks - ); - const backendDetails = toBackend(cloudBuildConnRepo); - return await createBackend(projectId, location, backendDetails, setup.frameworks.serviceName); - } else { - throw new FirebaseError( - `Failed to get or create a backend using the given initialization details: ${err}` - ); - } - } - - return undefined; -} - -async function getExistingBackend( +export async function onboardBackend( projectId: string, - setup: any, - location: string + location: string, + backendId: string ): Promise { - let backend = await gcp.getBackend(projectId, location, setup.frameworks.serviceName); - while (backend) { - setup.frameworks.serviceName = undefined; - await promptOnce( - { - name: "existingBackend", - type: "confirm", - default: true, - message: - "A backend already exists for the given serviceName, do you want to use existing backend? (yes/no)", - }, - setup.frameworks - ); - if (setup.frameworks.existingBackend) { - logBullet("Using the existing backend."); - return backend; - } - await promptOnce( - { - name: "serviceName", - type: "input", - default: "acme-inc-web", - message: "Please enter a new service name [1-30 characters]", - }, - setup.frameworks - ); - backend = await gcp.getBackend(projectId, location, setup.frameworks.serviceName); - setup.frameworks.existingBackend = undefined; - } - - return backend; + const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location); + const barnchName = await promptOnce({ + name: "branchName", + type: "input", + default: "main", + message: "Which branch do you want to deploy?", + }); + // branchName unused for now. + void barnchName; + const backendDetails = toBackend(cloudBuildConnRepo); + return await createBackend(projectId, location, backendDetails, backendId); } /** diff --git a/src/test/init/frameworks/index.spec.ts b/src/test/init/frameworks/index.spec.ts index 84ecadd34c4..1dd4b9ffbae 100644 --- a/src/test/init/frameworks/index.spec.ts +++ b/src/test/init/frameworks/index.spec.ts @@ -4,7 +4,8 @@ import { expect } from "chai"; import * as gcp from "../../../gcp/frameworks"; import * as repo from "../../../init/features/frameworks/repo"; import * as poller from "../../../operation-poller"; -import { createBackend, getOrCreateBackend } from "../../../init/features/frameworks/index"; +import * as prompt from "../../../prompt"; +import { createBackend, onboardBackend } from "../../../init/features/frameworks/index"; import { FirebaseError } from "../../../error"; describe("operationsConverter", () => { @@ -14,6 +15,7 @@ describe("operationsConverter", () => { let createBackendStub: sinon.SinonStub; let getBackendStub: sinon.SinonStub; let linkGitHubRepositoryStub: sinon.SinonStub; + let promptOnce: sinon.SinonStub; beforeEach(() => { pollOperationStub = sandbox @@ -24,20 +26,23 @@ describe("operationsConverter", () => { linkGitHubRepositoryStub = sandbox .stub(repo, "linkGitHubRepository") .throws("Unexpected getBackend call"); + promptOnce = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); }); afterEach(() => { sandbox.verifyAndRestore(); }); - describe("createBackend", () => { + describe("onboardBackend", () => { const projectId = "projectId"; const location = "us-central1"; const backendId = "backendId"; + const op = { name: `projects/${projectId}/locations/${location}/backends/${backendId}`, done: true, }; + const completeBackend = { name: `projects/${projectId}/locations/${location}/backends/${backendId}`, labels: {}, @@ -45,21 +50,14 @@ describe("operationsConverter", () => { updateTime: "1", uri: "https://placeholder.com", }; - const setup = { - frameworks: { - region: location, - serviceName: backendId, - existingBackend: true, - deployMethod: "github", - branchName: "main", - }, - }; + const cloudBuildConnRepo = { name: `projects/${projectId}/locations/${location}/connections/framework-${location}/repositories/repoId`, remoteUri: "remoteUri", createTime: "0", updateTime: "1", }; + const backendInput: Omit = { servingLocality: "GLOBAL_ACCESS", codebase: { @@ -68,6 +66,7 @@ describe("operationsConverter", () => { }, labels: {}, }; + it("should createBackend", async () => { createBackendStub.resolves(op); pollOperationStub.resolves(completeBackend); @@ -77,27 +76,18 @@ describe("operationsConverter", () => { expect(createBackendStub).to.be.calledWith(projectId, location, backendInput); }); - it("should return a backend, if user wants use the exiting backend", async () => { - getBackendStub.resolves(completeBackend); - - const result = await getOrCreateBackend("projectId", setup); - - expect(result).to.deep.equal(completeBackend); - expect(getBackendStub.calledOnceWithExactly(projectId, location, backendId)).to.be.true; - }); - - it("should create a new backend, if backend doesn't exist", async () => { + it("should onboard a new backend", async () => { const newBackendId = "newBackendId"; const newPath = `projects/${projectId}/locations/${location}/backends/${newBackendId}`; - setup.frameworks.serviceName = newBackendId; op.name = newPath; completeBackend.name = newPath; getBackendStub.throws(new FirebaseError("error", { status: 404 })); linkGitHubRepositoryStub.resolves(cloudBuildConnRepo); createBackendStub.resolves(op); pollOperationStub.resolves(completeBackend); + promptOnce.resolves("main"); - const result = await getOrCreateBackend(projectId, setup); + const result = await onboardBackend(projectId, location, backendId); expect(result).to.deep.equal(completeBackend); expect(createBackendStub).to.be.calledWith(projectId, location, backendInput); From 2aa54a7b59005f2581e004f83ced331f955ffb9d Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 6 Dec 2023 18:37:06 +0900 Subject: [PATCH 12/12] Refactor createBackend and other utility functions. --- src/init/features/frameworks/index.ts | 137 +++++++++---------------- src/test/init/frameworks/index.spec.ts | 36 +++---- 2 files changed, 60 insertions(+), 113 deletions(-) diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index 1a42ae475d0..8897481e600 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -2,7 +2,7 @@ import * as clc from "colorette"; import * as repo from "./repo"; import * as poller from "../../../operation-poller"; import * as gcp from "../../../gcp/frameworks"; -import { logBullet, logSuccess } from "../../../utils"; +import { logBullet, logSuccess, logWarning } from "../../../utils"; import { frameworksOrigin } from "../../../api"; import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks"; import { Repository } from "../../../gcp/cloudbuild"; @@ -26,32 +26,40 @@ export async function doSetup(setup: any, projectId: string): Promise { logBullet("First we need a few details to create your backend."); - await promptOnce( - { - name: "serviceName", + const location = await promptOnce({ + name: "region", + type: "list", + default: DEFAULT_REGION, + message: + "Please select a region " + + `(${clc.yellow("info")}: Your region determines where your backend is located):\n`, + choices: ALLOWED_REGIONS, + }); + + logSuccess(`Region set to ${location}.\n`); + + let backendId: string; + while (true) { + backendId = await promptOnce({ + name: "backendId", type: "input", default: "acme-inc-web", message: "Create a name for your backend [1-30 characters]", - }, - setup.frameworks - ); - - await promptOnce( - { - name: "region", - type: "list", - default: DEFAULT_REGION, - message: - "Please select a region " + - `(${clc.yellow("info")}: Your region determines where your backend is located):\n`, - choices: ALLOWED_REGIONS, - }, - setup.frameworks - ); - - logSuccess(`Region set to ${setup.frameworks.region}.\n`); - - const backend: Backend | undefined = await getOrCreateBackend(projectId, setup); + }); + try { + await gcp.getBackend(projectId, location, backendId); + } catch (err: any) { + if (err.status === 404) { + break; + } + throw new FirebaseError( + `Failed to check if backend with id ${backendId} already exists in ${location}`, + { original: err } + ); + } + logWarning(`Backend with id ${backendId} already exists in ${location}`); + } + const backend: Backend = await onboardBackend(projectId, location, backendId); if (backend) { logSuccess(`Successfully created backend:\n\t${backend.name}`); @@ -74,75 +82,24 @@ function toBackend(cloudBuildConnRepo: Repository): Omit { - const location: string = setup.frameworks.region; - try { - return await getExistingBackend(projectId, setup, location); - } catch (err: unknown) { - if ((err as FirebaseError).status === 404) { - const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location); - await promptOnce( - { - name: "branchName", - type: "input", - default: "main", - message: "Which branch do you want to deploy?", - }, - setup.frameworks - ); - const backendDetails = toBackend(cloudBuildConnRepo); - return await createBackend(projectId, location, backendDetails, setup.frameworks.serviceName); - } else { - throw new FirebaseError( - `Failed to get or create a backend using the given initialization details: ${err}` - ); - } - } - - return undefined; -} - -async function getExistingBackend( +export async function onboardBackend( projectId: string, - setup: any, - location: string + location: string, + backendId: string ): Promise { - let backend = await gcp.getBackend(projectId, location, setup.frameworks.serviceName); - while (backend) { - setup.frameworks.serviceName = undefined; - await promptOnce( - { - name: "existingBackend", - type: "confirm", - default: true, - message: - "A backend already exists for the given serviceName, do you want to use existing backend? (yes/no)", - }, - setup.frameworks - ); - if (setup.frameworks.existingBackend) { - logBullet("Using the existing backend."); - return backend; - } - await promptOnce( - { - name: "serviceName", - type: "input", - default: "acme-inc-web", - message: "Please enter a new service name [1-30 characters]", - }, - setup.frameworks - ); - backend = await gcp.getBackend(projectId, location, setup.frameworks.serviceName); - setup.frameworks.existingBackend = undefined; - } - - return backend; + const cloudBuildConnRepo = await repo.linkGitHubRepository(projectId, location); + const barnchName = await promptOnce({ + name: "branchName", + type: "input", + default: "main", + message: "Which branch do you want to deploy?", + }); + // branchName unused for now. + void barnchName; + const backendDetails = toBackend(cloudBuildConnRepo); + return await createBackend(projectId, location, backendDetails, backendId); } /** diff --git a/src/test/init/frameworks/index.spec.ts b/src/test/init/frameworks/index.spec.ts index 84ecadd34c4..1dd4b9ffbae 100644 --- a/src/test/init/frameworks/index.spec.ts +++ b/src/test/init/frameworks/index.spec.ts @@ -4,7 +4,8 @@ import { expect } from "chai"; import * as gcp from "../../../gcp/frameworks"; import * as repo from "../../../init/features/frameworks/repo"; import * as poller from "../../../operation-poller"; -import { createBackend, getOrCreateBackend } from "../../../init/features/frameworks/index"; +import * as prompt from "../../../prompt"; +import { createBackend, onboardBackend } from "../../../init/features/frameworks/index"; import { FirebaseError } from "../../../error"; describe("operationsConverter", () => { @@ -14,6 +15,7 @@ describe("operationsConverter", () => { let createBackendStub: sinon.SinonStub; let getBackendStub: sinon.SinonStub; let linkGitHubRepositoryStub: sinon.SinonStub; + let promptOnce: sinon.SinonStub; beforeEach(() => { pollOperationStub = sandbox @@ -24,20 +26,23 @@ describe("operationsConverter", () => { linkGitHubRepositoryStub = sandbox .stub(repo, "linkGitHubRepository") .throws("Unexpected getBackend call"); + promptOnce = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); }); afterEach(() => { sandbox.verifyAndRestore(); }); - describe("createBackend", () => { + describe("onboardBackend", () => { const projectId = "projectId"; const location = "us-central1"; const backendId = "backendId"; + const op = { name: `projects/${projectId}/locations/${location}/backends/${backendId}`, done: true, }; + const completeBackend = { name: `projects/${projectId}/locations/${location}/backends/${backendId}`, labels: {}, @@ -45,21 +50,14 @@ describe("operationsConverter", () => { updateTime: "1", uri: "https://placeholder.com", }; - const setup = { - frameworks: { - region: location, - serviceName: backendId, - existingBackend: true, - deployMethod: "github", - branchName: "main", - }, - }; + const cloudBuildConnRepo = { name: `projects/${projectId}/locations/${location}/connections/framework-${location}/repositories/repoId`, remoteUri: "remoteUri", createTime: "0", updateTime: "1", }; + const backendInput: Omit = { servingLocality: "GLOBAL_ACCESS", codebase: { @@ -68,6 +66,7 @@ describe("operationsConverter", () => { }, labels: {}, }; + it("should createBackend", async () => { createBackendStub.resolves(op); pollOperationStub.resolves(completeBackend); @@ -77,27 +76,18 @@ describe("operationsConverter", () => { expect(createBackendStub).to.be.calledWith(projectId, location, backendInput); }); - it("should return a backend, if user wants use the exiting backend", async () => { - getBackendStub.resolves(completeBackend); - - const result = await getOrCreateBackend("projectId", setup); - - expect(result).to.deep.equal(completeBackend); - expect(getBackendStub.calledOnceWithExactly(projectId, location, backendId)).to.be.true; - }); - - it("should create a new backend, if backend doesn't exist", async () => { + it("should onboard a new backend", async () => { const newBackendId = "newBackendId"; const newPath = `projects/${projectId}/locations/${location}/backends/${newBackendId}`; - setup.frameworks.serviceName = newBackendId; op.name = newPath; completeBackend.name = newPath; getBackendStub.throws(new FirebaseError("error", { status: 404 })); linkGitHubRepositoryStub.resolves(cloudBuildConnRepo); createBackendStub.resolves(op); pollOperationStub.resolves(completeBackend); + promptOnce.resolves("main"); - const result = await getOrCreateBackend(projectId, setup); + const result = await onboardBackend(projectId, location, backendId); expect(result).to.deep.equal(completeBackend); expect(createBackendStub).to.be.calledWith(projectId, location, backendInput);