firebase-tools
Version:
Command-Line Interface for Firebase
240 lines (239 loc) • 11.1 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchAllRepositories = exports.listAppHostingConnections = exports.getOrCreateRepository = exports.getOrCreateConnection = exports.createConnection = exports.getOrCreateOauthConnection = exports.linkGitHubRepository = exports.parseConnectionName = void 0;
const clc = require("colorette");
const gcb = require("../gcp/cloudbuild");
const rm = require("../gcp/resourceManager");
const poller = require("../operation-poller");
const utils = require("../utils");
const api_1 = require("../api");
const error_1 = require("../error");
const prompt_1 = require("../prompt");
const getProjectNumber_1 = require("../getProjectNumber");
const fuzzy = require("fuzzy");
const APPHOSTING_CONN_PATTERN = /.+\/apphosting-github-conn-.+$/;
const APPHOSTING_OAUTH_CONN_NAME = "apphosting-github-oauth";
const CONNECTION_NAME_REGEX = /^projects\/(?<projectId>[^\/]+)\/locations\/(?<location>[^\/]+)\/connections\/(?<id>[^\/]+)$/;
function parseConnectionName(name) {
const match = CONNECTION_NAME_REGEX.exec(name);
if (!match || typeof match.groups === undefined) {
return;
}
const { projectId, location, id } = match.groups;
return {
projectId,
location,
id,
};
}
exports.parseConnectionName = parseConnectionName;
const gcbPollerOptions = {
apiOrigin: (0, api_1.cloudbuildOrigin)(),
apiVersion: "v2",
masterTimeout: 25 * 60 * 1000,
maxBackoff: 10000,
};
function extractRepoSlugFromUri(remoteUri) {
const match = /github.com\/(.+).git/.exec(remoteUri);
if (!match) {
return undefined;
}
return match[1];
}
function generateRepositoryId(remoteUri) {
var _a;
return (_a = extractRepoSlugFromUri(remoteUri)) === null || _a === void 0 ? void 0 : _a.replaceAll("/", "-");
}
function generateConnectionId() {
const randomHash = Math.random().toString(36).slice(6);
return `apphosting-github-conn-${randomHash}`;
}
const ADD_CONN_CHOICE = "@ADD_CONN";
async function linkGitHubRepository(projectId, location) {
var _a, _b;
utils.logBullet(clc.bold(`${clc.yellow("===")} Import a GitHub repository`));
const oauthConn = await getOrCreateOauthConnection(projectId, location);
const existingConns = await listAppHostingConnections(projectId);
if (existingConns.length === 0) {
existingConns.push(await createFullyInstalledConnection(projectId, location, generateConnectionId(), oauthConn));
}
let repoRemoteUri;
let connection;
do {
if (repoRemoteUri === ADD_CONN_CHOICE) {
existingConns.push(await createFullyInstalledConnection(projectId, location, generateConnectionId(), oauthConn));
}
const selection = await promptRepositoryUri(projectId, existingConns);
repoRemoteUri = selection.remoteUri;
connection = selection.connection;
} while (repoRemoteUri === ADD_CONN_CHOICE);
const { id: connectionId } = parseConnectionName(connection.name);
await getOrCreateConnection(projectId, location, connectionId, {
authorizerCredential: (_a = connection.githubConfig) === null || _a === void 0 ? void 0 : _a.authorizerCredential,
appInstallationId: (_b = connection.githubConfig) === null || _b === void 0 ? void 0 : _b.appInstallationId,
});
const repo = await getOrCreateRepository(projectId, location, connectionId, repoRemoteUri);
utils.logSuccess(`Successfully linked GitHub repository at remote URI`);
utils.logSuccess(`\t${repoRemoteUri}`);
return repo;
}
exports.linkGitHubRepository = linkGitHubRepository;
async function createFullyInstalledConnection(projectId, location, connectionId, oauthConn) {
var _a;
let conn = await createConnection(projectId, location, connectionId, {
authorizerCredential: (_a = oauthConn.githubConfig) === null || _a === void 0 ? void 0 : _a.authorizerCredential,
});
while (conn.installationState.stage !== "COMPLETE") {
utils.logBullet("Install the Cloud Build GitHub app to enable access to GitHub repositories");
const targetUri = conn.installationState.actionUri;
utils.logBullet(targetUri);
await utils.openInBrowser(targetUri);
await (0, prompt_1.input)("Press Enter once you have installed or configured the Cloud Build GitHub app to access your GitHub repo.");
conn = await gcb.getConnection(projectId, location, connectionId);
}
return conn;
}
async function getOrCreateOauthConnection(projectId, location) {
let conn;
try {
conn = await gcb.getConnection(projectId, location, APPHOSTING_OAUTH_CONN_NAME);
}
catch (err) {
if (err.status === 404) {
await ensureSecretManagerAdminGrant(projectId);
conn = await createConnection(projectId, location, APPHOSTING_OAUTH_CONN_NAME);
}
else {
throw err;
}
}
while (conn.installationState.stage === "PENDING_USER_OAUTH") {
utils.logBullet("You must authorize the Cloud Build GitHub app.");
utils.logBullet("Sign in to GitHub and authorize Cloud Build GitHub app:");
const { url, cleanup } = await utils.openInBrowserPopup(conn.installationState.actionUri, "Authorize the GitHub app");
utils.logBullet(`\t${url}`);
await (0, prompt_1.input)("Press Enter once you have authorized the app");
cleanup();
const { projectId, location, id } = parseConnectionName(conn.name);
conn = await gcb.getConnection(projectId, location, id);
}
return conn;
}
exports.getOrCreateOauthConnection = getOrCreateOauthConnection;
async function promptRepositoryUri(projectId, connections) {
const { repos, remoteUriToConnection } = await fetchAllRepositories(projectId, connections);
const remoteUri = await (0, prompt_1.search)({
message: "Which GitHub repo do you want to deploy?",
source: (input) => [
new prompt_1.Separator(),
{
name: "Missing a repo? Select this option to configure your GitHub connection settings",
value: ADD_CONN_CHOICE,
},
new prompt_1.Separator(),
...fuzzy
.filter(input !== null && input !== void 0 ? input : "", repos, {
extract: (repo) => extractRepoSlugFromUri(repo.remoteUri) || "",
})
.map((result) => {
return {
name: extractRepoSlugFromUri(result.original.remoteUri) || "",
value: result.original.remoteUri,
};
}),
],
});
return { remoteUri, connection: remoteUriToConnection[remoteUri] };
}
async function ensureSecretManagerAdminGrant(projectId) {
const projectNumber = await (0, getProjectNumber_1.getProjectNumber)({ projectId });
const cbsaEmail = gcb.getDefaultServiceAgent(projectNumber);
const alreadyGranted = await rm.serviceAccountHasRoles(projectId, cbsaEmail, ["roles/secretmanager.admin"], true);
if (alreadyGranted) {
return;
}
utils.logBullet("To create a new GitHub connection, Secret Manager Admin role (roles/secretmanager.admin) is required on the Cloud Build Service Agent.");
const grant = await (0, prompt_1.confirm)("Grant the required role to the Cloud Build Service Agent?");
if (!grant) {
utils.logBullet("You, or your project administrator, should run the following command to grant the required role:\n\n" +
"You, or your project adminstrator, can run the following command to grant the required role manually:\n\n" +
`\tgcloud projects add-iam-policy-binding ${projectId} \\\n` +
`\t --member="serviceAccount:${cbsaEmail} \\\n` +
`\t --role="roles/secretmanager.admin\n`);
throw new error_1.FirebaseError("Insufficient IAM permissions to create a new connection to GitHub");
}
await rm.addServiceAccountToRoles(projectId, cbsaEmail, ["roles/secretmanager.admin"], true);
utils.logSuccess("Successfully granted the required role to the Cloud Build Service Agent!");
}
async function createConnection(projectId, location, connectionId, githubConfig) {
const op = await gcb.createConnection(projectId, location, connectionId, githubConfig);
const conn = await poller.pollOperation(Object.assign(Object.assign({}, gcbPollerOptions), { pollerName: `create-${location}-${connectionId}`, operationResourceName: op.name }));
return conn;
}
exports.createConnection = createConnection;
async function getOrCreateConnection(projectId, location, connectionId, githubConfig) {
let conn;
try {
conn = await gcb.getConnection(projectId, location, connectionId);
}
catch (err) {
if (err.status === 404) {
conn = await createConnection(projectId, location, connectionId, githubConfig);
}
else {
throw err;
}
}
return conn;
}
exports.getOrCreateConnection = getOrCreateConnection;
async function getOrCreateRepository(projectId, location, connectionId, remoteUri) {
const repositoryId = generateRepositoryId(remoteUri);
if (!repositoryId) {
throw new error_1.FirebaseError(`Failed to generate repositoryId for URI "${remoteUri}".`);
}
let repo;
try {
repo = await gcb.getRepository(projectId, location, connectionId, repositoryId);
}
catch (err) {
if (err.status === 404) {
const op = await gcb.createRepository(projectId, location, connectionId, repositoryId, remoteUri);
repo = await poller.pollOperation(Object.assign(Object.assign({}, gcbPollerOptions), { pollerName: `create-${location}-${connectionId}-${repositoryId}`, operationResourceName: op.name }));
}
else {
throw err;
}
}
return repo;
}
exports.getOrCreateRepository = getOrCreateRepository;
async function listAppHostingConnections(projectId) {
const conns = await gcb.listConnections(projectId, "-");
return conns.filter((conn) => APPHOSTING_CONN_PATTERN.test(conn.name) &&
conn.installationState.stage === "COMPLETE" &&
!conn.disabled);
}
exports.listAppHostingConnections = listAppHostingConnections;
async function fetchAllRepositories(projectId, connections) {
const repos = [];
const remoteUriToConnection = {};
const getNextPage = async (conn, pageToken = "") => {
const { location, id } = parseConnectionName(conn.name);
const resp = await gcb.fetchLinkableRepositories(projectId, location, id, pageToken);
if (resp.repositories && resp.repositories.length > 0) {
for (const repo of resp.repositories) {
repos.push(repo);
remoteUriToConnection[repo.remoteUri] = conn;
}
}
if (resp.nextPageToken) {
await getNextPage(conn, resp.nextPageToken);
}
};
for (const conn of connections) {
await getNextPage(conn);
}
return { repos, remoteUriToConnection };
}
exports.fetchAllRepositories = fetchAllRepositories;
;