From 6d1804ceef41f4dca2bbda4f7001cf23c7c2cf12 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Fri, 2 Oct 2026 16:35:51 +0000 Subject: [PATCH 01/10] feat(crashlytics): add heap dump collection management commands - Add crashlytics:heapdumps:enable to check billing, auto-provision GCS bucket with CORS and 90-day file TTL, generate Crashlytics service identity, grant P4SA storage object creator role, and enable Profiling Manager collection. - Add crashlytics:heapdumps:disable to disable heap dump collection. - Add crashlytics:heapdumps:status to query collection status and bucket details. - Implement Profiling Manager API helpers in src/crashlytics/profilingManager.ts. - Add comprehensive unit tests in src/commands/crashlytics-heapdumps.spec.ts and src/crashlytics/profilingManager.spec.ts. # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + src/commands/crashlytics-heapdumps-disable.ts | 63 ++++ src/commands/crashlytics-heapdumps-enable.ts | 102 +++++++ src/commands/crashlytics-heapdumps-status.ts | 40 +++ src/commands/crashlytics-heapdumps.spec.ts | 174 +++++++++++ src/commands/index.ts | 4 + src/crashlytics/profilingManager.spec.ts | 277 ++++++++++++++++++ src/crashlytics/profilingManager.ts | 263 +++++++++++++++++ src/gcp/storage.ts | 55 ++-- 9 files changed, 948 insertions(+), 31 deletions(-) create mode 100644 src/commands/crashlytics-heapdumps-disable.ts create mode 100644 src/commands/crashlytics-heapdumps-enable.ts create mode 100644 src/commands/crashlytics-heapdumps-status.ts create mode 100644 src/commands/crashlytics-heapdumps.spec.ts create mode 100644 src/crashlytics/profilingManager.spec.ts create mode 100644 src/crashlytics/profilingManager.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6857e740fdf..61051fb966b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,4 @@ - - [Fixed] Report the GCFv2-to-GCFv1 downgrade error during validation instead of a misleading CPU error (#5461). - Fixed `functions:delete` recreating an already-deleted Cloud Tasks queue for task queue functions. (#9305) - Batched function deletions across instances when uninstalling a Function Kit (#11189). +- Added `crashlytics:heapdumps:{enable,disable,status}` commands to manage Crashlytics Android heap dump collection. (#11225) diff --git a/src/commands/crashlytics-heapdumps-disable.ts b/src/commands/crashlytics-heapdumps-disable.ts new file mode 100644 index 00000000000..39c9b506f01 --- /dev/null +++ b/src/commands/crashlytics-heapdumps-disable.ts @@ -0,0 +1,63 @@ +import * as clc from "colorette"; +import * as ora from "ora"; + +import { Command } from "../command"; +import { logger } from "../logger"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { confirm } from "../prompt"; +import { requireAuth } from "../requireAuth"; +import { + getProfilingManagerConfig, + resolveAndroidAppId, + updateProfilingManagerConfig, +} from "../crashlytics/profilingManager"; + +interface CommandOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:heapdumps:disable") + .description("disable Crashlytics heap dump collection for an Android app") + .option("--app ", "the app id of your Firebase Android app") + .option("--force", "automatically disable without prompting for confirmation") + .before(requireAuth) + .action(async (options: CommandOptions) => { + const projectId = needProjectId(options); + const appId = await resolveAndroidAppId(projectId, options); + + const confirmed = await confirm({ + message: `Disable Crashlytics heap dump collection for ${appId}?`, + default: true, + force: options.force, + nonInteractive: options.nonInteractive, + }); + + if (!confirmed) { + logger.info("Heap dump collection disablement canceled."); + return; + } + + const spinner = ora("Disabling Crashlytics heap dump collection...").start(); + try { + const currentConfig = await getProfilingManagerConfig(appId); + await updateProfilingManagerConfig(appId, { + gcsBucket: currentConfig.gcsBucket, + heapDumpCollectionEnabled: false, + }); + spinner.succeed("Successfully disabled Crashlytics heap dump collection!"); + } catch (err: unknown) { + spinner.fail("Failed to disable Crashlytics heap dump collection."); + throw err; + } + + logger.info(""); + logger.info(clc.bold("Heap Dump Collection Details:")); + logger.info(` App ID: ${clc.cyan(appId)}`); + logger.info(` Status: ${clc.red("Disabled")}`); + + return { + appId, + heapDumpCollectionEnabled: false, + }; + }); diff --git a/src/commands/crashlytics-heapdumps-enable.ts b/src/commands/crashlytics-heapdumps-enable.ts new file mode 100644 index 00000000000..948b6c94c47 --- /dev/null +++ b/src/commands/crashlytics-heapdumps-enable.ts @@ -0,0 +1,102 @@ +import * as clc from "colorette"; +import * as ora from "ora"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { logger } from "../logger"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { confirm } from "../prompt"; +import { requireAuth } from "../requireAuth"; +import * as cloudbilling from "../gcp/cloudbilling"; +import { parseProjectNumber } from "../crashlytics/utils"; +import { + createBucketName, + DEFAULT_BUCKET_LOCATION, + DEFAULT_FILE_TTL_DAYS, + ensureHeapDumpP4saRole, + ensureHeapDumpStorageBucket, + resolveAndroidAppId, + updateProfilingManagerConfig, +} from "../crashlytics/profilingManager"; + +interface CommandOptions extends Options { + app?: string; + location?: string; +} + +export const command = new Command("crashlytics:heapdumps:enable") + .description("enable Crashlytics heap dump collection for an Android app") + .option("--app ", "the app id of your Firebase Android app") + .option( + "--location ", + `the location for the Cloud Storage bucket (default: ${DEFAULT_BUCKET_LOCATION})`, + DEFAULT_BUCKET_LOCATION, + ) + .option("--force", "automatically configure without prompting for confirmation") + .before(requireAuth) + .action(async (options: CommandOptions) => { + const projectId = needProjectId(options); + const appId = await resolveAndroidAppId(projectId, options); + const projectNumber = parseProjectNumber(appId); + const location = options.location || DEFAULT_BUCKET_LOCATION; + + // 1. Verify project billing is enabled (required for GCS bucket creation and storage) + const isBillingEnabled = await cloudbilling.checkBillingEnabled(projectId); + if (!isBillingEnabled) { + throw new FirebaseError( + `Project '${projectId}' does not have billing enabled. Crashlytics Heap Dump Collection requires a metered (Blaze) billing plan for Google Cloud Storage.\n` + + `To enable billing, visit: https://console.firebase.google.com/project/${projectId}/usage/details`, + ); + } + + // 2. Derive deterministic bucket name + const bucketName = createBucketName(appId); + + // 3. Confirm with user if running interactively without --force + const confirmed = await confirm({ + message: `Enable Crashlytics heap dump collection for ${appId} using Cloud Storage bucket '${bucketName}' in ${location} (with ${DEFAULT_FILE_TTL_DAYS}-day file TTL)?`, + default: true, + force: options.force, + nonInteractive: options.nonInteractive, + }); + + if (!confirmed) { + logger.info("Heap dump collection enablement canceled."); + return; + } + + // 4. Provision / configure GCS bucket and IAM roles + const spinner = ora("Configuring Google Cloud Storage bucket...").start(); + try { + await ensureHeapDumpStorageBucket(projectId, appId, location); + spinner.text = "Configuring service agent permissions..."; + await ensureHeapDumpP4saRole(projectId, projectNumber); + spinner.text = "Enabling Crashlytics heap dump collection..."; + await updateProfilingManagerConfig(appId, { + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + spinner.succeed("Successfully enabled Crashlytics heap dump collection!"); + } catch (err: unknown) { + spinner.fail("Failed to enable Crashlytics heap dump collection."); + throw err; + } + + logger.info(""); + logger.info(clc.bold("Heap Dump Collection Details:")); + logger.info(` App ID: ${clc.cyan(appId)}`); + logger.info(` GCS Bucket: ${clc.cyan(bucketName)}`); + logger.info(` File TTL: ${clc.cyan(`${DEFAULT_FILE_TTL_DAYS} days`)}`); + logger.info(` Status: ${clc.green("Enabled")}`); + logger.info(""); + logger.info( + `View collected heap dumps in the Firebase Console: https://console.firebase.google.com/project/${projectId}/crashlytics/app/${appId}`, + ); + + return { + appId, + bucketName, + heapDumpCollectionEnabled: true, + }; + }); diff --git a/src/commands/crashlytics-heapdumps-status.ts b/src/commands/crashlytics-heapdumps-status.ts new file mode 100644 index 00000000000..a326b5645b4 --- /dev/null +++ b/src/commands/crashlytics-heapdumps-status.ts @@ -0,0 +1,40 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { logger } from "../logger"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { requireAuth } from "../requireAuth"; +import { getProfilingManagerConfig, resolveAndroidAppId } from "../crashlytics/profilingManager"; + +interface CommandOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:heapdumps:status") + .description("get Crashlytics heap dump collection status and configuration for an Android app") + .option("--app ", "the app id of your Firebase Android app") + .before(requireAuth) + .action(async (options: CommandOptions) => { + const projectId = needProjectId(options); + const appId = await resolveAndroidAppId(projectId, options); + + const config = await getProfilingManagerConfig(appId); + + const tableHead = ["App ID", "Collection Status", "Cloud Storage Bucket"]; + const table = new Table({ head: tableHead, style: { head: ["green"] } }); + table.push([ + appId, + config.heapDumpCollectionEnabled ? clc.green("Enabled") : clc.red("Disabled"), + config.gcsBucket || clc.yellow("(none)"), + ]); + + logger.info(""); + logger.info(table.toString()); + + return { + appId, + ...config, + }; + }); diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts new file mode 100644 index 00000000000..4bfdb76dd4c --- /dev/null +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -0,0 +1,174 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import { command as enableCommand } from "./crashlytics-heapdumps-enable"; +import { command as disableCommand } from "./crashlytics-heapdumps-disable"; +import { command as statusCommand } from "./crashlytics-heapdumps-status"; +import * as profilingManager from "../crashlytics/profilingManager"; +import * as cloudbilling from "../gcp/cloudbilling"; +import * as requireAuthModule from "../requireAuth"; +import * as promptModule from "../prompt"; +import { FirebaseError } from "../error"; + +describe("crashlytics:heapdumps commands", () => { + const projectId = "test-project"; + const projectNumber = "1234567890"; + const hashedAppId = "abcdef123456"; + const appId = `1:${projectNumber}:android:${hashedAppId}`; + const bucketName = `firebasecrashlytics-heap-dumps-${hashedAppId}`; + + beforeEach(() => { + sinon.stub(requireAuthModule, "requireAuth").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("crashlytics:heapdumps:enable", () => { + let checkBillingStub: sinon.SinonStub; + let ensureBucketStub: sinon.SinonStub; + let ensureP4saStub: sinon.SinonStub; + let updateConfigStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + + beforeEach(() => { + checkBillingStub = sinon.stub(cloudbilling, "checkBillingEnabled").resolves(true); + ensureBucketStub = sinon + .stub(profilingManager, "ensureHeapDumpStorageBucket") + .resolves(bucketName); + ensureP4saStub = sinon.stub(profilingManager, "ensureHeapDumpP4saRole").resolves(); + updateConfigStub = sinon.stub(profilingManager, "updateProfilingManagerConfig").resolves(); + confirmStub = sinon.stub(promptModule, "confirm").resolves(true); + }); + + it("should fail if billing is not enabled", async () => { + checkBillingStub.resolves(false); + + await expect( + enableCommand.runner()({ + project: projectId, + app: appId, + force: true, + }), + ).to.be.rejectedWith(FirebaseError, "does not have billing enabled"); + + expect(ensureBucketStub).to.not.have.been.called; + expect(updateConfigStub).to.not.have.been.called; + }); + + it("should cancel if user denies confirmation", async () => { + confirmStub.resolves(false); + + const result = await enableCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.be.undefined; + expect(ensureBucketStub).to.not.have.been.called; + expect(updateConfigStub).to.not.have.been.called; + }); + + it("should successfully provision bucket, grant P4SA role, and enable collection", async () => { + const result = await enableCommand.runner()({ + project: projectId, + app: appId, + location: "us-central1", + force: true, + }); + + expect(result).to.deep.equal({ + appId, + bucketName, + heapDumpCollectionEnabled: true, + }); + + expect(checkBillingStub).to.have.been.calledWith(projectId); + expect(ensureBucketStub).to.have.been.calledWith(projectId, appId, "us-central1"); + expect(ensureP4saStub).to.have.been.calledWith(projectId, projectNumber); + expect(updateConfigStub).to.have.been.calledWith(appId, { + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + }); + + it("should throw if an invalid app ID is supplied", async () => { + await expect( + enableCommand.runner()({ + project: projectId, + app: `1:${projectNumber}:ios:${hashedAppId}`, + force: true, + }), + ).to.be.rejectedWith( + FirebaseError, + "Heap dump collection is only supported for Android apps.", + ); + }); + }); + + describe("crashlytics:heapdumps:disable", () => { + let getConfigStub: sinon.SinonStub; + let updateConfigStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + + beforeEach(() => { + getConfigStub = sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + updateConfigStub = sinon.stub(profilingManager, "updateProfilingManagerConfig").resolves(); + confirmStub = sinon.stub(promptModule, "confirm").resolves(true); + }); + + it("should cancel if user denies confirmation", async () => { + confirmStub.resolves(false); + + const result = await disableCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.be.undefined; + expect(updateConfigStub).to.not.have.been.called; + }); + + it("should successfully disable collection", async () => { + const result = await disableCommand.runner()({ + project: projectId, + app: appId, + force: true, + }); + + expect(result).to.deep.equal({ + appId, + heapDumpCollectionEnabled: false, + }); + + expect(getConfigStub).to.have.been.calledWith(appId); + expect(updateConfigStub).to.have.been.calledWith(appId, { + gcsBucket: bucketName, + heapDumpCollectionEnabled: false, + }); + }); + }); + + describe("crashlytics:heapdumps:status", () => { + it("should return the status and configuration", async () => { + sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + + const result = await statusCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + }); + }); +}); diff --git a/src/commands/index.ts b/src/commands/index.ts index 0878d348906..8583fd39da9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -93,6 +93,10 @@ export function load(client: CLIClient): CLIClient { client.crashlytics.sourcemap = {}; client.crashlytics.sourcemap.upload = loadCommand("crashlytics-sourcemap-upload"); } + client.crashlytics.heapdumps = {}; + client.crashlytics.heapdumps.enable = loadCommand("crashlytics-heapdumps-enable"); + client.crashlytics.heapdumps.disable = loadCommand("crashlytics-heapdumps-disable"); + client.crashlytics.heapdumps.status = loadCommand("crashlytics-heapdumps-status"); client.database = {}; client.database.get = loadCommand("database-get"); client.database.import = loadCommand("database-import"); diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts new file mode 100644 index 00000000000..a89da702bd1 --- /dev/null +++ b/src/crashlytics/profilingManager.spec.ts @@ -0,0 +1,277 @@ +import * as chai from "chai"; +import * as sinon from "sinon"; +import * as nock from "nock"; +import * as chaiAsPromised from "chai-as-promised"; + +import { + createBucketName, + getCrashlyticsP4sa, + getProfilingManagerConfig, + updateProfilingManagerConfig, + ensureHeapDumpStorageBucket, + ensureHeapDumpP4saRole, + CRASHLYTICS_SERVICE_NAME, + STORAGE_OBJECT_CREATOR_ROLE, + DEFAULT_CORS_ORIGINS, + DEFAULT_FILE_TTL_DAYS, +} from "./profilingManager"; +import { FirebaseError } from "../error"; +import { crashlyticsApiOrigin } from "../api"; +import * as storage from "../gcp/storage"; +import * as resourceManager from "../gcp/resourceManager"; +import * as serviceusage from "../gcp/serviceusage"; +import { Policy } from "../gcp/iam"; + +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe("profilingManager", () => { + const projectNumber = "1234567890"; + const hashedAppId = "abcdef123456"; + const appId = `1:${projectNumber}:android:${hashedAppId}`; + const expectedBucketName = `firebasecrashlytics-heap-dumps-${hashedAppId}`; + const projectId = "my-test-project"; + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + describe("createBucketName", () => { + it("should generate the deterministic bucket name for a valid Android app ID", () => { + const result = createBucketName(appId); + expect(result).to.equal(expectedBucketName); + }); + + it("should throw a FirebaseError for an iOS app ID", () => { + const iosAppId = `1:${projectNumber}:ios:${hashedAppId}`; + expect(() => createBucketName(iosAppId)).to.throw( + FirebaseError, + "Heap dump collection is only supported for Android apps.", + ); + }); + + it("should throw a FirebaseError for an invalid app ID format", () => { + expect(() => createBucketName("invalid-app-id")).to.throw(FirebaseError); + }); + }); + + describe("getCrashlyticsP4sa", () => { + it("should return the formatted Crashlytics service agent email", () => { + expect(getCrashlyticsP4sa(projectNumber)).to.equal( + `service-${projectNumber}@gcp-sa-crashlytics.iam.gserviceaccount.com`, + ); + }); + }); + + describe("getProfilingManagerConfig", () => { + it("should return the configuration on successful response", async () => { + const mockResponse = { + configuration: { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }, + }; + + nock(crashlyticsApiOrigin()) + .get(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`) + .reply(200, mockResponse); + + const result = await getProfilingManagerConfig(appId); + expect(result).to.deep.equal({ + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }); + expect(nock.isDone()).to.be.true; + }); + + it("should return default values when configuration is empty", async () => { + nock(crashlyticsApiOrigin()) + .get(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`) + .reply(200, {}); + + const result = await getProfilingManagerConfig(appId); + expect(result).to.deep.equal({ + gcsBucket: "", + heapDumpCollectionEnabled: false, + }); + }); + + it("should throw a FirebaseError if the appId is invalid", async () => { + await expect(getProfilingManagerConfig("invalid-app")).to.be.rejectedWith( + FirebaseError, + "Unable to get the projectId from the AppId.", + ); + }); + }); + + describe("updateProfilingManagerConfig", () => { + it("should send the update request successfully", async () => { + nock(crashlyticsApiOrigin()) + .post(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, { + projectNumber, + gmpAppId: appId, + configuration: { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }, + }) + .reply(200, {}); + + await updateProfilingManagerConfig(appId, { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }); + + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError if the appId is invalid", async () => { + await expect( + updateProfilingManagerConfig("invalid-app", { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }), + ).to.be.rejectedWith(FirebaseError, "Unable to get the projectId from the AppId."); + }); + }); + + describe("ensureHeapDumpStorageBucket", () => { + const expectedCors = [ + { + origin: DEFAULT_CORS_ORIGINS, + method: ["GET", "HEAD", "OPTIONS"], + responseHeader: ["Content-Type", "Access-Control-Allow-Origin", "Content-Length"], + }, + ]; + const expectedLifecycle = { + rule: [ + { + action: { type: "Delete" }, + condition: { age: DEFAULT_FILE_TTL_DAYS }, + }, + ], + }; + + it("should create a new bucket with CORS and 90-day TTL when it does not exist", async () => { + const notFoundError = new FirebaseError("Not found", { status: 404 }); + const getBucketStub = sinon.stub(storage, "getBucket").rejects(notFoundError); + const createBucketStub = sinon + .stub(storage, "createBucket") + .resolves({} as storage.BucketResponse); + + const result = await ensureHeapDumpStorageBucket(projectId, appId, "us-central1"); + + expect(result).to.equal(expectedBucketName); + expect(getBucketStub).to.have.been.calledWith(expectedBucketName); + expect(createBucketStub).to.have.been.calledWith( + projectId, + { + name: expectedBucketName, + location: "us-central1", + cors: expectedCors, + lifecycle: expectedLifecycle, + }, + true, + ); + }); + + it("should patch CORS and lifecycle if bucket exists without either configuration", async () => { + sinon + .stub(storage, "getBucket") + .resolves({ name: expectedBucketName, cors: [] } as unknown as storage.BucketResponse); + const patchBucketStub = sinon + .stub(storage, "patchBucket") + .resolves({} as storage.BucketResponse); + const createBucketStub = sinon.stub(storage, "createBucket"); + + const result = await ensureHeapDumpStorageBucket(projectId, appId); + + expect(result).to.equal(expectedBucketName); + expect(patchBucketStub).to.have.been.calledWith(expectedBucketName, { + cors: expectedCors, + lifecycle: expectedLifecycle, + }); + expect(createBucketStub).to.not.have.been.called; + }); + + it("should patch only lifecycle if bucket exists with CORS but without lifecycle", async () => { + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + cors: expectedCors, + } as unknown as storage.BucketResponse); + const patchBucketStub = sinon + .stub(storage, "patchBucket") + .resolves({} as storage.BucketResponse); + const createBucketStub = sinon.stub(storage, "createBucket"); + + const result = await ensureHeapDumpStorageBucket(projectId, appId); + + expect(result).to.equal(expectedBucketName); + expect(patchBucketStub).to.have.been.calledWith(expectedBucketName, { + lifecycle: expectedLifecycle, + }); + expect(createBucketStub).to.not.have.been.called; + }); + + it("should do nothing if bucket exists with both CORS and lifecycle configuration", async () => { + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + cors: expectedCors, + lifecycle: expectedLifecycle, + } as unknown as storage.BucketResponse); + const patchBucketStub = sinon.stub(storage, "patchBucket"); + const createBucketStub = sinon.stub(storage, "createBucket"); + + const result = await ensureHeapDumpStorageBucket(projectId, appId); + + expect(result).to.equal(expectedBucketName); + expect(patchBucketStub).to.not.have.been.called; + expect(createBucketStub).to.not.have.been.called; + }); + }); + + describe("ensureHeapDumpP4saRole", () => { + it("should generate service identity and add the role if the P4SA does not currently have it", async () => { + const p4saEmail = getCrashlyticsP4sa(projectNumber); + const generateIdentityStub = sinon + .stub(serviceusage, "generateServiceIdentityAndPoll") + .resolves(); + sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(false); + const addRoleStub = sinon + .stub(resourceManager, "addServiceAccountToRoles") + .resolves({} as Policy); + + await ensureHeapDumpP4saRole(projectId, projectNumber); + + expect(generateIdentityStub).to.have.been.calledOnceWith( + projectNumber, + CRASHLYTICS_SERVICE_NAME, + "crashlytics", + ); + expect(addRoleStub).to.have.been.calledWith( + projectId, + p4saEmail, + [STORAGE_OBJECT_CREATOR_ROLE], + true, + ); + }); + + it("should not add the role if the P4SA already has it", async () => { + const generateIdentityStub = sinon + .stub(serviceusage, "generateServiceIdentityAndPoll") + .resolves(); + sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); + const addRoleStub = sinon.stub(resourceManager, "addServiceAccountToRoles"); + + await ensureHeapDumpP4saRole(projectId, projectNumber); + + expect(generateIdentityStub).to.have.been.calledOnceWith( + projectNumber, + CRASHLYTICS_SERVICE_NAME, + "crashlytics", + ); + expect(addRoleStub).to.not.have.been.called; + }); + }); +}); diff --git a/src/crashlytics/profilingManager.ts b/src/crashlytics/profilingManager.ts new file mode 100644 index 00000000000..a338cbef483 --- /dev/null +++ b/src/crashlytics/profilingManager.ts @@ -0,0 +1,263 @@ +import { Client } from "../apiv2"; +import { crashlyticsApiOrigin } from "../api"; +import { FirebaseError, getErrStatus } from "../error"; +import { logger } from "../logger"; +import { parseProjectNumber, TIMEOUT } from "./utils"; +import * as storage from "../gcp/storage"; +import * as resourceManager from "../gcp/resourceManager"; +import * as serviceusage from "../gcp/serviceusage"; +import { AndroidAppMetadata, AppPlatform, listFirebaseApps } from "../management/apps"; +import { select } from "../prompt"; + +export const CRASHLYTICS_V1_API_CLIENT = new Client({ + urlPrefix: crashlyticsApiOrigin(), + apiVersion: "v1", +}); + +export const CRASHLYTICS_SERVICE_NAME = "firebasecrashlytics.googleapis.com"; +export const STORAGE_OBJECT_CREATOR_ROLE = "roles/storage.objectCreator"; +export const DEFAULT_CORS_ORIGINS = ["https://console.firebase.google.com"]; +export const DEFAULT_BUCKET_LOCATION = "us-east1"; +export const DEFAULT_FILE_TTL_DAYS = 90; + +export interface ProfilingManagerConfig { + gcsBucket?: string; + heapDumpCollectionEnabled?: boolean; +} + +export interface GetProfilingManagerConfigResponse { + configuration?: ProfilingManagerConfig; +} + +export interface UpdateProfilingManagerConfigRequest { + projectNumber: string; + gmpAppId: string; + configuration: { + gcsBucket: string; + heapDumpCollectionEnabled: boolean; + }; +} + +/** + * Creates a deterministic GCS bucket name for the given GMP App ID. + * Follows the convention: firebasecrashlytics-heap-dumps-${hashedPackageName} + * @param appId GMP App ID (e.g. 1:123456789:android:abcdef123456) + */ +export function createBucketName(appId: string): string { + const appIdSplit = appId.split(":"); + if (appIdSplit.length < 4 || appIdSplit[2] !== "android") { + throw new FirebaseError( + `App ID ${appId} is not a valid Android app ID. Heap dump collection is only supported for Android apps.`, + ); + } + const hashedPackageName = appIdSplit[3]; + return `firebasecrashlytics-heap-dumps-${hashedPackageName}`; +} + +/** + * Returns the Crashlytics P4SA (service agent) email for the given project number. + * https://firebase.google.com/support/guides/service-accounts + */ +export function getCrashlyticsP4sa(projectNumber: string): string { + return `service-${projectNumber}@gcp-sa-crashlytics.iam.gserviceaccount.com`; +} + +/** + * Resolves the Android app ID from options.app, or interactively prompts if multiple exist. + */ +export async function resolveAndroidAppId( + projectId: string, + options: { app?: string; nonInteractive?: boolean }, +): Promise { + if (options.app) { + const appIdParts = options.app.split(":"); + if (appIdParts.length < 4 || appIdParts[2] !== "android") { + throw new FirebaseError( + `App ID '${options.app}' is not a valid Android app ID. Heap dump collection is only supported for Android apps.`, + ); + } + return options.app; + } + + const apps = await listFirebaseApps(projectId, AppPlatform.ANDROID); + if (apps.length === 0) { + throw new FirebaseError( + `No Android apps found in project '${projectId}'. Heap dump collection is only supported for Android apps.\n` + + "You can register an Android app using 'firebase apps:create ANDROID '", + ); + } + + if (apps.length === 1) { + return apps[0].appId; + } + + if (options.nonInteractive) { + throw new FirebaseError( + `Project '${projectId}' has multiple Android apps. Please specify an app ID with '--app '.`, + ); + } + + const choices = (apps as AndroidAppMetadata[]).map((app) => ({ + name: `${app.displayName || app.packageName || app.appId} (${app.appId})`, + value: app.appId, + })); + + return await select({ + message: "Select an Android app:", + choices, + }); +} + +/** + * Fetches the Crashlytics Profiling Manager configuration for an app. + * @param appId GMP App ID + */ +export async function getProfilingManagerConfig(appId: string): Promise { + const projectNumber = parseProjectNumber(appId); + logger.debug(`[crashlytics] getProfilingManagerConfig called with appId: ${appId}`); + const response = await CRASHLYTICS_V1_API_CLIENT.request( + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + path: `/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, + timeout: TIMEOUT, + }, + ); + const config = response.body?.configuration || {}; + return { + gcsBucket: config.gcsBucket || "", + heapDumpCollectionEnabled: config.heapDumpCollectionEnabled ?? false, + }; +} + +/** + * Updates the Crashlytics Profiling Manager configuration for an app. + * @param appId GMP App ID + * @param config Profiling manager configuration + */ +export async function updateProfilingManagerConfig( + appId: string, + config: ProfilingManagerConfig, +): Promise { + const projectNumber = parseProjectNumber(appId); + logger.debug( + `[crashlytics] updateProfilingManagerConfig called with appId: ${appId}, config: ${JSON.stringify(config)}`, + ); + await CRASHLYTICS_V1_API_CLIENT.request({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + path: `/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, + body: { + projectNumber, + gmpAppId: appId, + configuration: { + gcsBucket: config.gcsBucket ?? "", + heapDumpCollectionEnabled: config.heapDumpCollectionEnabled ?? false, + }, + }, + timeout: TIMEOUT, + }); +} + +/** + * Ensures the GCS bucket for heap dumps exists and is configured with CORS and 90-day file TTL. + * Creates the bucket if it does not already exist. + * @param projectId Cloud Project ID + * @param appId GMP App ID + * @param location Bucket location / region + */ +export async function ensureHeapDumpStorageBucket( + projectId: string, + appId: string, + location: string = DEFAULT_BUCKET_LOCATION, +): Promise { + const bucketName = createBucketName(appId); + const corsRules: storage.CorsRule[] = [ + { + origin: DEFAULT_CORS_ORIGINS, + method: ["GET", "HEAD", "OPTIONS"], + responseHeader: ["Content-Type", "Access-Control-Allow-Origin", "Content-Length"], + }, + ]; + const lifecycle: { rule: storage.LifecycleRule[] } = { + rule: [ + { + action: { type: "Delete" }, + condition: { age: DEFAULT_FILE_TTL_DAYS }, + }, + ], + }; + + try { + const bucket = await storage.getBucket(bucketName); + const patchPayload: Partial = {}; + if (!bucket.cors || bucket.cors.length === 0) { + patchPayload.cors = corsRules; + } + if (!bucket.lifecycle?.rule || bucket.lifecycle.rule.length === 0) { + patchPayload.lifecycle = lifecycle; + } + if (Object.keys(patchPayload).length > 0) { + logger.debug( + `[crashlytics] Bucket ${bucketName} found without full configuration. Patching ${Object.keys(patchPayload).join(", ")}.`, + ); + await storage.patchBucket(bucketName, patchPayload); + } + return bucketName; + } catch (err: unknown) { + const fe = err as FirebaseError; + const status = fe?.original ? getErrStatus(fe.original, fe.status) : getErrStatus(err); + if (status !== 404) { + throw err; + } + } + + logger.debug(`[crashlytics] Creating GCS bucket ${bucketName} in ${location}...`); + await storage.createBucket( + projectId, + { + name: bucketName, + location, + cors: corsRules, + lifecycle, + }, + true /* projectPrivate */, + ); + return bucketName; +} + +/** + * Generates the Crashlytics service identity and ensures the P4SA has the roles/storage.objectCreator role. + * @param projectId Cloud Project ID + * @param projectNumber Cloud Project Number + */ +export async function ensureHeapDumpP4saRole( + projectId: string, + projectNumber: string, +): Promise { + await serviceusage.generateServiceIdentityAndPoll( + projectNumber, + CRASHLYTICS_SERVICE_NAME, + "crashlytics", + ); + const p4saEmail = getCrashlyticsP4sa(projectNumber); + const hasRole = await resourceManager.serviceAccountHasRoles( + projectId, + p4saEmail, + [STORAGE_OBJECT_CREATOR_ROLE], + true /* skipAccountLookup */, + ); + if (!hasRole) { + logger.debug(`[crashlytics] Adding ${STORAGE_OBJECT_CREATOR_ROLE} to ${p4saEmail}...`); + await resourceManager.addServiceAccountToRoles( + projectId, + p4saEmail, + [STORAGE_OBJECT_CREATOR_ROLE], + true /* skipAccountLookup */, + ); + } +} diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index 6814c74ec00..c0fbeb9b14d 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -18,7 +18,7 @@ export enum ContentType { } /** Bucket Interface */ -interface BucketResponse { +export interface BucketResponse { kind: string; id: string; selfLink: string; @@ -94,34 +94,9 @@ interface BucketResponse { versioning: { enabled: boolean; }; - cors: [ - { - origin: [string]; - method: [string]; - responseHeader: [string]; - maxAgeSeconds: number; - }, - ]; - lifecycle: { - rule: [ - { - action: { - type: string; - storageClass: string; - }; - condition: { - age: number; - createdBefore: string; - customTimeBefore: string; - daysSinceCustomTime: number; - daysSinceNoncurrentTime: number; - isLive: boolean; - matchesStorageClass: [string]; - noncurrentTimeBefore: string; - numNewerVersions: number; - }; - }, - ]; + cors?: CorsRule[]; + lifecycle?: { + rule: LifecycleRule[]; }; labels: Record; storageClass: string; @@ -145,6 +120,13 @@ interface GetDefaultBucketResponse { }; } +export interface CorsRule { + origin?: string[]; + method?: string[]; + responseHeader?: string[]; + maxAgeSeconds?: number; +} + export interface UpsertBucketRequest { baseName: string; location: string; @@ -158,17 +140,27 @@ export interface CreateBucketRequest { name: string; location: string; labels?: Record; - lifecycle: { + lifecycle?: { rule: LifecycleRule[]; }; + cors?: CorsRule[]; } export interface LifecycleRule { action: { type: string; + storageClass?: string; }; condition: { - age: number; + age?: number; + createdBefore?: string; + customTimeBefore?: string; + daysSinceCustomTime?: number; + daysSinceNoncurrentTime?: number; + isLive?: boolean; + matchesStorageClass?: string[]; + noncurrentTimeBefore?: string; + numNewerVersions?: number; }; } @@ -343,6 +335,7 @@ export async function getBucket(bucketName: string): Promise { logger.debug(err); throw new FirebaseError("Failed to obtain the storage bucket", { original: err, + status: getErrStatus(err), }); } } From 1018801db5176038a2807eb3b36036e4d71ba32c Mon Sep 17 00:00:00 2001 From: Anit Das Date: Fri, 2 Oct 2026 15:45:53 +0000 Subject: [PATCH 02/10] fix(crashlytics): address PR #11225 review feedback - Add 'cors' to fieldMasks doNotRecurseIn list in storage.patchBucket - Instantiate Crashlytics v1 API client lazily via getCrashlyticsV1Client() - Ensure hashedPackageName is lowercased in createBucketName() - Simplify error status extraction in ensureHeapDumpStorageBucket() --- src/crashlytics/profilingManager.spec.ts | 6 +++++ src/crashlytics/profilingManager.ts | 33 ++++++++++++------------ src/gcp/storage.ts | 1 + 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts index a89da702bd1..bb57366f02b 100644 --- a/src/crashlytics/profilingManager.spec.ts +++ b/src/crashlytics/profilingManager.spec.ts @@ -43,6 +43,12 @@ describe("profilingManager", () => { expect(result).to.equal(expectedBucketName); }); + it("should lowercase uppercase characters in the hashed package name", () => { + const upperAppId = `1:${projectNumber}:android:ABCDEF123456`; + const result = createBucketName(upperAppId); + expect(result).to.equal(expectedBucketName); + }); + it("should throw a FirebaseError for an iOS app ID", () => { const iosAppId = `1:${projectNumber}:ios:${hashedAppId}`; expect(() => createBucketName(iosAppId)).to.throw( diff --git a/src/crashlytics/profilingManager.ts b/src/crashlytics/profilingManager.ts index a338cbef483..ab80595bd12 100644 --- a/src/crashlytics/profilingManager.ts +++ b/src/crashlytics/profilingManager.ts @@ -9,10 +9,12 @@ import * as serviceusage from "../gcp/serviceusage"; import { AndroidAppMetadata, AppPlatform, listFirebaseApps } from "../management/apps"; import { select } from "../prompt"; -export const CRASHLYTICS_V1_API_CLIENT = new Client({ - urlPrefix: crashlyticsApiOrigin(), - apiVersion: "v1", -}); +export function getCrashlyticsV1Client(): Client { + return new Client({ + urlPrefix: crashlyticsApiOrigin(), + apiVersion: "v1", + }); +} export const CRASHLYTICS_SERVICE_NAME = "firebasecrashlytics.googleapis.com"; export const STORAGE_OBJECT_CREATOR_ROLE = "roles/storage.objectCreator"; @@ -50,7 +52,7 @@ export function createBucketName(appId: string): string { `App ID ${appId} is not a valid Android app ID. Heap dump collection is only supported for Android apps.`, ); } - const hashedPackageName = appIdSplit[3]; + const hashedPackageName = appIdSplit[3].toLowerCase(); return `firebasecrashlytics-heap-dumps-${hashedPackageName}`; } @@ -115,16 +117,14 @@ export async function resolveAndroidAppId( export async function getProfilingManagerConfig(appId: string): Promise { const projectNumber = parseProjectNumber(appId); logger.debug(`[crashlytics] getProfilingManagerConfig called with appId: ${appId}`); - const response = await CRASHLYTICS_V1_API_CLIENT.request( - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - path: `/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, - timeout: TIMEOUT, + const response = await getCrashlyticsV1Client().request({ + method: "GET", + headers: { + "Content-Type": "application/json", }, - ); + path: `/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, + timeout: TIMEOUT, + }); const config = response.body?.configuration || {}; return { gcsBucket: config.gcsBucket || "", @@ -145,7 +145,7 @@ export async function updateProfilingManagerConfig( logger.debug( `[crashlytics] updateProfilingManagerConfig called with appId: ${appId}, config: ${JSON.stringify(config)}`, ); - await CRASHLYTICS_V1_API_CLIENT.request({ + await getCrashlyticsV1Client().request({ method: "POST", headers: { "Content-Type": "application/json", @@ -209,8 +209,7 @@ export async function ensureHeapDumpStorageBucket( } return bucketName; } catch (err: unknown) { - const fe = err as FirebaseError; - const status = fe?.original ? getErrStatus(fe.original, fe.status) : getErrStatus(err); + const status = getErrStatus(err); if (status !== 404) { throw err; } diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index c0fbeb9b14d..a3addd6adeb 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -396,6 +396,7 @@ export async function patchBucket( "acl", "defaultObjectAcl", "lifecycle", + "cors", ); const result = await localAPIClient.patch, BucketResponse>( `/storage/v1/b/${bucketName}`, From ec15741b52254273f53fc6e553e38547fa2fae84 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Fri, 2 Oct 2026 18:47:46 +0000 Subject: [PATCH 03/10] test(crashlytics): bypass requireAuth pre-action hook in command unit tests --- src/commands/crashlytics-heapdumps.spec.ts | 19 +++++++++++++++++-- src/crashlytics/profilingManager.spec.ts | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index 4bfdb76dd4c..83ec8dc8262 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -5,8 +5,8 @@ import { command as disableCommand } from "./crashlytics-heapdumps-disable"; import { command as statusCommand } from "./crashlytics-heapdumps-status"; import * as profilingManager from "../crashlytics/profilingManager"; import * as cloudbilling from "../gcp/cloudbilling"; -import * as requireAuthModule from "../requireAuth"; import * as promptModule from "../prompt"; +import { requireAuth } from "../requireAuth"; import { FirebaseError } from "../error"; describe("crashlytics:heapdumps commands", () => { @@ -16,14 +16,29 @@ describe("crashlytics:heapdumps commands", () => { const appId = `1:${projectNumber}:android:${hashedAppId}`; const bucketName = `firebasecrashlytics-heap-dumps-${hashedAppId}`; + const originalEnableBefores = [...(enableCommand["befores"] || [])]; + const originalDisableBefores = [...(disableCommand["befores"] || [])]; + const originalStatusBefores = [...(statusCommand["befores"] || [])]; + beforeEach(() => { - sinon.stub(requireAuthModule, "requireAuth").resolves(); + enableCommand["befores"] = []; + disableCommand["befores"] = []; + statusCommand["befores"] = []; }); afterEach(() => { + enableCommand["befores"] = [...originalEnableBefores]; + disableCommand["befores"] = [...originalDisableBefores]; + statusCommand["befores"] = [...originalStatusBefores]; sinon.restore(); }); + it("should require authentication on all heapdumps commands", () => { + expect(originalEnableBefores).to.deep.equal([{ fn: requireAuth, args: [] }]); + expect(originalDisableBefores).to.deep.equal([{ fn: requireAuth, args: [] }]); + expect(originalStatusBefores).to.deep.equal([{ fn: requireAuth, args: [] }]); + }); + describe("crashlytics:heapdumps:enable", () => { let checkBillingStub: sinon.SinonStub; let ensureBucketStub: sinon.SinonStub; diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts index bb57366f02b..657f153e12a 100644 --- a/src/crashlytics/profilingManager.spec.ts +++ b/src/crashlytics/profilingManager.spec.ts @@ -1,6 +1,6 @@ import * as chai from "chai"; import * as sinon from "sinon"; -import * as nock from "nock"; +import nock from "../test/helpers/nock"; import * as chaiAsPromised from "chai-as-promised"; import { From 8fba5a2c90edef291c35102002a1beede03ddd06 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Fri, 2 Oct 2026 21:28:01 +0000 Subject: [PATCH 04/10] test(crashlytics): add full unit test coverage for heapdumps and storage helpers --- src/commands/crashlytics-heapdumps.spec.ts | 61 ++++++++- src/crashlytics/profilingManager.spec.ts | 137 +++++++++++++++++++++ src/gcp/storage.spec.ts | 70 +++++++++++ 3 files changed, 267 insertions(+), 1 deletion(-) diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index 83ec8dc8262..1ca4bd33c41 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -107,6 +107,34 @@ describe("crashlytics:heapdumps commands", () => { }); }); + it("should default location to DEFAULT_BUCKET_LOCATION when location option is empty", async () => { + await enableCommand.runner()({ + project: projectId, + app: appId, + location: "", + force: true, + }); + + expect(ensureBucketStub).to.have.been.calledWith( + projectId, + appId, + profilingManager.DEFAULT_BUCKET_LOCATION, + ); + }); + + it("should rethrow error when provisioning or enabling collection fails", async () => { + const boom = new FirebaseError("Storage failure"); + ensureBucketStub.rejects(boom); + + await expect( + enableCommand.runner()({ + project: projectId, + app: appId, + force: true, + }), + ).to.be.rejectedWith(boom); + }); + it("should throw if an invalid app ID is supplied", async () => { await expect( enableCommand.runner()({ @@ -165,10 +193,23 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: false, }); }); + + it("should rethrow error when disabling collection fails", async () => { + const boom = new FirebaseError("Update failed"); + updateConfigStub.rejects(boom); + + await expect( + disableCommand.runner()({ + project: projectId, + app: appId, + force: true, + }), + ).to.be.rejectedWith(boom); + }); }); describe("crashlytics:heapdumps:status", () => { - it("should return the status and configuration", async () => { + it("should return the status and configuration when enabled", async () => { sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ gcsBucket: bucketName, heapDumpCollectionEnabled: true, @@ -185,5 +226,23 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: true, }); }); + + it("should return the status and configuration when disabled with no bucket", async () => { + sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ + gcsBucket: "", + heapDumpCollectionEnabled: false, + }); + + const result = await statusCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + gcsBucket: "", + heapDumpCollectionEnabled: false, + }); + }); }); }); diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts index 657f153e12a..f4f525445f9 100644 --- a/src/crashlytics/profilingManager.spec.ts +++ b/src/crashlytics/profilingManager.spec.ts @@ -6,6 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; import { createBucketName, getCrashlyticsP4sa, + resolveAndroidAppId, getProfilingManagerConfig, updateProfilingManagerConfig, ensureHeapDumpStorageBucket, @@ -20,6 +21,8 @@ import { crashlyticsApiOrigin } from "../api"; import * as storage from "../gcp/storage"; import * as resourceManager from "../gcp/resourceManager"; import * as serviceusage from "../gcp/serviceusage"; +import * as appsModule from "../management/apps"; +import * as promptModule from "../prompt"; import { Policy } from "../gcp/iam"; chai.use(chaiAsPromised); @@ -70,6 +73,112 @@ describe("profilingManager", () => { }); }); + describe("resolveAndroidAppId", () => { + it("should return the explicitly provided valid Android app ID", async () => { + const result = await resolveAndroidAppId(projectId, { app: appId }); + expect(result).to.equal(appId); + }); + + it("should throw a FirebaseError when an invalid or non-Android app ID is provided", async () => { + await expect( + resolveAndroidAppId(projectId, { app: `1:${projectNumber}:ios:${hashedAppId}` }), + ).to.be.rejectedWith( + FirebaseError, + "Heap dump collection is only supported for Android apps.", + ); + }); + + it("should throw a FirebaseError when no Android apps exist in the project", async () => { + sinon.stub(appsModule, "listFirebaseApps").resolves([]); + + await expect(resolveAndroidAppId(projectId, {})).to.be.rejectedWith( + FirebaseError, + `No Android apps found in project '${projectId}'`, + ); + }); + + it("should automatically return the app ID when exactly one Android app exists", async () => { + sinon.stub(appsModule, "listFirebaseApps").resolves([ + { + name: `projects/${projectId}/androidApps/${appId}`, + projectId, + appId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.app", + }, + ]); + + const result = await resolveAndroidAppId(projectId, {}); + expect(result).to.equal(appId); + }); + + it("should throw a FirebaseError when multiple Android apps exist in non-interactive mode", async () => { + const secondAppId = `1:${projectNumber}:android:fedcba654321`; + sinon.stub(appsModule, "listFirebaseApps").resolves([ + { + name: `projects/${projectId}/androidApps/${appId}`, + projectId, + appId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.one", + }, + { + name: `projects/${projectId}/androidApps/${secondAppId}`, + projectId, + appId: secondAppId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.two", + }, + ]); + + await expect(resolveAndroidAppId(projectId, { nonInteractive: true })).to.be.rejectedWith( + FirebaseError, + `Project '${projectId}' has multiple Android apps. Please specify an app ID with '--app '.`, + ); + }); + + it("should prompt the user to select an app when multiple Android apps exist interactively", async () => { + const secondAppId = `1:${projectNumber}:android:fedcba654321`; + const thirdAppId = `1:${projectNumber}:android:111111222222`; + sinon.stub(appsModule, "listFirebaseApps").resolves([ + { + name: `projects/${projectId}/androidApps/${appId}`, + projectId, + appId, + displayName: "First App", + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.one", + }, + { + name: `projects/${projectId}/androidApps/${secondAppId}`, + projectId, + appId: secondAppId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.two", + }, + { + name: `projects/${projectId}/androidApps/${thirdAppId}`, + projectId, + appId: thirdAppId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "", + }, + ]); + const selectStub = sinon.stub(promptModule, "select").resolves(secondAppId); + + const result = await resolveAndroidAppId(projectId, {}); + expect(result).to.equal(secondAppId); + expect(selectStub).to.have.been.calledOnceWith({ + message: "Select an Android app:", + choices: [ + { name: `First App (${appId})`, value: appId }, + { name: `com.example.two (${secondAppId})`, value: secondAppId }, + { name: `${thirdAppId} (${thirdAppId})`, value: thirdAppId }, + ], + }); + }); + }); + describe("getProfilingManagerConfig", () => { it("should return the configuration on successful response", async () => { const mockResponse = { @@ -132,6 +241,23 @@ describe("profilingManager", () => { expect(nock.isDone()).to.be.true; }); + it("should default empty configuration fields when omitted", async () => { + nock(crashlyticsApiOrigin()) + .post(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, { + projectNumber, + gmpAppId: appId, + configuration: { + gcsBucket: "", + heapDumpCollectionEnabled: false, + }, + }) + .reply(200, {}); + + await updateProfilingManagerConfig(appId, {}); + + expect(nock.isDone()).to.be.true; + }); + it("should throw a FirebaseError if the appId is invalid", async () => { await expect( updateProfilingManagerConfig("invalid-app", { @@ -182,6 +308,17 @@ describe("profilingManager", () => { ); }); + it("should rethrow non-404 errors from getBucket", async () => { + const forbiddenError = new FirebaseError("Forbidden", { status: 403 }); + sinon.stub(storage, "getBucket").rejects(forbiddenError); + const createBucketStub = sinon.stub(storage, "createBucket"); + + await expect(ensureHeapDumpStorageBucket(projectId, appId)).to.be.rejectedWith( + forbiddenError, + ); + expect(createBucketStub).to.not.have.been.called; + }); + it("should patch CORS and lifecycle if bucket exists without either configuration", async () => { sinon .stub(storage, "getBucket") diff --git a/src/gcp/storage.spec.ts b/src/gcp/storage.spec.ts index 8d6eec993ca..7142f7abb2d 100644 --- a/src/gcp/storage.spec.ts +++ b/src/gcp/storage.spec.ts @@ -346,4 +346,74 @@ describe("storage", () => { ).to.be.rejectedWith(FirebaseError, "Expected a file name ending in .zip"); }); }); + + describe("getBucket", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should return bucket metadata on success", async () => { + const mockBucket = { name: "my-bucket", location: "US-EAST1" } as storage.BucketResponse; + const getStub = sinon.stub(Client.prototype, "get").resolves({ + status: 200, + response: new Response(), + body: mockBucket, + }); + + const result = await storage.getBucket("my-bucket"); + + expect(result).to.deep.equal(mockBucket); + expect(getStub).to.be.calledOnceWith("/storage/v1/b/my-bucket"); + }); + + it("should throw FirebaseError with status when request fails", async () => { + const apiError = new FirebaseError("Not Found", { status: 404 }); + sinon.stub(Client.prototype, "get").rejects(apiError); + + try { + await storage.getBucket("missing-bucket"); + expect.fail("Expected getBucket to throw"); + } catch (err: unknown) { + expect(err).to.be.instanceOf(FirebaseError); + expect((err as FirebaseError).message).to.equal("Failed to obtain the storage bucket"); + expect((err as FirebaseError).status).to.equal(404); + } + }); + }); + + describe("patchBucket", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should patch bucket metadata with non-recursing updateMask for cors and lifecycle", async () => { + const metadata: Partial = { + cors: [{ origin: ["https://console.firebase.google.com"], method: ["GET"] }], + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 90 } }] }, + }; + const mockBucket = { name: "my-bucket", ...metadata } as storage.BucketResponse; + const patchStub = sinon.stub(Client.prototype, "patch").resolves({ + status: 200, + response: new Response(), + body: mockBucket, + }); + + const result = await storage.patchBucket("my-bucket", metadata); + + expect(result).to.deep.equal(mockBucket); + expect(patchStub).to.be.calledOnceWith("/storage/v1/b/my-bucket", metadata, { + queryParams: { updateMask: "cors,lifecycle" }, + }); + }); + + it("should throw FirebaseError when patch request fails", async () => { + const apiError = new FirebaseError("Forbidden", { status: 403 }); + sinon.stub(Client.prototype, "patch").rejects(apiError); + + await expect(storage.patchBucket("my-bucket", { cors: [] })).to.be.rejectedWith( + FirebaseError, + "Failed to patch the storage bucket", + ); + }); + }); }); From 49d4800265e1adab2f3dac5b471a5b5c43475b86 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Sat, 3 Oct 2026 03:56:35 +0000 Subject: [PATCH 05/10] test(crashlytics): explicitly type AndroidAppMetadata mocks in profilingManager.spec.ts --- src/crashlytics/profilingManager.spec.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts index f4f525445f9..aa73a42f2a8 100644 --- a/src/crashlytics/profilingManager.spec.ts +++ b/src/crashlytics/profilingManager.spec.ts @@ -98,7 +98,7 @@ describe("profilingManager", () => { }); it("should automatically return the app ID when exactly one Android app exists", async () => { - sinon.stub(appsModule, "listFirebaseApps").resolves([ + const mockApps: appsModule.AndroidAppMetadata[] = [ { name: `projects/${projectId}/androidApps/${appId}`, projectId, @@ -106,7 +106,8 @@ describe("profilingManager", () => { platform: appsModule.AppPlatform.ANDROID, packageName: "com.example.app", }, - ]); + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); const result = await resolveAndroidAppId(projectId, {}); expect(result).to.equal(appId); @@ -114,7 +115,7 @@ describe("profilingManager", () => { it("should throw a FirebaseError when multiple Android apps exist in non-interactive mode", async () => { const secondAppId = `1:${projectNumber}:android:fedcba654321`; - sinon.stub(appsModule, "listFirebaseApps").resolves([ + const mockApps: appsModule.AndroidAppMetadata[] = [ { name: `projects/${projectId}/androidApps/${appId}`, projectId, @@ -129,7 +130,8 @@ describe("profilingManager", () => { platform: appsModule.AppPlatform.ANDROID, packageName: "com.example.two", }, - ]); + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); await expect(resolveAndroidAppId(projectId, { nonInteractive: true })).to.be.rejectedWith( FirebaseError, @@ -140,7 +142,7 @@ describe("profilingManager", () => { it("should prompt the user to select an app when multiple Android apps exist interactively", async () => { const secondAppId = `1:${projectNumber}:android:fedcba654321`; const thirdAppId = `1:${projectNumber}:android:111111222222`; - sinon.stub(appsModule, "listFirebaseApps").resolves([ + const mockApps: appsModule.AndroidAppMetadata[] = [ { name: `projects/${projectId}/androidApps/${appId}`, projectId, @@ -163,7 +165,8 @@ describe("profilingManager", () => { platform: appsModule.AppPlatform.ANDROID, packageName: "", }, - ]); + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); const selectStub = sinon.stub(promptModule, "select").resolves(secondAppId); const result = await resolveAndroidAppId(projectId, {}); From 69ae8efc0e90d209bfd227ed24f0d1ad83fa948a Mon Sep 17 00:00:00 2001 From: Anit Das Date: Mon, 5 Oct 2026 16:25:48 +0000 Subject: [PATCH 06/10] chore(crashlytics): resolve eslint warnings in heapdumps and command loader --- src/commands/crashlytics-heapdumps.spec.ts | 12 +++++------ src/commands/index.ts | 25 +++++++++++++--------- src/crashlytics/profilingManager.ts | 3 +++ src/gcp/storage.ts | 12 +++++------ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index 1ca4bd33c41..fac9a3764cb 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -74,7 +74,7 @@ describe("crashlytics:heapdumps commands", () => { it("should cancel if user denies confirmation", async () => { confirmStub.resolves(false); - const result = await enableCommand.runner()({ + const result: unknown = await enableCommand.runner()({ project: projectId, app: appId, }); @@ -85,7 +85,7 @@ describe("crashlytics:heapdumps commands", () => { }); it("should successfully provision bucket, grant P4SA role, and enable collection", async () => { - const result = await enableCommand.runner()({ + const result: unknown = await enableCommand.runner()({ project: projectId, app: appId, location: "us-central1", @@ -166,7 +166,7 @@ describe("crashlytics:heapdumps commands", () => { it("should cancel if user denies confirmation", async () => { confirmStub.resolves(false); - const result = await disableCommand.runner()({ + const result: unknown = await disableCommand.runner()({ project: projectId, app: appId, }); @@ -176,7 +176,7 @@ describe("crashlytics:heapdumps commands", () => { }); it("should successfully disable collection", async () => { - const result = await disableCommand.runner()({ + const result: unknown = await disableCommand.runner()({ project: projectId, app: appId, force: true, @@ -215,7 +215,7 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: true, }); - const result = await statusCommand.runner()({ + const result: unknown = await statusCommand.runner()({ project: projectId, app: appId, }); @@ -233,7 +233,7 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: false, }); - const result = await statusCommand.runner()({ + const result: unknown = await statusCommand.runner()({ project: projectId, app: appId, }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 8583fd39da9..694411e5d84 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,27 +1,30 @@ -import { CLIClient } from "../command"; +import { CLIClient, Command } from "../command"; import * as experiments from "../experiments"; -type CommandRunner = ((...args: any[]) => Promise) & { load: () => void }; +type CommandRunner = ((...args: unknown[]) => Promise) & { load: () => void }; /** * Loads all commands for our parser. */ export function load(client: CLIClient): CLIClient { function loadCommand(name: string): CommandRunner { - const load = () => { - const { command: cmd } = require(`./${name}`); + const load = (): ((...a: unknown[]) => Promise) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { command: cmd } = require(`./${name}`) as { command: Command }; cmd.register(client); return cmd.runner(); }; - const runner = (async (...args: any[]) => { + const runner = (async (...args: unknown[]) => { const run = load(); return run(...args); }) as CommandRunner; // Store the load function on the runner so we can trigger it without running. runner.load = () => { - require(`./${name}`).command.register(client); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { command: cmd } = require(`./${name}`) as { command: Command }; + cmd.register(client); }; return runner; @@ -93,10 +96,12 @@ export function load(client: CLIClient): CLIClient { client.crashlytics.sourcemap = {}; client.crashlytics.sourcemap.upload = loadCommand("crashlytics-sourcemap-upload"); } - client.crashlytics.heapdumps = {}; - client.crashlytics.heapdumps.enable = loadCommand("crashlytics-heapdumps-enable"); - client.crashlytics.heapdumps.disable = loadCommand("crashlytics-heapdumps-disable"); - client.crashlytics.heapdumps.status = loadCommand("crashlytics-heapdumps-status"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + client.crashlytics.heapdumps = { + enable: loadCommand("crashlytics-heapdumps-enable"), + disable: loadCommand("crashlytics-heapdumps-disable"), + status: loadCommand("crashlytics-heapdumps-status"), + }; client.database = {}; client.database.get = loadCommand("database-get"); client.database.import = loadCommand("database-import"); diff --git a/src/crashlytics/profilingManager.ts b/src/crashlytics/profilingManager.ts index ab80595bd12..4184477dfe3 100644 --- a/src/crashlytics/profilingManager.ts +++ b/src/crashlytics/profilingManager.ts @@ -9,6 +9,9 @@ import * as serviceusage from "../gcp/serviceusage"; import { AndroidAppMetadata, AppPlatform, listFirebaseApps } from "../management/apps"; import { select } from "../prompt"; +/** + * Returns an authenticated v1 API client for the Firebase Crashlytics API. + */ export function getCrashlyticsV1Client(): Client { return new Client({ urlPrefix: crashlyticsApiOrigin(), diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index a3addd6adeb..cfdbd6b1e01 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -331,10 +331,10 @@ export async function getBucket(bucketName: string): Promise { const localAPIClient = new Client({ urlPrefix: storageOrigin() }); const result = await localAPIClient.get(`/storage/v1/b/${bucketName}`); return result.body; - } catch (err: any) { - logger.debug(err); + } catch (err: unknown) { + logger.debug(err as Error); throw new FirebaseError("Failed to obtain the storage bucket", { - original: err, + original: err as Error, status: getErrStatus(err), }); } @@ -404,10 +404,10 @@ export async function patchBucket( { queryParams: { updateMask: mask.join(",") } }, ); return result.body; - } catch (err: any) { - logger.debug(err); + } catch (err: unknown) { + logger.debug(err as Error); throw new FirebaseError("Failed to patch the storage bucket", { - original: err, + original: err as Error, }); } } From 81eb97dc662bdc28d6860064acd78f020c3d8540 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Mon, 5 Oct 2026 17:22:57 +0000 Subject: [PATCH 07/10] fix(storage): include status code on FirebaseError in patchBucket --- src/commands/crashlytics-heapdumps.spec.ts | 35 ++++++++++++++++++++++ src/gcp/storage.spec.ts | 14 +++++---- src/gcp/storage.ts | 1 + 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index fac9a3764cb..1767ba9797e 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -1,5 +1,8 @@ import * as sinon from "sinon"; import { expect } from "chai"; +import * as program from "commander"; +import { CLIClient } from "../command"; +import { load } from "./index"; import { command as enableCommand } from "./crashlytics-heapdumps-enable"; import { command as disableCommand } from "./crashlytics-heapdumps-disable"; import { command as statusCommand } from "./crashlytics-heapdumps-status"; @@ -244,5 +247,37 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: false, }); }); + + it("should register and run crashlytics.heapdumps commands via command loader", async () => { + sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + + const client: CLIClient = { + cli: program, + errorOut: sinon.stub(), + }; + load(client); + const heapdumps = ( + client.crashlytics as { + heapdumps: { + status: ((options: Record) => Promise) & { load: () => void }; + }; + } + ).heapdumps; + + heapdumps.status.load(); + const result: unknown = await heapdumps.status({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + }); }); }); diff --git a/src/gcp/storage.spec.ts b/src/gcp/storage.spec.ts index 7142f7abb2d..e2dbcd8d939 100644 --- a/src/gcp/storage.spec.ts +++ b/src/gcp/storage.spec.ts @@ -406,14 +406,18 @@ describe("storage", () => { }); }); - it("should throw FirebaseError when patch request fails", async () => { + it("should throw FirebaseError with status when patch request fails", async () => { const apiError = new FirebaseError("Forbidden", { status: 403 }); sinon.stub(Client.prototype, "patch").rejects(apiError); - await expect(storage.patchBucket("my-bucket", { cors: [] })).to.be.rejectedWith( - FirebaseError, - "Failed to patch the storage bucket", - ); + try { + await storage.patchBucket("my-bucket", { cors: [] }); + expect.fail("Expected patchBucket to throw"); + } catch (err: unknown) { + expect(err).to.be.instanceOf(FirebaseError); + expect((err as FirebaseError).message).to.equal("Failed to patch the storage bucket"); + expect((err as FirebaseError).status).to.equal(403); + } }); }); }); diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index cfdbd6b1e01..44af43ea642 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -408,6 +408,7 @@ export async function patchBucket( logger.debug(err as Error); throw new FirebaseError("Failed to patch the storage bucket", { original: err as Error, + status: getErrStatus(err), }); } } From 4e4cffc36c499a17140d43009f14c24b506a3b8c Mon Sep 17 00:00:00 2001 From: Anit Das Date: Tue, 6 Oct 2026 02:50:50 +0000 Subject: [PATCH 08/10] refactor(crashlytics): address PR review feedback and lint warnings --- src/commands/crashlytics-heapdumps-disable.ts | 22 +-- src/commands/crashlytics-heapdumps-enable.ts | 34 +--- src/commands/crashlytics-heapdumps.spec.ts | 48 ++--- src/commands/index.ts | 10 +- src/crashlytics/profilingManager.spec.ts | 164 +++++++++++++----- src/crashlytics/profilingManager.ts | 142 ++++++++++----- src/gcp/storage.spec.ts | 33 ++-- src/gcp/storage.ts | 12 +- 8 files changed, 276 insertions(+), 189 deletions(-) diff --git a/src/commands/crashlytics-heapdumps-disable.ts b/src/commands/crashlytics-heapdumps-disable.ts index 39c9b506f01..7edcd733f22 100644 --- a/src/commands/crashlytics-heapdumps-disable.ts +++ b/src/commands/crashlytics-heapdumps-disable.ts @@ -1,5 +1,4 @@ import * as clc from "colorette"; -import * as ora from "ora"; import { Command } from "../command"; import { logger } from "../logger"; @@ -7,11 +6,7 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { confirm } from "../prompt"; import { requireAuth } from "../requireAuth"; -import { - getProfilingManagerConfig, - resolveAndroidAppId, - updateProfilingManagerConfig, -} from "../crashlytics/profilingManager"; +import { disableHeapDumpCollection, resolveAndroidAppId } from "../crashlytics/profilingManager"; interface CommandOptions extends Options { app?: string; @@ -20,7 +15,7 @@ interface CommandOptions extends Options { export const command = new Command("crashlytics:heapdumps:disable") .description("disable Crashlytics heap dump collection for an Android app") .option("--app ", "the app id of your Firebase Android app") - .option("--force", "automatically disable without prompting for confirmation") + .withForce("automatically disable without prompting for confirmation") .before(requireAuth) .action(async (options: CommandOptions) => { const projectId = needProjectId(options); @@ -38,18 +33,7 @@ export const command = new Command("crashlytics:heapdumps:disable") return; } - const spinner = ora("Disabling Crashlytics heap dump collection...").start(); - try { - const currentConfig = await getProfilingManagerConfig(appId); - await updateProfilingManagerConfig(appId, { - gcsBucket: currentConfig.gcsBucket, - heapDumpCollectionEnabled: false, - }); - spinner.succeed("Successfully disabled Crashlytics heap dump collection!"); - } catch (err: unknown) { - spinner.fail("Failed to disable Crashlytics heap dump collection."); - throw err; - } + await disableHeapDumpCollection(appId); logger.info(""); logger.info(clc.bold("Heap Dump Collection Details:")); diff --git a/src/commands/crashlytics-heapdumps-enable.ts b/src/commands/crashlytics-heapdumps-enable.ts index 948b6c94c47..6dbf863daf2 100644 --- a/src/commands/crashlytics-heapdumps-enable.ts +++ b/src/commands/crashlytics-heapdumps-enable.ts @@ -1,5 +1,4 @@ import * as clc from "colorette"; -import * as ora from "ora"; import { Command } from "../command"; import { FirebaseError } from "../error"; @@ -8,16 +7,14 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { confirm } from "../prompt"; import { requireAuth } from "../requireAuth"; +import { consoleUrl } from "../utils"; import * as cloudbilling from "../gcp/cloudbilling"; -import { parseProjectNumber } from "../crashlytics/utils"; import { createBucketName, DEFAULT_BUCKET_LOCATION, DEFAULT_FILE_TTL_DAYS, - ensureHeapDumpP4saRole, - ensureHeapDumpStorageBucket, + enableHeapDumpCollection, resolveAndroidAppId, - updateProfilingManagerConfig, } from "../crashlytics/profilingManager"; interface CommandOptions extends Options { @@ -33,20 +30,19 @@ export const command = new Command("crashlytics:heapdumps:enable") `the location for the Cloud Storage bucket (default: ${DEFAULT_BUCKET_LOCATION})`, DEFAULT_BUCKET_LOCATION, ) - .option("--force", "automatically configure without prompting for confirmation") + .withForce("automatically configure without prompting for confirmation") .before(requireAuth) .action(async (options: CommandOptions) => { const projectId = needProjectId(options); const appId = await resolveAndroidAppId(projectId, options); - const projectNumber = parseProjectNumber(appId); const location = options.location || DEFAULT_BUCKET_LOCATION; // 1. Verify project billing is enabled (required for GCS bucket creation and storage) const isBillingEnabled = await cloudbilling.checkBillingEnabled(projectId); if (!isBillingEnabled) { throw new FirebaseError( - `Project '${projectId}' does not have billing enabled. Crashlytics Heap Dump Collection requires a metered (Blaze) billing plan for Google Cloud Storage.\n` + - `To enable billing, visit: https://console.firebase.google.com/project/${projectId}/usage/details`, + `Project '${projectId}' is not on the Blaze (pay-as-you-go) plan. Crashlytics heap dump collection requires the Blaze plan for Google Cloud Storage.\n` + + `To upgrade your project, visit: ${consoleUrl(projectId, "/usage/details")}`, ); } @@ -66,22 +62,8 @@ export const command = new Command("crashlytics:heapdumps:enable") return; } - // 4. Provision / configure GCS bucket and IAM roles - const spinner = ora("Configuring Google Cloud Storage bucket...").start(); - try { - await ensureHeapDumpStorageBucket(projectId, appId, location); - spinner.text = "Configuring service agent permissions..."; - await ensureHeapDumpP4saRole(projectId, projectNumber); - spinner.text = "Enabling Crashlytics heap dump collection..."; - await updateProfilingManagerConfig(appId, { - gcsBucket: bucketName, - heapDumpCollectionEnabled: true, - }); - spinner.succeed("Successfully enabled Crashlytics heap dump collection!"); - } catch (err: unknown) { - spinner.fail("Failed to enable Crashlytics heap dump collection."); - throw err; - } + // 4. Provision / configure GCS bucket, IAM roles, and Profiling Manager config + await enableHeapDumpCollection(projectId, appId, location); logger.info(""); logger.info(clc.bold("Heap Dump Collection Details:")); @@ -91,7 +73,7 @@ export const command = new Command("crashlytics:heapdumps:enable") logger.info(` Status: ${clc.green("Enabled")}`); logger.info(""); logger.info( - `View collected heap dumps in the Firebase Console: https://console.firebase.google.com/project/${projectId}/crashlytics/app/${appId}`, + `View collected heap dumps in the Firebase Console: ${consoleUrl(projectId, `/crashlytics/app/${appId}`)}`, ); return { diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index 1767ba9797e..a3493b03373 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -44,18 +44,14 @@ describe("crashlytics:heapdumps commands", () => { describe("crashlytics:heapdumps:enable", () => { let checkBillingStub: sinon.SinonStub; - let ensureBucketStub: sinon.SinonStub; - let ensureP4saStub: sinon.SinonStub; - let updateConfigStub: sinon.SinonStub; + let enableCollectionStub: sinon.SinonStub; let confirmStub: sinon.SinonStub; beforeEach(() => { checkBillingStub = sinon.stub(cloudbilling, "checkBillingEnabled").resolves(true); - ensureBucketStub = sinon - .stub(profilingManager, "ensureHeapDumpStorageBucket") + enableCollectionStub = sinon + .stub(profilingManager, "enableHeapDumpCollection") .resolves(bucketName); - ensureP4saStub = sinon.stub(profilingManager, "ensureHeapDumpP4saRole").resolves(); - updateConfigStub = sinon.stub(profilingManager, "updateProfilingManagerConfig").resolves(); confirmStub = sinon.stub(promptModule, "confirm").resolves(true); }); @@ -68,10 +64,9 @@ describe("crashlytics:heapdumps commands", () => { app: appId, force: true, }), - ).to.be.rejectedWith(FirebaseError, "does not have billing enabled"); + ).to.be.rejectedWith(FirebaseError, "is not on the Blaze (pay-as-you-go) plan"); - expect(ensureBucketStub).to.not.have.been.called; - expect(updateConfigStub).to.not.have.been.called; + expect(enableCollectionStub).to.not.have.been.called; }); it("should cancel if user denies confirmation", async () => { @@ -83,8 +78,7 @@ describe("crashlytics:heapdumps commands", () => { }); expect(result).to.be.undefined; - expect(ensureBucketStub).to.not.have.been.called; - expect(updateConfigStub).to.not.have.been.called; + expect(enableCollectionStub).to.not.have.been.called; }); it("should successfully provision bucket, grant P4SA role, and enable collection", async () => { @@ -102,12 +96,7 @@ describe("crashlytics:heapdumps commands", () => { }); expect(checkBillingStub).to.have.been.calledWith(projectId); - expect(ensureBucketStub).to.have.been.calledWith(projectId, appId, "us-central1"); - expect(ensureP4saStub).to.have.been.calledWith(projectId, projectNumber); - expect(updateConfigStub).to.have.been.calledWith(appId, { - gcsBucket: bucketName, - heapDumpCollectionEnabled: true, - }); + expect(enableCollectionStub).to.have.been.calledOnceWith(projectId, appId, "us-central1"); }); it("should default location to DEFAULT_BUCKET_LOCATION when location option is empty", async () => { @@ -118,7 +107,7 @@ describe("crashlytics:heapdumps commands", () => { force: true, }); - expect(ensureBucketStub).to.have.been.calledWith( + expect(enableCollectionStub).to.have.been.calledOnceWith( projectId, appId, profilingManager.DEFAULT_BUCKET_LOCATION, @@ -127,7 +116,7 @@ describe("crashlytics:heapdumps commands", () => { it("should rethrow error when provisioning or enabling collection fails", async () => { const boom = new FirebaseError("Storage failure"); - ensureBucketStub.rejects(boom); + enableCollectionStub.rejects(boom); await expect( enableCommand.runner()({ @@ -153,16 +142,11 @@ describe("crashlytics:heapdumps commands", () => { }); describe("crashlytics:heapdumps:disable", () => { - let getConfigStub: sinon.SinonStub; - let updateConfigStub: sinon.SinonStub; + let disableCollectionStub: sinon.SinonStub; let confirmStub: sinon.SinonStub; beforeEach(() => { - getConfigStub = sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ - gcsBucket: bucketName, - heapDumpCollectionEnabled: true, - }); - updateConfigStub = sinon.stub(profilingManager, "updateProfilingManagerConfig").resolves(); + disableCollectionStub = sinon.stub(profilingManager, "disableHeapDumpCollection").resolves(); confirmStub = sinon.stub(promptModule, "confirm").resolves(true); }); @@ -175,7 +159,7 @@ describe("crashlytics:heapdumps commands", () => { }); expect(result).to.be.undefined; - expect(updateConfigStub).to.not.have.been.called; + expect(disableCollectionStub).to.not.have.been.called; }); it("should successfully disable collection", async () => { @@ -190,16 +174,12 @@ describe("crashlytics:heapdumps commands", () => { heapDumpCollectionEnabled: false, }); - expect(getConfigStub).to.have.been.calledWith(appId); - expect(updateConfigStub).to.have.been.calledWith(appId, { - gcsBucket: bucketName, - heapDumpCollectionEnabled: false, - }); + expect(disableCollectionStub).to.have.been.calledOnceWith(appId); }); it("should rethrow error when disabling collection fails", async () => { const boom = new FirebaseError("Update failed"); - updateConfigStub.rejects(boom); + disableCollectionStub.rejects(boom); await expect( disableCommand.runner()({ diff --git a/src/commands/index.ts b/src/commands/index.ts index 694411e5d84..3f6a7f18c4f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -96,12 +96,10 @@ export function load(client: CLIClient): CLIClient { client.crashlytics.sourcemap = {}; client.crashlytics.sourcemap.upload = loadCommand("crashlytics-sourcemap-upload"); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - client.crashlytics.heapdumps = { - enable: loadCommand("crashlytics-heapdumps-enable"), - disable: loadCommand("crashlytics-heapdumps-disable"), - status: loadCommand("crashlytics-heapdumps-status"), - }; + client.crashlytics.heapdumps = {}; + client.crashlytics.heapdumps.enable = loadCommand("crashlytics-heapdumps-enable"); + client.crashlytics.heapdumps.disable = loadCommand("crashlytics-heapdumps-disable"); + client.crashlytics.heapdumps.status = loadCommand("crashlytics-heapdumps-status"); client.database = {}; client.database.get = loadCommand("database-get"); client.database.import = loadCommand("database-import"); diff --git a/src/crashlytics/profilingManager.spec.ts b/src/crashlytics/profilingManager.spec.ts index aa73a42f2a8..a5d94954b41 100644 --- a/src/crashlytics/profilingManager.spec.ts +++ b/src/crashlytics/profilingManager.spec.ts @@ -4,6 +4,7 @@ import nock from "../test/helpers/nock"; import * as chaiAsPromised from "chai-as-promised"; import { + parseAndroidHashedId, createBucketName, getCrashlyticsP4sa, resolveAndroidAppId, @@ -11,10 +12,12 @@ import { updateProfilingManagerConfig, ensureHeapDumpStorageBucket, ensureHeapDumpP4saRole, + enableHeapDumpCollection, + disableHeapDumpCollection, CRASHLYTICS_SERVICE_NAME, STORAGE_OBJECT_CREATOR_ROLE, - DEFAULT_CORS_ORIGINS, - DEFAULT_FILE_TTL_DAYS, + DEFAULT_CORS_RULES, + DEFAULT_LIFECYCLE_RULES, } from "./profilingManager"; import { FirebaseError } from "../error"; import { crashlyticsApiOrigin } from "../api"; @@ -22,7 +25,6 @@ import * as storage from "../gcp/storage"; import * as resourceManager from "../gcp/resourceManager"; import * as serviceusage from "../gcp/serviceusage"; import * as appsModule from "../management/apps"; -import * as promptModule from "../prompt"; import { Policy } from "../gcp/iam"; chai.use(chaiAsPromised); @@ -40,16 +42,16 @@ describe("profilingManager", () => { sinon.restore(); }); - describe("createBucketName", () => { - it("should generate the deterministic bucket name for a valid Android app ID", () => { - const result = createBucketName(appId); - expect(result).to.equal(expectedBucketName); + describe("parseAndroidHashedId & createBucketName", () => { + it("should extract lowercased hashed ID and generate deterministic bucket name for a valid Android app ID", () => { + expect(parseAndroidHashedId(appId)).to.equal(hashedAppId); + expect(createBucketName(appId)).to.equal(expectedBucketName); }); it("should lowercase uppercase characters in the hashed package name", () => { const upperAppId = `1:${projectNumber}:android:ABCDEF123456`; - const result = createBucketName(upperAppId); - expect(result).to.equal(expectedBucketName); + expect(parseAndroidHashedId(upperAppId)).to.equal(hashedAppId); + expect(createBucketName(upperAppId)).to.equal(expectedBucketName); }); it("should throw a FirebaseError for an iOS app ID", () => { @@ -60,8 +62,9 @@ describe("profilingManager", () => { ); }); - it("should throw a FirebaseError for an invalid app ID format", () => { + it("should throw a FirebaseError for an invalid or empty-segment app ID format", () => { expect(() => createBucketName("invalid-app-id")).to.throw(FirebaseError); + expect(() => createBucketName(`1:${projectNumber}:android:`)).to.throw(FirebaseError); }); }); @@ -141,7 +144,6 @@ describe("profilingManager", () => { it("should prompt the user to select an app when multiple Android apps exist interactively", async () => { const secondAppId = `1:${projectNumber}:android:fedcba654321`; - const thirdAppId = `1:${projectNumber}:android:111111222222`; const mockApps: appsModule.AndroidAppMetadata[] = [ { name: `projects/${projectId}/androidApps/${appId}`, @@ -158,26 +160,14 @@ describe("profilingManager", () => { platform: appsModule.AppPlatform.ANDROID, packageName: "com.example.two", }, - { - name: `projects/${projectId}/androidApps/${thirdAppId}`, - projectId, - appId: thirdAppId, - platform: appsModule.AppPlatform.ANDROID, - packageName: "", - }, ]; sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); - const selectStub = sinon.stub(promptModule, "select").resolves(secondAppId); + const selectAppStub = sinon.stub(appsModule, "selectAppInteractively").resolves(mockApps[1]); const result = await resolveAndroidAppId(projectId, {}); expect(result).to.equal(secondAppId); - expect(selectStub).to.have.been.calledOnceWith({ + expect(selectAppStub).to.have.been.calledOnceWith(mockApps, appsModule.AppPlatform.ANDROID, { message: "Select an Android app:", - choices: [ - { name: `First App (${appId})`, value: appId }, - { name: `com.example.two (${secondAppId})`, value: secondAppId }, - { name: `${thirdAppId} (${thirdAppId})`, value: thirdAppId }, - ], }); }); }); @@ -213,6 +203,7 @@ describe("profilingManager", () => { gcsBucket: "", heapDumpCollectionEnabled: false, }); + expect(nock.isDone()).to.be.true; }); it("should throw a FirebaseError if the appId is invalid", async () => { @@ -272,20 +263,8 @@ describe("profilingManager", () => { }); describe("ensureHeapDumpStorageBucket", () => { - const expectedCors = [ - { - origin: DEFAULT_CORS_ORIGINS, - method: ["GET", "HEAD", "OPTIONS"], - responseHeader: ["Content-Type", "Access-Control-Allow-Origin", "Content-Length"], - }, - ]; const expectedLifecycle = { - rule: [ - { - action: { type: "Delete" }, - condition: { age: DEFAULT_FILE_TTL_DAYS }, - }, - ], + rule: DEFAULT_LIFECYCLE_RULES, }; it("should create a new bucket with CORS and 90-day TTL when it does not exist", async () => { @@ -304,7 +283,7 @@ describe("profilingManager", () => { { name: expectedBucketName, location: "us-central1", - cors: expectedCors, + cors: DEFAULT_CORS_RULES, lifecycle: expectedLifecycle, }, true, @@ -322,10 +301,25 @@ describe("profilingManager", () => { expect(createBucketStub).to.not.have.been.called; }); + it("should throw a FirebaseError if the bucket is owned by another project", async () => { + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + projectNumber: "9999999999", + } as unknown as storage.BucketResponse); + const patchBucketStub = sinon.stub(storage, "patchBucket"); + + await expect(ensureHeapDumpStorageBucket(projectId, appId)).to.be.rejectedWith( + FirebaseError, + `Cloud Storage bucket '${expectedBucketName}' already exists and is owned by another project.`, + ); + expect(patchBucketStub).to.not.have.been.called; + }); + it("should patch CORS and lifecycle if bucket exists without either configuration", async () => { - sinon - .stub(storage, "getBucket") - .resolves({ name: expectedBucketName, cors: [] } as unknown as storage.BucketResponse); + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + projectNumber, + } as unknown as storage.BucketResponse); const patchBucketStub = sinon .stub(storage, "patchBucket") .resolves({} as storage.BucketResponse); @@ -335,16 +329,42 @@ describe("profilingManager", () => { expect(result).to.equal(expectedBucketName); expect(patchBucketStub).to.have.been.calledWith(expectedBucketName, { - cors: expectedCors, + cors: DEFAULT_CORS_RULES, lifecycle: expectedLifecycle, }); expect(createBucketStub).to.not.have.been.called; }); + it("should merge required CORS and lifecycle rules with pre-existing unrelated rules", async () => { + const existingCors: storage.CorsRule = { origin: ["https://example.com"], method: ["GET"] }; + const existingLifecycleRule: storage.LifecycleRule = { + action: { type: "AbortIncompleteMultipartUpload" }, + condition: { age: 7 }, + }; + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + projectNumber, + cors: [existingCors], + lifecycle: { rule: [existingLifecycleRule] }, + } as unknown as storage.BucketResponse); + const patchBucketStub = sinon + .stub(storage, "patchBucket") + .resolves({} as storage.BucketResponse); + + const result = await ensureHeapDumpStorageBucket(projectId, appId); + + expect(result).to.equal(expectedBucketName); + expect(patchBucketStub).to.have.been.calledWith(expectedBucketName, { + cors: [existingCors, ...DEFAULT_CORS_RULES], + lifecycle: { rule: [existingLifecycleRule, ...DEFAULT_LIFECYCLE_RULES] }, + }); + }); + it("should patch only lifecycle if bucket exists with CORS but without lifecycle", async () => { sinon.stub(storage, "getBucket").resolves({ name: expectedBucketName, - cors: expectedCors, + projectNumber, + cors: DEFAULT_CORS_RULES, } as unknown as storage.BucketResponse); const patchBucketStub = sinon .stub(storage, "patchBucket") @@ -363,7 +383,8 @@ describe("profilingManager", () => { it("should do nothing if bucket exists with both CORS and lifecycle configuration", async () => { sinon.stub(storage, "getBucket").resolves({ name: expectedBucketName, - cors: expectedCors, + projectNumber, + cors: DEFAULT_CORS_RULES, lifecycle: expectedLifecycle, } as unknown as storage.BucketResponse); const patchBucketStub = sinon.stub(storage, "patchBucket"); @@ -420,4 +441,57 @@ describe("profilingManager", () => { expect(addRoleStub).to.not.have.been.called; }); }); + + describe("enableHeapDumpCollection & disableHeapDumpCollection", () => { + it("should orchestrate bucket provisioning, P4SA role setup, and config enablement", async () => { + sinon.stub(storage, "getBucket").resolves({ + name: expectedBucketName, + projectNumber, + cors: DEFAULT_CORS_RULES, + lifecycle: { rule: DEFAULT_LIFECYCLE_RULES }, + } as unknown as storage.BucketResponse); + sinon.stub(serviceusage, "generateServiceIdentityAndPoll").resolves(); + sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); + nock(crashlyticsApiOrigin()) + .post(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, { + projectNumber, + gmpAppId: appId, + configuration: { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }, + }) + .reply(200, {}); + + const result = await enableHeapDumpCollection(projectId, appId); + + expect(result).to.equal(expectedBucketName); + expect(nock.isDone()).to.be.true; + }); + + it("should preserve existing bucket name and disable heap dump collection", async () => { + nock(crashlyticsApiOrigin()) + .get(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`) + .reply(200, { + configuration: { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }, + }); + nock(crashlyticsApiOrigin()) + .post(`/v1/projects/${projectNumber}/apps/${appId}/appconfig:profilingManager`, { + projectNumber, + gmpAppId: appId, + configuration: { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: false, + }, + }) + .reply(200, {}); + + await disableHeapDumpCollection(appId); + + expect(nock.isDone()).to.be.true; + }); + }); }); diff --git a/src/crashlytics/profilingManager.ts b/src/crashlytics/profilingManager.ts index 4184477dfe3..961c7a65661 100644 --- a/src/crashlytics/profilingManager.ts +++ b/src/crashlytics/profilingManager.ts @@ -2,12 +2,12 @@ import { Client } from "../apiv2"; import { crashlyticsApiOrigin } from "../api"; import { FirebaseError, getErrStatus } from "../error"; import { logger } from "../logger"; +import { logLabeledBullet, logLabeledSuccess } from "../utils"; import { parseProjectNumber, TIMEOUT } from "./utils"; import * as storage from "../gcp/storage"; import * as resourceManager from "../gcp/resourceManager"; import * as serviceusage from "../gcp/serviceusage"; -import { AndroidAppMetadata, AppPlatform, listFirebaseApps } from "../management/apps"; -import { select } from "../prompt"; +import { AppPlatform, listFirebaseApps, selectAppInteractively } from "../management/apps"; /** * Returns an authenticated v1 API client for the Firebase Crashlytics API. @@ -21,9 +21,25 @@ export function getCrashlyticsV1Client(): Client { export const CRASHLYTICS_SERVICE_NAME = "firebasecrashlytics.googleapis.com"; export const STORAGE_OBJECT_CREATOR_ROLE = "roles/storage.objectCreator"; +export const HEAP_DUMP_BUCKET_PREFIX = "firebasecrashlytics-heap-dumps-"; export const DEFAULT_CORS_ORIGINS = ["https://console.firebase.google.com"]; export const DEFAULT_BUCKET_LOCATION = "us-east1"; export const DEFAULT_FILE_TTL_DAYS = 90; +export const DEFAULT_CORS_RULES: storage.CorsRule[] = [ + { + origin: DEFAULT_CORS_ORIGINS, + method: ["GET", "HEAD", "OPTIONS"], + responseHeader: ["Content-Type", "Access-Control-Allow-Origin", "Content-Length"], + }, +]; +export const DEFAULT_LIFECYCLE_RULES: storage.LifecycleRule[] = [ + { + action: { type: "Delete" }, + condition: { age: DEFAULT_FILE_TTL_DAYS }, + }, +]; + +const ANDROID_APP_ID_REGEX = /^\d+:(\d+):android:([a-fA-F0-9]+)$/; export interface ProfilingManagerConfig { gcsBucket?: string; @@ -44,19 +60,28 @@ export interface UpdateProfilingManagerConfigRequest { } /** - * Creates a deterministic GCS bucket name for the given GMP App ID. - * Follows the convention: firebasecrashlytics-heap-dumps-${hashedPackageName} + * Validates an Android GMP App ID and returns its lowercased hashed package name segment. * @param appId GMP App ID (e.g. 1:123456789:android:abcdef123456) */ -export function createBucketName(appId: string): string { - const appIdSplit = appId.split(":"); - if (appIdSplit.length < 4 || appIdSplit[2] !== "android") { +export function parseAndroidHashedId(appId: string): string { + const match = ANDROID_APP_ID_REGEX.exec(appId); + if (!match) { throw new FirebaseError( - `App ID ${appId} is not a valid Android app ID. Heap dump collection is only supported for Android apps.`, + `App ID '${appId}' is not a valid Android app ID (expected format '1::android:'). ` + + "Heap dump collection is only supported for Android apps. " + + "Run 'firebase apps:list ANDROID' to view valid Android app IDs for your project.", ); } - const hashedPackageName = appIdSplit[3].toLowerCase(); - return `firebasecrashlytics-heap-dumps-${hashedPackageName}`; + return match[2].toLowerCase(); +} + +/** + * Creates a deterministic GCS bucket name for the given GMP App ID. + * Follows the convention: firebasecrashlytics-heap-dumps-${hashedPackageName} + * @param appId GMP App ID (e.g. 1:123456789:android:abcdef123456) + */ +export function createBucketName(appId: string): string { + return `${HEAP_DUMP_BUCKET_PREFIX}${parseAndroidHashedId(appId)}`; } /** @@ -75,12 +100,7 @@ export async function resolveAndroidAppId( options: { app?: string; nonInteractive?: boolean }, ): Promise { if (options.app) { - const appIdParts = options.app.split(":"); - if (appIdParts.length < 4 || appIdParts[2] !== "android") { - throw new FirebaseError( - `App ID '${options.app}' is not a valid Android app ID. Heap dump collection is only supported for Android apps.`, - ); - } + parseAndroidHashedId(options.app); return options.app; } @@ -102,15 +122,10 @@ export async function resolveAndroidAppId( ); } - const choices = (apps as AndroidAppMetadata[]).map((app) => ({ - name: `${app.displayName || app.packageName || app.appId} (${app.appId})`, - value: app.appId, - })); - - return await select({ + const selectedApp = await selectAppInteractively(apps, AppPlatform.ANDROID, { message: "Select an Android app:", - choices, }); + return selectedApp.appId; } /** @@ -179,30 +194,29 @@ export async function ensureHeapDumpStorageBucket( location: string = DEFAULT_BUCKET_LOCATION, ): Promise { const bucketName = createBucketName(appId); - const corsRules: storage.CorsRule[] = [ - { - origin: DEFAULT_CORS_ORIGINS, - method: ["GET", "HEAD", "OPTIONS"], - responseHeader: ["Content-Type", "Access-Control-Allow-Origin", "Content-Length"], - }, - ]; - const lifecycle: { rule: storage.LifecycleRule[] } = { - rule: [ - { - action: { type: "Delete" }, - condition: { age: DEFAULT_FILE_TTL_DAYS }, - }, - ], - }; + const projectNumber = parseProjectNumber(appId); try { const bucket = await storage.getBucket(bucketName); + if (bucket.projectNumber && String(bucket.projectNumber) !== projectNumber) { + throw new FirebaseError( + `Cloud Storage bucket '${bucketName}' already exists and is owned by another project.`, + ); + } const patchPayload: Partial = {}; - if (!bucket.cors || bucket.cors.length === 0) { - patchPayload.cors = corsRules; + const hasConsoleCors = bucket.cors?.some((rule) => + DEFAULT_CORS_ORIGINS.every((origin) => rule.origin?.includes(origin)), + ); + if (!hasConsoleCors) { + patchPayload.cors = [...(bucket.cors ?? []), ...DEFAULT_CORS_RULES]; } - if (!bucket.lifecycle?.rule || bucket.lifecycle.rule.length === 0) { - patchPayload.lifecycle = lifecycle; + const hasDeleteLifecycle = bucket.lifecycle?.rule?.some( + (rule) => rule.action.type === "Delete" && rule.condition.age !== undefined, + ); + if (!hasDeleteLifecycle) { + patchPayload.lifecycle = { + rule: [...(bucket.lifecycle?.rule ?? []), ...DEFAULT_LIFECYCLE_RULES], + }; } if (Object.keys(patchPayload).length > 0) { logger.debug( @@ -224,8 +238,8 @@ export async function ensureHeapDumpStorageBucket( { name: bucketName, location, - cors: corsRules, - lifecycle, + cors: DEFAULT_CORS_RULES, + lifecycle: { rule: DEFAULT_LIFECYCLE_RULES }, }, true /* projectPrivate */, ); @@ -263,3 +277,43 @@ export async function ensureHeapDumpP4saRole( ); } } + +/** + * Provisions the GCS bucket, configures Crashlytics service agent permissions, + * and enables heap dump collection for the specified Android app. + * @param projectId Cloud Project ID + * @param appId GMP App ID + * @param location Bucket location / region + */ +export async function enableHeapDumpCollection( + projectId: string, + appId: string, + location: string = DEFAULT_BUCKET_LOCATION, +): Promise { + const projectNumber = parseProjectNumber(appId); + logLabeledBullet("crashlytics", "Configuring Google Cloud Storage bucket..."); + const bucketName = await ensureHeapDumpStorageBucket(projectId, appId, location); + logLabeledBullet("crashlytics", "Configuring service agent permissions..."); + await ensureHeapDumpP4saRole(projectId, projectNumber); + logLabeledBullet("crashlytics", "Enabling Crashlytics heap dump collection..."); + await updateProfilingManagerConfig(appId, { + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + logLabeledSuccess("crashlytics", "Successfully enabled Crashlytics heap dump collection!"); + return bucketName; +} + +/** + * Disables heap dump collection for the specified Android app while preserving its bucket configuration. + * @param appId GMP App ID + */ +export async function disableHeapDumpCollection(appId: string): Promise { + logLabeledBullet("crashlytics", "Disabling Crashlytics heap dump collection..."); + const currentConfig = await getProfilingManagerConfig(appId); + await updateProfilingManagerConfig(appId, { + gcsBucket: currentConfig.gcsBucket, + heapDumpCollectionEnabled: false, + }); + logLabeledSuccess("crashlytics", "Successfully disabled Crashlytics heap dump collection!"); +} diff --git a/src/gcp/storage.spec.ts b/src/gcp/storage.spec.ts index e2dbcd8d939..1876855828a 100644 --- a/src/gcp/storage.spec.ts +++ b/src/gcp/storage.spec.ts @@ -10,7 +10,10 @@ import { Client } from "../apiv2"; describe("storage", () => { describe("upsertBucket", () => { let listBucketsStub: sinon.SinonStub; - let createBucketStub: sinon.SinonStub; + let createBucketStub: sinon.SinonStub< + Parameters, + ReturnType + >; let patchBucketStub: sinon.SinonStub; let logLabeledBulletStub: sinon.SinonStub; let logLabeledWarningStub: sinon.SinonStub; @@ -39,7 +42,7 @@ describe("storage", () => { listBucketsStub.resolves([ { name: bucketName, labels: { [PURPOSE_LABEL]: "true" } }, { name: "another-bucket", labels: {} }, - ] as any); + ] as unknown as storage.BucketResponse[]); const result = await storage.upsertBucket({ product: "test", @@ -60,7 +63,9 @@ describe("storage", () => { it("should patch an existing bucket if it does not have a purpose label", async () => { const bucketName = "existing-unmanaged-bucket"; - listBucketsStub.resolves([{ name: bucketName, labels: {} }] as any); + listBucketsStub.resolves([ + { name: bucketName, labels: {} }, + ] as unknown as storage.BucketResponse[]); const result = await storage.upsertBucket({ product: "test", @@ -83,8 +88,10 @@ describe("storage", () => { }); it("should create a new bucket if no bucket with the purpose label is found", async () => { - listBucketsStub.resolves([{ name: "another-bucket", labels: {} }] as any); - createBucketStub.resolves({ name: BASE_BUCKET_NAME } as any); + listBucketsStub.resolves([ + { name: "another-bucket", labels: {} }, + ] as unknown as storage.BucketResponse[]); + createBucketStub.resolves({ name: BASE_BUCKET_NAME } as unknown as storage.BucketResponse); const result = await storage.upsertBucket({ product: "test", @@ -136,13 +143,17 @@ describe("storage", () => { }); it("should retry with a new name on createBucket conflict", async () => { - const conflictError = new FirebaseError("Conflict", { original: { status: 409 } as any }); + const conflictError = new FirebaseError("Conflict", { + original: new FirebaseError("Conflict", { status: 409 }), + }); const randomSuffix = "abcdef"; const newBucketName = `${BASE_BUCKET_NAME}-${randomSuffix}`; listBucketsStub.resolves([]); createBucketStub.onFirstCall().rejects(conflictError); - createBucketStub.onSecondCall().resolves({ name: newBucketName } as any); + createBucketStub.onSecondCall().resolves({ + name: newBucketName, + } as unknown as storage.BucketResponse); const result = await storage.upsertBucket({ product: "test", @@ -164,7 +175,9 @@ describe("storage", () => { }); it("should error out after 5 createBucket conflicts", async () => { - const conflictError = new FirebaseError("Conflict", { original: { status: 409 } as any }); + const conflictError = new FirebaseError("Conflict", { + original: new FirebaseError("Conflict", { status: 409 }), + }); listBucketsStub.resolves([]); createBucketStub.rejects(conflictError); @@ -187,7 +200,7 @@ describe("storage", () => { it("should handle permission errors on createBucket", async () => { const permError = new FirebaseError("Permission denied", { - original: { status: 403 } as any, + original: new FirebaseError("Forbidden", { status: 403 }), }); listBucketsStub.resolves([]); createBucketStub.rejects(permError); @@ -212,7 +225,7 @@ describe("storage", () => { it("should forward unexpected errors from createBucket", async () => { const unexpectedError = new FirebaseError("Unexpected error", { - original: { status: 500 } as any, + original: new FirebaseError("Server Error", { status: 500 }), }); listBucketsStub.resolves([]); createBucketStub.rejects(unexpectedError); diff --git a/src/gcp/storage.ts b/src/gcp/storage.ts index 44af43ea642..c5715c28bba 100644 --- a/src/gcp/storage.ts +++ b/src/gcp/storage.ts @@ -5,7 +5,7 @@ import { randomInt } from "crypto"; import { firebaseStorageOrigin, storageOrigin } from "../api"; import { Client } from "../apiv2"; -import { FirebaseError, getErrStatus } from "../error"; +import { FirebaseError, getError, getErrStatus } from "../error"; import { logger } from "../logger"; import { ensure } from "../ensureApiEnabled"; import * as utils from "../utils"; @@ -332,9 +332,10 @@ export async function getBucket(bucketName: string): Promise { const result = await localAPIClient.get(`/storage/v1/b/${bucketName}`); return result.body; } catch (err: unknown) { - logger.debug(err as Error); + const error = getError(err); + logger.debug(error); throw new FirebaseError("Failed to obtain the storage bucket", { - original: err as Error, + original: error, status: getErrStatus(err), }); } @@ -405,9 +406,10 @@ export async function patchBucket( ); return result.body; } catch (err: unknown) { - logger.debug(err as Error); + const error = getError(err); + logger.debug(error); throw new FirebaseError("Failed to patch the storage bucket", { - original: err as Error, + original: error, status: getErrStatus(err), }); } From c9a40a43b6b154c37b2e7a0bfe55ff4a7893c9e3 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Tue, 6 Oct 2026 15:30:55 +0000 Subject: [PATCH 09/10] refactor(crashlytics): revert loadCommand changes in commands/index.ts --- src/commands/index.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index 3f6a7f18c4f..8583fd39da9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,30 +1,27 @@ -import { CLIClient, Command } from "../command"; +import { CLIClient } from "../command"; import * as experiments from "../experiments"; -type CommandRunner = ((...args: unknown[]) => Promise) & { load: () => void }; +type CommandRunner = ((...args: any[]) => Promise) & { load: () => void }; /** * Loads all commands for our parser. */ export function load(client: CLIClient): CLIClient { function loadCommand(name: string): CommandRunner { - const load = (): ((...a: unknown[]) => Promise) => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { command: cmd } = require(`./${name}`) as { command: Command }; + const load = () => { + const { command: cmd } = require(`./${name}`); cmd.register(client); return cmd.runner(); }; - const runner = (async (...args: unknown[]) => { + const runner = (async (...args: any[]) => { const run = load(); return run(...args); }) as CommandRunner; // Store the load function on the runner so we can trigger it without running. runner.load = () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { command: cmd } = require(`./${name}`) as { command: Command }; - cmd.register(client); + require(`./${name}`).command.register(client); }; return runner; From 8146ec2b24e7fdfb17abb9e11a9446ff02626e41 Mon Sep 17 00:00:00 2001 From: Anit Das Date: Thu, 8 Oct 2026 15:31:31 +0000 Subject: [PATCH 10/10] refactor(crashlytics): remove confirmation prompt from heapdumps:disable --- src/commands/crashlytics-heapdumps-disable.ts | 14 -------------- src/commands/crashlytics-heapdumps.spec.ts | 16 ---------------- 2 files changed, 30 deletions(-) diff --git a/src/commands/crashlytics-heapdumps-disable.ts b/src/commands/crashlytics-heapdumps-disable.ts index 7edcd733f22..4df5a0dec60 100644 --- a/src/commands/crashlytics-heapdumps-disable.ts +++ b/src/commands/crashlytics-heapdumps-disable.ts @@ -4,7 +4,6 @@ import { Command } from "../command"; import { logger } from "../logger"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; -import { confirm } from "../prompt"; import { requireAuth } from "../requireAuth"; import { disableHeapDumpCollection, resolveAndroidAppId } from "../crashlytics/profilingManager"; @@ -15,24 +14,11 @@ interface CommandOptions extends Options { export const command = new Command("crashlytics:heapdumps:disable") .description("disable Crashlytics heap dump collection for an Android app") .option("--app ", "the app id of your Firebase Android app") - .withForce("automatically disable without prompting for confirmation") .before(requireAuth) .action(async (options: CommandOptions) => { const projectId = needProjectId(options); const appId = await resolveAndroidAppId(projectId, options); - const confirmed = await confirm({ - message: `Disable Crashlytics heap dump collection for ${appId}?`, - default: true, - force: options.force, - nonInteractive: options.nonInteractive, - }); - - if (!confirmed) { - logger.info("Heap dump collection disablement canceled."); - return; - } - await disableHeapDumpCollection(appId); logger.info(""); diff --git a/src/commands/crashlytics-heapdumps.spec.ts b/src/commands/crashlytics-heapdumps.spec.ts index a3493b03373..a474614f04d 100644 --- a/src/commands/crashlytics-heapdumps.spec.ts +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -143,30 +143,15 @@ describe("crashlytics:heapdumps commands", () => { describe("crashlytics:heapdumps:disable", () => { let disableCollectionStub: sinon.SinonStub; - let confirmStub: sinon.SinonStub; beforeEach(() => { disableCollectionStub = sinon.stub(profilingManager, "disableHeapDumpCollection").resolves(); - confirmStub = sinon.stub(promptModule, "confirm").resolves(true); - }); - - it("should cancel if user denies confirmation", async () => { - confirmStub.resolves(false); - - const result: unknown = await disableCommand.runner()({ - project: projectId, - app: appId, - }); - - expect(result).to.be.undefined; - expect(disableCollectionStub).to.not.have.been.called; }); it("should successfully disable collection", async () => { const result: unknown = await disableCommand.runner()({ project: projectId, app: appId, - force: true, }); expect(result).to.deep.equal({ @@ -185,7 +170,6 @@ describe("crashlytics:heapdumps commands", () => { disableCommand.runner()({ project: projectId, app: appId, - force: true, }), ).to.be.rejectedWith(boom); });