diff --git a/CHANGELOG.md b/CHANGELOG.md index 7592ec2d3a6..3d7fdea108b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Add apphosting:rollouts:create CLI command. (#7687) - Updated emulator UI to version 1.14.0, which adds support for SDK defined extensions. - Added emulator support for SDK defined extensions. - Fixed various trigger handling issues in the Functions emualtor, including an issue where Eventarc functions would not be emulated correctly after a reload. diff --git a/src/apphosting/githubConnections.ts b/src/apphosting/githubConnections.ts index 18b3dc574da..b895ee0132c 100644 --- a/src/apphosting/githubConnections.ts +++ b/src/apphosting/githubConnections.ts @@ -7,10 +7,30 @@ import * as utils from "../utils"; import { FirebaseError } from "../error"; import { promptOnce } from "../prompt"; import { getProjectNumber } from "../getProjectNumber"; -import { apphostingGitHubAppInstallationURL, developerConnectOrigin } from "../api"; +import { + apphostingGitHubAppInstallationURL, + developerConnectOrigin, + githubApiOrigin, +} from "../api"; import * as fuzzy from "fuzzy"; import * as inquirer from "inquirer"; +import { Client } from "../apiv2"; + +const githubApiClient = new Client({ urlPrefix: githubApiOrigin(), auth: false }); + +export interface GitHubBranchInfo { + commit: GitHubCommitInfo; +} + +export interface GitHubCommitInfo { + sha: string; + commit: GitHubCommit; +} + +interface GitHubCommit { + message: string; +} interface ConnectionNameParts { projectId: string; @@ -212,6 +232,9 @@ async function manageInstallation(connection: devConnect.Connection): Promise { const branches = await devConnect.listAllBranches(repoLink.name); while (true) { const branch = await promptOnce({ @@ -579,3 +605,41 @@ export async function fetchRepositoryCloneUris( return cloneUris; } + +/** + * Gets the details of a GitHub branch from the GitHub REST API. + */ +export async function getGitHubBranch( + owner: string, + repo: string, + branch: string, + readToken: string, +): Promise { + const headers = { Authorization: `Bearer ${readToken}`, "User-Agent": "Firebase CLI" }; + const { body } = await githubApiClient.get( + `/repos/${owner}/${repo}/branches/${branch}`, + { + headers, + }, + ); + return body; +} + +/** + * Gets the details of a GitHub commit from the GitHub REST API. + */ +export async function getGitHubCommit( + owner: string, + repo: string, + ref: string, + readToken: string, +): Promise { + const headers = { Authorization: `Bearer ${readToken}`, "User-Agent": "Firebase CLI" }; + const { body } = await githubApiClient.get( + `/repos/${owner}/${repo}/commits/${ref}`, + { + headers, + }, + ); + return body; +} diff --git a/src/apphosting/index.ts b/src/apphosting/index.ts index ffe8ff97329..ccda072452b 100644 --- a/src/apphosting/index.ts +++ b/src/apphosting/index.ts @@ -13,7 +13,7 @@ import { iamOrigin, secretManagerOrigin, } from "../api"; -import { Backend, BackendOutputOnlyFields, API_VERSION, Build, Rollout } from "../gcp/apphosting"; +import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting"; import { addServiceAccountToRoles } from "../gcp/resourceManager"; import * as iam from "../gcp/iam"; import { FirebaseError } from "../error"; @@ -26,6 +26,7 @@ import { webApps } from "./app"; import { GitRepositoryLink } from "../gcp/devConnect"; import * as ora from "ora"; import fetch from "node-fetch"; +import { orchestrateRollout } from "./rollout"; const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute"; @@ -167,12 +168,18 @@ export async function doSetup( const createRolloutSpinner = ora( "Starting a new rollout; this may take a few minutes. It's safe to exit now.", ).start(); - await orchestrateRollout(projectId, location, backendId, { - source: { - codebase: { - branch, + await orchestrateRollout({ + projectId, + location, + backendId, + buildInput: { + source: { + codebase: { + branch, + }, }, }, + isFirstRollout: true, }); createRolloutSpinner.succeed("Rollout complete"); if (!(await tlsReady(url))) { @@ -340,85 +347,6 @@ export async function setDefaultTrafficPolicy( }); } -/** - * Creates a new build and rollout and polls both to completion. - */ -export async function orchestrateRollout( - projectId: string, - location: string, - backendId: string, - buildInput: DeepOmit, -): Promise<{ rollout: Rollout; build: Build }> { - const buildId = await apphosting.getNextRolloutId(projectId, location, backendId, 1); - const buildOp = await apphosting.createBuild(projectId, location, backendId, buildId, buildInput); - - const rolloutBody = { - build: `projects/${projectId}/locations/${location}/backends/${backendId}/builds/${buildId}`, - }; - - let tries = 0; - let done = false; - while (!done) { - tries++; - try { - const validateOnly = true; - await apphosting.createRollout( - projectId, - location, - backendId, - buildId, - rolloutBody, - validateOnly, - ); - done = true; - } catch (err: unknown) { - if (err instanceof FirebaseError && err.status === 400) { - if (tries >= 5) { - throw err; - } - await sleep(1000); - } else { - throw err; - } - } - } - - const rolloutOp = await apphosting.createRollout( - projectId, - location, - backendId, - buildId, - rolloutBody, - ); - - const rolloutPoll = poller.pollOperation({ - ...apphostingPollerOptions, - pollerName: `create-${projectId}-${location}-backend-${backendId}-rollout-${buildId}`, - operationResourceName: rolloutOp.name, - }); - const buildPoll = poller.pollOperation({ - ...apphostingPollerOptions, - pollerName: `create-${projectId}-${location}-backend-${backendId}-build-${buildId}`, - operationResourceName: buildOp.name, - }); - - const [rollout, build] = await Promise.all([rolloutPoll, buildPoll]); - - if (build.state !== "READY") { - if (!build.buildLogsUri) { - throw new FirebaseError( - "Failed to build your app, but failed to get build logs as well. " + - "This is an internal error and should be reported", - ); - } - throw new FirebaseError( - `Failed to build your app. Please inspect the build logs at ${build.buildLogsUri}.`, - { children: [build.error] }, - ); - } - return { rollout, build }; -} - /** * Deletes the given backend. Polls till completion. */ @@ -440,7 +368,7 @@ export async function deleteBackendAndPoll( */ export async function promptLocation( projectId: string, - prompt: string = "Please select a location:", + prompt = "Please select a location:", ): Promise { const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId); if (allowedLocations.length === 1) { diff --git a/src/apphosting/rollout.spec.ts b/src/apphosting/rollout.spec.ts new file mode 100644 index 00000000000..0794b3d5dcd --- /dev/null +++ b/src/apphosting/rollout.spec.ts @@ -0,0 +1,261 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import { createRollout, orchestrateRollout } from "./rollout"; +import * as devConnect from "../gcp/devConnect"; +import * as githubConnections from "../apphosting/githubConnections"; +import * as apphosting from "../gcp/apphosting"; +import { FirebaseError } from "../error"; +import * as poller from "../operation-poller"; +import * as utils from "../utils"; + +describe("apphosting rollouts", () => { + const user = "user"; + const repo = "repo"; + const commitSha = "0123456"; + const branchId = "main"; + + const projectId = "projectId"; + const location = "us-central1"; + const backendId = "backendId"; + const connectionId = "apphosting-github-conn-a1b2c3"; + const gitRepoLinkId = `${user}-${repo}`; + const buildAndRolloutId = "build-2024-10-01-001"; + + let getBackendStub: sinon.SinonStub; + let getRepoDetailsFromBackendStub: sinon.SinonStub; + let listAllBranchesStub: sinon.SinonStub; + let getGitHubBranchStub: sinon.SinonStub; + let getGitHubCommitStub: sinon.SinonStub; + let getNextRolloutIdStub: sinon.SinonStub; + let createBuildStub: sinon.SinonStub; + let createRolloutStub: sinon.SinonStub; + let pollOperationStub: sinon.SinonStub; + let promptGitHubBranchStub: sinon.SinonStub; + let sleepStub: sinon.SinonStub; + + beforeEach(() => { + getBackendStub = sinon.stub(apphosting, "getBackend").throws("unexpected getBackend call"); + getRepoDetailsFromBackendStub = sinon + .stub(devConnect, "getRepoDetailsFromBackend") + .throws("unexpected getRepoDetailsFromBackend call"); + listAllBranchesStub = sinon + .stub(devConnect, "listAllBranches") + .throws("unexpected listAllBranches call"); + getGitHubBranchStub = sinon + .stub(githubConnections, "getGitHubBranch") + .throws("unexpected getGitHubBranch call"); + getGitHubCommitStub = sinon + .stub(githubConnections, "getGitHubCommit") + .throws("unexpected getGitHubCommit call"); + getNextRolloutIdStub = sinon + .stub(apphosting, "getNextRolloutId") + .throws("unexpected getNextRolloutId call"); + createBuildStub = sinon.stub(apphosting, "createBuild").throws("unexpected createBuild call"); + createRolloutStub = sinon + .stub(apphosting, "createRollout") + .throws("unexpected createRollout call"); + pollOperationStub = sinon.stub(poller, "pollOperation").throws("unexpected pollOperation call"); + promptGitHubBranchStub = sinon + .stub(githubConnections, "promptGitHubBranch") + .throws("unexpected promptGitHubBranch call"); + sleepStub = sinon.stub(utils, "sleep").throws("unexpected sleep call"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + describe("apphosting rollouts", () => { + const repoLinkId = `projects/${projectId}/location/${location}/connections/${connectionId}/gitRepositoryLinks/${gitRepoLinkId}`; + + const backend = { + name: `projects/${projectId}/locations/${location}/backends/${backendId}`, + labels: {}, + createTime: "0", + updateTime: "1", + uri: "https://placeholder.com", + codebase: { + repository: repoLinkId, + rootDirectory: "/", + }, + }; + + const buildInput = { + source: { + codebase: { + commit: commitSha, + }, + }, + }; + + const repoLinkDetails = { + repoLink: { + name: repoLinkId, + cloneUri: `https://github.com/${user}/${repo}.git`, + createTime: "create-time", + updateTime: "update-time", + reconciling: true, + uid: "00000000", + }, + owner: user, + repo: repo, + readToken: { + token: "read-token", + expirationTime: "some-time", + gitUsername: user, + }, + }; + + const branches = new Set(); + branches.add(branchId); + + const commitInfo = { + sha: commitSha, + commit: { + message: "new commit", + }, + }; + + const branchInfo = { + commit: commitInfo, + }; + + const buildOp = { + name: "build-op", + done: true, + }; + + const rolloutOp = { + name: "rollout-op", + done: true, + }; + + const build = { + name: buildAndRolloutId, + state: "READY", + }; + + const rollout = { + name: buildAndRolloutId, + state: "READY", + }; + + describe("createRollout", () => { + it("should create a new rollout from user-specified branch", async () => { + getBackendStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + listAllBranchesStub.resolves(branches); + getGitHubBranchStub.resolves(branchInfo); + getNextRolloutIdStub.resolves(buildAndRolloutId); + createBuildStub.resolves(buildOp); + createRolloutStub.resolves(rolloutOp); + pollOperationStub.onFirstCall().resolves(rollout); + pollOperationStub.onSecondCall().resolves(build); + + await createRollout(backendId, projectId, location, branchId, undefined, true); + + expect(createBuildStub).to.be.called; + expect(createRolloutStub).to.be.called; + expect(pollOperationStub).to.be.called; + }); + + it("should create a new rollout from user-specified commit", async () => { + getBackendStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + getGitHubCommitStub.resolves(commitInfo); + getNextRolloutIdStub.resolves(buildAndRolloutId); + createBuildStub.resolves(buildOp); + createRolloutStub.resolves(rolloutOp); + pollOperationStub.onFirstCall().resolves(rollout); + pollOperationStub.onSecondCall().resolves(build); + + await createRollout(backendId, projectId, location, undefined, commitSha, true); + + expect(createBuildStub).to.be.called; + expect(createRolloutStub).to.be.called; + expect(pollOperationStub).to.be.called; + }); + + it("should prompt user for a branch if branch or commit ID is not specified", async () => { + getBackendStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + promptGitHubBranchStub.resolves(branchId); + getGitHubBranchStub.resolves(branchInfo); + getNextRolloutIdStub.resolves(buildAndRolloutId); + createBuildStub.resolves(buildOp); + createRolloutStub.resolves(rolloutOp); + pollOperationStub.onFirstCall().resolves(rollout); + pollOperationStub.onSecondCall().resolves(build); + + await createRollout(backendId, projectId, location, undefined, undefined, true); + + expect(promptGitHubBranchStub).to.be.called; + expect(createBuildStub).to.be.called; + expect(createRolloutStub).to.be.called; + expect(pollOperationStub).to.be.called; + }); + + it("should throw an error if GitHub branch is not found", async () => { + getBackendStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + listAllBranchesStub.resolves(branches); + + await expect( + createRollout(backendId, projectId, location, "invalid-branch", undefined, true), + ).to.be.rejectedWith(/Unrecognized git branch/); + }); + + it("should throw an error if GitHub commit is not found", async () => { + getBackendStub.resolves(backend); + getRepoDetailsFromBackendStub.resolves(repoLinkDetails); + getGitHubCommitStub.rejects(new FirebaseError("error", { status: 422 })); + + await expect( + createRollout(backendId, projectId, location, undefined, commitSha, true), + ).to.be.rejectedWith(/Unrecognized git commit/); + }); + }); + + describe("orchestrateRollout", () => { + it("should successfully create build and rollout", async () => { + getNextRolloutIdStub.resolves(buildAndRolloutId); + createBuildStub.resolves(buildOp); + createRolloutStub.resolves(rolloutOp); + pollOperationStub.onFirstCall().resolves(rollout); + pollOperationStub.onSecondCall().resolves(build); + sleepStub.resolves(); + + await orchestrateRollout({ + projectId, + location, + backendId, + buildInput, + }); + + expect(createBuildStub).to.be.called; + expect(createRolloutStub).to.be.called; + }); + + it("should retry createRollout call on HTTP 400 errors", async () => { + getNextRolloutIdStub.resolves(buildAndRolloutId); + createBuildStub.resolves(buildOp); + createRolloutStub.onFirstCall().rejects(new FirebaseError("error", { status: 400 })); + createRolloutStub.resolves(rolloutOp); + pollOperationStub.onFirstCall().resolves(rollout); + pollOperationStub.onSecondCall().resolves(build); + sleepStub.resolves(); + + await orchestrateRollout({ + projectId, + location, + backendId, + buildInput, + }); + + expect(createBuildStub).to.be.called; + expect(createRolloutStub).to.be.calledThrice; + expect(pollOperationStub).to.be.called; + }); + }); + }); +}); diff --git a/src/apphosting/rollout.ts b/src/apphosting/rollout.ts new file mode 100644 index 00000000000..e1f175adc19 --- /dev/null +++ b/src/apphosting/rollout.ts @@ -0,0 +1,214 @@ +import * as apphosting from "../gcp/apphosting"; +import { FirebaseError } from "../error"; +import * as ora from "ora"; +import { getRepoDetailsFromBackend, listAllBranches } from "../gcp/devConnect"; +import { + getGitHubBranch, + getGitHubCommit, + GitHubCommitInfo, + promptGitHubBranch, +} from "../apphosting/githubConnections"; +import * as poller from "../operation-poller"; + +import { confirm } from "../prompt"; +import { logBullet, sleep } from "../utils"; +import { apphostingOrigin, consoleOrigin } from "../api"; +import { DeepOmit } from "../metaprogramming"; + +const apphostingPollerOptions: Omit = { + apiOrigin: apphostingOrigin(), + apiVersion: apphosting.API_VERSION, + masterTimeout: 25 * 60 * 1_000, + maxBackoff: 10_000, +}; + +const GIT_COMMIT_SHA_REGEX = /^(?:[0-9a-f]{40}|[0-9a-f]{7})$/; + +/** + * Create a new App Hosting rollout for a backend. + * Implements core logic for apphosting:rollouts:create command. + */ +export async function createRollout( + backendId: string, + projectId: string, + location: string, + branch?: string, + commit?: string, + force?: boolean, +): Promise { + const backend = await apphosting.getBackend(projectId, location, backendId); + if (!backend.codebase.repository) { + throw new FirebaseError( + `Backend ${backendId} is misconfigured due to missing a connected repository. You can delete and recreate your backend using 'firebase apphosting:backends:delete' and 'firebase apphosting:backends:create'.`, + ); + } + const { repoLink, owner, repo, readToken } = await getRepoDetailsFromBackend( + projectId, + location, + backend.codebase.repository, + ); + + let targetCommit: GitHubCommitInfo; + if (branch) { + const branches = await listAllBranches(repoLink.name); + if (!branches.has(branch)) { + throw new FirebaseError( + `Unrecognized git branch ${branch}. Please double-check your branch name and try again.`, + ); + } + const branchInfo = await getGitHubBranch(owner, repo, branch, readToken.token); + targetCommit = branchInfo.commit; + } else if (commit) { + if (!GIT_COMMIT_SHA_REGEX.test(commit)) { + throw new FirebaseError(`Invalid git commit ${commit}. Must be a valid SHA1 hash.`); + } + try { + const commitInfo = await getGitHubCommit(owner, repo, commit, readToken.token); + targetCommit = commitInfo; + } catch (err: unknown) { + // 422 HTTP status code returned by GitHub indicates it was unable to find the commit. + if ((err as FirebaseError).status === 422) { + throw new FirebaseError( + `Unrecognized git commit ${commit}. Please double-check your commit hash and try again.`, + ); + } + throw err; + } + } else { + branch = await promptGitHubBranch(repoLink); + const branchInfo = await getGitHubBranch(owner, repo, branch, readToken.token); + targetCommit = branchInfo.commit; + } + + logBullet( + `You are about to deploy [${targetCommit.sha.substring(0, 7)}]: ${targetCommit.commit.message}`, + ); + const confirmRollout = await confirm({ + force: !!force, + message: "Do you want to continue?", + }); + if (!confirmRollout) { + return; + } + logBullet( + `You may also track this rollout at:\n\t${consoleOrigin()}/project/${projectId}/apphosting`, + ); + + const createRolloutSpinner = ora( + "Starting a new rollout; this may take a few minutes. It's safe to exit now.", + ).start(); + + try { + await orchestrateRollout({ + projectId, + location, + backendId, + buildInput: { + source: { + codebase: { + commit: targetCommit.sha, + }, + }, + }, + }); + } catch (err: unknown) { + createRolloutSpinner.fail("Rollout failed."); + throw err; + } + createRolloutSpinner.succeed("Successfully created a new rollout!"); +} + +interface OrchestrateRolloutArgs { + projectId: string; + location: string; + backendId: string; + buildInput: DeepOmit; + // Used to determine if a rollout ID needs to be computed. + // If we know this is the first rollout for a backend, + // we can avoid multiple API calls and default to: + // build-{year}-{month}-{day}-001. + isFirstRollout?: boolean; +} + +/** + * Creates a new build and rollout and polls both to completion. + */ +export async function orchestrateRollout( + args: OrchestrateRolloutArgs, +): Promise<{ rollout: apphosting.Rollout; build: apphosting.Build }> { + const { projectId, location, backendId, buildInput, isFirstRollout } = args; + + const buildId = await apphosting.getNextRolloutId( + projectId, + location, + backendId, + isFirstRollout ? 1 : undefined, + ); + const buildOp = await apphosting.createBuild(projectId, location, backendId, buildId, buildInput); + + const rolloutBody = { + build: `projects/${projectId}/locations/${location}/backends/${backendId}/builds/${buildId}`, + }; + + let tries = 0; + let done = false; + while (!done) { + tries++; + try { + const validateOnly = true; + await apphosting.createRollout( + projectId, + location, + backendId, + buildId, + rolloutBody, + validateOnly, + ); + done = true; + } catch (err: unknown) { + if (err instanceof FirebaseError && err.status === 400) { + if (tries >= 5) { + throw err; + } + await sleep(1000); + } else { + throw err; + } + } + } + + const rolloutOp = await apphosting.createRollout( + projectId, + location, + backendId, + buildId, + rolloutBody, + ); + + const rolloutPoll = poller.pollOperation({ + ...apphostingPollerOptions, + pollerName: `create-${projectId}-${location}-backend-${backendId}-rollout-${buildId}`, + operationResourceName: rolloutOp.name, + }); + const buildPoll = poller.pollOperation({ + ...apphostingPollerOptions, + pollerName: `create-${projectId}-${location}-backend-${backendId}-build-${buildId}`, + operationResourceName: buildOp.name, + }); + + const [rollout, build] = await Promise.all([rolloutPoll, buildPoll]); + + if (build.state !== "READY") { + if (!build.buildLogsUri) { + throw new FirebaseError( + "Failed to build your app, but failed to get build logs as well. " + + "This is an internal error and should be reported", + ); + } + throw new FirebaseError( + `Failed to build your app. Please inspect the build logs at ${build.buildLogsUri}.`, + { children: [build.error] }, + ); + } + return { rollout, build }; +} diff --git a/src/commands/apphosting-rollouts-create.ts b/src/commands/apphosting-rollouts-create.ts index 5c3a64ee692..82962568994 100644 --- a/src/commands/apphosting-rollouts-create.ts +++ b/src/commands/apphosting-rollouts-create.ts @@ -1,27 +1,32 @@ import * as apphosting from "../gcp/apphosting"; -import { logger } from "../logger"; import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; +import { FirebaseError } from "../error"; +import { createRollout } from "../apphosting/rollout"; -export const command = new Command("apphosting:rollouts:create ") +export const command = new Command("apphosting:rollouts:create ") .description("create a rollout using a build for an App Hosting backend") .option("-l, --location ", "specify the region of the backend", "us-central1") .option("-i, --id ", "id of the rollout (defaults to autogenerating a random id)", "") + .option( + "-gb, --git-branch ", + "repository branch to deploy (mutually exclusive with -gc)", + ) + .option("-gc, --git-commit ", "git commit to deploy (mutually exclusive with -gb)") + .withForce("Skip confirmation before creating rollout") .before(apphosting.ensureApiEnabled) - .action(async (backendId: string, buildId: string, options: Options) => { + .action(async (backendId: string, options: Options) => { const projectId = needProjectId(options); const location = options.location as string; - // TODO: Should we just reuse the buildId? - const rolloutId = - (options.buildId as string) || - (await apphosting.getNextRolloutId(projectId, location, backendId)); - const build = `projects/${projectId}/backends/${backendId}/builds/${buildId}`; - const op = await apphosting.createRollout(projectId, location, backendId, rolloutId, { - build, - }); - logger.info(`Started a rollout for backend ${backendId} with build ${buildId}.`); - logger.info("Check status by running:"); - logger.info(`\tfirebase apphosting:rollouts:list --location ${location}`); - return op; + + const branch = options.gitBranch as string | undefined; + const commit = options.gitCommit as string | undefined; + if (branch && commit) { + throw new FirebaseError( + "Cannot specify both a branch and commit to deploy. Please specify either --git-branch or --git-commit.", + ); + } + + await createRollout(backendId, projectId, location, branch, commit, options.force); }); diff --git a/src/gcp/devConnect.ts b/src/gcp/devConnect.ts index 54b62cead10..05143825d73 100644 --- a/src/gcp/devConnect.ts +++ b/src/gcp/devConnect.ts @@ -1,6 +1,8 @@ import { Client } from "../apiv2"; import { developerConnectOrigin, developerConnectP4SADomain } from "../api"; import { generateServiceIdentityAndPoll } from "./serviceusage"; +import { FirebaseError } from "../error"; +import { extractRepoSlugFromUri } from "../apphosting/githubConnections"; const PAGE_SIZE_MAX = 1000; const LOCATION_OVERRIDE = process.env.FIREBASE_DEVELOPERCONNECT_LOCATION_OVERRIDE; @@ -123,6 +125,19 @@ export interface LinkableGitRepository { cloneUri: string; } +interface GitRepositoryLinkReadToken { + token: string; + expirationTime: string; + gitUsername: string; +} + +export interface GitRepositoryLinkDetails { + repoLink: GitRepositoryLink; + owner: string; + repo: string; + readToken: GitRepositoryLinkReadToken; +} + /** * Creates a Developer Connect Connection. */ @@ -245,7 +260,6 @@ export async function listAllLinkableGitRepositories( /** * Lists all branches for a given repo. Returns a set of branches. */ - export async function listAllBranches(repoLinkName: string): Promise> { const branches = new Set(); @@ -275,7 +289,7 @@ export async function listAllBranches(repoLinkName: string): Promise return branches; } -/* +/** * Fetch all GitHub installations available to the oauth token referenced by * the given connection */ @@ -291,7 +305,7 @@ export async function fetchGitHubInstallations( } /** - * Creates a GitRepositoryLink.Upon linking a Git Repository, Developer + * Creates a GitRepositoryLink. Upon linking a Git Repository, Developer * Connect will configure the Git Repository to send webhook events to * Developer Connect. */ @@ -327,6 +341,20 @@ export async function getGitRepositoryLink( return res.body; } +/** + * Fetch the read token for a GitRepositoryLink + */ +export async function fetchGitRepositoryLinkReadToken( + projectId: string, + location: string, + connectionId: string, + gitRepositoryLinkId: string, +): Promise { + const name = `projects/${projectId}/locations/${LOCATION_OVERRIDE ?? location}/connections/${connectionId}/gitRepositoryLinks/${gitRepositoryLinkId}:fetchReadToken`; + const res = await client.post(name); + return res.body; +} + /** * sorts the given list of connections by create_time from earliest to latest */ @@ -358,3 +386,57 @@ export async function generateP4SA(projectNumber: string): Promise { "apphosting", ); } + +/** + * Given a DevConnect GitRepositoryLink resource name, extracts the + * names of the connection and git repository link + */ +export function extractGitRepositoryLinkComponents(path: string): { + connection: string | null; + gitRepoLink: string | null; +} { + const connectionMatch = /connections\/([^\/]+)/.exec(path); + const repositoryMatch = /gitRepositoryLinks\/([^\/]+)/.exec(path); + + const connection = connectionMatch ? connectionMatch[1] : null; + const gitRepoLink = repositoryMatch ? repositoryMatch[1] : null; + + return { connection, gitRepoLink }; +} + +/** + * Given a GitRepositoryLink resource path, retrieves the GitRepositoryLink resource, + * owner, repository name, and read token for the Git repository + */ +export async function getRepoDetailsFromBackend( + projectId: string, + location: string, + gitRepoLinkPath: string, +): Promise { + const { connection, gitRepoLink } = extractGitRepositoryLinkComponents(gitRepoLinkPath); + if (!connection || !gitRepoLink) { + throw new FirebaseError( + `Failed to extract connection or repository resource names from backend repository name.`, + ); + } + const repoLink = await getGitRepositoryLink(projectId, location, connection, gitRepoLink); + const repoSlug = extractRepoSlugFromUri(repoLink.cloneUri); + const owner = repoSlug?.split("/")[0]; + const repo = repoSlug?.split("/")[1]; + if (!owner || !repo) { + throw new FirebaseError("Failed to parse owner and repo from git repository link"); + } + const readToken = await fetchGitRepositoryLinkReadToken( + projectId, + location, + connection, + gitRepoLink, + ); + + return { + repoLink, + owner, + repo, + readToken, + }; +} diff --git a/src/gcp/devconnect.spec.ts b/src/gcp/devconnect.spec.ts index a162a59f728..e11a14dee5f 100644 --- a/src/gcp/devconnect.spec.ts +++ b/src/gcp/devconnect.spec.ts @@ -9,7 +9,9 @@ describe("developer connect", () => { const projectId = "project"; const location = "us-central1"; const connectionId = "apphosting-connection"; + const gitRepoLinkId = "git-repo-link"; const connectionsRequestPath = `projects/${projectId}/locations/${location}/connections`; + const gitRepoLinkPath = `projects/${projectId}/locations/${location}/connections/${connectionId}/gitRepositoryLinks/${gitRepoLinkId}`; function mockConnection(id: string, createTime: string): devconnect.Connection { return { @@ -184,4 +186,13 @@ describe("developer connect", () => { }); }); }); + + describe("extractGitRepositoryLinkComponents", () => { + it("correctly extracts the connection and git repository link ID", () => { + expect(devconnect.extractGitRepositoryLinkComponents(gitRepoLinkPath)).to.deep.equal({ + connection: "apphosting-connection", + gitRepoLink: "git-repo-link", + }); + }); + }); });