diff --git a/src/gcp/cloudbuild.ts b/src/gcp/cloudbuild.ts index ce1e238fa52..24b16f52ffa 100644 --- a/src/gcp/cloudbuild.ts +++ b/src/gcp/cloudbuild.ts @@ -85,14 +85,15 @@ interface LinkableRepositories { export async function createConnection( projectId: string, location: string, - connectionId: string + connectionId: string, + githubConfig: GitHubConfig = {} ): Promise { const res = await client.post< Omit, ConnectionOutputOnlyFields>, Operation >( `projects/${projectId}/locations/${location}/connections`, - { githubConfig: {} }, + { githubConfig }, { queryParams: { connectionId } } ); return res.body; diff --git a/src/init/features/frameworks/index.ts b/src/init/features/frameworks/index.ts index e47d8e8f591..0f778f16091 100644 --- a/src/init/features/frameworks/index.ts +++ b/src/init/features/frameworks/index.ts @@ -1,16 +1,16 @@ import * as clc from "colorette"; import * as utils from "../../../utils"; -import { logger } from "../../../logger"; -import { promptOnce } from "../../../prompt"; -import { DEFAULT_REGION, ALLOWED_REGIONS } from "./constants"; import * as repo from "./repo"; -import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks"; -import { Repository } from "../../../gcp/cloudbuild"; import * as poller from "../../../operation-poller"; -import { frameworksOrigin } from "../../../api"; import * as gcp from "../../../gcp/frameworks"; +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"; const frameworksPollerOptions: Omit = { apiOrigin: frameworksOrigin, @@ -53,6 +53,7 @@ export async function doSetup(setup: any, projectId: string): Promise { utils.logSuccess(`Region set to ${setup.frameworks.region}.`); const backend: Backend | undefined = await getOrCreateBackend(projectId, setup); + if (backend) { logger.info(); utils.logSuccess(`Successfully created backend:\n ${backend.name}`); diff --git a/src/init/features/frameworks/repo.ts b/src/init/features/frameworks/repo.ts index 7d77d7667f7..92ea3efc502 100644 --- a/src/init/features/frameworks/repo.ts +++ b/src/init/features/frameworks/repo.ts @@ -1,13 +1,40 @@ -import { cloudbuildOrigin } from "../../../api"; -import { FirebaseError } from "../../../error"; +import * as clc from "colorette"; + import * as gcb from "../../../gcp/cloudbuild"; -import { logger } from "../../../logger"; 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 * as clc from "colorette"; + +export interface ConnectionNameParts { + projectId: string; + location: string; + id: string; +} const FRAMEWORKS_CONN_PATTERN = /.+\/frameworks-github-conn-.+$/; +const FRAMEWORKS_OAUTH_CONN_NAME = "frameworks-github-oauth"; +const CONNECTION_NAME_REGEX = + /^projects\/(?[^\/]+)\/locations\/(?[^\/]+)\/connections\/(?[^\/]+)$/; + +/** + * Exported for unit testing. + */ +export function parseConnectionName(name: string): ConnectionNameParts | undefined { + const match = name.match(CONNECTION_NAME_REGEX); + + if (!match || typeof match.groups === undefined) { + return; + } + const { projectId, location, id } = match.groups as unknown as ConnectionNameParts; + return { + projectId, + location, + id, + }; +} const gcbPollerOptions: Omit = { apiOrigin: cloudbuildOrigin, @@ -20,7 +47,7 @@ const gcbPollerOptions: Omit "user/repo" */ -function extractRepoSlugFromURI(remoteUri: string): string | undefined { +function extractRepoSlugFromUri(remoteUri: string): string | undefined { const match = /github.com\/(.+).git/.exec(remoteUri); if (!match) { return undefined; @@ -30,21 +57,18 @@ function extractRepoSlugFromURI(remoteUri: string): string | undefined { /** * Generates a repository ID. - * The relation is 1:* between Cloud Build Connection and Github Repositories. + * The relation is 1:* between Cloud Build Connection and GitHub Repositories. */ function generateRepositoryId(remoteUri: string): string | undefined { - return extractRepoSlugFromURI(remoteUri)?.replaceAll("/", "-"); + return extractRepoSlugFromUri(remoteUri)?.replaceAll("/", "-"); } /** - * The 'frameworks-' is prefixed, to seperate the Cloud Build connections created from - * Frameworks platforms with rest of manually created Cloud Build connections. - * - * The reason suffix 'location' is because of - * 1:1 relation between location and Cloud Build connection. + * Generates connection id that matches specific id format recognized by all Firebase clients. */ -function generateConnectionId(location: string): string { - return `frameworks-${location}`; +function generateConnectionId(): string { + const randomHash = Math.random().toString(36).slice(6); + return `frameworks-github-conn-${randomHash}`; } /** @@ -54,11 +78,27 @@ export async function linkGitHubRepository( projectId: string, location: string ): Promise { - logger.info(clc.bold(`\n${clc.white("===")} Connect a github repository`)); - const connectionId = generateConnectionId(location); - await getOrCreateConnection(projectId, location, connectionId); + logger.info(clc.bold(`\n${clc.yellow("===")} Connect a GitHub repository`)); + const existingConns = await listFrameworksConnections(projectId); + if (existingConns.length < 1) { + let oauthConn = await getOrCreateConnection(projectId, location, FRAMEWORKS_OAUTH_CONN_NAME); + while (oauthConn.installationState.stage === "PENDING_USER_OAUTH") { + oauthConn = await promptConnectionAuth(oauthConn); + } + // Create or get connection resource that contains reference to the GitHub oauth token. + // Oauth token associated with this connection should be used to create other connection resources. + const connectionId = generateConnectionId(); + const conn = await createConnection(projectId, location, connectionId, { + authorizerCredential: oauthConn.githubConfig?.authorizerCredential, + }); + let refreshedConn = conn; + while (refreshedConn.installationState.stage !== "COMPLETE") { + refreshedConn = await promptAppInstall(conn); + } + existingConns.push(refreshedConn); + } - let remoteUri = await promptRepositoryURI(projectId, location, connectionId); + let { remoteUri, connection } = await promptRepositoryUri(projectId, location, existingConns); while (remoteUri === "") { await utils.openInBrowser("https://github.com/apps/google-cloud-build/installations/new"); await promptOnce({ @@ -66,58 +106,100 @@ export async function linkGitHubRepository( message: "Press ENTER once you have finished configuring your installation's access settings.", }); - remoteUri = await promptRepositoryURI(projectId, location, connectionId); + const selection = await promptRepositoryUri(projectId, location, existingConns); + remoteUri = selection.remoteUri; + connection = selection.connection; } + // Ensure that the selected connection exists in the same region as the backend + const { id: connectionId } = parseConnectionName(connection.name)!; + await getOrCreateConnection(projectId, location, connectionId, { + authorizerCredential: connection.githubConfig?.authorizerCredential, + 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; } -async function promptRepositoryURI( +async function promptRepositoryUri( projectId: string, location: string, - connectionId: string -): Promise { - const resp = await gcb.fetchLinkableRepositories(projectId, location, connectionId); - if (!resp.repositories || resp.repositories.length === 0) { - throw new FirebaseError( - "The GitHub App does not have access to any repositories. Please configure " + - "your app installation permissions at https://github.com/settings/installations." - ); + connections: gcb.Connection[] +): Promise<{ remoteUri: string; connection: gcb.Connection }> { + const remoteUriToConnection: Record = {}; + for (const conn of connections) { + const { id } = parseConnectionName(conn.name)!; + const resp = await gcb.fetchLinkableRepositories(projectId, location, id); + if (resp.repositories && resp.repositories.length > 1) { + for (const repo of resp.repositories) { + remoteUriToConnection[repo.remoteUri] = conn; + } + } } - const choices = resp.repositories.map((repo: gcb.Repository) => ({ - name: extractRepoSlugFromURI(repo.remoteUri) || repo.remoteUri, - value: repo.remoteUri, + + const choices = Object.keys(remoteUriToConnection).map((remoteUri: string) => ({ + name: extractRepoSlugFromUri(remoteUri) || remoteUri, + value: remoteUri, })); choices.push({ name: "Missing a repo? Select this option to configure your installation's access settings", value: "", }); - return await promptOnce({ + const remoteUri = await promptOnce({ type: "list", message: "Which of the following repositories would you like to deploy?", choices, }); + return { remoteUri, connection: remoteUriToConnection[remoteUri] }; } -async function promptConnectionAuth( - conn: gcb.Connection, - projectId: string, - location: string, - connectionId: string -): Promise { - logger.info("First, log in to GitHub, install and authorize Cloud Build app:"); - logger.info(conn.installationState.actionUri); - await utils.openInBrowser(conn.installationState.actionUri); +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( + conn.installationState.actionUri, + "Authorize the GitHub app" + ); + await promptOnce({ + type: "input", + message: "Press Enter once you have authorized the app", + }); + cleanup(); + const { projectId, location, id } = parseConnectionName(conn.name)!; + return await gcb.getConnection(projectId, location, id); +} + +async function promptAppInstall(conn: gcb.Connection): Promise { + logger.info("Now, install the Cloud Build GitHub app:"); + const targetUri = conn.installationState.actionUri.replace("install_v2", "direct_install_v2"); + logger.info(targetUri); + await utils.openInBrowser(targetUri); await promptOnce({ type: "input", message: - "Press Enter once you have authorized the app (Cloud Build) to access your GitHub repo.", + "Press Enter once you have installed or configured the Cloud Build GitHub app to access your GitHub repo.", + }); + const { projectId, location, id } = parseConnectionName(conn.name)!; + return await gcb.getConnection(projectId, location, id); +} + +export async function createConnection( + projectId: string, + location: string, + connectionId: string, + githubConfig?: gcb.GitHubConfig +): Promise { + const op = await gcb.createConnection(projectId, location, connectionId, githubConfig); + const conn = await poller.pollOperation({ + ...gcbPollerOptions, + pollerName: `create-${location}-${connectionId}`, + operationResourceName: op.name, }); - return await gcb.getConnection(projectId, location, connectionId); + return conn; } /** @@ -126,27 +208,19 @@ async function promptConnectionAuth( export async function getOrCreateConnection( projectId: string, location: string, - connectionId: string + connectionId: string, + githubConfig?: gcb.GitHubConfig ): Promise { let conn: gcb.Connection; try { conn = await gcb.getConnection(projectId, location, connectionId); } catch (err: unknown) { if ((err as FirebaseError).status === 404) { - const op = await gcb.createConnection(projectId, location, connectionId); - conn = await poller.pollOperation({ - ...gcbPollerOptions, - pollerName: `create-${location}-${connectionId}`, - operationResourceName: op.name, - }); + conn = await createConnection(projectId, location, connectionId, githubConfig); } else { throw err; } } - - while (conn.installationState.stage !== "COMPLETE") { - conn = await promptConnectionAuth(conn, projectId, location, connectionId); - } return conn; } @@ -166,7 +240,7 @@ export async function getOrCreateRepository( let repo: gcb.Repository; try { repo = await gcb.getRepository(projectId, location, connectionId, repositoryId); - const repoSlug = extractRepoSlugFromURI(repo.remoteUri); + const repoSlug = extractRepoSlugFromUri(repo.remoteUri); if (repoSlug) { throw new FirebaseError(`${repoSlug} has already been linked.`); } @@ -193,5 +267,10 @@ export async function getOrCreateRepository( export async function listFrameworksConnections(projectId: string) { const conns = await gcb.listConnections(projectId, "-"); - return conns.filter((conn) => FRAMEWORKS_CONN_PATTERN.test(conn.name)); + return conns.filter( + (conn) => + FRAMEWORKS_CONN_PATTERN.test(conn.name) && + conn.installationState.stage === "COMPLETE" && + !conn.disabled + ); } diff --git a/src/test/init/frameworks/repo.spec.ts b/src/test/init/frameworks/repo.spec.ts index 159e1063fe0..6302a9933a0 100644 --- a/src/test/init/frameworks/repo.spec.ts +++ b/src/test/init/frameworks/repo.spec.ts @@ -136,6 +136,29 @@ describe("composer", () => { }); }); + describe("parseConnectionName", () => { + it("should parse valid connection name", () => { + const str = "projects/my-project/locations/us-central1/connections/my-conn"; + + const expected = { + projectId: "my-project", + location: "us-central1", + id: "my-conn", + }; + + expect(repo.parseConnectionName(str)).to.deep.equal(expected); + }); + + it("should return undefined for invalid", () => { + expect( + repo.parseConnectionName( + "projects/my-project/locations/us-central1/connections/my-conn/repositories/repo" + ) + ).to.be.undefined; + expect(repo.parseConnectionName("foobar")).to.be.undefined; + }); + }); + describe("listFrameworksConnections", () => { const sandbox: sinon.SinonSandbox = sinon.createSandbox(); let listConnectionsStub: sinon.SinonStub; diff --git a/src/utils.ts b/src/utils.ts index 536450dec13..e2932f50e28 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,7 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Socket } from "node:net"; + import * as _ from "lodash"; import * as url from "url"; import * as http from "http"; @@ -10,12 +14,12 @@ import * as winston from "winston"; import { SPLAT } from "triple-beam"; import { AssertionError } from "assert"; const stripAnsi = require("strip-ansi"); +import { getPortPromise as getPort } from "portfinder"; import { configstore } from "./configstore"; import { FirebaseError } from "./error"; import { logger, LogLevel } from "./logger"; import { LogDataOrUndefined } from "./emulator/loggingEmulator"; -import { Socket } from "net"; const IS_WINDOWS = process.platform === "win32"; const SUCCESS_CHAR = IS_WINDOWS ? "+" : "✔"; @@ -759,3 +763,34 @@ export function connectableHostname(hostname: string): string { export async function openInBrowser(url: string): Promise { await open(url); } + +/** + * Like openInBrowser but opens the url in a popup. + */ +export async function openInBrowserPopup(url: string, buttonText: string): Promise<() => void> { + const popupPage = fs + .readFileSync(path.join(__dirname, "../templates/popup.html"), { encoding: "utf-8" }) + .replace("${url}", url) + .replace("${buttonText}", buttonText); + + const port = await getPort(); + + const server = http.createServer((req, res) => { + res.writeHead(200, { + "Content-Length": popupPage.length, + "Content-Type": "text/html", + }); + res.end(popupPage); + req.socket.destroy(); + }); + + server.listen(port); + + const popupPageUri = `http://localhost:${port}`; + logger.info(popupPageUri); + await openInBrowser(popupPageUri); + + return () => { + server.close(); + }; +} diff --git a/templates/popup.html b/templates/popup.html new file mode 100644 index 00000000000..8e14fa0e109 --- /dev/null +++ b/templates/popup.html @@ -0,0 +1,64 @@ + + + + + + +