Skip to content

Commit c031540

Browse files
committed
WIP: Manage GCB connection resources more carefully.
1 parent d52bbff commit c031540

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)