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

Refactor createBackend and other utility functions. #6580

Merged
merged 13 commits into from
Dec 7, 2023
2 changes: 2 additions & 0 deletions src/commands/frameworks-backends-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/commands/frameworks-backends-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
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");

Check warning on line 12 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

Check warning on line 12 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Require statement not part of import statement

const COLUMN_LENGTH = 20;
const TABLE_HEAD = [
Expand All @@ -24,6 +26,7 @@
.option("-l, --location <location>", "App Backend location", "")
.option("-s, --backend <backend>", "Backend Id", "")
.withForce()
.before(ensureApiEnabled)
.action(async (options: Options) => {
const projectId = needProjectId(options);
let location = options.location as string;
Expand All @@ -33,7 +36,7 @@
}

if (!location) {
location = await promptOnce({

Check warning on line 39 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
name: "region",
type: "list",
default: DEFAULT_REGION,
Expand All @@ -42,24 +45,24 @@
});
}

const table = new Table({

Check warning on line 48 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

Check warning on line 48 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe construction of an any type value
head: TABLE_HEAD,
style: { head: ["green"] },
});
table.colWidths = COLUMN_LENGTH;

Check warning on line 52 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .colWidths on an `any` value

let backend;
try {
backend = await gcp.getBackend(projectId, location, backendId);
populateTable(backend, table);
} catch (err: any) {

Check warning on line 58 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(`No backends found with given parameters. Command aborted.`, {
original: err,

Check warning on line 60 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
});
}

utils.logWarning("You are about to permanently delete the backend:");
logger.info(table.toString());

Check warning on line 65 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Error`

Check warning on line 65 in src/commands/frameworks-backends-delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .toString on an `any` value

const confirmDeletion = await promptOnce(
{
Expand Down
3 changes: 3 additions & 0 deletions src/commands/frameworks-backends-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -18,6 +20,7 @@ export const command = new Command("backends:get")
.description("Get backend details of a Firebase project")
.option("-l, --location <location>", "App Backend location", "-")
.option("-b, --backend <backend>", "Backend Id", "")
.before(ensureApiEnabled)
.action(async (options: Options) => {
const projectId = needProjectId(options);
const location = options.location as string;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/frameworks-backends-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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;
const TABLE_HEAD = ["Backend Id", "Repository", "Location", "URL", "Created Date", "Updated Date"];
export const command = new Command("backends:list")
.description("List backends of a Firebase project.")
.option("-l, --location <location>", "App Backend location", "-")
.before(ensureApiEnabled)
.action(async (options: Options) => {
const projectId = needProjectId(options);
const location = options.location as string;
Expand Down
7 changes: 7 additions & 0 deletions src/gcp/cloudbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,10 @@ export async function deleteRepository(
const res = await client.delete<Operation>(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`;
}
20 changes: 20 additions & 0 deletions src/gcp/frameworks.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -16,11 +19,20 @@ 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. */
export interface Backend {
name: string;
mode?: string;
codebase: Codebase;
servingLocality: ServingLocality;
labels: Record<string, string>;
createTime: string;
updateTime: string;
Expand Down Expand Up @@ -162,3 +174,11 @@ export async function createBuild(

return res.body;
}

/**
* Ensure that Frameworks API is enabled on the project.
*/
export async function ensureApiEnabled(options: any): Promise<void> {
const projectId = needProjectId(options);
return await ensure(projectId, API_HOST, "frameworks", true);
}
155 changes: 53 additions & 102 deletions src/init/features/frameworks/index.ts
Original file line number Diff line number Diff line change
@@ -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, logWarning } 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";

Expand All @@ -25,50 +24,55 @@ const frameworksPollerOptions: Omit<poller.OperationPollerOptions, "operationRes
export async function doSetup(setup: any, projectId: string): Promise<void> {
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(
{
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Contributor Author

@taeold taeold Dec 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This infinite loop is to repeatedly ask for user input given an invalid name for the backend, e.g.

$ firebase --project danielylee-test-6  backends:create
i  First we need a few details to create your backend.
? Please select a region (info: Your region determines where your backend is located):
 us-central1 (Iowa)
✔  Region set to us-central1.

? Create a name for your backend [1-30 characters] backend
⚠  Backend with id backend already exists in us-central1
? Create a name for your backend [1-30 characters] backend
⚠  Backend with id backend already exists in us-central1
? Create a name for your backend [1-30 characters] foobar
i  === Set up a GitHub connection

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider adding a err.status === 409 then so this is explicit

Copy link
Contributor Author

@taeold taeold Dec 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of relying on 409, the CLI make a GET request to check if backend exists early in the onboarding. This prevents cases where users goes through all the hurdles to create a new GitHub connection only to have their backend failed to be created because of a bad name.

I think there are other flows we could've written to use status 409, but the current implementation is consistent with how the console would work.

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
);

utils.logSuccess(`Region set to ${setup.frameworks.region}.`);

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) {
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\t${backend.name}`);
logSuccess(`Your site is being deployed at:\n\thttps://${backend.uri}`);
logSuccess(
`View the rollout status by running:\n\tfirebase backends:get --backend=${backend.name}`
);
logger.info();
}
}

function toBackend(cloudBuildConnRepo: Repository): Omit<Backend, BackendOutputOnlyFields> {
return {
servingLocality: "GLOBAL_ACCESS",
codebase: {
repository: `${cloudBuildConnRepo.name}`,
rootDirectory: "/",
Expand All @@ -78,77 +82,24 @@ function toBackend(cloudBuildConnRepo: Repository): Omit<Backend, BackendOutputO
}

/**
* Creates backend if it doesn't exist.
* Walkthrough the flow for creating a new backend.
*/
export async function getOrCreateBackend(
projectId: string,
setup: any
): Promise<Backend | undefined> {
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);
logger.info();
await promptOnce(
{
name: "branchName",
type: "input",
default: "main",
message: "Which branch do you want to deploy?",
},
setup.frameworks
);
const backendDetails = toBackend(cloudBuildConnRepo);
logger.info(clc.bold(`\n${clc.white("===")} Creating your backend`));
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<Backend> {
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) {
logger.info("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);
}

/**
Expand Down
Loading
Loading