Skip to content

Commit 39b23e0

Browse files
authored
Manage GCB connection resources more efficiently (#6536)
During onboarding, the CLI will create only the necessary GCB connection and repositories. Unlike previous implemention where a connection was created per project/region, the new algorithm manages a single "oauth" connection resource and the same project/region connection. The new implementation also biases heavily towards reusing the GCB connection when any valid connection already exists in the project. See internal doc for more detail. The specific format for naming the GCB connection resource is one shared with the Firebase console so that all Firebase client manages the GCB resource in the same way.
1 parent 1f4f6f4 commit 39b23e0

File tree

6 files changed

+268
-65
lines changed

6 files changed

+268
-65
lines changed

src/gcp/cloudbuild.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,15 @@ interface LinkableRepositories {
8585
export async function createConnection(
8686
projectId: string,
8787
location: string,
88-
connectionId: string
88+
connectionId: string,
89+
githubConfig: GitHubConfig = {}
8990
): Promise<Operation> {
9091
const res = await client.post<
9192
Omit<Omit<Connection, "name">, ConnectionOutputOnlyFields>,
9293
Operation
9394
>(
9495
`projects/${projectId}/locations/${location}/connections`,
95-
{ githubConfig: {} },
96+
{ githubConfig },
9697
{ queryParams: { connectionId } }
9798
);
9899
return res.body;

src/init/features/frameworks/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import * as clc from "colorette";
22
import * as utils from "../../../utils";
3-
import { logger } from "../../../logger";
4-
import { promptOnce } from "../../../prompt";
5-
import { DEFAULT_REGION, ALLOWED_REGIONS } from "./constants";
63
import * as repo from "./repo";
7-
import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks";
8-
import { Repository } from "../../../gcp/cloudbuild";
94
import * as poller from "../../../operation-poller";
10-
import { frameworksOrigin } from "../../../api";
115
import * as gcp from "../../../gcp/frameworks";
6+
import { frameworksOrigin } from "../../../api";
7+
import { Backend, BackendOutputOnlyFields } from "../../../gcp/frameworks";
8+
import { Repository } from "../../../gcp/cloudbuild";
129
import { API_VERSION } from "../../../gcp/frameworks";
1310
import { FirebaseError } from "../../../error";
11+
import { logger } from "../../../logger";
12+
import { promptOnce } from "../../../prompt";
13+
import { DEFAULT_REGION, ALLOWED_REGIONS } from "./constants";
1414

1515
const frameworksPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
1616
apiOrigin: frameworksOrigin,
@@ -53,6 +53,7 @@ export async function doSetup(setup: any, projectId: string): Promise<void> {
5353
utils.logSuccess(`Region set to ${setup.frameworks.region}.`);
5454

5555
const backend: Backend | undefined = await getOrCreateBackend(projectId, setup);
56+
5657
if (backend) {
5758
logger.info();
5859
utils.logSuccess(`Successfully created backend:\n ${backend.name}`);

src/init/features/frameworks/repo.ts

Lines changed: 135 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
1-
import { cloudbuildOrigin } from "../../../api";
2-
import { FirebaseError } from "../../../error";
1+
import * as clc from "colorette";
2+
33
import * as gcb from "../../../gcp/cloudbuild";
4-
import { logger } from "../../../logger";
54
import * as poller from "../../../operation-poller";
65
import * as utils from "../../../utils";
6+
import { cloudbuildOrigin } from "../../../api";
7+
import { FirebaseError } from "../../../error";
8+
import { logger } from "../../../logger";
79
import { promptOnce } from "../../../prompt";
8-
import * as clc from "colorette";
10+
11+
export interface ConnectionNameParts {
12+
projectId: string;
13+
location: string;
14+
id: string;
15+
}
916

1017
const FRAMEWORKS_CONN_PATTERN = /.+\/frameworks-github-conn-.+$/;
18+
const FRAMEWORKS_OAUTH_CONN_NAME = "frameworks-github-oauth";
19+
const CONNECTION_NAME_REGEX =
20+
/^projects\/(?<projectId>[^\/]+)\/locations\/(?<location>[^\/]+)\/connections\/(?<id>[^\/]+)$/;
21+
22+
/**
23+
* Exported for unit testing.
24+
*/
25+
export function parseConnectionName(name: string): ConnectionNameParts | undefined {
26+
const match = name.match(CONNECTION_NAME_REGEX);
27+
28+
if (!match || typeof match.groups === undefined) {
29+
return;
30+
}
31+
const { projectId, location, id } = match.groups as unknown as ConnectionNameParts;
32+
return {
33+
projectId,
34+
location,
35+
id,
36+
};
37+
}
1138

1239
const gcbPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
1340
apiOrigin: cloudbuildOrigin,
@@ -20,7 +47,7 @@ const gcbPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceNa
2047
* Example usage:
2148
* extractRepoSlugFromURI("https://github.com/user/repo.git") => "user/repo"
2249
*/
23-
function extractRepoSlugFromURI(remoteUri: string): string | undefined {
50+
function extractRepoSlugFromUri(remoteUri: string): string | undefined {
2451
const match = /github.com\/(.+).git/.exec(remoteUri);
2552
if (!match) {
2653
return undefined;
@@ -30,21 +57,18 @@ function extractRepoSlugFromURI(remoteUri: string): string | undefined {
3057

3158
/**
3259
* Generates a repository ID.
33-
* The relation is 1:* between Cloud Build Connection and Github Repositories.
60+
* The relation is 1:* between Cloud Build Connection and GitHub Repositories.
3461
*/
3562
function generateRepositoryId(remoteUri: string): string | undefined {
36-
return extractRepoSlugFromURI(remoteUri)?.replaceAll("/", "-");
63+
return extractRepoSlugFromUri(remoteUri)?.replaceAll("/", "-");
3764
}
3865

3966
/**
40-
* The 'frameworks-' is prefixed, to seperate the Cloud Build connections created from
41-
* Frameworks platforms with rest of manually created Cloud Build connections.
42-
*
43-
* The reason suffix 'location' is because of
44-
* 1:1 relation between location and Cloud Build connection.
67+
* Generates connection id that matches specific id format recognized by all Firebase clients.
4568
*/
46-
function generateConnectionId(location: string): string {
47-
return `frameworks-${location}`;
69+
function generateConnectionId(): string {
70+
const randomHash = Math.random().toString(36).slice(6);
71+
return `frameworks-github-conn-${randomHash}`;
4872
}
4973

5074
/**
@@ -54,70 +78,128 @@ export async function linkGitHubRepository(
5478
projectId: string,
5579
location: string
5680
): Promise<gcb.Repository> {
57-
logger.info(clc.bold(`\n${clc.white("===")} Connect a github repository`));
58-
const connectionId = generateConnectionId(location);
59-
await getOrCreateConnection(projectId, location, connectionId);
81+
logger.info(clc.bold(`\n${clc.yellow("===")} Connect a GitHub repository`));
82+
const existingConns = await listFrameworksConnections(projectId);
83+
if (existingConns.length < 1) {
84+
let oauthConn = await getOrCreateConnection(projectId, location, FRAMEWORKS_OAUTH_CONN_NAME);
85+
while (oauthConn.installationState.stage === "PENDING_USER_OAUTH") {
86+
oauthConn = await promptConnectionAuth(oauthConn);
87+
}
88+
// Create or get connection resource that contains reference to the GitHub oauth token.
89+
// Oauth token associated with this connection should be used to create other connection resources.
90+
const connectionId = generateConnectionId();
91+
const conn = await createConnection(projectId, location, connectionId, {
92+
authorizerCredential: oauthConn.githubConfig?.authorizerCredential,
93+
});
94+
let refreshedConn = conn;
95+
while (refreshedConn.installationState.stage !== "COMPLETE") {
96+
refreshedConn = await promptAppInstall(conn);
97+
}
98+
existingConns.push(refreshedConn);
99+
}
60100

61-
let remoteUri = await promptRepositoryURI(projectId, location, connectionId);
101+
let { remoteUri, connection } = await promptRepositoryUri(projectId, location, existingConns);
62102
while (remoteUri === "") {
63103
await utils.openInBrowser("https://github.com/apps/google-cloud-build/installations/new");
64104
await promptOnce({
65105
type: "input",
66106
message:
67107
"Press ENTER once you have finished configuring your installation's access settings.",
68108
});
69-
remoteUri = await promptRepositoryURI(projectId, location, connectionId);
109+
const selection = await promptRepositoryUri(projectId, location, existingConns);
110+
remoteUri = selection.remoteUri;
111+
connection = selection.connection;
70112
}
71113

114+
// Ensure that the selected connection exists in the same region as the backend
115+
const { id: connectionId } = parseConnectionName(connection.name)!;
116+
await getOrCreateConnection(projectId, location, connectionId, {
117+
authorizerCredential: connection.githubConfig?.authorizerCredential,
118+
appInstallationId: connection.githubConfig?.appInstallationId,
119+
});
72120
const repo = await getOrCreateRepository(projectId, location, connectionId, remoteUri);
73121
logger.info();
74122
utils.logSuccess(`Successfully linked GitHub repository at remote URI:\n ${remoteUri}`);
75123
return repo;
76124
}
77125

78-
async function promptRepositoryURI(
126+
async function promptRepositoryUri(
79127
projectId: string,
80128
location: string,
81-
connectionId: string
82-
): Promise<string> {
83-
const resp = await gcb.fetchLinkableRepositories(projectId, location, connectionId);
84-
if (!resp.repositories || resp.repositories.length === 0) {
85-
throw new FirebaseError(
86-
"The GitHub App does not have access to any repositories. Please configure " +
87-
"your app installation permissions at https://github.com/settings/installations."
88-
);
129+
connections: gcb.Connection[]
130+
): Promise<{ remoteUri: string; connection: gcb.Connection }> {
131+
const remoteUriToConnection: Record<string, gcb.Connection> = {};
132+
for (const conn of connections) {
133+
const { id } = parseConnectionName(conn.name)!;
134+
const resp = await gcb.fetchLinkableRepositories(projectId, location, id);
135+
if (resp.repositories && resp.repositories.length > 1) {
136+
for (const repo of resp.repositories) {
137+
remoteUriToConnection[repo.remoteUri] = conn;
138+
}
139+
}
89140
}
90-
const choices = resp.repositories.map((repo: gcb.Repository) => ({
91-
name: extractRepoSlugFromURI(repo.remoteUri) || repo.remoteUri,
92-
value: repo.remoteUri,
141+
142+
const choices = Object.keys(remoteUriToConnection).map((remoteUri: string) => ({
143+
name: extractRepoSlugFromUri(remoteUri) || remoteUri,
144+
value: remoteUri,
93145
}));
94146
choices.push({
95147
name: "Missing a repo? Select this option to configure your installation's access settings",
96148
value: "",
97149
});
98150

99-
return await promptOnce({
151+
const remoteUri = await promptOnce({
100152
type: "list",
101153
message: "Which of the following repositories would you like to deploy?",
102154
choices,
103155
});
156+
return { remoteUri, connection: remoteUriToConnection[remoteUri] };
104157
}
105158

106-
async function promptConnectionAuth(
107-
conn: gcb.Connection,
108-
projectId: string,
109-
location: string,
110-
connectionId: string
111-
): Promise<gcb.Connection> {
112-
logger.info("First, log in to GitHub, install and authorize Cloud Build app:");
113-
logger.info(conn.installationState.actionUri);
114-
await utils.openInBrowser(conn.installationState.actionUri);
159+
async function promptConnectionAuth(conn: gcb.Connection): Promise<gcb.Connection> {
160+
logger.info("You must authorize the Cloud Build GitHub app.");
161+
logger.info();
162+
logger.info("First, sign in to GitHub and authorize Cloud Build GitHub app:");
163+
const cleanup = await utils.openInBrowserPopup(
164+
conn.installationState.actionUri,
165+
"Authorize the GitHub app"
166+
);
167+
await promptOnce({
168+
type: "input",
169+
message: "Press Enter once you have authorized the app",
170+
});
171+
cleanup();
172+
const { projectId, location, id } = parseConnectionName(conn.name)!;
173+
return await gcb.getConnection(projectId, location, id);
174+
}
175+
176+
async function promptAppInstall(conn: gcb.Connection): Promise<gcb.Connection> {
177+
logger.info("Now, install the Cloud Build GitHub app:");
178+
const targetUri = conn.installationState.actionUri.replace("install_v2", "direct_install_v2");
179+
logger.info(targetUri);
180+
await utils.openInBrowser(targetUri);
115181
await promptOnce({
116182
type: "input",
117183
message:
118-
"Press Enter once you have authorized the app (Cloud Build) to access your GitHub repo.",
184+
"Press Enter once you have installed or configured the Cloud Build GitHub app to access your GitHub repo.",
185+
});
186+
const { projectId, location, id } = parseConnectionName(conn.name)!;
187+
return await gcb.getConnection(projectId, location, id);
188+
}
189+
190+
export async function createConnection(
191+
projectId: string,
192+
location: string,
193+
connectionId: string,
194+
githubConfig?: gcb.GitHubConfig
195+
): Promise<gcb.Connection> {
196+
const op = await gcb.createConnection(projectId, location, connectionId, githubConfig);
197+
const conn = await poller.pollOperation<gcb.Connection>({
198+
...gcbPollerOptions,
199+
pollerName: `create-${location}-${connectionId}`,
200+
operationResourceName: op.name,
119201
});
120-
return await gcb.getConnection(projectId, location, connectionId);
202+
return conn;
121203
}
122204

123205
/**
@@ -126,27 +208,19 @@ async function promptConnectionAuth(
126208
export async function getOrCreateConnection(
127209
projectId: string,
128210
location: string,
129-
connectionId: string
211+
connectionId: string,
212+
githubConfig?: gcb.GitHubConfig
130213
): Promise<gcb.Connection> {
131214
let conn: gcb.Connection;
132215
try {
133216
conn = await gcb.getConnection(projectId, location, connectionId);
134217
} catch (err: unknown) {
135218
if ((err as FirebaseError).status === 404) {
136-
const op = await gcb.createConnection(projectId, location, connectionId);
137-
conn = await poller.pollOperation<gcb.Connection>({
138-
...gcbPollerOptions,
139-
pollerName: `create-${location}-${connectionId}`,
140-
operationResourceName: op.name,
141-
});
219+
conn = await createConnection(projectId, location, connectionId, githubConfig);
142220
} else {
143221
throw err;
144222
}
145223
}
146-
147-
while (conn.installationState.stage !== "COMPLETE") {
148-
conn = await promptConnectionAuth(conn, projectId, location, connectionId);
149-
}
150224
return conn;
151225
}
152226

@@ -166,7 +240,7 @@ export async function getOrCreateRepository(
166240
let repo: gcb.Repository;
167241
try {
168242
repo = await gcb.getRepository(projectId, location, connectionId, repositoryId);
169-
const repoSlug = extractRepoSlugFromURI(repo.remoteUri);
243+
const repoSlug = extractRepoSlugFromUri(repo.remoteUri);
170244
if (repoSlug) {
171245
throw new FirebaseError(`${repoSlug} has already been linked.`);
172246
}
@@ -193,5 +267,10 @@ export async function getOrCreateRepository(
193267

194268
export async function listFrameworksConnections(projectId: string) {
195269
const conns = await gcb.listConnections(projectId, "-");
196-
return conns.filter((conn) => FRAMEWORKS_CONN_PATTERN.test(conn.name));
270+
return conns.filter(
271+
(conn) =>
272+
FRAMEWORKS_CONN_PATTERN.test(conn.name) &&
273+
conn.installationState.stage === "COMPLETE" &&
274+
!conn.disabled
275+
);
197276
}

src/test/init/frameworks/repo.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,29 @@ describe("composer", () => {
136136
});
137137
});
138138

139+
describe("parseConnectionName", () => {
140+
it("should parse valid connection name", () => {
141+
const str = "projects/my-project/locations/us-central1/connections/my-conn";
142+
143+
const expected = {
144+
projectId: "my-project",
145+
location: "us-central1",
146+
id: "my-conn",
147+
};
148+
149+
expect(repo.parseConnectionName(str)).to.deep.equal(expected);
150+
});
151+
152+
it("should return undefined for invalid", () => {
153+
expect(
154+
repo.parseConnectionName(
155+
"projects/my-project/locations/us-central1/connections/my-conn/repositories/repo"
156+
)
157+
).to.be.undefined;
158+
expect(repo.parseConnectionName("foobar")).to.be.undefined;
159+
});
160+
});
161+
139162
describe("listFrameworksConnections", () => {
140163
const sandbox: sinon.SinonSandbox = sinon.createSandbox();
141164
let listConnectionsStub: sinon.SinonStub;

0 commit comments

Comments
 (0)