diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7058f86bc..69b9f437feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ +- Fix rollouts:create to handle backend regionality & other fixes. (#7862) - Fixed Next.js issue with PPR routes not rendering correctly. (#7625) diff --git a/src/apphosting/index.spec.ts b/src/apphosting/backend.spec.ts similarity index 99% rename from src/apphosting/index.spec.ts rename to src/apphosting/backend.spec.ts index b4d0c09d027..ddf5629d5c0 100644 --- a/src/apphosting/index.spec.ts +++ b/src/apphosting/backend.spec.ts @@ -13,7 +13,7 @@ import { setDefaultTrafficPolicy, ensureAppHostingComputeServiceAccount, getBackendForAmbiguousLocation, -} from "./index"; +} from "./backend"; import * as deploymentTool from "../deploymentTool"; import { FirebaseError } from "../error"; diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts new file mode 100644 index 00000000000..9cff51eeea3 --- /dev/null +++ b/src/apphosting/backend.ts @@ -0,0 +1,482 @@ +import * as clc from "colorette"; +import * as poller from "../operation-poller"; +import * as apphosting from "../gcp/apphosting"; +import * as githubConnections from "./githubConnections"; +import { logBullet, logSuccess, logWarning, sleep } from "../utils"; +import { + apphostingOrigin, + artifactRegistryDomain, + cloudRunApiOrigin, + cloudbuildOrigin, + consoleOrigin, + developerConnectOrigin, + iamOrigin, + secretManagerOrigin, +} from "../api"; +import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting"; +import { addServiceAccountToRoles } from "../gcp/resourceManager"; +import * as iam from "../gcp/iam"; +import { FirebaseError } from "../error"; +import { promptOnce } from "../prompt"; +import { DEFAULT_LOCATION } from "./constants"; +import { ensure } from "../ensureApiEnabled"; +import * as deploymentTool from "../deploymentTool"; +import { DeepOmit } from "../metaprogramming"; +import { webApps } from "./app"; +import { GitRepositoryLink } from "../gcp/devConnect"; +import * as ora from "ora"; +import fetch from "node-fetch"; +import { orchestrateRollout } from "./rollout"; + +const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute"; + +const apphostingPollerOptions: Omit = { + apiOrigin: apphostingOrigin(), + apiVersion: API_VERSION, + masterTimeout: 25 * 60 * 1_000, + maxBackoff: 10_000, +}; + +async function tlsReady(url: string): Promise { + // Note, we do not use the helper libraries because they impose additional logic on content type and parsing. + try { + await fetch(url); + return true; + } catch (err) { + // At the time of this writing, the error code is ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE. + // I've chosen to use a regexp in an attempt to be forwards compatible with new versions of + // SSL. + const maybeNodeError = err as { cause: { code: string }; code: string }; + if ( + /HANDSHAKE_FAILURE/.test(maybeNodeError?.cause?.code) || + "EPROTO" === maybeNodeError?.code + ) { + return false; + } + return true; + } +} + +async function awaitTlsReady(url: string): Promise { + let ready; + do { + ready = await tlsReady(url); + if (!ready) { + await sleep(1000 /* ms */); + } + } while (!ready); +} + +/** + * Set up a new App Hosting backend. + */ +export async function doSetup( + projectId: string, + webAppName: string | null, + location: string | null, + serviceAccount: string | null, +): Promise { + await Promise.all([ + ensure(projectId, developerConnectOrigin(), "apphosting", true), + ensure(projectId, cloudbuildOrigin(), "apphosting", true), + ensure(projectId, secretManagerOrigin(), "apphosting", true), + ensure(projectId, cloudRunApiOrigin(), "apphosting", true), + ensure(projectId, artifactRegistryDomain(), "apphosting", true), + ensure(projectId, iamOrigin(), "apphosting", true), + ]); + + // Hack: Because IAM can take ~45 seconds to propagate, we provision the service account as soon as + // possible to reduce the likelihood that the subsequent Cloud Build fails. See b/336862200. + await ensureAppHostingComputeServiceAccount(projectId, serviceAccount); + + const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); + if (location) { + if (!allowedLocations.includes(location)) { + throw new FirebaseError( + `Invalid location ${location}. Valid choices are ${allowedLocations.join(", ")}`, + ); + } + } + + location = + location || (await promptLocation(projectId, "Select a location to host your backend:\n")); + + const gitRepositoryLink: GitRepositoryLink = await githubConnections.linkGitHubRepository( + projectId, + location, + ); + + const rootDir = await promptOnce({ + name: "rootDir", + type: "input", + default: "/", + message: "Specify your app's root directory relative to your repository", + }); + + // TODO: Once tag patterns are implemented, prompt which method the user + // prefers. We could reduce the number of questions asked by letting people + // enter tag:? + const branch = await githubConnections.promptGitHubBranch(gitRepositoryLink); + logSuccess(`Repo linked successfully!\n`); + + logBullet(`${clc.yellow("===")} Set up your backend`); + const backendId = await promptNewBackendId(projectId, location, { + name: "backendId", + type: "input", + default: "my-web-app", + message: "Provide a name for your backend [1-30 characters]", + }); + logSuccess(`Name set to ${backendId}\n`); + + const webApp = await webApps.getOrCreateWebApp(projectId, webAppName, backendId); + if (!webApp) { + logWarning(`Firebase web app not set`); + } + + const createBackendSpinner = ora("Creating your new backend...").start(); + const backend = await createBackend( + projectId, + location, + backendId, + gitRepositoryLink, + serviceAccount, + webApp?.id, + rootDir, + ); + createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`); + + await setDefaultTrafficPolicy(projectId, location, backendId, branch); + + const confirmRollout = await promptOnce({ + type: "confirm", + name: "rollout", + default: true, + message: "Do you want to deploy now?", + }); + + if (!confirmRollout) { + logSuccess(`Your backend will be deployed at:\n\thttps://${backend.uri}`); + return; + } + + const url = `https://${backend.uri}`; + logBullet( + `You may also track this rollout at:\n\t${consoleOrigin()}/project/${projectId}/apphosting`, + ); + // TODO: Previous versions of this command printed the URL before the rollout started so that + // if a user does exit they will know where to go later. Should this be re-added? + const createRolloutSpinner = ora( + "Starting a new rollout; this may take a few minutes. It's safe to exit now.", + ).start(); + await orchestrateRollout({ + projectId, + location, + backendId, + buildInput: { + source: { + codebase: { + branch, + }, + }, + }, + isFirstRollout: true, + }); + createRolloutSpinner.succeed("Rollout complete"); + if (!(await tlsReady(url))) { + const tlsSpinner = ora( + "Finalizing your backend's TLS certificate; this may take a few minutes.", + ).start(); + await awaitTlsReady(url); + tlsSpinner.succeed("TLS certificate ready"); + } + logSuccess(`Your backend is now deployed at:\n\thttps://${backend.uri}`); +} + +/** + * Set up a new App Hosting-type Developer Connect GitRepoLink, optionally with a specific connection ID + */ +export async function createGitRepoLink( + projectId: string, + location: string | null, + connectionId?: string, +): Promise { + await Promise.all([ + ensure(projectId, developerConnectOrigin(), "apphosting", true), + ensure(projectId, secretManagerOrigin(), "apphosting", true), + ensure(projectId, iamOrigin(), "apphosting", true), + ]); + + const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); + if (location) { + if (!allowedLocations.includes(location)) { + throw new FirebaseError( + `Invalid location ${location}. Valid choices are ${allowedLocations.join(", ")}`, + ); + } + } + + location = + location || + (await promptLocation(projectId, "Select a location for your GitRepoLink's connection:\n")); + + await githubConnections.linkGitHubRepository(projectId, location, connectionId); +} + +/** + * Ensures the service account is present the user has permissions to use it by + * checking the `iam.serviceAccounts.actAs` permission. If the permissions + * check fails, this returns an error. If the permission check fails with a + * "not found" error, this attempts to provision the service account. + */ +export async function ensureAppHostingComputeServiceAccount( + projectId: string, + serviceAccount: string | null, +): Promise { + const sa = serviceAccount || defaultComputeServiceAccountEmail(projectId); + const name = `projects/${projectId}/serviceAccounts/${sa}`; + try { + await iam.testResourceIamPermissions( + iamOrigin(), + "v1", + name, + ["iam.serviceAccounts.actAs"], + `projects/${projectId}`, + ); + } catch (err: unknown) { + if (!(err instanceof FirebaseError)) { + throw err; + } + if (err.status === 404) { + await provisionDefaultComputeServiceAccount(projectId); + } else if (err.status === 403) { + throw new FirebaseError( + `Failed to create backend due to missing delegation permissions for ${sa}. Make sure you have the iam.serviceAccounts.actAs permission.`, + { original: err }, + ); + } + } +} + +/** + * Prompts the user for a backend id and verifies that it doesn't match a pre-existing backend. + */ +async function promptNewBackendId( + projectId: string, + location: string, + prompt: any, +): Promise { + while (true) { + const backendId = await promptOnce(prompt); + try { + await apphosting.getBackend(projectId, location, backendId); + } catch (err: any) { + if (err.status === 404) { + return backendId; + } + 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}`); + } +} + +function defaultComputeServiceAccountEmail(projectId: string): string { + return `${DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME}@${projectId}.iam.gserviceaccount.com`; +} + +/** + * Creates (and waits for) a new backend. Optionally may create the default compute service account if + * it was requested and doesn't exist. + */ +export async function createBackend( + projectId: string, + location: string, + backendId: string, + repository: GitRepositoryLink, + serviceAccount: string | null, + webAppId: string | undefined, + rootDir = "/", +): Promise { + const defaultServiceAccount = defaultComputeServiceAccountEmail(projectId); + const backendReqBody: Omit = { + servingLocality: "GLOBAL_ACCESS", + codebase: { + repository: `${repository.name}`, + rootDirectory: rootDir, + }, + labels: deploymentTool.labels(), + serviceAccount: serviceAccount || defaultServiceAccount, + appId: webAppId, + }; + + async function createBackendAndPoll(): Promise { + const op = await apphosting.createBackend(projectId, location, backendReqBody, backendId); + return await poller.pollOperation({ + ...apphostingPollerOptions, + pollerName: `create-${projectId}-${location}-${backendId}`, + operationResourceName: op.name, + }); + } + + return await createBackendAndPoll(); +} + +async function provisionDefaultComputeServiceAccount(projectId: string): Promise { + try { + await iam.createServiceAccount( + projectId, + DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME, + "Default service account used to run builds and deploys for Firebase App Hosting", + "Firebase App Hosting compute service account", + ); + } catch (err: any) { + // 409 Already Exists errors can safely be ignored. + if (err.status !== 409) { + throw err; + } + } + await addServiceAccountToRoles( + projectId, + defaultComputeServiceAccountEmail(projectId), + [ + "roles/firebaseapphosting.computeRunner", + "roles/firebase.sdkAdminServiceAgent", + "roles/developerconnect.readTokenAccessor", + ], + /* skipAccountLookup= */ true, + ); +} + +/** + * Sets the default rollout policy to route 100% of traffic to the latest deploy. + */ +export async function setDefaultTrafficPolicy( + projectId: string, + location: string, + backendId: string, + codebaseBranch: string, +): Promise { + const traffic: DeepOmit = { + rolloutPolicy: { + codebaseBranch: codebaseBranch, + stages: [ + { + progression: "IMMEDIATE", + targetPercent: 100, + }, + ], + }, + }; + const op = await apphosting.updateTraffic(projectId, location, backendId, traffic); + await poller.pollOperation({ + ...apphostingPollerOptions, + pollerName: `updateTraffic-${projectId}-${location}-${backendId}`, + operationResourceName: op.name, + }); +} + +/** + * Deletes the given backend. Polls till completion. + */ +export async function deleteBackendAndPoll( + projectId: string, + location: string, + backendId: string, +): Promise { + const op = await apphosting.deleteBackend(projectId, location, backendId); + await poller.pollOperation({ + ...apphostingPollerOptions, + pollerName: `delete-${projectId}-${location}-${backendId}`, + operationResourceName: op.name, + }); +} + +/** + * Prompts the user for a location. If there's only a single valid location, skips the prompt and returns that location. + */ +export async function promptLocation( + projectId: string, + prompt = "Please select a location:", +): Promise { + const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); + if (allowedLocations.length === 1) { + return allowedLocations[0]; + } + + const location = (await promptOnce({ + name: "location", + type: "list", + default: DEFAULT_LOCATION, + message: prompt, + choices: allowedLocations, + })) as string; + + logSuccess(`Location set to ${location}.\n`); + + return location; +} + +/** + * Fetches a backend from the server in the specified region (location). + */ +export async function getBackendForLocation( + projectId: string, + location: string, + backendId: string, +): Promise { + try { + return await apphosting.getBackend(projectId, location, backendId); + } catch (err: any) { + throw new FirebaseError(`No backend named "${backendId}" found in ${location}.`, { + original: err, + }); + } +} + +/** + * Fetches a backend from the server. If there are multiple backends with that name (ie multi-regional backends), + * prompts the user to disambiguate. If the force option is specified and multiple backends have the same name, + * it throws an error. + */ +export async function getBackendForAmbiguousLocation( + projectId: string, + backendId: string, + locationDisambugationPrompt: string, + force?: boolean, +): Promise { + let { unreachable, backends } = await apphosting.listBackends(projectId, "-"); + if (unreachable && unreachable.length !== 0) { + logWarning( + `The following locations are currently unreachable: ${unreachable}.\n` + + "If your backend is in one of these regions, please try again later.", + ); + } + backends = backends.filter( + (backend) => apphosting.parseBackendName(backend.name).id === backendId, + ); + if (backends.length === 0) { + throw new FirebaseError(`No backend named "${backendId}" found.`); + } + if (backends.length === 1) { + return backends[0]; + } + if (force) { + throw new FirebaseError( + `Multiple backends found with ID ${backendId}. Please specify the region of your target backend.`, + ); + } + + const backendsByLocation = new Map(); + backends.forEach((backend) => + backendsByLocation.set(apphosting.parseBackendName(backend.name).location, backend), + ); + const location = await promptOnce({ + name: "location", + type: "list", + message: locationDisambugationPrompt, + choices: [...backendsByLocation.keys()], + }); + return backendsByLocation.get(location)!; +} diff --git a/src/apphosting/githubConnections.spec.ts b/src/apphosting/githubConnections.spec.ts index 1db448bd7db..ff0988dd695 100644 --- a/src/apphosting/githubConnections.spec.ts +++ b/src/apphosting/githubConnections.spec.ts @@ -784,22 +784,5 @@ describe("githubConnections", () => { }; await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("main"); }); - - it("re-prompts if user enters a branch that does not exist in given repo", async () => { - listAllBranchesStub.returns(new Set(["main", "test1"])); - - promptOnceStub.onFirstCall().returns("not-main"); - promptOnceStub.onSecondCall().returns("test1"); - const testRepoLink = { - name: "test", - cloneUri: "/test", - createTime: "", - updateTime: "", - deleteTime: "", - reconciling: false, - uid: "", - }; - await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("test1"); - }); }); }); diff --git a/src/apphosting/githubConnections.ts b/src/apphosting/githubConnections.ts index 1791bf545da..17b521efc62 100644 --- a/src/apphosting/githubConnections.ts +++ b/src/apphosting/githubConnections.ts @@ -453,22 +453,28 @@ async function promptCloneUri( */ export async function promptGitHubBranch(repoLink: devConnect.GitRepositoryLink): Promise { const branches = await devConnect.listAllBranches(repoLink.name); - while (true) { - const branch = await promptOnce({ - name: "branch", - type: "input", - default: "main", - message: "Pick a branch for continuous deployment", - }); - - if (branches.has(branch)) { - return branch; - } + const branch = await promptOnce({ + type: "autocomplete", + name: "branch", + message: "Pick a branch for continuous deployment", + source: (_: any, input = ""): Promise<(inquirer.DistinctChoice | inquirer.Separator)[]> => { + return new Promise((resolve) => + resolve([ + ...fuzzy.filter(input, Array.from(branches)).map((result) => { + return { + name: result.original, + value: result.original, + }; + }), + ]), + ); + }, + }); - utils.logWarning( - `The branch "${branch}" does not exist on "${extractRepoSlugFromUri(repoLink.cloneUri)}". Please enter a valid branch for this repo.`, - ); - } + utils.logWarning( + `The branch "${branch}" does not exist on "${extractRepoSlugFromUri(repoLink.cloneUri) ?? ""}". Please enter a valid branch for this repo.`, + ); + return branch; } /** diff --git a/src/apphosting/index.ts b/src/apphosting/index.ts index 47bda7e16d7..0ecc6703ff5 100644 --- a/src/apphosting/index.ts +++ b/src/apphosting/index.ts @@ -1,458 +1,3 @@ -import * as clc from "colorette"; -import * as poller from "../operation-poller"; -import * as apphosting from "../gcp/apphosting"; -import * as githubConnections from "./githubConnections"; -import { logBullet, logSuccess, logWarning, sleep } from "../utils"; -import { - apphostingOrigin, - artifactRegistryDomain, - cloudRunApiOrigin, - cloudbuildOrigin, - consoleOrigin, - developerConnectOrigin, - iamOrigin, - secretManagerOrigin, -} from "../api"; -import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting"; -import { addServiceAccountToRoles } from "../gcp/resourceManager"; -import * as iam from "../gcp/iam"; -import { FirebaseError } from "../error"; -import { promptOnce } from "../prompt"; -import { DEFAULT_LOCATION } from "./constants"; -import { ensure } from "../ensureApiEnabled"; -import * as deploymentTool from "../deploymentTool"; -import { DeepOmit } from "../metaprogramming"; -import { webApps } from "./app"; -import { GitRepositoryLink } from "../gcp/devConnect"; -import * as ora from "ora"; -import fetch from "node-fetch"; -import { orchestrateRollout } from "./rollout"; +import { doSetup } from "./backend"; -const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute"; - -const apphostingPollerOptions: Omit = { - apiOrigin: apphostingOrigin(), - apiVersion: API_VERSION, - masterTimeout: 25 * 60 * 1_000, - maxBackoff: 10_000, -}; - -async function tlsReady(url: string): Promise { - // Note, we do not use the helper libraries because they impose additional logic on content type and parsing. - try { - await fetch(url); - return true; - } catch (err) { - // At the time of this writing, the error code is ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE. - // I've chosen to use a regexp in an attempt to be forwards compatible with new versions of - // SSL. - const maybeNodeError = err as { cause: { code: string }; code: string }; - if ( - /HANDSHAKE_FAILURE/.test(maybeNodeError?.cause?.code) || - "EPROTO" === maybeNodeError?.code - ) { - return false; - } - return true; - } -} - -async function awaitTlsReady(url: string): Promise { - let ready; - do { - ready = await tlsReady(url); - if (!ready) { - await sleep(1000 /* ms */); - } - } while (!ready); -} - -/** - * Set up a new App Hosting backend. - */ -export async function doSetup( - projectId: string, - webAppName: string | null, - location: string | null, - serviceAccount: string | null, -): Promise { - await Promise.all([ - ensure(projectId, developerConnectOrigin(), "apphosting", true), - ensure(projectId, cloudbuildOrigin(), "apphosting", true), - ensure(projectId, secretManagerOrigin(), "apphosting", true), - ensure(projectId, cloudRunApiOrigin(), "apphosting", true), - ensure(projectId, artifactRegistryDomain(), "apphosting", true), - ensure(projectId, iamOrigin(), "apphosting", true), - ]); - - // Hack: Because IAM can take ~45 seconds to propagate, we provision the service account as soon as - // possible to reduce the likelihood that the subsequent Cloud Build fails. See b/336862200. - await ensureAppHostingComputeServiceAccount(projectId, serviceAccount); - - const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); - if (location) { - if (!allowedLocations.includes(location)) { - throw new FirebaseError( - `Invalid location ${location}. Valid choices are ${allowedLocations.join(", ")}`, - ); - } - } - - location = - location || (await promptLocation(projectId, "Select a location to host your backend:\n")); - - const gitRepositoryLink: GitRepositoryLink = await githubConnections.linkGitHubRepository( - projectId, - location, - ); - - const rootDir = await promptOnce({ - name: "rootDir", - type: "input", - default: "/", - message: "Specify your app's root directory relative to your repository", - }); - - // TODO: Once tag patterns are implemented, prompt which method the user - // prefers. We could reduce the number of questions asked by letting people - // enter tag:? - const branch = await githubConnections.promptGitHubBranch(gitRepositoryLink); - logSuccess(`Repo linked successfully!\n`); - - logBullet(`${clc.yellow("===")} Set up your backend`); - const backendId = await promptNewBackendId(projectId, location, { - name: "backendId", - type: "input", - default: "my-web-app", - message: "Provide a name for your backend [1-30 characters]", - }); - logSuccess(`Name set to ${backendId}\n`); - - const webApp = await webApps.getOrCreateWebApp(projectId, webAppName, backendId); - if (!webApp) { - logWarning(`Firebase web app not set`); - } - - const createBackendSpinner = ora("Creating your new backend...").start(); - const backend = await createBackend( - projectId, - location, - backendId, - gitRepositoryLink, - serviceAccount, - webApp?.id, - rootDir, - ); - createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`); - - await setDefaultTrafficPolicy(projectId, location, backendId, branch); - - const confirmRollout = await promptOnce({ - type: "confirm", - name: "rollout", - default: true, - message: "Do you want to deploy now?", - }); - - if (!confirmRollout) { - logSuccess(`Your backend will be deployed at:\n\thttps://${backend.uri}`); - return; - } - - const url = `https://${backend.uri}`; - logBullet( - `You may also track this rollout at:\n\t${consoleOrigin()}/project/${projectId}/apphosting`, - ); - // TODO: Previous versions of this command printed the URL before the rollout started so that - // if a user does exit they will know where to go later. Should this be re-added? - const createRolloutSpinner = ora( - "Starting a new rollout; this may take a few minutes. It's safe to exit now.", - ).start(); - await orchestrateRollout({ - projectId, - location, - backendId, - buildInput: { - source: { - codebase: { - branch, - }, - }, - }, - isFirstRollout: true, - }); - createRolloutSpinner.succeed("Rollout complete"); - if (!(await tlsReady(url))) { - const tlsSpinner = ora( - "Finalizing your backend's TLS certificate; this may take a few minutes.", - ).start(); - await awaitTlsReady(url); - tlsSpinner.succeed("TLS certificate ready"); - } - logSuccess(`Your backend is now deployed at:\n\thttps://${backend.uri}`); -} - -/** - * Set up a new App Hosting-type Developer Connect GitRepoLink, optionally with a specific connection ID - */ -export async function createGitRepoLink( - projectId: string, - location: string | null, - connectionId?: string, -): Promise { - await Promise.all([ - ensure(projectId, developerConnectOrigin(), "apphosting", true), - ensure(projectId, secretManagerOrigin(), "apphosting", true), - ensure(projectId, iamOrigin(), "apphosting", true), - ]); - - const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); - if (location) { - if (!allowedLocations.includes(location)) { - throw new FirebaseError( - `Invalid location ${location}. Valid choices are ${allowedLocations.join(", ")}`, - ); - } - } - - location = - location || - (await promptLocation(projectId, "Select a location for your GitRepoLink's connection:\n")); - - await githubConnections.linkGitHubRepository(projectId, location, connectionId); -} - -/** - * Ensures the service account is present the user has permissions to use it by - * checking the `iam.serviceAccounts.actAs` permission. If the permissions - * check fails, this returns an error. If the permission check fails with a - * "not found" error, this attempts to provision the service account. - */ -export async function ensureAppHostingComputeServiceAccount( - projectId: string, - serviceAccount: string | null, -): Promise { - const sa = serviceAccount || defaultComputeServiceAccountEmail(projectId); - const name = `projects/${projectId}/serviceAccounts/${sa}`; - try { - await iam.testResourceIamPermissions( - iamOrigin(), - "v1", - name, - ["iam.serviceAccounts.actAs"], - `projects/${projectId}`, - ); - } catch (err: unknown) { - if (!(err instanceof FirebaseError)) { - throw err; - } - if (err.status === 404) { - await provisionDefaultComputeServiceAccount(projectId); - } else if (err.status === 403) { - throw new FirebaseError( - `Failed to create backend due to missing delegation permissions for ${sa}. Make sure you have the iam.serviceAccounts.actAs permission.`, - { original: err }, - ); - } - } -} - -/** - * Prompts the user for a backend id and verifies that it doesn't match a pre-existing backend. - */ -async function promptNewBackendId( - projectId: string, - location: string, - prompt: any, -): Promise { - while (true) { - const backendId = await promptOnce(prompt); - try { - await apphosting.getBackend(projectId, location, backendId); - } catch (err: any) { - if (err.status === 404) { - return backendId; - } - 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}`); - } -} - -function defaultComputeServiceAccountEmail(projectId: string): string { - return `${DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME}@${projectId}.iam.gserviceaccount.com`; -} - -/** - * Creates (and waits for) a new backend. Optionally may create the default compute service account if - * it was requested and doesn't exist. - */ -export async function createBackend( - projectId: string, - location: string, - backendId: string, - repository: GitRepositoryLink, - serviceAccount: string | null, - webAppId: string | undefined, - rootDir = "/", -): Promise { - const defaultServiceAccount = defaultComputeServiceAccountEmail(projectId); - const backendReqBody: Omit = { - servingLocality: "GLOBAL_ACCESS", - codebase: { - repository: `${repository.name}`, - rootDirectory: rootDir, - }, - labels: deploymentTool.labels(), - serviceAccount: serviceAccount || defaultServiceAccount, - appId: webAppId, - }; - - async function createBackendAndPoll(): Promise { - const op = await apphosting.createBackend(projectId, location, backendReqBody, backendId); - return await poller.pollOperation({ - ...apphostingPollerOptions, - pollerName: `create-${projectId}-${location}-${backendId}`, - operationResourceName: op.name, - }); - } - - return await createBackendAndPoll(); -} - -async function provisionDefaultComputeServiceAccount(projectId: string): Promise { - try { - await iam.createServiceAccount( - projectId, - DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME, - "Default service account used to run builds and deploys for Firebase App Hosting", - "Firebase App Hosting compute service account", - ); - } catch (err: any) { - // 409 Already Exists errors can safely be ignored. - if (err.status !== 409) { - throw err; - } - } - await addServiceAccountToRoles( - projectId, - defaultComputeServiceAccountEmail(projectId), - [ - "roles/firebaseapphosting.computeRunner", - "roles/firebase.sdkAdminServiceAgent", - "roles/developerconnect.readTokenAccessor", - ], - /* skipAccountLookup= */ true, - ); -} - -/** - * Sets the default rollout policy to route 100% of traffic to the latest deploy. - */ -export async function setDefaultTrafficPolicy( - projectId: string, - location: string, - backendId: string, - codebaseBranch: string, -): Promise { - const traffic: DeepOmit = { - rolloutPolicy: { - codebaseBranch: codebaseBranch, - stages: [ - { - progression: "IMMEDIATE", - targetPercent: 100, - }, - ], - }, - }; - const op = await apphosting.updateTraffic(projectId, location, backendId, traffic); - await poller.pollOperation({ - ...apphostingPollerOptions, - pollerName: `updateTraffic-${projectId}-${location}-${backendId}`, - operationResourceName: op.name, - }); -} - -/** - * Deletes the given backend. Polls till completion. - */ -export async function deleteBackendAndPoll( - projectId: string, - location: string, - backendId: string, -): Promise { - const op = await apphosting.deleteBackend(projectId, location, backendId); - await poller.pollOperation({ - ...apphostingPollerOptions, - pollerName: `delete-${projectId}-${location}-${backendId}`, - operationResourceName: op.name, - }); -} - -/** - * Prompts the user for a location. If there's only a single valid location, skips the prompt and returns that location. - */ -export async function promptLocation( - projectId: string, - prompt = "Please select a location:", -): Promise { - const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); - if (allowedLocations.length === 1) { - return allowedLocations[0]; - } - - const location = (await promptOnce({ - name: "location", - type: "list", - default: DEFAULT_LOCATION, - message: prompt, - choices: allowedLocations, - })) as string; - - logSuccess(`Location set to ${location}.\n`); - - return location; -} - -/** - * Fetches a backend from the server. If there are multiple backends with that name (ie multi-regional backends), - * prompts the user to disambiguate. - */ -export async function getBackendForAmbiguousLocation( - projectId: string, - backendId: string, - locationDisambugationPrompt: string, -): Promise { - let { unreachable, backends } = await apphosting.listBackends(projectId, "-"); - if (unreachable && unreachable.length !== 0) { - logWarning( - `The following locations are currently unreachable: ${unreachable}.\n` + - "If your backend is in one of these regions, please try again later.", - ); - } - backends = backends.filter( - (backend) => apphosting.parseBackendName(backend.name).id === backendId, - ); - if (backends.length === 0) { - throw new FirebaseError(`No backend named "${backendId}" found.`); - } - if (backends.length === 1) { - return backends[0]; - } - - const backendsByLocation = new Map(); - backends.forEach((backend) => - backendsByLocation.set(apphosting.parseBackendName(backend.name).location, backend), - ); - const location = await promptOnce({ - name: "location", - type: "list", - message: locationDisambugationPrompt, - choices: [...backendsByLocation.keys()], - }); - return backendsByLocation.get(location)!; -} +export { doSetup as setupBackend }; diff --git a/src/apphosting/rollout.spec.ts b/src/apphosting/rollout.spec.ts index 0794b3d5dcd..8b943d1bfb5 100644 --- a/src/apphosting/rollout.spec.ts +++ b/src/apphosting/rollout.spec.ts @@ -4,6 +4,7 @@ import { createRollout, orchestrateRollout } from "./rollout"; import * as devConnect from "../gcp/devConnect"; import * as githubConnections from "../apphosting/githubConnections"; import * as apphosting from "../gcp/apphosting"; +import * as backend from "./backend"; import { FirebaseError } from "../error"; import * as poller from "../operation-poller"; import * as utils from "../utils"; @@ -21,7 +22,8 @@ describe("apphosting rollouts", () => { const gitRepoLinkId = `${user}-${repo}`; const buildAndRolloutId = "build-2024-10-01-001"; - let getBackendStub: sinon.SinonStub; + let getBackendForLocationStub: sinon.SinonStub; + let getBackendForAmbiguousLocationStub: sinon.SinonStub; let getRepoDetailsFromBackendStub: sinon.SinonStub; let listAllBranchesStub: sinon.SinonStub; let getGitHubBranchStub: sinon.SinonStub; @@ -34,7 +36,12 @@ describe("apphosting rollouts", () => { let sleepStub: sinon.SinonStub; beforeEach(() => { - getBackendStub = sinon.stub(apphosting, "getBackend").throws("unexpected getBackend call"); + getBackendForLocationStub = sinon + .stub(backend, "getBackendForLocation") + .throws("unexpected getBackendForLocation call"); + getBackendForAmbiguousLocationStub = sinon + .stub(backend, "getBackendForAmbiguousLocation") + .throws("unexpected getBackendForAmbiguousLocation call"); getRepoDetailsFromBackendStub = sinon .stub(devConnect, "getRepoDetailsFromBackend") .throws("unexpected getRepoDetailsFromBackend call"); @@ -142,7 +149,8 @@ describe("apphosting rollouts", () => { describe("createRollout", () => { it("should create a new rollout from user-specified branch", async () => { - getBackendStub.resolves(backend); + getBackendForLocationStub.resolves(backend); + getBackendForAmbiguousLocationStub.resolves(backend); getRepoDetailsFromBackendStub.resolves(repoLinkDetails); listAllBranchesStub.resolves(branches); getGitHubBranchStub.resolves(branchInfo); @@ -160,7 +168,8 @@ describe("apphosting rollouts", () => { }); it("should create a new rollout from user-specified commit", async () => { - getBackendStub.resolves(backend); + getBackendForLocationStub.resolves(backend); + getBackendForAmbiguousLocationStub.resolves(backend); getRepoDetailsFromBackendStub.resolves(repoLinkDetails); getGitHubCommitStub.resolves(commitInfo); getNextRolloutIdStub.resolves(buildAndRolloutId); @@ -177,7 +186,8 @@ describe("apphosting rollouts", () => { }); it("should prompt user for a branch if branch or commit ID is not specified", async () => { - getBackendStub.resolves(backend); + getBackendForLocationStub.resolves(backend); + getBackendForAmbiguousLocationStub.resolves(backend); getRepoDetailsFromBackendStub.resolves(repoLinkDetails); promptGitHubBranchStub.resolves(branchId); getGitHubBranchStub.resolves(branchInfo); @@ -187,7 +197,7 @@ describe("apphosting rollouts", () => { pollOperationStub.onFirstCall().resolves(rollout); pollOperationStub.onSecondCall().resolves(build); - await createRollout(backendId, projectId, location, undefined, undefined, true); + await createRollout(backendId, projectId, location, undefined, undefined, false); expect(promptGitHubBranchStub).to.be.called; expect(createBuildStub).to.be.called; @@ -196,7 +206,8 @@ describe("apphosting rollouts", () => { }); it("should throw an error if GitHub branch is not found", async () => { - getBackendStub.resolves(backend); + getBackendForLocationStub.resolves(backend); + getBackendForAmbiguousLocationStub.resolves(backend); getRepoDetailsFromBackendStub.resolves(repoLinkDetails); listAllBranchesStub.resolves(branches); @@ -206,7 +217,8 @@ describe("apphosting rollouts", () => { }); it("should throw an error if GitHub commit is not found", async () => { - getBackendStub.resolves(backend); + getBackendForLocationStub.resolves(backend); + getBackendForAmbiguousLocationStub.resolves(backend); getRepoDetailsFromBackendStub.resolves(repoLinkDetails); getGitHubCommitStub.rejects(new FirebaseError("error", { status: 422 })); @@ -214,6 +226,15 @@ describe("apphosting rollouts", () => { createRollout(backendId, projectId, location, undefined, commitSha, true), ).to.be.rejectedWith(/Unrecognized git commit/); }); + + it("should throw an error if --force flag is specified but --git-branch and --git-commit are missing", async () => { + getBackendForLocationStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + + await expect( + createRollout(backendId, projectId, location, undefined, undefined, true), + ).to.be.rejectedWith(/Failed to create rollout with --force option/); + }); }); describe("orchestrateRollout", () => { diff --git a/src/apphosting/rollout.ts b/src/apphosting/rollout.ts index e1f175adc19..32b2bcb8d4e 100644 --- a/src/apphosting/rollout.ts +++ b/src/apphosting/rollout.ts @@ -10,10 +10,10 @@ import { } from "../apphosting/githubConnections"; import * as poller from "../operation-poller"; -import { confirm } from "../prompt"; import { logBullet, sleep } from "../utils"; import { apphostingOrigin, consoleOrigin } from "../api"; import { DeepOmit } from "../metaprogramming"; +import { getBackendForAmbiguousLocation, getBackendForLocation } from "./backend"; const apphostingPollerOptions: Omit = { apiOrigin: apphostingOrigin(), @@ -36,7 +36,19 @@ export async function createRollout( commit?: string, force?: boolean, ): Promise { - const backend = await apphosting.getBackend(projectId, location, backendId); + let backend: apphosting.Backend; + if (location === "-" || location === "") { + backend = await getBackendForAmbiguousLocation( + projectId, + backendId, + "Please select the location of the backend you'd like to roll out:", + force, + ); + location = apphosting.parseBackendName(backend.name).location; + } else { + backend = await getBackendForLocation(projectId, location, backendId); + } + if (!backend.codebase.repository) { throw new FirebaseError( `Backend ${backendId} is misconfigured due to missing a connected repository. You can delete and recreate your backend using 'firebase apphosting:backends:delete' and 'firebase apphosting:backends:create'.`, @@ -75,6 +87,11 @@ export async function createRollout( throw err; } } else { + if (force) { + throw new FirebaseError( + `Failed to create rollout with --force option because no target branch or commit was specified. Please specify which branch or commit to roll out with the --git-branch or --git-commit flag.`, + ); + } branch = await promptGitHubBranch(repoLink); const branchInfo = await getGitHubBranch(owner, repo, branch, readToken.token); targetCommit = branchInfo.commit; @@ -83,13 +100,6 @@ export async function createRollout( logBullet( `You are about to deploy [${targetCommit.sha.substring(0, 7)}]: ${targetCommit.commit.message}`, ); - const confirmRollout = await confirm({ - force: !!force, - message: "Do you want to continue?", - }); - if (!confirmRollout) { - return; - } logBullet( `You may also track this rollout at:\n\t${consoleOrigin()}/project/${projectId}/apphosting`, ); diff --git a/src/commands/apphosting-backends-create.ts b/src/commands/apphosting-backends-create.ts index d855d76e535..440870fe853 100644 --- a/src/commands/apphosting-backends-create.ts +++ b/src/commands/apphosting-backends-create.ts @@ -2,7 +2,7 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import requireInteractive from "../requireInteractive"; -import { doSetup } from "../apphosting"; +import { doSetup } from "../apphosting/backend"; import { ensureApiEnabled } from "../gcp/apphosting"; import { APPHOSTING_TOS_ID } from "../gcp/firedata"; import { requireTosAcceptance } from "../requireTosAcceptance"; diff --git a/src/commands/apphosting-backends-delete.ts b/src/commands/apphosting-backends-delete.ts index d54e4f6192b..c3d50f94a9e 100644 --- a/src/commands/apphosting-backends-delete.ts +++ b/src/commands/apphosting-backends-delete.ts @@ -6,7 +6,11 @@ import { promptOnce } from "../prompt"; import * as utils from "../utils"; import * as apphosting from "../gcp/apphosting"; import { printBackendsTable } from "./apphosting-backends-list"; -import { deleteBackendAndPoll, getBackendForAmbiguousLocation } from "../apphosting"; +import { + deleteBackendAndPoll, + getBackendForAmbiguousLocation, + getBackendForLocation, +} from "../apphosting/backend"; import * as ora from "ora"; export const command = new Command("apphosting:backends:delete ") @@ -54,17 +58,3 @@ export const command = new Command("apphosting:backends:delete ") throw new FirebaseError(`Failed to delete backend: ${backendId}.`, { original: err }); } }); - -async function getBackendForLocation( - projectId: string, - location: string, - backendId: string, -): Promise { - try { - return await apphosting.getBackend(projectId, location, backendId); - } catch (err: any) { - throw new FirebaseError(`No backend named "${backendId}" found in ${location}.`, { - original: err, - }); - } -} diff --git a/src/commands/apphosting-repos-create.ts b/src/commands/apphosting-repos-create.ts index bb822c7e6d8..e361a35b8bc 100644 --- a/src/commands/apphosting-repos-create.ts +++ b/src/commands/apphosting-repos-create.ts @@ -2,7 +2,7 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import requireInteractive from "../requireInteractive"; -import { createGitRepoLink } from "../apphosting"; +import { createGitRepoLink } from "../apphosting/backend"; import { ensureApiEnabled } from "../gcp/apphosting"; import { APPHOSTING_TOS_ID } from "../gcp/firedata"; import { requireTosAcceptance } from "../requireTosAcceptance"; diff --git a/src/commands/apphosting-rollouts-create.ts b/src/commands/apphosting-rollouts-create.ts index 82962568994..5b827aa0f2b 100644 --- a/src/commands/apphosting-rollouts-create.ts +++ b/src/commands/apphosting-rollouts-create.ts @@ -7,13 +7,12 @@ import { createRollout } from "../apphosting/rollout"; export const command = new Command("apphosting:rollouts:create ") .description("create a rollout using a build for an App Hosting backend") - .option("-l, --location ", "specify the region of the backend", "us-central1") - .option("-i, --id ", "id of the rollout (defaults to autogenerating a random id)", "") + .option("-l, --location ", "specify the region of the backend", "-") .option( - "-gb, --git-branch ", - "repository branch to deploy (mutually exclusive with -gc)", + "-b, --git-branch ", + "repository branch to deploy (mutually exclusive with -g)", ) - .option("-gc, --git-commit ", "git commit to deploy (mutually exclusive with -gb)") + .option("-g, --git-commit ", "git commit to deploy (mutually exclusive with -b)") .withForce("Skip confirmation before creating rollout") .before(apphosting.ensureApiEnabled) .action(async (backendId: string, options: Options) => { diff --git a/src/commands/apphosting-secrets-grantaccess.ts b/src/commands/apphosting-secrets-grantaccess.ts index a99da896620..8d6c7da4134 100644 --- a/src/commands/apphosting-secrets-grantaccess.ts +++ b/src/commands/apphosting-secrets-grantaccess.ts @@ -7,7 +7,7 @@ import * as secretManager from "../gcp/secretManager"; import { requirePermissions } from "../requirePermissions"; import * as apphosting from "../gcp/apphosting"; import * as secrets from "../apphosting/secrets"; -import { getBackendForAmbiguousLocation } from "../apphosting"; +import { getBackendForAmbiguousLocation } from "../apphosting/backend"; export const command = new Command("apphosting:secrets:grantaccess ") .description("grant service accounts permissions to the provided secret") diff --git a/src/gcp/apphosting.ts b/src/gcp/apphosting.ts index 871d46f65d4..aa4b5f961bc 100644 --- a/src/gcp/apphosting.ts +++ b/src/gcp/apphosting.ts @@ -7,7 +7,7 @@ import * as deploymentTool from "../deploymentTool"; import { FirebaseError } from "../error"; import { DeepOmit, RecursiveKeyOf, assertImplements } from "../metaprogramming"; -export const API_VERSION = "v1alpha"; +export const API_VERSION = "v1beta"; export const client = new Client({ urlPrefix: apphostingOrigin(),