From 944ab1f058c98af62bfc8083d9862d8079bd41c1 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 7 May 2025 15:58:57 -0400 Subject: [PATCH 01/22] deploy from local source --- src/apphosting/backend.ts | 23 ++- src/checkValidTargetFilters.ts | 1 + src/commands/deploy.ts | 1 + src/deploy/apphosting/args.ts | 7 + src/deploy/apphosting/deploy.spec.ts | 117 ++++++++++++ src/deploy/apphosting/deploy.ts | 91 ++++++++++ src/deploy/apphosting/index.ts | 5 + src/deploy/apphosting/prepare.spec.ts | 251 ++++++++++++++++++++++++++ src/deploy/apphosting/prepare.ts | 182 +++++++++++++++++++ src/deploy/apphosting/release.spec.ts | 77 ++++++++ src/deploy/apphosting/release.ts | 59 ++++++ src/deploy/apphosting/util.ts | 93 ++++++++++ src/deploy/index.ts | 2 + src/gcp/apphosting.ts | 18 +- src/gcp/devConnect.ts | 13 ++ src/gcp/storage.ts | 72 +++++++- 16 files changed, 1009 insertions(+), 3 deletions(-) create mode 100644 src/deploy/apphosting/args.ts create mode 100644 src/deploy/apphosting/deploy.spec.ts create mode 100644 src/deploy/apphosting/deploy.ts create mode 100644 src/deploy/apphosting/index.ts create mode 100644 src/deploy/apphosting/prepare.spec.ts create mode 100644 src/deploy/apphosting/prepare.ts create mode 100644 src/deploy/apphosting/release.spec.ts create mode 100644 src/deploy/apphosting/release.ts create mode 100644 src/deploy/apphosting/util.ts diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index 66d37afedb1..baa189c933b 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -14,7 +14,7 @@ import { secretManagerOrigin, } from "../api"; import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting"; -import { addServiceAccountToRoles } from "../gcp/resourceManager"; +import { addServiceAccountToRoles, getIamPolicy } from "../gcp/resourceManager"; import * as iam from "../gcp/iam"; import { FirebaseError, getErrStatus, getError } from "../error"; import { input, confirm, select, checkbox, search, Choice } from "../prompt"; @@ -218,6 +218,7 @@ export async function createGitRepoLink( export async function ensureAppHostingComputeServiceAccount( projectId: string, serviceAccount: string | null, + deployFromSource = false, ): Promise { const sa = serviceAccount || defaultComputeServiceAccountEmail(projectId); const name = `projects/${projectId}/serviceAccounts/${sa}`; @@ -242,6 +243,25 @@ export async function ensureAppHostingComputeServiceAccount( ); } } + if (deployFromSource) { + const policy = await getIamPolicy(projectId); + const objectViewerBinding = policy.bindings.find( + (binding) => binding.role === "roles/storage.objectViewer", + ); + if ( + !objectViewerBinding || + !objectViewerBinding.members.includes( + `serviceAccount:${defaultComputeServiceAccountEmail(projectId)}`, + ) + ) { + await addServiceAccountToRoles( + projectId, + defaultComputeServiceAccountEmail(projectId), + ["roles/storage.objectViewer"], + /* skipAccountLookup= */ true, + ); + } + } } /** @@ -333,6 +353,7 @@ async function provisionDefaultComputeServiceAccount(projectId: string): Promise "roles/firebaseapphosting.computeRunner", "roles/firebase.sdkAdminServiceAgent", "roles/developerconnect.readTokenAccessor", + "roles/storage.objectViewer", ], /* skipAccountLookup= */ true, ); diff --git a/src/checkValidTargetFilters.ts b/src/checkValidTargetFilters.ts index 211deeaf33d..46ee10a9ebf 100644 --- a/src/checkValidTargetFilters.ts +++ b/src/checkValidTargetFilters.ts @@ -30,6 +30,7 @@ const FILTERABLE_TARGETS = new Set([ "storage", "database", "dataconnect", + "apphosting", ]); /** diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 322d18cadd8..5b3e036138b 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -23,6 +23,7 @@ export const VALID_DEPLOY_TARGETS = [ "remoteconfig", "extensions", "dataconnect", + "apphosting", ]; export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = { database: ["firebasedatabase.instances.update"], diff --git a/src/deploy/apphosting/args.ts b/src/deploy/apphosting/args.ts new file mode 100644 index 00000000000..7c94697e120 --- /dev/null +++ b/src/deploy/apphosting/args.ts @@ -0,0 +1,7 @@ +import { AppHostingSingle } from "../../firebaseConfig"; + +export interface Context { + backendConfigs: Map; + backendLocations: Map; + backendStorageUris: Map; +} diff --git a/src/deploy/apphosting/deploy.spec.ts b/src/deploy/apphosting/deploy.spec.ts new file mode 100644 index 00000000000..994c79e9c3b --- /dev/null +++ b/src/deploy/apphosting/deploy.spec.ts @@ -0,0 +1,117 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Config } from "../../config"; +import { FirebaseError } from "../../error"; +import { AppHostingSingle } from "../../firebaseConfig"; +import * as gcs from "../../gcp/storage"; +import { RC } from "../../rc"; +import { Context } from "./args"; +import deploy from "./deploy"; +import * as util from "./util"; +import * as fs from "fs"; +import * as getProjectNumber from "../../getProjectNumber"; + +const BASE_OPTS = { + cwd: "/", + configPath: "/", + except: "", + force: false, + nonInteractive: false, + interactive: false, + debug: false, + filteredTargets: [], + rc: new RC(), + json: false, +}; + +function initializeContext(): Context { + return { + backendConfigs: new Map([ + [ + "foo", + { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + ], + ]), + backendLocations: new Map([["foo", "us-central1"]]), + backendStorageUris: new Map(), + }; +} + +describe("apphosting", () => { + let getBucketStub: sinon.SinonStub; + let createBucketStub: sinon.SinonStub; + let uploadObjectStub: sinon.SinonStub; + let createArchiveStub: sinon.SinonStub; + let createReadStreamStub: sinon.SinonStub; + let getProjectNumberStub: sinon.SinonStub; + + beforeEach(() => { + getProjectNumberStub = sinon + .stub(getProjectNumber, "getProjectNumber") + .throws("Unexpected getProjectNumber call"); + getBucketStub = sinon.stub(gcs, "getBucket").throws("Unexpected getBucket call"); + createBucketStub = sinon.stub(gcs, "createBucket").throws("Unexpected createBucket call"); + uploadObjectStub = sinon.stub(gcs, "uploadObject").throws("Unexpected uploadObject call"); + createArchiveStub = sinon.stub(util, "createArchive").throws("Unexpected createArchive call"); + createReadStreamStub = sinon + .stub(fs, "createReadStream") + .throws("Unexpected createReadStream call"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + describe("deploy", () => { + const opts = { + ...BASE_OPTS, + projectId: "my-project", + only: "apphosting", + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + }), + }; + + it("creates regional GCS bucket if one doesn't exist yet", async () => { + const context = initializeContext(); + getProjectNumberStub.resolves("000000000000"); + getBucketStub.onFirstCall().rejects( + new FirebaseError("error", { + original: new FirebaseError("original error", { status: 404 }), + }), + ); + createBucketStub.resolves(); + createArchiveStub.resolves("path/to/foo-1234.zip"); + uploadObjectStub.resolves(); + createReadStreamStub.resolves(); + + await deploy(context, opts); + + expect(createBucketStub).to.be.calledOnce; + }); + + it("correctly creates and sets storage URIs", async () => { + const context = initializeContext(); + getProjectNumberStub.resolves("000000000000"); + getBucketStub.resolves(); + createBucketStub.resolves(); + createArchiveStub.resolves("path/to/foo-1234.zip"); + uploadObjectStub.resolves(); + createReadStreamStub.resolves(); + + await deploy(context, opts); + + expect(context.backendStorageUris.get("foo")).to.equal( + "gs://firebaseapphosting-sources-000000000000-us-central1/foo-1234.zip", + ); + }); + }); +}); diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts new file mode 100644 index 00000000000..262bb3e6a3f --- /dev/null +++ b/src/deploy/apphosting/deploy.ts @@ -0,0 +1,91 @@ +import * as fs from "fs"; +import * as path from "path"; +import { FirebaseError, getErrStatus } from "../../error"; +import * as gcs from "../../gcp/storage"; +import { getProjectNumber } from "../../getProjectNumber"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { logBullet, logWarning } from "../../utils"; +import { Context } from "./args"; +import { createArchive } from "./util"; + +/** + * Zips and uploads App Hosting source code to Google Cloud Storage in preparation for + * build and deployment. Creates storage buckets if necessary. + */ +export default async function (context: Context, options: Options): Promise { + const projectId = needProjectId(options); + options.projectNumber = await getProjectNumber(options); + if (!context.backendConfigs) { + return; + } + + // Ensure that a bucket exists in each region that a backend is or will be deployed to + const backendLocations = context.backendLocations.values() || []; + for (const loc of backendLocations) { + const bucketName = `firebaseapphosting-sources-${options.projectNumber}-${loc.toLowerCase()}`; + try { + await gcs.getBucket(bucketName); + } catch (err) { + const errStatus = getErrStatus((err as FirebaseError).original); + // Unfortunately, requests for a non-existent bucket from the GCS API sometimes return 403 responses as well as 404s. + // We must attempt to create a new bucket on both 403s and 404s. + if (errStatus === 403 || errStatus === 404) { + logBullet( + `Creating Cloud Storage bucket in ${loc} to store App Hosting source code uploads at ${bucketName}...`, + ); + try { + await gcs.createBucket(projectId, { + name: bucketName, + location: "US-CENTRAL1", + lifecycle: { + rule: [ + { + action: { + type: "Delete", + }, + condition: { + age: 30, + }, + }, + ], + }, + }); + } catch (err) { + if (getErrStatus((err as FirebaseError).original) === 403) { + logWarning( + "Failed to create Cloud Storage bucket because user does not have sufficient permissions. " + + "See https://cloud.google.com/storage/docs/access-control/iam-roles for more details on " + + "IAM roles that are able to create a Cloud Storage bucket, and ask your project administrator " + + "to grant you one of those roles.", + ); + throw (err as FirebaseError).original; + } + } + } else { + throw err; + } + } + } + + for (const cfg of context.backendConfigs.values()) { + const zippedSourcePath = await createArchive(cfg, options.projectRoot); + const backendLocation = context.backendLocations.get(cfg.backendId); + if (!backendLocation) { + throw new FirebaseError( + `Failed to find location for backend ${cfg.backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`, + ); + } + await gcs.uploadObject( + { + file: zippedSourcePath, + stream: fs.createReadStream(zippedSourcePath), + }, + `firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}`, + ); + context.backendStorageUris.set( + cfg.backendId, + `gs://firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}/${path.basename(zippedSourcePath)}`, + ); + } +} diff --git a/src/deploy/apphosting/index.ts b/src/deploy/apphosting/index.ts new file mode 100644 index 00000000000..6827ff14f87 --- /dev/null +++ b/src/deploy/apphosting/index.ts @@ -0,0 +1,5 @@ +import prepare from "./prepare"; +import deploy from "./deploy"; +import release from "./release"; + +export { prepare, deploy, release }; diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts new file mode 100644 index 00000000000..fd0c325d470 --- /dev/null +++ b/src/deploy/apphosting/prepare.spec.ts @@ -0,0 +1,251 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { webApps } from "../../apphosting/app"; +import * as backend from "../../apphosting/backend"; +import { Config } from "../../config"; +import * as apiEnabled from "../../ensureApiEnabled"; +import { AppHostingSingle } from "../../firebaseConfig"; +import * as apphosting from "../../gcp/apphosting"; +import * as devconnect from "../../gcp/devConnect"; +import * as prompt from "../../prompt"; +import { RC } from "../../rc"; +import { Context } from "./args"; +import prepare, { getBackendConfigs } from "./prepare"; + +const BASE_OPTS = { + cwd: "/", + configPath: "/", + except: "", + force: false, + nonInteractive: false, + interactive: false, + debug: false, + filteredTargets: [], + rc: new RC(), + json: false, +}; + +function initializeContext(): Context { + return { + backendConfigs: new Map(), + backendLocations: new Map(), + backendStorageUris: new Map(), + }; +} + +describe("apphosting", () => { + const opts = { + ...BASE_OPTS, + projectId: "my-project", + only: "apphosting", + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + }), + }; + + let promptOnceStub: sinon.SinonStub; + let promptLocationStub: sinon.SinonStub; + let getOrCreateWebAppStub: sinon.SinonStub; + let createBackendStub: sinon.SinonStub; + let listBackendsStub: sinon.SinonStub; + let getGitRepositoryLinkStub: sinon.SinonStub; + + beforeEach(() => { + sinon.stub(opts.config, "writeProjectFile").returns(); + promptOnceStub = sinon.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); + promptLocationStub = sinon + .stub(backend, "promptLocation") + .throws("Unexpected promptLocation call"); + getOrCreateWebAppStub = sinon + .stub(webApps, "getOrCreateWebApp") + .throws("Unexpected getOrCreateWebAppStub call"); + createBackendStub = sinon + .stub(backend, "createBackend") + .throws("Unexpected createBackend call"); + listBackendsStub = sinon + .stub(apphosting, "listBackends") + .throws("Unexpected listBackends call"); + sinon.stub(backend, "ensureAppHostingComputeServiceAccount").resolves(); + sinon.stub(apiEnabled, "ensure").resolves(); + getGitRepositoryLinkStub = sinon + .stub(devconnect, "getGitRepositoryLink") + .throws("Unexpected getGitRepositoryLink call"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + describe("prepare", () => { + it("links to existing backend if it already exists", async () => { + const context = initializeContext(); + listBackendsStub.onFirstCall().resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + }, + ], + }); + + await prepare(context, opts); + + expect(context.backendLocations.get("foo")).to.equal("us-central1"); + expect(context.backendConfigs.get("foo")).to.deep.equal({ + backendId: "foo", + rootDir: "/", + ignore: [], + }); + }); + + it("creates a backend if it doesn't exist yet", async () => { + const context = initializeContext(); + listBackendsStub.onFirstCall().resolves({ + backends: [], + }); + promptLocationStub.onFirstCall().resolves("us-central1"); + getOrCreateWebAppStub.onFirstCall().resolves({ + id: "my-web-app", + }); + createBackendStub.onFirstCall().resolves({ name: "foo" }); + + await prepare(context, opts); + + expect(createBackendStub).to.be.calledWith( + "my-project", + "us-central1", + "foo", + null, + undefined, + "my-web-app", + ); + expect(context.backendLocations.get("foo")).to.equal("us-central1"); + expect(context.backendConfigs.get("foo")).to.deep.equal({ + backendId: "foo", + rootDir: "/", + ignore: [], + }); + }); + + it("skips backend deployment if alwaysDeployFromSource is false", async () => { + const optsWithAlwaysDeploy = { + ...opts, + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + alwaysDeployFromSource: false, + }, + }), + }; + const context = initializeContext(); + listBackendsStub.onFirstCall().resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + codebase: { + repository: "remote-repo.git", + }, + }, + ], + }); + + await prepare(context, optsWithAlwaysDeploy); + + expect(context.backendLocations.get("foo")).to.equal(undefined); + expect(context.backendConfigs.get("foo")).to.deep.equal(undefined); + }); + + it("prompts user if codebase is already connected and alwaysDeployFromSource is undefined", async () => { + const context = initializeContext(); + listBackendsStub.onFirstCall().resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + codebase: { + repository: + "projects/my-project/locations/us-central1/connections/my-connection/gitRepositoryLinks/foo", + }, + }, + ], + }); + getGitRepositoryLinkStub.onFirstCall().resolves({ + cloneUri: "github.com/my-org/foo.git", + }); + promptOnceStub.onFirstCall().resolves(true); + + await prepare(context, opts); + + expect(context.backendLocations.get("foo")).to.equal("us-central1"); + expect(context.backendConfigs.get("foo")).to.deep.equal({ + backendId: "foo", + rootDir: "/", + ignore: [], + alwaysDeployFromSource: true, + }); + }); + }); + + describe("getBackendConfigs", () => { + const apphostingConfig = [ + { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + { + backendId: "bar", + rootDir: "/", + ignore: [], + }, + ]; + + it("selects all backends when --apphosting is passed", () => { + const configs = getBackendConfigs({ + ...BASE_OPTS, + only: "apphosting", + config: new Config({ + apphosting: apphostingConfig, + }), + }); + + expect(configs).to.deep.equal(apphostingConfig); + }); + + it("selects App Hosting backends when multiple product filters are passed", () => { + const configs = getBackendConfigs({ + ...BASE_OPTS, + only: "functions,apphosting", + config: new Config({ + functions: {}, + hosting: {}, + apphosting: apphostingConfig, + }), + }); + + expect(configs).to.deep.equal(apphostingConfig); + }); + + it("selects a specific App Hosting backend", () => { + const configs = getBackendConfigs({ + ...BASE_OPTS, + only: "apphosting:foo", + config: new Config({ + apphosting: apphostingConfig, + }), + }); + + expect(configs).to.deep.equal([ + { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + ]); + }); + }); +}); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts new file mode 100644 index 00000000000..6c6eef2a2e5 --- /dev/null +++ b/src/deploy/apphosting/prepare.ts @@ -0,0 +1,182 @@ +import * as ora from "ora"; +import { webApps } from "../../apphosting/app"; +import { + createBackend, + ensureAppHostingComputeServiceAccount, + promptLocation, +} from "../../apphosting/backend"; +import { FirebaseError } from "../../error"; +import { AppHostingMultiple, AppHostingSingle } from "../../firebaseConfig"; +import { listBackends, parseBackendName } from "../../gcp/apphosting"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { promptOnce } from "../../prompt"; +import { logBullet, logWarning } from "../../utils"; +import { Context } from "./args"; +import { getGitRepositoryLink, parseGitRepositoryLinkName } from "../../gcp/devConnect"; +import * as path from "path"; +import { + developerConnectOrigin, + cloudbuildOrigin, + secretManagerOrigin, + cloudRunApiOrigin, + artifactRegistryDomain, + iamOrigin, +} from "../../api"; +import { ensure } from "../../ensureApiEnabled"; + +/** + * Prepare backend targets to deploy from source. Checks that all required APIs are enabled, + * and that the App Hosting Compute Service Account exists and has the necessary IAM roles. + */ +export default async function (context: Context, options: Options): Promise { + const projectId = needProjectId(options); + + context.backendConfigs = new Map(); + context.backendLocations = new Map(); + context.backendStorageUris = new Map(); + + await Promise.all([ + ensure(projectId, developerConnectOrigin(), "apphosting", true), + ensure(projectId, cloudbuildOrigin(), "apphosting", true), + ensure(projectId, secretManagerOrigin(), "apphosting", true), + ensure(projectId, cloudRunApiOrigin(), "apphosting", true), + ensure(projectId, artifactRegistryDomain(), "apphosting", true), + ensure(projectId, iamOrigin(), "apphosting", true), + ]); + await ensureAppHostingComputeServiceAccount( + projectId, + /* serviceAccount= */ null, + /* deployFromSource= */ true, + ); + + const configs = getBackendConfigs(options); + const { backends } = await listBackends(projectId, "-"); + for (const cfg of configs) { + const filteredBackends = backends.filter( + (backend) => parseBackendName(backend.name).id === cfg.backendId, + ); + if (filteredBackends.length === 0) { + if (options.force) { + throw new FirebaseError( + `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + + "and we cannot create one for you because you must choose a primary region for the backend. " + + "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", + ); + } + logBullet(`No backend '${cfg.backendId}' found. Creating a new backend...`); + const location = await promptLocation( + projectId, + "Select a primary region to host your backend:\n", + ); + const webApp = await webApps.getOrCreateWebApp(projectId, null, cfg.backendId); + if (!webApp) { + logWarning(`Firebase web app not set`); + } + const createBackendSpinner = ora("Creating your new backend...").start(); + const backend = await createBackend( + projectId, + location, + cfg.backendId, + null, + undefined, + webApp?.id, + ); + createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`); + context.backendConfigs.set(cfg.backendId, cfg); + context.backendLocations.set(cfg.backendId, location); + } else if (filteredBackends.length === 1) { + const backend = filteredBackends[0]; + const { location } = parseBackendName(backend.name); + if (cfg.alwaysDeployFromSource === false) { + continue; + } + // We prompt the user for confirmation if they are attempting to deploy from source + // when the backend already has a remote repo connected. We force deploy if the command + // is run with the --force flag. + if (cfg.alwaysDeployFromSource === undefined && backend.codebase?.repository) { + const { connectionName, id } = parseGitRepositoryLinkName(backend.codebase.repository); + const gitRepositoryLink = await getGitRepositoryLink( + projectId, + location, + connectionName, + id, + ); + + let confirmDeploy: boolean; + if (!options.force) { + confirmDeploy = await promptOnce({ + type: "confirm", + name: "deploy", + default: true, + message: `${cfg.backendId} is linked to the remote repository at ${gitRepositoryLink.cloneUri}. Are you sure you want to deploy your local source?`, + }); + cfg.alwaysDeployFromSource = confirmDeploy; + const configPath = path.join(options.projectRoot || "", "firebase.json"); + options.config.writeProjectFile(configPath, options.config.src); + logBullet( + `Your deployment preferences have been saved to firebase.json. On future invocations of "firebase deploy", your local source will be deployed to my-backend. You can edit this setting in your firebase.json at any time.`, + ); + } else { + confirmDeploy = true; + } + if (!confirmDeploy) { + logWarning(`Skipping deployment of backend ${cfg.backendId}`); + continue; + } + } + context.backendConfigs.set(cfg.backendId, cfg); + context.backendLocations.set(cfg.backendId, location); + } else { + const locations = filteredBackends.map((b) => parseBackendName(b.name).location); + throw new FirebaseError( + `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + + "Please delete and recreate any backends that share an ID with another backend.", + ); + } + } + return; +} + +/** + * Exported for unit testing. Filters backend configs based on user input. + */ +export function getBackendConfigs(options: Options): AppHostingMultiple { + if (!options.only) { + return []; + } + if (!options.config.src.apphosting) { + return []; + } + const backendConfigs = Array.isArray(options.config.src.apphosting) + ? options.config.src.apphosting + : [options.config.src.apphosting]; + + const selectors = options.only.split(","); + const backendIds: string[] = []; + for (const selector of selectors) { + // if the user passes the "apphosting" selector, we default to deploying all backends + // listed in the user's firebase.json App Hosting config. + if (selector === "apphosting") { + return backendConfigs; + } + if (selector.startsWith("apphosting:")) { + const backendId = selector.replace("apphosting:", ""); + if (selector.length > 0) { + backendIds.push(backendId); + } + } + } + if (backendIds.length === 0) { + return []; + } + + const filtered = []; + for (const id of backendIds) { + const cfg = backendConfigs.find((cfg) => cfg.backendId === id); + if (cfg) { + filtered.push(cfg); + } + } + return filtered; +} diff --git a/src/deploy/apphosting/release.spec.ts b/src/deploy/apphosting/release.spec.ts new file mode 100644 index 00000000000..6083a99ccb6 --- /dev/null +++ b/src/deploy/apphosting/release.spec.ts @@ -0,0 +1,77 @@ +import * as sinon from "sinon"; +import * as rollout from "../../apphosting/rollout"; +import { Config } from "../../config"; +import { AppHostingSingle } from "../../firebaseConfig"; +import { RC } from "../../rc"; +import { Context } from "./args"; +import release from "./release"; +import { expect } from "chai"; + +const BASE_OPTS = { + cwd: "/", + configPath: "/", + except: "", + force: false, + nonInteractive: false, + interactive: false, + debug: false, + filteredTargets: [], + rc: new RC(), + json: false, +}; + +function initializeContext(): Context { + return { + backendConfigs: new Map([ + [ + "foo", + { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + ], + ]), + backendLocations: new Map([["foo", "us-central1"]]), + backendStorageUris: new Map([ + ["foo", "gs://firebaseapphosting-sources-us-central1/foo-1234.zip"], + ]), + }; +} + +describe("apphosting", () => { + let orchestrateRolloutStub: sinon.SinonStub; + + beforeEach(() => { + orchestrateRolloutStub = sinon + .stub(rollout, "orchestrateRollout") + .throws("Unexpected orchestrateRollout call"); + }); + + afterEach(() => { + sinon.verifyAndRestore(); + }); + + describe("release", () => { + const opts = { + ...BASE_OPTS, + projectId: "my-project", + only: "apphosting", + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + }, + }), + }; + + it("does not block rollouts of other backends if one rollout fails", async () => { + const context = initializeContext(); + orchestrateRolloutStub.onFirstCall().rejects(); + orchestrateRolloutStub.onSecondCall().resolves(); + + await expect(release(context, opts)).to.eventually.not.rejected; + }); + }); +}); diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts new file mode 100644 index 00000000000..2d9417cc4a6 --- /dev/null +++ b/src/deploy/apphosting/release.ts @@ -0,0 +1,59 @@ +import { orchestrateRollout } from "../../apphosting/rollout"; +import { logError } from "../../logError"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { logBullet, logSuccess, logWarning } from "../../utils"; +import { Context } from "./args"; + +/** + * Orchestrates rollouts for the backends targeted for deployment. + */ +export default async function (context: Context, options: Options): Promise { + const projectId = needProjectId(options); + + const rollouts = []; + const rolloutIds = []; + for (const backendId of context.backendConfigs.keys()) { + const config = context.backendConfigs.get(backendId); + const location = context.backendLocations.get(backendId); + const storageUri = context.backendStorageUris.get(backendId); + if (!config || !location || !storageUri) { + logWarning( + `Failed to find metadata for backend ${backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`, + ); + continue; + } + rolloutIds.push(backendId); + rollouts.push( + orchestrateRollout({ + projectId, + location, + backendId, + buildInput: { + source: { + archive: { + userStorageUri: storageUri, + rootDirectory: config.rootDir, + }, + }, + }, + }), + ); + } + + const multipleRolloutsMessage = `Starting new rollouts for backends ${Array.from(context.backendConfigs.keys()).join(", ")}`; + const singleRolloutMessage = `Starting a new rollout for backend ${Array.from(context.backendConfigs.keys()).join(", ")}`; + logBullet( + `${rollouts.length > 1 ? multipleRolloutsMessage : singleRolloutMessage}; this may take a few minutes. It's safe to exit now.`, + ); + const results = await Promise.allSettled(rollouts); + for (let i = 0; i < results.length; i++) { + const res = results[i]; + if (res.status === "fulfilled") { + logSuccess(`Rollout for backend ${rolloutIds[i]} complete`); + } else { + logWarning(`Rollout for backend ${rolloutIds[i]} failed.`); + logError(res.reason); + } + } +} diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts new file mode 100644 index 00000000000..fc48b3dd138 --- /dev/null +++ b/src/deploy/apphosting/util.ts @@ -0,0 +1,93 @@ +import * as archiver from "archiver"; +import * as fs from "fs"; +import * as path from "path"; +import * as tmp from "tmp"; +import { FirebaseError } from "../../error"; +import { AppHostingSingle } from "../../firebaseConfig"; +import * as fsAsync from "../../fsAsync"; +import * as readline from "readline"; + +/** + * Locates the source code for a backend and creates an archive to eventually upload to GCS. + * Based heavily on functions upload logic in src/deploy/functions/prepareFunctionsUpload.ts. + */ +export async function createArchive( + config: AppHostingSingle, + projectRoot?: string, +): Promise { + const tmpFile = tmp.fileSync({ prefix: `${config.backendId}-`, postfix: ".zip" }).name; + const fileStream = fs.createWriteStream(tmpFile, { + flags: "w", + encoding: "binary", + }); + const archive = archiver("zip"); + + // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. + const ignore = config.ignore || ["node_modules", ".git"]; + ignore.push("firebase-debug.log", "firebase-debug.*.log"); + const gitIgnorePatterns = await parseGitIgnorePatterns(); + ignore.push(...gitIgnorePatterns); + + if (!projectRoot) { + projectRoot = process.cwd(); + } + try { + const files = await fsAsync.readdirRecursive({ path: projectRoot, ignore: ignore }); + for (const file of files) { + const name = path.relative(projectRoot, file.name); + archive.file(file.name, { + name, + mode: file.mode, + }); + } + await pipeAsync(archive, fileStream); + } catch (err: unknown) { + throw new FirebaseError( + "Could not read source directory. Remove links and shortcuts and try again.", + { original: err as Error, exit: 1 }, + ); + } + return tmpFile; +} + +async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise { + const absoluteFilePath: string = path.resolve(filePath); + const lines: string[] = []; + return new Promise((resolve, reject) => { + if (!fs.existsSync(absoluteFilePath)) { + resolve([]); + return; + } + const fileStream: fs.ReadStream = fs.createReadStream(absoluteFilePath); + const rl: readline.Interface = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + rl.on("line", (line: string) => { + if (line.startsWith("#") || line.trim() === "") { + return; + } + lines.push(line); + }); + rl.on("close", () => { + resolve(lines); + }); + rl.on("error", (err: Error) => { + console.error(`Error reading lines from file ${absoluteFilePath}:`, err); + reject(err); + }); + fileStream.on("error", (err: Error) => { + console.error(`Error with file stream for ${absoluteFilePath}:`, err); + reject(err); + }); + }); +} + +async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { + from.pipe(to); + await from.finalize(); + return new Promise((resolve, reject) => { + to.on("finish", resolve); + to.on("error", reject); + }); +} diff --git a/src/deploy/index.ts b/src/deploy/index.ts index c5583b937dc..7dd2d093a2b 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -17,6 +17,7 @@ import * as StorageTarget from "./storage"; import * as RemoteConfigTarget from "./remoteconfig"; import * as ExtensionsTarget from "./extensions"; import * as DataConnectTarget from "./dataconnect"; +import * as AppHostingTarget from "./apphosting"; import { prepareFrameworks } from "../frameworks"; import { Context } from "./hosting/context"; import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare"; @@ -35,6 +36,7 @@ const TARGETS = { remoteconfig: RemoteConfigTarget, extensions: ExtensionsTarget, dataconnect: DataConnectTarget, + apphosting: AppHostingTarget, }; export type DeployOptions = Options & { dryRun?: boolean }; diff --git a/src/gcp/apphosting.ts b/src/gcp/apphosting.ts index dfdb83571e8..8d734426fc6 100644 --- a/src/gcp/apphosting.ts +++ b/src/gcp/apphosting.ts @@ -100,7 +100,8 @@ export interface BuildConfig { } interface BuildSource { - codebase: CodebaseSource; + codebase?: CodebaseSource; + archive?: ArchiveSource; } interface CodebaseSource { @@ -116,6 +117,21 @@ interface CodebaseSource { commitTime: string; } +interface ArchiveSource { + // oneof reference + userStorageUri?: string; + externalSignedUri?: string; + // end oneof reference + rootDirectory?: string; + author?: SourceUserMetadata; +} + +interface SourceUserMetadata { + displayName: string; + email: string; + imageUri: string; +} + interface Status { code: number; message: string; diff --git a/src/gcp/devConnect.ts b/src/gcp/devConnect.ts index 05143825d73..2598cc88bfa 100644 --- a/src/gcp/devConnect.ts +++ b/src/gcp/devConnect.ts @@ -304,6 +304,19 @@ export async function fetchGitHubInstallations( return res.body.installations; } +/** + * Splits a Git Repository Link resource name into its parts. + */ +export function parseGitRepositoryLinkName(gitRepositoryLinkName: string): { + projectName: string; + location: string; + connectionName: string; + id: string; +} { + const [, projectName, , location, , connectionName, , id] = gitRepositoryLinkName.split("/"); + return { projectName, location, connectionName, id }; +} + /** * Creates a GitRepositoryLink. Upon linking a Git Repository, Developer * Connect will configure the Git Repository to send webhook events to diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index dc8c5259e82..a31c1daee95 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -142,6 +142,28 @@ interface GetDefaultBucketResponse { }; } +interface CreateBucketRequest { + name: string; + location: string; + lifecycle: { + rule: LifecycleRule[]; + }; +} + +interface LifecycleRule { + action: { + type: string; + }; + condition: { + age: number; + }; +} + +interface UploadObjectResponse { + selfLink: string; + mediaLink: string; +} + /** Response type for obtaining the storage service agent */ interface StorageServiceAccountResponse { email_address: string; @@ -230,7 +252,11 @@ export async function uploadObject( source: { file: string; stream: Readable }, /** Bucket to upload to. */ bucketName: string, -): Promise<{ bucket: string; object: string; generation: string | null }> { +): Promise<{ + bucket: string; + object: string; + generation: string | null; +}> { if (path.extname(source.file) !== ".zip") { throw new FirebaseError(`Expected a file name ending in .zip, got ${source.file}`); } @@ -252,6 +278,20 @@ export async function uploadObject( }; } +/** + * Get a storage object from GCP. + * @param {string} bucketName name of the storage bucket that contains the object + * @param {string} objectName name of the object + */ +export async function getObject( + bucketName: string, + objectName: string, +): Promise { + const client = new Client({ urlPrefix: storageOrigin() }); + const res = await client.get(`/storage/v1/b/${bucketName}/o/${objectName}`); + return res.body; +} + /** * Deletes an object via Firebase Storage. * @param {string} location A Firebase Storage location, of the form "/v0/b//o/" @@ -280,6 +320,36 @@ export async function getBucket(bucketName: string): Promise { } } +/** + * Creates a storage bucket on GCP. + * Ref: https://cloud.google.com/storage/docs/json_api/v1/buckets/insert + * @param {string} bucketName name of the storage bucket + * @return a bucket resource object + */ +export async function createBucket( + projectId: string, + req: CreateBucketRequest, +): Promise { + try { + const localAPIClient = new Client({ urlPrefix: storageOrigin() }); + const result = await localAPIClient.post( + `/storage/v1/b`, + req, + { + queryParams: { + project: projectId, + }, + }, + ); + return result.body; + } catch (err: any) { + logger.debug(err); + throw new FirebaseError("Failed to create the storage bucket", { + original: err, + }); + } +} + /** * Gets the list of storage buckets associated with a specific project from GCP. * Ref: https://cloud.google.com/storage/docs/json_api/v1/buckets/list From a0f00ff6755997a99a32a7e0c192c53c6996f546 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 8 May 2025 23:38:14 -0400 Subject: [PATCH 02/22] minor fixes & use fuzzy search for backends --- src/init/features/apphosting.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init/features/apphosting.ts b/src/init/features/apphosting.ts index 2a5cfec4472..ea2ef0ba80e 100644 --- a/src/init/features/apphosting.ts +++ b/src/init/features/apphosting.ts @@ -13,10 +13,10 @@ import { Config } from "../../config"; import { FirebaseError } from "../../error"; import { AppHostingSingle } from "../../firebaseConfig"; import { checkBillingEnabled } from "../../gcp/cloudbilling"; +import { input, select } from "../../prompt"; import { readTemplateSync } from "../../templates"; import * as utils from "../../utils"; import { logBullet } from "../../utils"; -import { input, select } from "../../prompt"; const APPHOSTING_YAML_TEMPLATE = readTemplateSync("init/apphosting/apphosting.yaml"); From 15882528f6d4bd419c8ed7dbc8a28bbff5a6d2a0 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Fri, 9 May 2025 00:00:38 -0400 Subject: [PATCH 03/22] add storage.objectViewer role on compute SA --- src/init/features/apphosting.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/init/features/apphosting.ts b/src/init/features/apphosting.ts index ea2ef0ba80e..74bca692702 100644 --- a/src/init/features/apphosting.ts +++ b/src/init/features/apphosting.ts @@ -6,6 +6,7 @@ import { webApps } from "../../apphosting/app"; import { createBackend, promptExistingBackend, + ensureAppHostingComputeServiceAccount, promptLocation, promptNewBackendId, } from "../../apphosting/backend"; @@ -26,6 +27,21 @@ const APPHOSTING_YAML_TEMPLATE = readTemplateSync("init/apphosting/apphosting.ya export async function doSetup(setup: any, config: Config): Promise { const projectId = setup.projectId as string; await checkBillingEnabled(projectId); + // N.B. To deploy from source, the App Hosting Compute Service Account must have + // the storage.objectViewer IAM role. For firebase-tools <= 14.3.0, the CLI does + // not add the objectViewer role, which means all existing customers will need to + // add it before deploying from source. + // + // We don't want to update the IAM permissions right before attempting to deploy, + // since IAM propagation delay will likely cause the first one to fail. However, + // `firebase init apphosting` is a prerequisite to the `firebase deploy` command, + // so we check and add the role here to give the IAM changes time to propagate. + await ensureAppHostingComputeServiceAccount( + projectId, + /* serviceAccount= */ null, + /* deployFromSource= */ true, + ); + utils.logBullet( "This command links your local project to Firebase App Hosting. You will be able to deploy your web app with `firebase deploy` after setup.", ); From f7be95374c03a550880f19e1f99f35566312391d Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 00:54:57 -0400 Subject: [PATCH 04/22] respond to first round of feedback --- src/apphosting/backend.ts | 4 ++++ src/deploy/apphosting/deploy.ts | 5 ++--- src/deploy/apphosting/prepare.ts | 31 +++++++++++++++---------------- src/init/features/apphosting.ts | 8 ++++---- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index baa189c933b..505eaeef2be 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -243,6 +243,10 @@ export async function ensureAppHostingComputeServiceAccount( ); } } + // N.B. To deploy from source, the App Hosting Compute Service Account must have + // the storage.objectViewer IAM role. For firebase-tools <= 14.3.0, the CLI does + // not add the objectViewer role, which means all existing customers will need to + // add it before deploying from source. if (deployFromSource) { const policy = await getIamPolicy(projectId); const objectViewerBinding = policy.bindings.find( diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts index 262bb3e6a3f..69eadc584c2 100644 --- a/src/deploy/apphosting/deploy.ts +++ b/src/deploy/apphosting/deploy.ts @@ -21,8 +21,7 @@ export default async function (context: Context, options: Options): Promise { const projectId = needProjectId(options); + await ensureApiEnabled(options); context.backendConfigs = new Map(); context.backendLocations = new Map(); @@ -105,9 +106,7 @@ export default async function (context: Context, options: Options): Promise { const projectId = setup.projectId as string; await checkBillingEnabled(projectId); - // N.B. To deploy from source, the App Hosting Compute Service Account must have - // the storage.objectViewer IAM role. For firebase-tools <= 14.3.0, the CLI does - // not add the objectViewer role, which means all existing customers will need to - // add it before deploying from source. + await ensureApiEnabled({ projectId }); + // N.B. Deploying a backend from source requires the App Hosting compute service + // account to have the storage.objectViewer IAM role. // // We don't want to update the IAM permissions right before attempting to deploy, // since IAM propagation delay will likely cause the first one to fail. However, From b5519ac656b70c1f158e7a028c910c7d47d3d18e Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 11:50:02 -0400 Subject: [PATCH 05/22] second round of feedback --- src/apphosting/backend.ts | 24 +++++++++++ src/deploy/apphosting/prepare.spec.ts | 38 ++++------------- src/deploy/apphosting/prepare.ts | 60 +++++++-------------------- src/deploy/apphosting/release.ts | 4 +- 4 files changed, 48 insertions(+), 78 deletions(-) diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index 505eaeef2be..9a0c449b215 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -179,6 +179,30 @@ export async function doSetup( logSuccess(`Your backend is now deployed at:\n\thttps://${backend.uri}`); } +/** + * Setup up a new App Hosting backend to deploy from source. + */ +export async function doSetupSourceDeploy( + projectId: string, + backendId: string, +): Promise<{ backend: Backend; location: string }> { + const location = await promptLocation( + projectId, + "Select a primary region to host your backend:\n", + ); + const webApp = await webApps.getOrCreateWebApp(projectId, null, backendId); + if (!webApp) { + logWarning(`Firebase web app not set`); + } + const createBackendSpinner = ora("Creating your new backend...").start(); + const backend = await createBackend(projectId, location, backendId, null, undefined, webApp?.id); + createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`); + return { + backend, + location, + }; +} + /** * Set up a new App Hosting-type Developer Connect GitRepoLink, optionally with a specific connection ID */ diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index fd0c325d470..aee407aebe9 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; import * as sinon from "sinon"; -import { webApps } from "../../apphosting/app"; import * as backend from "../../apphosting/backend"; import { Config } from "../../config"; import * as apiEnabled from "../../ensureApiEnabled"; @@ -47,25 +46,17 @@ describe("apphosting", () => { }), }; - let promptOnceStub: sinon.SinonStub; - let promptLocationStub: sinon.SinonStub; - let getOrCreateWebAppStub: sinon.SinonStub; - let createBackendStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + let doSetupSourceDeployStub: sinon.SinonStub; let listBackendsStub: sinon.SinonStub; let getGitRepositoryLinkStub: sinon.SinonStub; beforeEach(() => { sinon.stub(opts.config, "writeProjectFile").returns(); - promptOnceStub = sinon.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); - promptLocationStub = sinon - .stub(backend, "promptLocation") - .throws("Unexpected promptLocation call"); - getOrCreateWebAppStub = sinon - .stub(webApps, "getOrCreateWebApp") - .throws("Unexpected getOrCreateWebAppStub call"); - createBackendStub = sinon - .stub(backend, "createBackend") - .throws("Unexpected createBackend call"); + confirmStub = sinon.stub(prompt, "confirm").throws("Unexpected confirm call"); + doSetupSourceDeployStub = sinon + .stub(backend, "doSetupSourceDeploy") + .throws("Unexpected doSetupSourceDeploy call"); listBackendsStub = sinon .stub(apphosting, "listBackends") .throws("Unexpected listBackends call"); @@ -106,22 +97,11 @@ describe("apphosting", () => { listBackendsStub.onFirstCall().resolves({ backends: [], }); - promptLocationStub.onFirstCall().resolves("us-central1"); - getOrCreateWebAppStub.onFirstCall().resolves({ - id: "my-web-app", - }); - createBackendStub.onFirstCall().resolves({ name: "foo" }); + doSetupSourceDeployStub.resolves({ location: "us-central1" }); await prepare(context, opts); - expect(createBackendStub).to.be.calledWith( - "my-project", - "us-central1", - "foo", - null, - undefined, - "my-web-app", - ); + expect(doSetupSourceDeployStub).to.be.calledWith("my-project", "foo"); expect(context.backendLocations.get("foo")).to.equal("us-central1"); expect(context.backendConfigs.get("foo")).to.deep.equal({ backendId: "foo", @@ -176,7 +156,7 @@ describe("apphosting", () => { getGitRepositoryLinkStub.onFirstCall().resolves({ cloneUri: "github.com/my-org/foo.git", }); - promptOnceStub.onFirstCall().resolves(true); + confirmStub.onFirstCall().resolves(true); await prepare(context, opts); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 34b1525993a..6840616abf1 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -1,4 +1,3 @@ -import * as ora from "ora"; import * as path from "path"; import { artifactRegistryDomain, @@ -8,11 +7,9 @@ import { iamOrigin, secretManagerOrigin, } from "../../api"; -import { webApps } from "../../apphosting/app"; import { - createBackend, + doSetupSourceDeploy, ensureAppHostingComputeServiceAccount, - promptLocation, } from "../../apphosting/backend"; import { ensure } from "../../ensureApiEnabled"; import { FirebaseError } from "../../error"; @@ -57,6 +54,7 @@ export default async function (context: Context, options: Options): Promise parseBackendName(backend.name).id === cfg.backendId, ); + let location: string; if (filteredBackends.length === 0) { if (options.force) { throw new FirebaseError( @@ -66,32 +64,13 @@ export default async function (context: Context, options: Options): Promise parseBackendName(b.name).location); throw new FirebaseError( @@ -133,6 +107,8 @@ export default async function (context: Context, options: Options): Promise 0) { + if (backendId.length > 0) { backendIds.push(backendId); } } @@ -169,13 +145,5 @@ export function getBackendConfigs(options: Options): AppHostingMultiple { if (backendIds.length === 0) { return []; } - - const filtered = []; - for (const id of backendIds) { - const cfg = backendConfigs.find((cfg) => cfg.backendId === id); - if (cfg) { - filtered.push(cfg); - } - } - return filtered; + return backendConfigs.filter((cfg) => backendIds.includes(cfg.backendId)); } diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts index 2d9417cc4a6..6f562ca8b26 100644 --- a/src/deploy/apphosting/release.ts +++ b/src/deploy/apphosting/release.ts @@ -41,10 +41,8 @@ export default async function (context: Context, options: Options): Promise 1 ? multipleRolloutsMessage : singleRolloutMessage}; this may take a few minutes. It's safe to exit now.`, + `Starting rollout(s) for backend(s) ${Array.from(context.backendConfigs.keys()).join(", ")}; this may take a few minutes. It's safe to exit now.`, ); const results = await Promise.allSettled(rollouts); for (let i = 0; i < results.length; i++) { From e344fae20af6467281f4d3676f8415844c95d5a2 Mon Sep 17 00:00:00 2001 From: joehan Date: Mon, 12 May 2025 09:30:30 -0700 Subject: [PATCH 06/22] Fix VSCode import error (#8546) --- firebase-vscode/src/logger-wrapper.ts | 3 +-- src/logger.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/firebase-vscode/src/logger-wrapper.ts b/firebase-vscode/src/logger-wrapper.ts index f9264a32478..ec5b5ce0390 100644 --- a/firebase-vscode/src/logger-wrapper.ts +++ b/firebase-vscode/src/logger-wrapper.ts @@ -2,11 +2,10 @@ import * as path from "path"; import * as vscode from "vscode"; import * as fs from "fs"; import * as os from "os"; -import { transports, format } from "winston"; import Transport from "winston-transport"; import { stripVTControlCharacters } from "node:util"; import { SPLAT } from "triple-beam"; -import { logger as cliLogger, useConsoleLoggers, useFileLogger } from "../../src/logger"; +import { logger as cliLogger, useConsoleLoggers, useFileLogger, tryStringify } from "../../src/logger"; import { setInquirerLogger } from "./stubs/inquirer-stub"; import { getRootFolders } from "./core/config"; diff --git a/src/logger.ts b/src/logger.ts index 053b919ab6c..75870d0c636 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -147,7 +147,7 @@ export function findAvailableLogFile(): string { throw new Error("Unable to obtain permissions for firebase-debug.log"); } -function tryStringify(value: any) { +export function tryStringify(value: any) { if (typeof value === "string") { return value; } From 68caf08293f178f6a17b9056786c7153214c9270 Mon Sep 17 00:00:00 2001 From: blidd-google <112491344+blidd-google@users.noreply.github.com> Date: Mon, 12 May 2025 12:54:36 -0400 Subject: [PATCH 07/22] Add GCP API client functions to support App Hosting deploy from source feature (#8545) * add gcp api calls to support deploy from source --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbd1b91c6b..3b222c86216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ - Changed artifact registry cleanup policy error to warn for CI/CD workloads #8513 - Enhance firebase init apphosting to support local source deploys. (#8479) +- Add GCP API client functions to support App Hosting deploy from source feature. (#8545) From 588b11ff63b4a8d09ef774fe20969c601efc06ec Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Mon, 12 May 2025 10:20:43 -0700 Subject: [PATCH 08/22] fix(mcp): Make all input schemas valid for Gemini. (#8537) Gemini is currently not able to handle arbitrary key/value dictionaries, so I had to change some of the schemas around. --- src/gcp/auth.ts | 2 +- src/mcp/tools/auth/set_claims.ts | 31 ++++++++++++------- src/mcp/tools/dataconnect/converter.ts | 10 ++++++ src/mcp/tools/dataconnect/execute_graphql.ts | 13 +++++--- .../tools/dataconnect/execute_graphql_read.ts | 13 +++++--- src/mcp/tools/dataconnect/execute_mutation.ts | 13 +++++--- src/mcp/tools/dataconnect/execute_query.ts | 13 +++++--- 7 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/gcp/auth.ts b/src/gcp/auth.ts index 708a89bda41..abd95c9e26e 100644 --- a/src/gcp/auth.ts +++ b/src/gcp/auth.ts @@ -138,7 +138,7 @@ export async function findUser( expression: [expression], limit: "1", }); - if (res.body.userInfo.length === 0) { + if (!res.body.userInfo?.length) { throw new Error("No users found"); } const modifiedUserInfo = res.body.userInfo.map((ui) => { diff --git a/src/mcp/tools/auth/set_claims.ts b/src/mcp/tools/auth/set_claims.ts index b76a6112a01..aeb234af388 100644 --- a/src/mcp/tools/auth/set_claims.ts +++ b/src/mcp/tools/auth/set_claims.ts @@ -1,25 +1,26 @@ import { z } from "zod"; import { tool } from "../../tool.js"; -import { toContent } from "../../util.js"; +import { mcpError, toContent } from "../../util.js"; import { setCustomClaim } from "../../../gcp/auth.js"; export const set_claim = tool( { name: "set_claims", description: - "Sets custom claims on a specific user's account. Use to create trusted values associated with a user e.g. marking them as an admin. Claims are limited in size and should be succinct in name and value.", + "Sets custom claims on a specific user's account. Use to create trusted values associated with a user e.g. marking them as an admin. Claims are limited in size and should be succinct in name and value. Specify ONLY ONE OF `value` or `json_value` parameters.", inputSchema: z.object({ uid: z.string().describe("the UID of the user to update"), claim: z.string().describe("the name (key) of the claim to update, e.g. 'admin'"), value: z - .union([ - z.string(), - z.number(), - z.boolean(), - z.record(z.union([z.string(), z.number(), z.boolean()])), - z.array(z.union([z.string(), z.number(), z.boolean()])), - ]) - .describe("the value of the custom claim"), + .union([z.string(), z.number(), z.boolean()]) + .describe("set the value of the custom claim to the specified simple scalar value") + .optional(), + json_value: z + .string() + .optional() + .describe( + "set the claim to a complex JSON value like an object or an array by providing stringified JSON. string must be parseable as valid JSON", + ), }), annotations: { title: "Set custom Firebase Auth claim", @@ -30,7 +31,15 @@ export const set_claim = tool( requiresProject: true, }, }, - async ({ uid, claim, value }, { projectId }) => { + async ({ uid, claim, value, json_value }, { projectId }) => { + if (value && json_value) return mcpError("Must supply only `value` or `json_value`, not both."); + if (json_value) { + try { + value = JSON.parse(json_value); + } catch (e) { + return mcpError(`Provided \`json_value\` was not valid JSON: ${json_value}`); + } + } return toContent(await setCustomClaim(projectId!, uid, { [claim]: value }, { merge: true })); }, ); diff --git a/src/mcp/tools/dataconnect/converter.ts b/src/mcp/tools/dataconnect/converter.ts index 18e163c1938..5301aad4c69 100644 --- a/src/mcp/tools/dataconnect/converter.ts +++ b/src/mcp/tools/dataconnect/converter.ts @@ -56,3 +56,13 @@ export function graphqlResponseToToolResponse( return mcpError(JSON.stringify(g, null, 2)); } } + +export function parseVariables(unparsedVariables?: string): Record { + try { + const variables = JSON.parse(unparsedVariables || "{}"); + if (typeof variables !== "object") throw new Error("not an object"); + return variables; + } catch (e) { + throw new Error("Provided variables string `" + unparsedVariables + "` is not valid JSON."); + } +} diff --git a/src/mcp/tools/dataconnect/execute_graphql.ts b/src/mcp/tools/dataconnect/execute_graphql.ts index 8e38ea96c25..3dceab8328c 100644 --- a/src/mcp/tools/dataconnect/execute_graphql.ts +++ b/src/mcp/tools/dataconnect/execute_graphql.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { tool } from "../../tool.js"; import * as client from "../../../dataconnect/dataplaneClient.js"; import { pickService } from "../../../dataconnect/fileUtils.js"; -import { graphqlResponseToToolResponse } from "./converter.js"; +import { graphqlResponseToToolResponse, parseVariables } from "./converter.js"; export const execute_graphql = tool( { @@ -17,7 +17,12 @@ export const execute_graphql = tool( .describe( "The Firebase Data Connect service ID to look for. If there is only one service defined in firebase.json, this can be omitted and that will be used.", ), - variables: z.record(z.string()).optional().describe("Variables for this operation."), + variables: z + .string() + .optional() + .describe( + "A stringified JSON object containing variables for the operation. MUST be valid JSON.", + ), }), annotations: { title: "Executes a arbitrary GraphQL query or mutation against a Data Connect service", @@ -28,12 +33,12 @@ export const execute_graphql = tool( requiresAuth: true, }, }, - async ({ query, serviceId, variables }, { projectId, config }) => { + async ({ query, serviceId, variables: unparsedVariables }, { projectId, config }) => { const serviceInfo = await pickService(projectId!, config!, serviceId || undefined); const response = await client.executeGraphQL( client.dataconnectDataplaneClient(), serviceInfo.serviceName, - { name: "", query, variables }, + { name: "", query, variables: parseVariables(unparsedVariables) }, ); return graphqlResponseToToolResponse(response.body); }, diff --git a/src/mcp/tools/dataconnect/execute_graphql_read.ts b/src/mcp/tools/dataconnect/execute_graphql_read.ts index 3756e1824fa..da3b6f0947e 100644 --- a/src/mcp/tools/dataconnect/execute_graphql_read.ts +++ b/src/mcp/tools/dataconnect/execute_graphql_read.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { tool } from "../../tool.js"; import * as client from "../../../dataconnect/dataplaneClient.js"; import { pickService } from "../../../dataconnect/fileUtils.js"; -import { graphqlResponseToToolResponse } from "./converter.js"; +import { graphqlResponseToToolResponse, parseVariables } from "./converter.js"; export const execute_graphql_read = tool( { @@ -17,7 +17,12 @@ export const execute_graphql_read = tool( .describe( "The Firebase Data Connect service ID to look for. If there is only one service defined in firebase.json, this can be omitted and that will be used.", ), - variables: z.record(z.string()).optional().describe("Variables for this operation."), + variables: z + .string() + .optional() + .describe( + "A stringified JSON object containing variables for the operation. MUST be valid JSON.", + ), }), annotations: { title: "Executes a arbitrary GraphQL query against a Data Connect service", @@ -28,12 +33,12 @@ export const execute_graphql_read = tool( requiresAuth: true, }, }, - async ({ query, serviceId, variables }, { projectId, config }) => { + async ({ query, serviceId, variables: unparsedVariables }, { projectId, config }) => { const serviceInfo = await pickService(projectId!, config!, serviceId || undefined); const response = await client.executeGraphQLRead( client.dataconnectDataplaneClient(), serviceInfo.serviceName, - { name: "", query, variables }, + { name: "", query, variables: parseVariables(unparsedVariables) }, ); return graphqlResponseToToolResponse(response.body); }, diff --git a/src/mcp/tools/dataconnect/execute_mutation.ts b/src/mcp/tools/dataconnect/execute_mutation.ts index 5d1d514bc6c..045573e3f22 100644 --- a/src/mcp/tools/dataconnect/execute_mutation.ts +++ b/src/mcp/tools/dataconnect/execute_mutation.ts @@ -4,7 +4,7 @@ import { tool } from "../../tool.js"; import { mcpError } from "../../util.js"; import * as client from "../../../dataconnect/dataplaneClient.js"; import { pickService } from "../../../dataconnect/fileUtils.js"; -import { graphqlResponseToToolResponse } from "./converter.js"; +import { graphqlResponseToToolResponse, parseVariables } from "./converter.js"; export const execute_mutation = tool( { @@ -25,10 +25,10 @@ export const execute_mutation = tool( "The Firebase Data Connect connector ID to look for. If there is only one connector defined in dataconnect.yaml, this can be omitted and that will be used.", ), variables: z - .record(z.string()) + .string() .optional() .describe( - "Variables for this operation. Use dataconnect_get_connector to find the expected variables for this query", + "A stringified JSON object containing the variables needed to execute the operation. The value MUST be able to be parsed as a JSON object.", ), }), annotations: { @@ -40,7 +40,10 @@ export const execute_mutation = tool( requiresAuth: true, }, }, - async ({ operationName, serviceId, connectorId, variables }, { projectId, config }) => { + async ( + { operationName, serviceId, connectorId, variables: unparsedVariables }, + { projectId, config }, + ) => { const serviceInfo = await pickService(projectId!, config!, serviceId || undefined); if (!connectorId) { if (serviceInfo.connectorInfo.length === 0) { @@ -57,7 +60,7 @@ export const execute_mutation = tool( const response = await client.executeGraphQLMutation( client.dataconnectDataplaneClient(), connectorPath, - { operationName, variables }, + { operationName, variables: parseVariables(unparsedVariables) }, ); return graphqlResponseToToolResponse(response.body); }, diff --git a/src/mcp/tools/dataconnect/execute_query.ts b/src/mcp/tools/dataconnect/execute_query.ts index 8e2db5e5b20..e0efcf1b558 100644 --- a/src/mcp/tools/dataconnect/execute_query.ts +++ b/src/mcp/tools/dataconnect/execute_query.ts @@ -4,7 +4,7 @@ import { tool } from "../../tool.js"; import { mcpError } from "../../util.js"; import * as client from "../../../dataconnect/dataplaneClient.js"; import { pickService } from "../../../dataconnect/fileUtils.js"; -import { graphqlResponseToToolResponse } from "./converter.js"; +import { graphqlResponseToToolResponse, parseVariables } from "./converter.js"; export const execute_query = tool( { @@ -25,10 +25,10 @@ export const execute_query = tool( "The Firebase Data Connect connector ID to look for. If there is only one connector defined in dataconnect.yaml, this can be omitted and that will be used.", ), variables: z - .record(z.string()) + .string() .optional() .describe( - "Variables for this operation. Use dataconnect_get_connector to find the expected variables for this query", + "A stringified JSON object containing the variables needed to execute the operation. The value MUST be able to be parsed as a JSON object.", ), }), annotations: { @@ -40,7 +40,10 @@ export const execute_query = tool( requiresAuth: true, }, }, - async ({ operationName, serviceId, connectorId, variables }, { projectId, config }) => { + async ( + { operationName, serviceId, connectorId, variables: unparsedVariables }, + { projectId, config }, + ) => { const serviceInfo = await pickService(projectId!, config!, serviceId || undefined); if (!connectorId) { if (serviceInfo.connectorInfo.length === 0) { @@ -57,7 +60,7 @@ export const execute_query = tool( const response = await client.executeGraphQLQuery( client.dataconnectDataplaneClient(), connectorPath, - { operationName, variables }, + { operationName, variables: parseVariables(unparsedVariables) }, ); return graphqlResponseToToolResponse(response.body); }, From 219b9014327607afc8113694c4301e0e59b03aea Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 14:35:21 -0400 Subject: [PATCH 09/22] log source code upload path --- src/deploy/apphosting/deploy.spec.ts | 20 ++++++++++++++++---- src/deploy/apphosting/deploy.ts | 6 ++++-- src/deploy/apphosting/util.ts | 11 ++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/deploy/apphosting/deploy.spec.ts b/src/deploy/apphosting/deploy.spec.ts index 994c79e9c3b..0fafc735494 100644 --- a/src/deploy/apphosting/deploy.spec.ts +++ b/src/deploy/apphosting/deploy.spec.ts @@ -89,8 +89,14 @@ describe("apphosting", () => { }), ); createBucketStub.resolves(); - createArchiveStub.resolves("path/to/foo-1234.zip"); - uploadObjectStub.resolves(); + createArchiveStub.resolves({ + projectSourcePath: "my-project/", + zippedSourcePath: "path/to/foo-1234.zip", + }); + uploadObjectStub.resolves({ + bucket: "firebaseapphosting-sources-12345678-us-central1", + object: "foo-1234", + }); createReadStreamStub.resolves(); await deploy(context, opts); @@ -103,8 +109,14 @@ describe("apphosting", () => { getProjectNumberStub.resolves("000000000000"); getBucketStub.resolves(); createBucketStub.resolves(); - createArchiveStub.resolves("path/to/foo-1234.zip"); - uploadObjectStub.resolves(); + createArchiveStub.resolves({ + projectSourcePath: "my-project/", + zippedSourcePath: "path/to/foo-1234.zip", + }); + uploadObjectStub.resolves({ + bucket: "firebaseapphosting-sources-12345678-us-central1", + object: "foo-1234", + }); createReadStreamStub.resolves(); await deploy(context, opts); diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts index 69eadc584c2..88ccb73e43b 100644 --- a/src/deploy/apphosting/deploy.ts +++ b/src/deploy/apphosting/deploy.ts @@ -68,20 +68,22 @@ export default async function (context: Context, options: Options): Promise { +): Promise<{ projectSourcePath: string; zippedSourcePath: string }> { const tmpFile = tmp.fileSync({ prefix: `${config.backendId}-`, postfix: ".zip" }).name; const fileStream = fs.createWriteStream(tmpFile, { flags: "w", @@ -47,12 +47,11 @@ export async function createArchive( { original: err as Error, exit: 1 }, ); } - return tmpFile; + return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile }; } async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise { const absoluteFilePath: string = path.resolve(filePath); - const lines: string[] = []; return new Promise((resolve, reject) => { if (!fs.existsSync(absoluteFilePath)) { resolve([]); @@ -63,11 +62,13 @@ async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise { - if (line.startsWith("#") || line.trim() === "") { + const trimmedLine = line.trim(); + if (trimmedLine.startsWith("#") || trimmedLine === "") { return; } - lines.push(line); + lines.push(trimmedLine); }); rl.on("close", () => { resolve(lines); From 908eb34454f1f1b2ab1055ef445ff1ddc535a36b Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 16:25:02 -0400 Subject: [PATCH 10/22] simplify gitignore parsing --- src/deploy/apphosting/util.ts | 42 +++++++---------------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index d8c4b29dac0..a5b47ec1862 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -5,7 +5,6 @@ import * as tmp from "tmp"; import { FirebaseError } from "../../error"; import { AppHostingSingle } from "../../firebaseConfig"; import * as fsAsync from "../../fsAsync"; -import * as readline from "readline"; /** * Locates the source code for a backend and creates an archive to eventually upload to GCS. @@ -25,7 +24,7 @@ export async function createArchive( // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. const ignore = config.ignore || ["node_modules", ".git"]; ignore.push("firebase-debug.log", "firebase-debug.*.log"); - const gitIgnorePatterns = await parseGitIgnorePatterns(); + const gitIgnorePatterns = parseGitIgnorePatterns(); ignore.push(...gitIgnorePatterns); if (!projectRoot) { @@ -50,38 +49,13 @@ export async function createArchive( return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile }; } -async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise { - const absoluteFilePath: string = path.resolve(filePath); - return new Promise((resolve, reject) => { - if (!fs.existsSync(absoluteFilePath)) { - resolve([]); - return; - } - const fileStream: fs.ReadStream = fs.createReadStream(absoluteFilePath); - const rl: readline.Interface = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - const lines: string[] = []; - rl.on("line", (line: string) => { - const trimmedLine = line.trim(); - if (trimmedLine.startsWith("#") || trimmedLine === "") { - return; - } - lines.push(trimmedLine); - }); - rl.on("close", () => { - resolve(lines); - }); - rl.on("error", (err: Error) => { - console.error(`Error reading lines from file ${absoluteFilePath}:`, err); - reject(err); - }); - fileStream.on("error", (err: Error) => { - console.error(`Error with file stream for ${absoluteFilePath}:`, err); - reject(err); - }); - }); +function parseGitIgnorePatterns(filePath = ".gitignore"): string[] { + const absoluteFilePath = path.resolve(filePath); + return fs + .readFileSync(absoluteFilePath) + .toString() // Buffer -> string + .split("\n") // split into lines + .filter((line) => !line.trim().startsWith("#") && !(line.trim() === "")); // remove comments and empty lines } async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { From d771ce8be5104dbbbdb52def0e6bccc91f3d7052 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 18:03:55 -0400 Subject: [PATCH 11/22] fix bug not deploying with no --only flag --- src/deploy/apphosting/prepare.spec.ts | 12 ++++++++++++ src/deploy/apphosting/prepare.ts | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index aee407aebe9..e0a18bceb5e 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -184,6 +184,18 @@ describe("apphosting", () => { }, ]; + it("selects all backends when no --only is passed", () => { + const configs = getBackendConfigs({ + ...BASE_OPTS, + only: "", + config: new Config({ + apphosting: apphostingConfig, + }), + }); + + expect(configs).to.deep.equal(apphostingConfig); + }); + it("selects all backends when --apphosting is passed", () => { const configs = getBackendConfigs({ ...BASE_OPTS, diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 6840616abf1..7d7a5e1f069 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -117,16 +117,16 @@ export default async function (context: Context, options: Options): Promise Date: Mon, 12 May 2025 22:24:28 -0400 Subject: [PATCH 12/22] support negation rules in .gitignore --- src/deploy/apphosting/util.ts | 26 ++++++++++++++++++++------ src/fsAsync.spec.ts | 25 +++++++++++++++++++++---- src/fsAsync.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index a5b47ec1862..86f18d8aa7a 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -24,14 +24,18 @@ export async function createArchive( // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. const ignore = config.ignore || ["node_modules", ".git"]; ignore.push("firebase-debug.log", "firebase-debug.*.log"); - const gitIgnorePatterns = parseGitIgnorePatterns(); - ignore.push(...gitIgnorePatterns); + const { ignorePatterns, negationPatterns } = parseGitIgnorePatterns(); + ignore.push(...ignorePatterns); if (!projectRoot) { projectRoot = process.cwd(); } try { - const files = await fsAsync.readdirRecursive({ path: projectRoot, ignore: ignore }); + const files = await fsAsync.readdirRecursive({ + path: projectRoot, + ignore: ignore, + include: negationPatterns.map((pattern) => pattern.slice(1)), // remove "!" from pattern + }); for (const file of files) { const name = path.relative(projectRoot, file.name); archive.file(file.name, { @@ -49,13 +53,23 @@ export async function createArchive( return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile }; } -function parseGitIgnorePatterns(filePath = ".gitignore"): string[] { +function parseGitIgnorePatterns(filePath = ".gitignore"): { + ignorePatterns: string[]; + negationPatterns: string[]; +} { const absoluteFilePath = path.resolve(filePath); - return fs + const lines = fs .readFileSync(absoluteFilePath) .toString() // Buffer -> string .split("\n") // split into lines - .filter((line) => !line.trim().startsWith("#") && !(line.trim() === "")); // remove comments and empty lines + .map((line) => line.trim()) + .filter((line) => !line.startsWith("#") && !(line === "")); // remove comments and empty lines + const ignores = lines.filter((line) => !line.startsWith("!")); + const negations = lines.filter((line) => line.startsWith("!")); + return { + ignorePatterns: ignores, + negationPatterns: negations, + }; } async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { diff --git a/src/fsAsync.spec.ts b/src/fsAsync.spec.ts index 2e6cd62729f..e777577c11d 100644 --- a/src/fsAsync.spec.ts +++ b/src/fsAsync.spec.ts @@ -13,10 +13,11 @@ import * as fsAsync from "./fsAsync"; // visible // subdir/ // subfile -// nesteddir/ -// nestedfile -// node_modules/ -// nestednodemodules +// nesteddir/ +// nestedfile +// nestedfile2 +// node_modules/ +// nestednodemodules // node_modules // subfile describe("fsAsync", () => { @@ -26,6 +27,7 @@ describe("fsAsync", () => { "visible", "subdir/subfile", "subdir/nesteddir/nestedfile", + "subdir/nesteddir/nestedfile2", "subdir/node_modules/nestednodemodules", "node_modules/subfile", ]; @@ -93,5 +95,20 @@ describe("fsAsync", () => { .sort(); return expect(gotFileNames).to.deep.equal(expectFiles); }); + + it("should support git negation via options", async () => { + const results = await fsAsync.readdirRecursive({ + path: baseDir, + ignore: [path.join(baseDir, "subdir/nesteddir")], + include: ["subdir/nesteddir/nestedfile"], + }); + + const gotFileNames = results.map((r) => r.name).sort(); + const expectFiles = files + .map((file) => path.join(baseDir, file)) + .filter((file) => file !== path.join(baseDir, "subdir/nesteddir/nestedfile2")) + .sort(); + return expect(gotFileNames).to.deep.equal(expectFiles); + }); }); }); diff --git a/src/fsAsync.ts b/src/fsAsync.ts index f8a9eee66d3..cf4c364c90c 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -2,12 +2,15 @@ import { join } from "path"; import { readdirSync, statSync } from "fs-extra"; import * as _ from "lodash"; import * as minimatch from "minimatch"; +import { existsSync } from "fs"; export interface ReaddirRecursiveOpts { // The directory to recurse. path: string; // Files to ignore. ignore?: string[]; + // Files in the ignore array to include. + include?: string[]; } export interface ReaddirRecursiveFile { @@ -57,8 +60,30 @@ export async function readdirRecursive( return rule(t); }); }; - return readdirRecursiveHelper({ + const files = await readdirRecursiveHelper({ path: options.path, filter: filter, }); + const filenames = files.map((file) => file.name); + + // Re-include the files may have been previously ignored + for (const p of options.include || []) { + if (!existsSync(join(options.path, p))) { + continue; + } + if (filenames.includes(join(options.path, p))) { + continue; + } + const fstat = statSync(join(options.path, p)); + if (fstat.isFile()) { + files.push({ name: join(options.path, p), mode: fstat.mode }); + } else { + const filesToInclude = await readdirRecursiveHelper({ + path: join(options.path, p), + filter: (t: string) => filenames.includes(join(options.path, t)), + }); + files.push(...filesToInclude); + } + } + return files; } From bc0cfbfcf6dc3a19253c0ac6fe195c28c2c8e0b4 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Mon, 12 May 2025 22:59:52 -0400 Subject: [PATCH 13/22] clean up readdirrecursive logic --- src/fsAsync.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/fsAsync.ts b/src/fsAsync.ts index cf4c364c90c..c179a7c6927 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -68,18 +68,19 @@ export async function readdirRecursive( // Re-include the files may have been previously ignored for (const p of options.include || []) { - if (!existsSync(join(options.path, p))) { + const absPath = join(options.path, p); + if (!existsSync(absPath)) { continue; } - if (filenames.includes(join(options.path, p))) { + if (filenames.includes(absPath)) { continue; } - const fstat = statSync(join(options.path, p)); + const fstat = statSync(absPath); if (fstat.isFile()) { - files.push({ name: join(options.path, p), mode: fstat.mode }); + files.push({ name: absPath, mode: fstat.mode }); } else { const filesToInclude = await readdirRecursiveHelper({ - path: join(options.path, p), + path: absPath, filter: (t: string) => filenames.includes(join(options.path, t)), }); files.push(...filesToInclude); From f0eefccaffcf895c9e2fd599302d9abd8c569f82 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Tue, 13 May 2025 16:31:08 -0400 Subject: [PATCH 14/22] polish & cleanup --- src/apphosting/backend.ts | 23 +++++++++++++++-------- src/commands/init.ts | 2 +- src/deploy/apphosting/prepare.ts | 28 ++++++---------------------- src/deploy/apphosting/release.ts | 19 ++++++++++++------- src/init/index.ts | 7 +++++-- src/management/apps.ts | 11 +++++++++++ 6 files changed, 50 insertions(+), 40 deletions(-) diff --git a/src/apphosting/backend.ts b/src/apphosting/backend.ts index 9a0c449b215..c675a75b8e2 100644 --- a/src/apphosting/backend.ts +++ b/src/apphosting/backend.ts @@ -76,14 +76,7 @@ export async function doSetup( webAppName: string | null, serviceAccount: string | null, ): Promise { - await Promise.all([ - ensure(projectId, developerConnectOrigin(), "apphosting", true), - ensure(projectId, cloudbuildOrigin(), "apphosting", true), - ensure(projectId, secretManagerOrigin(), "apphosting", true), - ensure(projectId, cloudRunApiOrigin(), "apphosting", true), - ensure(projectId, artifactRegistryDomain(), "apphosting", true), - ensure(projectId, iamOrigin(), "apphosting", true), - ]); + await ensureRequiredApisEnabled(projectId); // Hack: Because IAM can take ~45 seconds to propagate, we provision the service account as soon as // possible to reduce the likelihood that the subsequent Cloud Build fails. See b/336862200. @@ -203,6 +196,20 @@ export async function doSetupSourceDeploy( }; } +/** + * Check that all GCP APIs required for App Hosting are enabled. + */ +export async function ensureRequiredApisEnabled(projectId: string): Promise { + await Promise.all([ + ensure(projectId, developerConnectOrigin(), "apphosting", true), + ensure(projectId, cloudbuildOrigin(), "apphosting", true), + ensure(projectId, secretManagerOrigin(), "apphosting", true), + ensure(projectId, cloudRunApiOrigin(), "apphosting", true), + ensure(projectId, artifactRegistryDomain(), "apphosting", true), + ensure(projectId, iamOrigin(), "apphosting", true), + ]); +} + /** * Set up a new App Hosting-type Developer Connect GitRepoLink, optionally with a specific connection ID */ diff --git a/src/commands/init.ts b/src/commands/init.ts index 358f017876b..9b41dd1c8f4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -48,7 +48,7 @@ let choices: { }, { value: "apphosting", - name: "App Hosting: Configure an apphosting.yaml file for App Hosting", + name: "App Hosting: Enable web app deployments with App Hosting", checked: false, hidden: false, }, diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 7d7a5e1f069..6eb31d87ae4 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -1,17 +1,9 @@ import * as path from "path"; -import { - artifactRegistryDomain, - cloudbuildOrigin, - cloudRunApiOrigin, - developerConnectOrigin, - iamOrigin, - secretManagerOrigin, -} from "../../api"; import { doSetupSourceDeploy, ensureAppHostingComputeServiceAccount, + ensureRequiredApisEnabled, } from "../../apphosting/backend"; -import { ensure } from "../../ensureApiEnabled"; import { FirebaseError } from "../../error"; import { AppHostingMultiple, AppHostingSingle } from "../../firebaseConfig"; import { ensureApiEnabled, listBackends, parseBackendName } from "../../gcp/apphosting"; @@ -29,25 +21,17 @@ import { Context } from "./args"; export default async function (context: Context, options: Options): Promise { const projectId = needProjectId(options); await ensureApiEnabled(options); - - context.backendConfigs = new Map(); - context.backendLocations = new Map(); - context.backendStorageUris = new Map(); - - await Promise.all([ - ensure(projectId, developerConnectOrigin(), "apphosting", true), - ensure(projectId, cloudbuildOrigin(), "apphosting", true), - ensure(projectId, secretManagerOrigin(), "apphosting", true), - ensure(projectId, cloudRunApiOrigin(), "apphosting", true), - ensure(projectId, artifactRegistryDomain(), "apphosting", true), - ensure(projectId, iamOrigin(), "apphosting", true), - ]); + await ensureRequiredApisEnabled(projectId); await ensureAppHostingComputeServiceAccount( projectId, /* serviceAccount= */ null, /* deployFromSource= */ true, ); + context.backendConfigs = new Map(); + context.backendLocations = new Map(); + context.backendStorageUris = new Map(); + const configs = getBackendConfigs(options); const { backends } = await listBackends(projectId, "-"); for (const cfg of configs) { diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts index 6f562ca8b26..2676f9fcfa8 100644 --- a/src/deploy/apphosting/release.ts +++ b/src/deploy/apphosting/release.ts @@ -1,8 +1,10 @@ +import * as ora from "ora"; +import { getBackend } from "../../apphosting/backend"; import { orchestrateRollout } from "../../apphosting/rollout"; import { logError } from "../../logError"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { logBullet, logSuccess, logWarning } from "../../utils"; +import { logSuccess, logWarning } from "../../utils"; import { Context } from "./args"; /** @@ -12,7 +14,7 @@ export default async function (context: Context, options: Options): Promise Promise; @@ -64,7 +65,7 @@ const featuresList: Feature[] = [ { name: "remoteconfig", doSetup: features.remoteconfig }, { name: "hosting:github", doSetup: features.hostingGithub }, { name: "genkit", doSetup: features.genkit }, - { name: "apphosting", doSetup: features.apphosting }, + { name: "apphosting", displayName: "App Hosting", doSetup: features.apphosting }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); @@ -82,7 +83,9 @@ export async function init(setup: Setup, config: any, options: any): Promise' and try again.", + { + exit: 2, + original: err, + }, + ); + } throw new FirebaseError( `Failed to list Firebase ${platform === AppPlatform.ANY ? "" : platform + " "}` + "apps. See firebase-debug.log for more info.", From f84ff770e213c09025319e0f076bd587e5de5287 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 14 May 2025 14:43:11 -0400 Subject: [PATCH 15/22] refactor to use 'ignore' pkg to apply .gitignore rules --- npm-shrinkwrap.json | 77 +++++++++++++++++++++++++++++++---- package.json | 1 + src/deploy/apphosting/util.ts | 18 +++----- src/fsAsync.ts | 39 ++++++------------ 4 files changed, 88 insertions(+), 47 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9be32acb4e1..4f43abe2231 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -41,6 +41,7 @@ "gaxios": "^6.7.0", "glob": "^10.4.1", "google-auth-library": "^9.11.0", + "ignore": "^7.0.4", "js-yaml": "^3.14.1", "jsonwebtoken": "^9.0.0", "leven": "^3.1.0", @@ -1417,6 +1418,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -5515,6 +5525,15 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9543,6 +9562,15 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/eslint/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -11102,6 +11130,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", @@ -12334,10 +12371,9 @@ ] }, "node_modules/ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", "engines": { "node": ">= 4" } @@ -22614,6 +22650,12 @@ "type-fest": "^0.20.2" } }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -25855,6 +25897,12 @@ "ms": "2.1.2" } }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -28624,6 +28672,12 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -29880,6 +29934,14 @@ "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" + }, + "dependencies": { + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + } } }, "globrex": { @@ -30841,10 +30903,9 @@ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==" }, "import-fresh": { "version": "3.3.0", diff --git a/package.json b/package.json index 859d5263da7..d1363e022e2 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "gaxios": "^6.7.0", "glob": "^10.4.1", "google-auth-library": "^9.11.0", + "ignore": "^7.0.4", "js-yaml": "^3.14.1", "jsonwebtoken": "^9.0.0", "leven": "^3.1.0", diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index 86f18d8aa7a..ece4ca82a32 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -24,8 +24,8 @@ export async function createArchive( // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. const ignore = config.ignore || ["node_modules", ".git"]; ignore.push("firebase-debug.log", "firebase-debug.*.log"); - const { ignorePatterns, negationPatterns } = parseGitIgnorePatterns(); - ignore.push(...ignorePatterns); + const gitIgnorePatterns = parseGitIgnorePatterns(); + ignore.push(...gitIgnorePatterns); if (!projectRoot) { projectRoot = process.cwd(); @@ -34,7 +34,7 @@ export async function createArchive( const files = await fsAsync.readdirRecursive({ path: projectRoot, ignore: ignore, - include: negationPatterns.map((pattern) => pattern.slice(1)), // remove "!" from pattern + isGitIgnore: true, }); for (const file of files) { const name = path.relative(projectRoot, file.name); @@ -53,10 +53,7 @@ export async function createArchive( return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile }; } -function parseGitIgnorePatterns(filePath = ".gitignore"): { - ignorePatterns: string[]; - negationPatterns: string[]; -} { +function parseGitIgnorePatterns(filePath = ".gitignore"): string[] { const absoluteFilePath = path.resolve(filePath); const lines = fs .readFileSync(absoluteFilePath) @@ -64,12 +61,7 @@ function parseGitIgnorePatterns(filePath = ".gitignore"): { .split("\n") // split into lines .map((line) => line.trim()) .filter((line) => !line.startsWith("#") && !(line === "")); // remove comments and empty lines - const ignores = lines.filter((line) => !line.startsWith("!")); - const negations = lines.filter((line) => line.startsWith("!")); - return { - ignorePatterns: ignores, - negationPatterns: negations, - }; + return lines; } async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { diff --git a/src/fsAsync.ts b/src/fsAsync.ts index c179a7c6927..26123f9636b 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -1,14 +1,15 @@ -import { join } from "path"; import { readdirSync, statSync } from "fs-extra"; +import ignorePkg from "ignore"; import * as _ from "lodash"; import * as minimatch from "minimatch"; -import { existsSync } from "fs"; +import { join, relative } from "path"; export interface ReaddirRecursiveOpts { // The directory to recurse. path: string; // Files to ignore. ignore?: string[]; + isGitIgnore?: boolean; // Files in the ignore array to include. include?: string[]; } @@ -55,36 +56,22 @@ export async function readdirRecursive( const rules = (options.ignore || []).map((glob) => { return (p: string) => minimatch(p, glob, mmopts); }); + const gitIgnoreRules = ignorePkg() + .add(options.ignore || []) + .createFilter(); + const filter = (t: string): boolean => { + if (options.isGitIgnore) { + // the git ignore filter will return true if given path should be included, + // so we need to negative that return false to avoid filtering it. + return !gitIgnoreRules(relative(options.path, t)); + } return rules.some((rule) => { return rule(t); }); }; - const files = await readdirRecursiveHelper({ + return await readdirRecursiveHelper({ path: options.path, filter: filter, }); - const filenames = files.map((file) => file.name); - - // Re-include the files may have been previously ignored - for (const p of options.include || []) { - const absPath = join(options.path, p); - if (!existsSync(absPath)) { - continue; - } - if (filenames.includes(absPath)) { - continue; - } - const fstat = statSync(absPath); - if (fstat.isFile()) { - files.push({ name: absPath, mode: fstat.mode }); - } else { - const filesToInclude = await readdirRecursiveHelper({ - path: absPath, - filter: (t: string) => filenames.includes(join(options.path, t)), - }); - files.push(...filesToInclude); - } - } - return files; } From 9fe41339df99e1f861cb5f80dd9a4151264a1cb4 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 14 May 2025 14:49:25 -0400 Subject: [PATCH 16/22] fix gitignore unit test --- src/fsAsync.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fsAsync.spec.ts b/src/fsAsync.spec.ts index e777577c11d..b4fee377249 100644 --- a/src/fsAsync.spec.ts +++ b/src/fsAsync.spec.ts @@ -96,11 +96,11 @@ describe("fsAsync", () => { return expect(gotFileNames).to.deep.equal(expectFiles); }); - it("should support git negation via options", async () => { + it("should support .gitignore rules via options", async () => { const results = await fsAsync.readdirRecursive({ path: baseDir, - ignore: [path.join(baseDir, "subdir/nesteddir")], - include: ["subdir/nesteddir/nestedfile"], + ignore: ["subdir/nesteddir/*", "!subdir/nesteddir/nestedfile"], + isGitIgnore: true, }); const gotFileNames = results.map((r) => r.name).sort(); From d019b57103b3bca1c0695e0ca1e3823bcd6b7056 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 14 May 2025 21:40:10 -0400 Subject: [PATCH 17/22] rework prepare flow and polish copy --- src/deploy/apphosting/deploy.ts | 2 +- src/deploy/apphosting/prepare.spec.ts | 4 + src/deploy/apphosting/prepare.ts | 196 ++++++++++++++++++++------ src/deploy/apphosting/release.ts | 2 +- src/management/apps.ts | 11 -- 5 files changed, 156 insertions(+), 59 deletions(-) diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts index 88ccb73e43b..0d8782f400d 100644 --- a/src/deploy/apphosting/deploy.ts +++ b/src/deploy/apphosting/deploy.ts @@ -75,7 +75,7 @@ export default async function (context: Context, options: Options): Promise { }; let confirmStub: sinon.SinonStub; + let checkboxStub: sinon.SinonStub; let doSetupSourceDeployStub: sinon.SinonStub; let listBackendsStub: sinon.SinonStub; let getGitRepositoryLinkStub: sinon.SinonStub; @@ -54,6 +55,7 @@ describe("apphosting", () => { beforeEach(() => { sinon.stub(opts.config, "writeProjectFile").returns(); confirmStub = sinon.stub(prompt, "confirm").throws("Unexpected confirm call"); + checkboxStub = sinon.stub(prompt, "checkbox").throws("Unexpected checkbox scall"); doSetupSourceDeployStub = sinon .stub(backend, "doSetupSourceDeploy") .throws("Unexpected doSetupSourceDeploy call"); @@ -98,6 +100,8 @@ describe("apphosting", () => { backends: [], }); doSetupSourceDeployStub.resolves({ location: "us-central1" }); + confirmStub.resolves(true); + checkboxStub.resolves(["foo"]); await prepare(context, opts); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 6eb31d87ae4..a9dabeb92ea 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -10,7 +10,7 @@ import { ensureApiEnabled, listBackends, parseBackendName } from "../../gcp/apph import { getGitRepositoryLink, parseGitRepositoryLinkName } from "../../gcp/devConnect"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { confirm } from "../../prompt"; +import { checkbox, confirm } from "../../prompt"; import { logBullet, logWarning } from "../../utils"; import { Context } from "./args"; @@ -34,66 +34,170 @@ export default async function (context: Context, options: Options): Promise parseBackendName(backend.name).id === cfg.backendId, ); - let location: string; if (filteredBackends.length === 0) { - if (options.force) { - throw new FirebaseError( - `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + - "and we cannot create one for you because you must choose a primary region for the backend. " + - "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", - ); - } - logBullet(`No backend '${cfg.backendId}' found. Creating a new backend...`); - ({ location } = await doSetupSourceDeploy(projectId, cfg.backendId)); + notFoundBackends.push(cfg); } else if (filteredBackends.length === 1) { - if (cfg.alwaysDeployFromSource === false) { - continue; - } - const backend = filteredBackends[0]; - ({ location } = parseBackendName(backend.name)); - // We prompt the user for confirmation if they are attempting to deploy from source - // when the backend already has a remote repo connected. We force deploy if the command - // is run with the --force flag. - if (cfg.alwaysDeployFromSource === undefined && backend.codebase?.repository) { - const { connectionName, id } = parseGitRepositoryLinkName(backend.codebase.repository); - const gitRepositoryLink = await getGitRepositoryLink( - projectId, - location, - connectionName, - id, - ); + foundBackends.push(cfg); + } else { + ambiguousBackends.push(cfg); + } + } + + // log warning for each ambiguous backend + for (const cfg of ambiguousBackends) { + const filteredBackends = backends.filter( + (backend) => parseBackendName(backend.name).id === cfg.backendId, + ); + const locations = filteredBackends.map((b) => parseBackendName(b.name).location); + logWarning( + `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + + "Please delete and recreate any backends that share an ID with another backend.", + ); + } + + if (foundBackends.length > 0) { + logBullet(`Found backend(s) ${foundBackends.map((cfg) => cfg.backendId).join(", ")}`); + } + for (const cfg of foundBackends) { + const filteredBackends = backends.filter( + (backend) => parseBackendName(backend.name).id === cfg.backendId, + ); + if (cfg.alwaysDeployFromSource === false) { + continue; + } + const backend = filteredBackends[0]; + const { location } = parseBackendName(backend.name); + // We prompt the user for confirmation if they are attempting to deploy from source + // when the backend already has a remote repo connected. We force deploy if the command + // is run with the --force flag. + if (cfg.alwaysDeployFromSource === undefined && backend.codebase?.repository) { + const { connectionName, id } = parseGitRepositoryLinkName(backend.codebase.repository); + const gitRepositoryLink = await getGitRepositoryLink(projectId, location, connectionName, id); - if (!options.force) { - const confirmDeploy = await confirm({ - default: true, - message: `${cfg.backendId} is linked to the remote repository at ${gitRepositoryLink.cloneUri}. Are you sure you want to deploy your local source?`, - }); - cfg.alwaysDeployFromSource = confirmDeploy; - const configPath = path.join(options.projectRoot || "", "firebase.json"); - options.config.writeProjectFile(configPath, options.config.src); - logBullet( - `Your deployment preferences have been saved to firebase.json. On future invocations of "firebase deploy", your local source will be deployed to my-backend. You can edit this setting in your firebase.json at any time.`, - ); - if (!confirmDeploy) { - logWarning(`Skipping deployment of backend ${cfg.backendId}`); - continue; - } + if (!options.force) { + const confirmDeploy = await confirm({ + default: true, + message: `${cfg.backendId} is linked to the remote repository at ${gitRepositoryLink.cloneUri}. Are you sure you want to deploy your local source?`, + }); + cfg.alwaysDeployFromSource = confirmDeploy; + const configPath = path.join(options.projectRoot || "", "firebase.json"); + options.config.writeProjectFile(configPath, options.config.src); + logBullet( + `Your deployment preferences have been saved to firebase.json. On future invocations of "firebase deploy", your local source will be deployed to my-backend. You can edit this setting in your firebase.json at any time.`, + ); + if (!confirmDeploy) { + logWarning(`Skipping deployment of backend ${cfg.backendId}`); + continue; } } - } else { - const locations = filteredBackends.map((b) => parseBackendName(b.name).location); + } + context.backendConfigs.set(cfg.backendId, cfg); + context.backendLocations.set(cfg.backendId, location); + } + + if (notFoundBackends.length === 0) { + return; + } + const confirmCreate = await confirm({ + default: true, + message: `Did not find backend(s) ${notFoundBackends.map((cfg) => cfg.backendId).join(", ")}. Do you want to create them (you'll have the option to select which to create in the next step)?`, + }); + if (!confirmCreate) { + return; + } + const selected = await checkbox({ + message: "Which backends do you want to create and deploy to?", + choices: notFoundBackends.map((cfg) => cfg.backendId), + }); + const selectedBackends = selected.map((id) => + notFoundBackends.find((backend) => backend.backendId === id), + ) as AppHostingSingle[]; + for (const cfg of selectedBackends) { + if (options.force) { throw new FirebaseError( - `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + - "Please delete and recreate any backends that share an ID with another backend.", + `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + + "and we cannot create one for you because you must choose a primary region for the backend. " + + "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", ); } + logBullet(`Creating a new backend ${cfg.backendId}...`); + const { location } = await doSetupSourceDeploy(projectId, cfg.backendId); context.backendConfigs.set(cfg.backendId, cfg); context.backendLocations.set(cfg.backendId, location); } + + // if (process.env.SHORT_CIRCUIT) { + // throw new FirebaseError("short circuit"); + // } + + // for (const cfg of configs) { + // const filteredBackends = backends.filter( + // (backend) => parseBackendName(backend.name).id === cfg.backendId, + // ); + // let location: string; + // if (filteredBackends.length === 0) { + // if (options.force) { + // throw new FirebaseError( + // `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + + // "and we cannot create one for you because you must choose a primary region for the backend. " + + // "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", + // ); + // } + // logBullet(`No backend '${cfg.backendId}' found. Creating a new backend...`); + // ({ location } = await doSetupSourceDeploy(projectId, cfg.backendId)); + // } else if (filteredBackends.length === 1) { + // if (cfg.alwaysDeployFromSource === false) { + // continue; + // } + // const backend = filteredBackends[0]; + // ({ location } = parseBackendName(backend.name)); + // // We prompt the user for confirmation if they are attempting to deploy from source + // // when the backend already has a remote repo connected. We force deploy if the command + // // is run with the --force flag. + // if (cfg.alwaysDeployFromSource === undefined && backend.codebase?.repository) { + // const { connectionName, id } = parseGitRepositoryLinkName(backend.codebase.repository); + // const gitRepositoryLink = await getGitRepositoryLink( + // projectId, + // location, + // connectionName, + // id, + // ); + + // if (!options.force) { + // const confirmDeploy = await confirm({ + // default: true, + // message: `${cfg.backendId} is linked to the remote repository at ${gitRepositoryLink.cloneUri}. Are you sure you want to deploy your local source?`, + // }); + // cfg.alwaysDeployFromSource = confirmDeploy; + // const configPath = path.join(options.projectRoot || "", "firebase.json"); + // options.config.writeProjectFile(configPath, options.config.src); + // logBullet( + // `Your deployment preferences have been saved to firebase.json. On future invocations of "firebase deploy", your local source will be deployed to my-backend. You can edit this setting in your firebase.json at any time.`, + // ); + // if (!confirmDeploy) { + // logWarning(`Skipping deployment of backend ${cfg.backendId}`); + // continue; + // } + // } + // } + // } else { + // const locations = filteredBackends.map((b) => parseBackendName(b.name).location); + // throw new FirebaseError( + // `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + + // "Please delete and recreate any backends that share an ID with another backend.", + // ); + // } + // context.backendConfigs.set(cfg.backendId, cfg); + // context.backendLocations.set(cfg.backendId, location); + // } return; } diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts index 2676f9fcfa8..f6a90df52af 100644 --- a/src/deploy/apphosting/release.ts +++ b/src/deploy/apphosting/release.ts @@ -44,7 +44,7 @@ export default async function (context: Context, options: Options): Promise' and try again.", - { - exit: 2, - original: err, - }, - ); - } throw new FirebaseError( `Failed to list Firebase ${platform === AppPlatform.ANY ? "" : platform + " "}` + "apps. See firebase-debug.log for more info.", From e72954e4634e34c0c303f43b40f2a5326edadb5e Mon Sep 17 00:00:00 2001 From: Brian Li Date: Wed, 14 May 2025 23:31:07 -0400 Subject: [PATCH 18/22] fix merge issues --- src/init/features/apphosting.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/init/features/apphosting.ts b/src/init/features/apphosting.ts index b2f848979fc..e246177200d 100644 --- a/src/init/features/apphosting.ts +++ b/src/init/features/apphosting.ts @@ -9,7 +9,6 @@ import { ensureAppHostingComputeServiceAccount, ensureRequiredApisEnabled, promptExistingBackend, - ensureAppHostingComputeServiceAccount, promptLocation, promptNewBackendId, } from "../../apphosting/backend"; @@ -22,9 +21,6 @@ import { input, select } from "../../prompt"; import { readTemplateSync } from "../../templates"; import * as utils from "../../utils"; import { logBullet } from "../../utils"; -import { ensureApiEnabled } from "../../gcp/apphosting"; -import { Setup } from ".."; -import { isBillingEnabled } from "../../gcp/cloudbilling"; const APPHOSTING_YAML_TEMPLATE = readTemplateSync("init/apphosting/apphosting.yaml"); From 57b9d4c52112d184ab05668061da61dca5fd321d Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 15 May 2025 10:05:10 -0400 Subject: [PATCH 19/22] more polish & cleanup --- src/deploy/apphosting/deploy.ts | 15 ++++-- src/deploy/apphosting/prepare.ts | 84 +++++--------------------------- src/deploy/apphosting/release.ts | 24 ++++++--- src/init/features/apphosting.ts | 2 +- 4 files changed, 40 insertions(+), 85 deletions(-) diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts index 0d8782f400d..06def5ef56a 100644 --- a/src/deploy/apphosting/deploy.ts +++ b/src/deploy/apphosting/deploy.ts @@ -5,7 +5,7 @@ import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { logBullet, logWarning } from "../../utils"; +import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { Context } from "./args"; import { createArchive } from "./util"; @@ -30,7 +30,8 @@ export default async function (context: Context, options: Options): Promise parseBackendName(backend.name).id === cfg.backendId, ); const locations = filteredBackends.map((b) => parseBackendName(b.name).location); - logWarning( + logLabeledWarning( + "apphosting", `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + "Please delete and recreate any backends that share an ID with another backend.", ); } if (foundBackends.length > 0) { - logBullet(`Found backend(s) ${foundBackends.map((cfg) => cfg.backendId).join(", ")}`); + logLabeledBullet( + "apphosting", + `Found backend(s) ${foundBackends.map((cfg) => cfg.backendId).join(", ")}`, + ); } for (const cfg of foundBackends) { const filteredBackends = backends.filter( @@ -90,11 +94,12 @@ export default async function (context: Context, options: Options): Promise parseBackendName(backend.name).id === cfg.backendId, - // ); - // let location: string; - // if (filteredBackends.length === 0) { - // if (options.force) { - // throw new FirebaseError( - // `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + - // "and we cannot create one for you because you must choose a primary region for the backend. " + - // "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", - // ); - // } - // logBullet(`No backend '${cfg.backendId}' found. Creating a new backend...`); - // ({ location } = await doSetupSourceDeploy(projectId, cfg.backendId)); - // } else if (filteredBackends.length === 1) { - // if (cfg.alwaysDeployFromSource === false) { - // continue; - // } - // const backend = filteredBackends[0]; - // ({ location } = parseBackendName(backend.name)); - // // We prompt the user for confirmation if they are attempting to deploy from source - // // when the backend already has a remote repo connected. We force deploy if the command - // // is run with the --force flag. - // if (cfg.alwaysDeployFromSource === undefined && backend.codebase?.repository) { - // const { connectionName, id } = parseGitRepositoryLinkName(backend.codebase.repository); - // const gitRepositoryLink = await getGitRepositoryLink( - // projectId, - // location, - // connectionName, - // id, - // ); - - // if (!options.force) { - // const confirmDeploy = await confirm({ - // default: true, - // message: `${cfg.backendId} is linked to the remote repository at ${gitRepositoryLink.cloneUri}. Are you sure you want to deploy your local source?`, - // }); - // cfg.alwaysDeployFromSource = confirmDeploy; - // const configPath = path.join(options.projectRoot || "", "firebase.json"); - // options.config.writeProjectFile(configPath, options.config.src); - // logBullet( - // `Your deployment preferences have been saved to firebase.json. On future invocations of "firebase deploy", your local source will be deployed to my-backend. You can edit this setting in your firebase.json at any time.`, - // ); - // if (!confirmDeploy) { - // logWarning(`Skipping deployment of backend ${cfg.backendId}`); - // continue; - // } - // } - // } - // } else { - // const locations = filteredBackends.map((b) => parseBackendName(b.name).location); - // throw new FirebaseError( - // `You have multiple backends with the same ${cfg.backendId} ID in regions: ${locations.join(", ")}. This is not allowed until we can support more locations. ` + - // "Please delete and recreate any backends that share an ID with another backend.", - // ); - // } - // context.backendConfigs.set(cfg.backendId, cfg); - // context.backendLocations.set(cfg.backendId, location); - // } return; } diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts index f6a90df52af..606ec27bcd9 100644 --- a/src/deploy/apphosting/release.ts +++ b/src/deploy/apphosting/release.ts @@ -1,10 +1,15 @@ import * as ora from "ora"; +import { consoleOrigin } from "../../api"; import { getBackend } from "../../apphosting/backend"; import { orchestrateRollout } from "../../apphosting/rollout"; -import { logError } from "../../logError"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { logSuccess, logWarning } from "../../utils"; +import { + logLabeledBullet, + logLabeledError, + logLabeledSuccess, + logLabeledWarning, +} from "../../utils"; import { Context } from "./args"; /** @@ -20,7 +25,8 @@ export default async function (context: Context, options: Options): Promise { const projectId = setup.projectId as string; if (!(await isBillingEnabled(setup))) { throw new FirebaseError( - "Firebase App Hosting requires billing to be enabled on your project. Please enable billing by following the steps at https://cloud.google.com/billing/docs/how-to/modify-project", + `Firebase App Hosting requires billing to be enabled on your project. To upgrade, visit the following URL: https://console.firebase.google.com/project/${projectId}/usage/details`, ); } await ensureApiEnabled({ projectId }); From d6e37bcf61672881300cbad17d3da76b9c3ba7fd Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 15 May 2025 12:03:19 -0400 Subject: [PATCH 20/22] skip not found backends with force --- src/deploy/apphosting/prepare.ts | 39 +++++++++++++++++++++----------- src/deploy/apphosting/util.ts | 13 +++++------ src/init/features/apphosting.ts | 13 +++++------ 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 08b4737411f..728973fc314 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -4,7 +4,6 @@ import { ensureAppHostingComputeServiceAccount, ensureRequiredApisEnabled, } from "../../apphosting/backend"; -import { FirebaseError } from "../../error"; import { AppHostingMultiple, AppHostingSingle } from "../../firebaseConfig"; import { ensureApiEnabled, listBackends, parseBackendName } from "../../gcp/apphosting"; import { getGitRepositoryLink, parseGitRepositoryLinkName } from "../../gcp/devConnect"; @@ -13,6 +12,7 @@ import { needProjectId } from "../../projectUtils"; import { checkbox, confirm } from "../../prompt"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { Context } from "./args"; +import { FirebaseError } from "../../error"; /** * Prepare backend targets to deploy from source. Checks that all required APIs are enabled, @@ -22,11 +22,21 @@ export default async function (context: Context, options: Options): Promise(); context.backendLocations = new Map(); @@ -111,6 +121,16 @@ export default async function (context: Context, options: Options): Promise cfg.backendId).join(", ")}; ` + + "the backend(s) do not exist yet and we cannot create them for you because you must choose primary regions for each one. " + + "Please run 'firebase deploy' without the --force flag, or 'firebase apphosting:backends:create' to create the backend, " + + "then retry deployment.", + ); + return; + } const confirmCreate = await confirm({ default: true, message: `Did not find backend(s) ${notFoundBackends.map((cfg) => cfg.backendId).join(", ")}. Do you want to create them (you'll have the option to select which to create in the next step)?`, @@ -126,13 +146,6 @@ export default async function (context: Context, options: Options): Promise backend.backendId === id), ) as AppHostingSingle[]; for (const cfg of selectedBackends) { - if (options.force) { - throw new FirebaseError( - `Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` + - "and we cannot create one for you because you must choose a primary region for the backend. " + - "Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.", - ); - } logLabeledBullet("apphosting", `Creating a new backend ${cfg.backendId}...`); const { location } = await doSetupSourceDeploy(projectId, cfg.backendId); context.backendConfigs.set(cfg.backendId, cfg); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index ece4ca82a32..01a1ff48d93 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -21,15 +21,14 @@ export async function createArchive( }); const archive = archiver("zip"); + if (!projectRoot) { + projectRoot = process.cwd(); + } // We must ignore firebase-debug.log or weird things happen if you're in the public dir when you deploy. const ignore = config.ignore || ["node_modules", ".git"]; ignore.push("firebase-debug.log", "firebase-debug.*.log"); - const gitIgnorePatterns = parseGitIgnorePatterns(); + const gitIgnorePatterns = parseGitIgnorePatterns(projectRoot); ignore.push(...gitIgnorePatterns); - - if (!projectRoot) { - projectRoot = process.cwd(); - } try { const files = await fsAsync.readdirRecursive({ path: projectRoot, @@ -53,8 +52,8 @@ export async function createArchive( return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile }; } -function parseGitIgnorePatterns(filePath = ".gitignore"): string[] { - const absoluteFilePath = path.resolve(filePath); +function parseGitIgnorePatterns(projectRoot: string, gitIgnorePath = ".gitignore"): string[] { + const absoluteFilePath = path.resolve(path.join(projectRoot, gitIgnorePath)); const lines = fs .readFileSync(absoluteFilePath) .toString() // Buffer -> string diff --git a/src/init/features/apphosting.ts b/src/init/features/apphosting.ts index 6dedd1baf4a..69020fc9686 100644 --- a/src/init/features/apphosting.ts +++ b/src/init/features/apphosting.ts @@ -35,6 +35,7 @@ export async function doSetup(setup: Setup, config: Config): Promise { ); } await ensureApiEnabled({ projectId }); + await ensureRequiredApisEnabled(projectId); // N.B. Deploying a backend from source requires the App Hosting compute service // account to have the storage.objectViewer IAM role. // @@ -42,14 +43,12 @@ export async function doSetup(setup: Setup, config: Config): Promise { // since IAM propagation delay will likely cause the first one to fail. However, // `firebase init apphosting` is a prerequisite to the `firebase deploy` command, // so we check and add the role here to give the IAM changes time to propagate. - await ensureAppHostingComputeServiceAccount( - projectId, - /* serviceAccount= */ null, - /* deployFromSource= */ true, - ); - await ensureRequiredApisEnabled(projectId); try { - await ensureAppHostingComputeServiceAccount(projectId, /* serviceAccount= */ ""); + await ensureAppHostingComputeServiceAccount( + projectId, + /* serviceAccount= */ "", + /* deployFromSource= */ true, + ); } catch (err) { if ((err as FirebaseError).status === 400) { utils.logWarning( From 611dc971b3bdcc561acba74b2bc71e21bada3300 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 15 May 2025 12:57:59 -0400 Subject: [PATCH 21/22] return empty array if git ignore doesn't exist --- src/deploy/apphosting/util.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index 01a1ff48d93..b39c0e10065 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -54,6 +54,9 @@ export async function createArchive( function parseGitIgnorePatterns(projectRoot: string, gitIgnorePath = ".gitignore"): string[] { const absoluteFilePath = path.resolve(path.join(projectRoot, gitIgnorePath)); + if (!fs.existsSync(absoluteFilePath)) { + return []; + } const lines = fs .readFileSync(absoluteFilePath) .toString() // Buffer -> string From 1526d3dfe8e709f503216b16aa6f69e0387a5e96 Mon Sep 17 00:00:00 2001 From: Brian Li Date: Thu, 15 May 2025 13:30:17 -0400 Subject: [PATCH 22/22] return empty array if no gitignore found --- src/deploy/apphosting/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index b39c0e10065..b16705f31c6 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -53,7 +53,7 @@ export async function createArchive( } function parseGitIgnorePatterns(projectRoot: string, gitIgnorePath = ".gitignore"): string[] { - const absoluteFilePath = path.resolve(path.join(projectRoot, gitIgnorePath)); + const absoluteFilePath = path.resolve(projectRoot, gitIgnorePath); if (!fs.existsSync(absoluteFilePath)) { return []; }