Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App Hosting: Default to on-boarding user with Developer Connect #6963

Closed
wants to merge 7 commits into from
154 changes: 154 additions & 0 deletions src/apphosting/app.ts
@@ -0,0 +1,154 @@
import * as fuzzy from "fuzzy";
import * as inquirer from "inquirer";
import { AppPlatform, WebAppMetadata, createWebApp, listFirebaseApps } from "../management/apps";
import { promptOnce } from "../prompt";
import { FirebaseError } from "../error";

const CREATE_NEW_FIREBASE_WEB_APP = "CREATE_NEW_WEB_APP";
const CONTINUE_WITHOUT_SELECTING_FIREBASE_WEB_APP = "CONTINUE_WITHOUT_SELECTING_FIREBASE_WEB_APP";

// Note: exported like this for testing (to stub a function in the same file).
const webApps = {
CREATE_NEW_FIREBASE_WEB_APP,
CONTINUE_WITHOUT_SELECTING_FIREBASE_WEB_APP,
getOrCreateWebApp,
promptFirebaseWebApp,
};

type FirebaseWebApp = { name: string; id: string };

/**
* If firebaseWebAppName is provided and a matching web app exists, it is
* returned. If firebaseWebAppName is not provided then the user is prompted to
* choose from one of their existing web apps or to create a new one or to skip
* without selecting a web app. If user chooses to create a new web app,
* a new web app with the given backendId is created. If user chooses to skip
* without selecting a web app nothing is returned.
* @param projectId user's projectId
* @param firebaseWebAppName (optional) name of an existing Firebase web app
* @param backendId name of the app hosting backend
* @return app name and app id
*/
async function getOrCreateWebApp(
projectId: string,
firebaseWebAppName: string | null,
backendId: string,
): Promise<FirebaseWebApp | undefined> {
const webAppsInProject = await listFirebaseApps(projectId, AppPlatform.WEB);

if (webAppsInProject.length === 0) {
// create a web app using backend id
const { name, appId } = await createFirebaseWebApp(projectId, { displayName: backendId });
return { name, id: appId };
}

const existingUserProjectWebApps = new Map(
webAppsInProject.map((obj) => [
// displayName can be null, use app id instead if so. Example - displayName: "mathusan-web-app", appId: "1:461896338144:web:426291191cccce65fede85"
obj.displayName ?? obj.appId,
obj.appId,
]),
);

if (firebaseWebAppName) {
if (existingUserProjectWebApps.get(firebaseWebAppName) === undefined) {
throw new FirebaseError(
`The web app '${firebaseWebAppName}' does not exist in project ${projectId}`,
);
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return { name: firebaseWebAppName, id: existingUserProjectWebApps.get(firebaseWebAppName)! };
}

return await webApps.promptFirebaseWebApp(projectId, backendId, existingUserProjectWebApps);
}

/**
* Prompts the user for the web app that they would like to associate their backend with
* @param projectId user's projectId
* @param backendId user's backendId
* @param existingUserProjectWebApps a map of a user's firebase web apps to their ids
* @return the name and ID of a web app
*/
async function promptFirebaseWebApp(
projectId: string,
backendId: string,
existingUserProjectWebApps: Map<string, string>,
): Promise<FirebaseWebApp | undefined> {
const existingWebAppKeys = Array.from(existingUserProjectWebApps.keys());

const firebaseWebAppName = await promptOnce({
type: "autocomplete",
name: "app",
message:
"Which of the following Firebase web apps would you like to associate your backend with?",
source: (_: any, input = ""): Promise<(inquirer.DistinctChoice | inquirer.Separator)[]> => {

Check warning on line 86 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
return new Promise((resolve) =>
resolve([
new inquirer.Separator(),
{
name: "Create a new Firebase web app.",
value: CREATE_NEW_FIREBASE_WEB_APP,
},
{
name: "Continue without associating App Hosting backend to a web app.",
value: CONTINUE_WITHOUT_SELECTING_FIREBASE_WEB_APP,
},
new inquirer.Separator(),
...fuzzy.filter(input, existingWebAppKeys).map((result) => {
return result.original;
}),
]),
);
},
});

if (firebaseWebAppName === CREATE_NEW_FIREBASE_WEB_APP) {
const newFirebaseWebApp = await createFirebaseWebApp(projectId, { displayName: backendId });
return { name: newFirebaseWebApp.displayName, id: newFirebaseWebApp.appId };
} else if (firebaseWebAppName === CONTINUE_WITHOUT_SELECTING_FIREBASE_WEB_APP) {
return;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return { name: firebaseWebAppName, id: existingUserProjectWebApps.get(firebaseWebAppName)! };
}

/**
* A wrapper for createWebApp to catch and log quota errors
*/
async function createFirebaseWebApp(
projectId: string,
options: { displayName?: string },
): Promise<WebAppMetadata> {
try {
return await createWebApp(projectId, options);
} catch (e) {
if (isQuotaError(e)) {
throw new FirebaseError(
"Unable to create a new web app, the project has reached the quota for Firebase apps. Navigate to your Firebase console to manage or delete a Firebase app to continue. ",
{ original: e instanceof Error ? e : undefined },
);
}

throw new FirebaseError("Unable to create a Firebase web app", {
original: e instanceof Error ? e : undefined,
});
}
}

/**
* TODO: Make this generic to be re-used in other parts of the CLI
*/
function isQuotaError(error: any): boolean {

Check warning on line 144 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
const original = error.original as any;

Check warning on line 145 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

Check warning on line 145 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

This assertion is unnecessary since it does not change the type of the expression

Check warning on line 145 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .original on an `any` value

Check warning on line 145 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
const code: number | undefined =

Check warning on line 146 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
original?.status ||

Check warning on line 147 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .status on an `any` value
original?.context?.response?.statusCode ||

Check warning on line 148 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .context on an `any` value
original?.context?.body?.error?.code;

Check warning on line 149 in src/apphosting/app.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .context on an `any` value

return code === 429;
}

export = webApps;
25 changes: 16 additions & 9 deletions src/apphosting/index.ts
@@ -1,6 +1,5 @@
import * as clc from "colorette";

import * as repo from "./repo";
import * as poller from "../operation-poller";
import * as apphosting from "../gcp/apphosting";
import * as githubConnections from "./githubConnections";
Expand All @@ -23,8 +22,8 @@ import { DEFAULT_REGION } from "./constants";
import { ensure } from "../ensureApiEnabled";
import * as deploymentTool from "../deploymentTool";
import { DeepOmit } from "../metaprogramming";
import * as apps from "./app";
import { GitRepositoryLink } from "../gcp/devConnect";

const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute";

const apphostingPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
Expand All @@ -39,20 +38,19 @@ const apphostingPollerOptions: Omit<poller.OperationPollerOptions, "operationRes
*/
export async function doSetup(
projectId: string,
webAppName: string | null,
location: string | null,
serviceAccount: string | null,
withDevConnect: boolean,
): Promise<void> {
await Promise.all([
...(withDevConnect ? [ensure(projectId, developerConnectOrigin(), "apphosting", true)] : []),
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),
]);

const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId);

if (location) {
if (!allowedLocations.includes(location)) {
throw new FirebaseError(
Expand All @@ -61,7 +59,7 @@ export async function doSetup(
}
}

logBullet("First we need a few details to create your backend.");
logBullet("First we need a few details to create your backend.\n");

location =
location ||
Expand All @@ -84,16 +82,23 @@ export async function doSetup(
message: "Create a name for your backend [1-30 characters]",
});

const gitRepositoryConnection: Repository | GitRepositoryLink = withDevConnect
? await githubConnections.linkGitHubRepository(projectId, location)
: await repo.linkGitHubRepository(projectId, location);
const webApp = await apps.getOrCreateWebApp(projectId, webAppName, backendId);
if (webApp) {
logSuccess(`Firebase web app set to ${webApp.name}.\n`);
} else {
logWarning(`Firebase web app not set`);
}

const gitRepositoryConnection: Repository | GitRepositoryLink =
await githubConnections.linkGitHubRepository(projectId, location);

const backend = await createBackend(
projectId,
location,
backendId,
gitRepositoryConnection,
serviceAccount,
webApp?.id,
);

// TODO: Once tag patterns are implemented, prompt which method the user
Expand Down Expand Up @@ -172,6 +177,7 @@ export async function createBackend(
backendId: string,
repository: Repository | GitRepositoryLink,
serviceAccount: string | null,
webAppId: string | undefined,
): Promise<Backend> {
const defaultServiceAccount = defaultComputeServiceAccountEmail(projectId);
const backendReqBody: Omit<Backend, BackendOutputOnlyFields> = {
Expand All @@ -182,6 +188,7 @@ export async function createBackend(
},
labels: deploymentTool.labels(),
serviceAccount: serviceAccount || defaultServiceAccount,
appId: webAppId,
};

// TODO: remove computeServiceAccount when the backend supports the field.
Expand Down