diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..8e8f93a21e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- 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..4df5a0dec60 --- /dev/null +++ b/src/commands/crashlytics-heapdumps-disable.ts @@ -0,0 +1,33 @@ +import * as clc from "colorette"; + +import { Command } from "../command"; +import { logger } from "../logger"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { requireAuth } from "../requireAuth"; +import { disableHeapDumpCollection, resolveAndroidAppId } 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") + .before(requireAuth) + .action(async (options: CommandOptions) => { + const projectId = needProjectId(options); + const appId = await resolveAndroidAppId(projectId, options); + + await disableHeapDumpCollection(appId); + + 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..6dbf863daf2 --- /dev/null +++ b/src/commands/crashlytics-heapdumps-enable.ts @@ -0,0 +1,84 @@ +import * as clc from "colorette"; + +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 { consoleUrl } from "../utils"; +import * as cloudbilling from "../gcp/cloudbilling"; +import { + createBucketName, + DEFAULT_BUCKET_LOCATION, + DEFAULT_FILE_TTL_DAYS, + enableHeapDumpCollection, + resolveAndroidAppId, +} 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, + ) + .withForce("automatically configure without prompting for confirmation") + .before(requireAuth) + .action(async (options: CommandOptions) => { + const projectId = needProjectId(options); + const appId = await resolveAndroidAppId(projectId, options); + 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}' 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")}`, + ); + } + + // 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, IAM roles, and Profiling Manager config + await enableHeapDumpCollection(projectId, appId, location); + + 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: ${consoleUrl(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..a474614f04d --- /dev/null +++ b/src/commands/crashlytics-heapdumps.spec.ts @@ -0,0 +1,247 @@ +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"; +import * as profilingManager from "../crashlytics/profilingManager"; +import * as cloudbilling from "../gcp/cloudbilling"; +import * as promptModule from "../prompt"; +import { requireAuth } from "../requireAuth"; +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}`; + + const originalEnableBefores = [...(enableCommand["befores"] || [])]; + const originalDisableBefores = [...(disableCommand["befores"] || [])]; + const originalStatusBefores = [...(statusCommand["befores"] || [])]; + + beforeEach(() => { + 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 enableCollectionStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + + beforeEach(() => { + checkBillingStub = sinon.stub(cloudbilling, "checkBillingEnabled").resolves(true); + enableCollectionStub = sinon + .stub(profilingManager, "enableHeapDumpCollection") + .resolves(bucketName); + 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, "is not on the Blaze (pay-as-you-go) plan"); + + expect(enableCollectionStub).to.not.have.been.called; + }); + + it("should cancel if user denies confirmation", async () => { + confirmStub.resolves(false); + + const result: unknown = await enableCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.be.undefined; + expect(enableCollectionStub).to.not.have.been.called; + }); + + it("should successfully provision bucket, grant P4SA role, and enable collection", async () => { + const result: unknown = 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(enableCollectionStub).to.have.been.calledOnceWith(projectId, appId, "us-central1"); + }); + + 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(enableCollectionStub).to.have.been.calledOnceWith( + projectId, + appId, + profilingManager.DEFAULT_BUCKET_LOCATION, + ); + }); + + it("should rethrow error when provisioning or enabling collection fails", async () => { + const boom = new FirebaseError("Storage failure"); + enableCollectionStub.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()({ + 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 disableCollectionStub: sinon.SinonStub; + + beforeEach(() => { + disableCollectionStub = sinon.stub(profilingManager, "disableHeapDumpCollection").resolves(); + }); + + it("should successfully disable collection", async () => { + const result: unknown = await disableCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + heapDumpCollectionEnabled: false, + }); + + expect(disableCollectionStub).to.have.been.calledOnceWith(appId); + }); + + it("should rethrow error when disabling collection fails", async () => { + const boom = new FirebaseError("Update failed"); + disableCollectionStub.rejects(boom); + + await expect( + disableCommand.runner()({ + project: projectId, + app: appId, + }), + ).to.be.rejectedWith(boom); + }); + }); + + describe("crashlytics:heapdumps:status", () => { + it("should return the status and configuration when enabled", async () => { + sinon.stub(profilingManager, "getProfilingManagerConfig").resolves({ + gcsBucket: bucketName, + heapDumpCollectionEnabled: true, + }); + + const result: unknown = await statusCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + gcsBucket: bucketName, + 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: unknown = await statusCommand.runner()({ + project: projectId, + app: appId, + }); + + expect(result).to.deep.equal({ + appId, + gcsBucket: "", + 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/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..a5d94954b41 --- /dev/null +++ b/src/crashlytics/profilingManager.spec.ts @@ -0,0 +1,497 @@ +import * as chai from "chai"; +import * as sinon from "sinon"; +import nock from "../test/helpers/nock"; +import * as chaiAsPromised from "chai-as-promised"; + +import { + parseAndroidHashedId, + createBucketName, + getCrashlyticsP4sa, + resolveAndroidAppId, + getProfilingManagerConfig, + updateProfilingManagerConfig, + ensureHeapDumpStorageBucket, + ensureHeapDumpP4saRole, + enableHeapDumpCollection, + disableHeapDumpCollection, + CRASHLYTICS_SERVICE_NAME, + STORAGE_OBJECT_CREATOR_ROLE, + DEFAULT_CORS_RULES, + DEFAULT_LIFECYCLE_RULES, +} 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 * as appsModule from "../management/apps"; +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("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`; + expect(parseAndroidHashedId(upperAppId)).to.equal(hashedAppId); + expect(createBucketName(upperAppId)).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 or empty-segment app ID format", () => { + expect(() => createBucketName("invalid-app-id")).to.throw(FirebaseError); + expect(() => createBucketName(`1:${projectNumber}:android:`)).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("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 () => { + const mockApps: appsModule.AndroidAppMetadata[] = [ + { + name: `projects/${projectId}/androidApps/${appId}`, + projectId, + appId, + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.app", + }, + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); + + 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`; + const mockApps: appsModule.AndroidAppMetadata[] = [ + { + 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", + }, + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); + + 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 mockApps: appsModule.AndroidAppMetadata[] = [ + { + 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", + }, + ]; + sinon.stub(appsModule, "listFirebaseApps").resolves(mockApps); + const selectAppStub = sinon.stub(appsModule, "selectAppInteractively").resolves(mockApps[1]); + + const result = await resolveAndroidAppId(projectId, {}); + expect(result).to.equal(secondAppId); + expect(selectAppStub).to.have.been.calledOnceWith(mockApps, appsModule.AppPlatform.ANDROID, { + message: "Select an Android app:", + }); + }); + }); + + 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, + }); + expect(nock.isDone()).to.be.true; + }); + + 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 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", { + gcsBucket: expectedBucketName, + heapDumpCollectionEnabled: true, + }), + ).to.be.rejectedWith(FirebaseError, "Unable to get the projectId from the AppId."); + }); + }); + + describe("ensureHeapDumpStorageBucket", () => { + const expectedLifecycle = { + rule: DEFAULT_LIFECYCLE_RULES, + }; + + 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: DEFAULT_CORS_RULES, + lifecycle: expectedLifecycle, + }, + true, + ); + }); + + 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 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, + projectNumber, + } 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: 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, + projectNumber, + cors: DEFAULT_CORS_RULES, + } 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, + projectNumber, + cors: DEFAULT_CORS_RULES, + 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; + }); + }); + + 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 new file mode 100644 index 00000000000..961c7a65661 --- /dev/null +++ b/src/crashlytics/profilingManager.ts @@ -0,0 +1,319 @@ +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 { AppPlatform, listFirebaseApps, selectAppInteractively } from "../management/apps"; + +/** + * Returns an authenticated v1 API client for the Firebase Crashlytics API. + */ +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"; +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; + heapDumpCollectionEnabled?: boolean; +} + +export interface GetProfilingManagerConfigResponse { + configuration?: ProfilingManagerConfig; +} + +export interface UpdateProfilingManagerConfigRequest { + projectNumber: string; + gmpAppId: string; + configuration: { + gcsBucket: string; + heapDumpCollectionEnabled: boolean; + }; +} + +/** + * 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 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 (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.", + ); + } + 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)}`; +} + +/** + * 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) { + parseAndroidHashedId(options.app); + 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 selectedApp = await selectAppInteractively(apps, AppPlatform.ANDROID, { + message: "Select an Android app:", + }); + return selectedApp.appId; +} + +/** + * 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 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 || "", + 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 getCrashlyticsV1Client().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 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 = {}; + const hasConsoleCors = bucket.cors?.some((rule) => + DEFAULT_CORS_ORIGINS.every((origin) => rule.origin?.includes(origin)), + ); + if (!hasConsoleCors) { + patchPayload.cors = [...(bucket.cors ?? []), ...DEFAULT_CORS_RULES]; + } + 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( + `[crashlytics] Bucket ${bucketName} found without full configuration. Patching ${Object.keys(patchPayload).join(", ")}.`, + ); + await storage.patchBucket(bucketName, patchPayload); + } + return bucketName; + } catch (err: unknown) { + const 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: DEFAULT_CORS_RULES, + lifecycle: { rule: DEFAULT_LIFECYCLE_RULES }, + }, + 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 */, + ); + } +} + +/** + * 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 8d6eec993ca..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); @@ -346,4 +359,78 @@ 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 with status when patch request fails", async () => { + const apiError = new FirebaseError("Forbidden", { status: 403 }); + sinon.stub(Client.prototype, "patch").rejects(apiError); + + 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 6814c74ec00..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"; @@ -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; }; } @@ -339,10 +331,12 @@ 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) { + const error = getError(err); + logger.debug(error); throw new FirebaseError("Failed to obtain the storage bucket", { - original: err, + original: error, + status: getErrStatus(err), }); } } @@ -403,6 +397,7 @@ export async function patchBucket( "acl", "defaultObjectAcl", "lifecycle", + "cors", ); const result = await localAPIClient.patch, BucketResponse>( `/storage/v1/b/${bucketName}`, @@ -410,10 +405,12 @@ export async function patchBucket( { queryParams: { updateMask: mask.join(",") } }, ); return result.body; - } catch (err: any) { - logger.debug(err); + } catch (err: unknown) { + const error = getError(err); + logger.debug(error); throw new FirebaseError("Failed to patch the storage bucket", { - original: err, + original: error, + status: getErrStatus(err), }); } }