diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a25fa7c0fa..4b7d2d46607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ +- [Added] Support for creating Firestore Enterprise databases using `firestore:databases:create --edition enterprise`. (#8952) - [Added] Support for Firestore Enterprise database index configurations. (#8939) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 9eb0f6a618f..517f12ca713 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -88,6 +88,9 @@ "database": { "type": "string" }, + "edition": { + "type": "string" + }, "indexes": { "type": "string" }, diff --git a/src/commands/firestore-databases-create.spec.ts b/src/commands/firestore-databases-create.spec.ts new file mode 100644 index 00000000000..8e6231890e5 --- /dev/null +++ b/src/commands/firestore-databases-create.spec.ts @@ -0,0 +1,259 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import { Command } from "../command"; +import { command as firestoreDatabasesCreate } from "./firestore-databases-create"; +import * as fsi from "../firestore/api"; +import * as types from "../firestore/api-types"; +import { FirebaseError } from "../error"; +import * as requireAuthModule from "../requireAuth"; + +describe("firestore:databases:create", () => { + const PROJECT = "test-project"; + const DATABASE = "test-database"; + const LOCATION = "nam5"; + + let command: Command; + let firestoreApiStub: sinon.SinonStubbedInstance; + let requireAuthStub: sinon.SinonStub; + + beforeEach(() => { + command = firestoreDatabasesCreate; + firestoreApiStub = sinon.createStubInstance(fsi.FirestoreApi); + requireAuthStub = sinon.stub(requireAuthModule, "requireAuth"); + sinon.stub(fsi, "FirestoreApi").returns(firestoreApiStub); + requireAuthStub.resolves("a@b.com"); + }); + + afterEach(() => { + sinon.restore(); + }); + + const mockDatabaseResp = (overrides: Partial): types.DatabaseResp => { + return { + name: `projects/${PROJECT}/databases/${DATABASE}`, + uid: "test-uid", + createTime: "2025-07-28T12:00:00Z", + updateTime: "2025-07-28T12:00:00Z", + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.STANDARD, + concurrencyMode: "OPTIMISTIC", + appEngineIntegrationMode: "DISABLED", + keyPrefix: `projects/${PROJECT}/databases/${DATABASE}`, + deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, + etag: "test-etag", + versionRetentionPeriod: "1h", + earliestVersionTime: "2025-07-28T11:00:00Z", + ...overrides, + }; + }; + + it("should create a new database with the correct parameters", async () => { + const options = { + project: PROJECT, + location: LOCATION, + json: true, + }; + const expectedDatabase = mockDatabaseResp({}); + firestoreApiStub.createDatabase.resolves(expectedDatabase); + + const result = await command.runner()(DATABASE, options); + + expect(result).to.deep.equal(expectedDatabase); + expect( + firestoreApiStub.createDatabase.calledOnceWith({ + project: PROJECT, + databaseId: DATABASE, + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.STANDARD, + deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, + cmekConfig: undefined, + }), + ).to.be.true; + }); + + it("should throw an error if location is not provided", async () => { + const options = { + project: PROJECT, + }; + + await expect(command.runner()(DATABASE, options)).to.be.rejectedWith( + FirebaseError, + "Missing required flag --location", + ); + }); + + it("should throw an error for invalid delete protection option", async () => { + const options = { + project: PROJECT, + location: LOCATION, + deleteProtection: "INVALID", + }; + + await expect(command.runner()(DATABASE, options)).to.be.rejectedWith( + FirebaseError, + "Invalid value for flag --delete-protection", + ); + }); + + it("should throw an error for invalid point-in-time recovery option", async () => { + const options = { + project: PROJECT, + location: LOCATION, + pointInTimeRecovery: "INVALID", + }; + + await expect(command.runner()(DATABASE, options)).to.be.rejectedWith( + FirebaseError, + "Invalid value for flag --point-in-time-recovery", + ); + }); + + it("should throw an error for invalid edition option", async () => { + const options = { + project: PROJECT, + location: LOCATION, + edition: "INVALID", + }; + + await expect(command.runner()(DATABASE, options)).to.be.rejectedWith( + FirebaseError, + "Invalid value for flag --edition", + ); + }); + + it("should create a database with enterprise edition", async () => { + const options = { + project: PROJECT, + location: LOCATION, + edition: "enterprise", + json: true, + }; + const expectedDatabase = mockDatabaseResp({ + databaseEdition: types.DatabaseEdition.ENTERPRISE, + }); + firestoreApiStub.createDatabase.resolves(expectedDatabase); + + const result = await command.runner()(DATABASE, options); + + expect(result).to.deep.equal(expectedDatabase); + expect( + firestoreApiStub.createDatabase.calledOnceWith({ + project: PROJECT, + databaseId: DATABASE, + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.ENTERPRISE, + deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, + cmekConfig: undefined, + }), + ).to.be.true; + }); + + it("should create a database with delete protection enabled", async () => { + const options = { + project: PROJECT, + location: LOCATION, + deleteProtection: "ENABLED", + json: true, + }; + const expectedDatabase = mockDatabaseResp({ + deleteProtectionState: types.DatabaseDeleteProtectionState.ENABLED, + }); + firestoreApiStub.createDatabase.resolves(expectedDatabase); + + const result = await command.runner()(DATABASE, options); + + expect(result).to.deep.equal(expectedDatabase); + expect( + firestoreApiStub.createDatabase.calledOnceWith({ + project: PROJECT, + databaseId: DATABASE, + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.STANDARD, + deleteProtectionState: types.DatabaseDeleteProtectionState.ENABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, + cmekConfig: undefined, + }), + ).to.be.true; + }); + + it("should create a database with point-in-time recovery enabled", async () => { + const options = { + project: PROJECT, + location: LOCATION, + pointInTimeRecovery: "ENABLED", + json: true, + }; + const expectedDatabase = mockDatabaseResp({ + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.ENABLED, + }); + firestoreApiStub.createDatabase.resolves(expectedDatabase); + + const result = await command.runner()(DATABASE, options); + + expect(result).to.deep.equal(expectedDatabase); + expect( + firestoreApiStub.createDatabase.calledOnceWith({ + project: PROJECT, + databaseId: DATABASE, + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.STANDARD, + deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.ENABLED, + cmekConfig: undefined, + }), + ).to.be.true; + }); + + it("should create a database with a KMS key", async () => { + const KMS_KEY = "test-kms-key"; + const options = { + project: PROJECT, + location: LOCATION, + kmsKeyName: KMS_KEY, + json: true, + }; + const expectedDatabase = mockDatabaseResp({ + cmekConfig: { + kmsKeyName: KMS_KEY, + }, + }); + firestoreApiStub.createDatabase.resolves(expectedDatabase); + + const result = await command.runner()(DATABASE, options); + + expect(result).to.deep.equal(expectedDatabase); + expect( + firestoreApiStub.createDatabase.calledOnceWith({ + project: PROJECT, + databaseId: DATABASE, + locationId: LOCATION, + type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: types.DatabaseEdition.STANDARD, + deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, + cmekConfig: { + kmsKeyName: KMS_KEY, + }, + }), + ).to.be.true; + }); + + it("should throw an error if the API call fails", async () => { + const options = { + project: PROJECT, + location: LOCATION, + }; + const apiError = new Error("API Error"); + firestoreApiStub.createDatabase.rejects(apiError); + + await expect(command.runner()(DATABASE, options)).to.be.rejectedWith(apiError); + }); +}); diff --git a/src/commands/firestore-databases-create.ts b/src/commands/firestore-databases-create.ts index 8b218ac1e92..632d8c66fd4 100644 --- a/src/commands/firestore-databases-create.ts +++ b/src/commands/firestore-databases-create.ts @@ -17,6 +17,10 @@ export const command = new Command("firestore:databases:create ") "--location ", "region to create database, for example 'nam5'. Run 'firebase firestore:locations' to get a list of eligible locations (required)", ) + .option( + "--edition ", + "the edition of the database to create, for example 'standard' or 'enterprise'. If not provided, 'standard' is used as a default.", + ) .option( "--delete-protection ", "whether or not to prevent deletion of database, for example 'ENABLED' or 'DISABLED'. Default is 'DISABLED'", @@ -44,6 +48,20 @@ export const command = new Command("firestore:databases:create ") } // Type is always Firestore Native since Firebase does not support Datastore Mode const type: types.DatabaseType = types.DatabaseType.FIRESTORE_NATIVE; + + // Figure out the database edition. + let databaseEdition: types.DatabaseEdition = types.DatabaseEdition.STANDARD; + if (options.edition) { + const edition = options.edition.toUpperCase(); + if ( + edition !== types.DatabaseEdition.STANDARD && + edition !== types.DatabaseEdition.ENTERPRISE + ) { + throw new FirebaseError(`Invalid value for flag --edition. ${helpCommandText}`); + } + databaseEdition = edition as types.DatabaseEdition; + } + if ( options.deleteProtection && options.deleteProtection !== types.DatabaseDeleteProtectionStateOption.ENABLED && @@ -82,6 +100,7 @@ export const command = new Command("firestore:databases:create ") databaseId: database, locationId: options.location, type, + databaseEdition, deleteProtectionState, pointInTimeRecoveryEnablement, cmekConfig, diff --git a/src/deploy/firestore/deploy.ts b/src/deploy/firestore/deploy.ts index 5883b38ea5d..317434f0434 100644 --- a/src/deploy/firestore/deploy.ts +++ b/src/deploy/firestore/deploy.ts @@ -25,6 +25,21 @@ async function createDatabase(context: any, options: Options): Promise { if (!firestoreCfg.database) { firestoreCfg.database = "(default)"; } + + let edition: types.DatabaseEdition = types.DatabaseEdition.STANDARD; + if (firestoreCfg.edition) { + const upperEdition = firestoreCfg.edition.toUpperCase(); + if ( + upperEdition !== types.DatabaseEdition.STANDARD && + upperEdition !== types.DatabaseEdition.ENTERPRISE + ) { + throw new FirebaseError( + `Invalid edition specified for database in firebase.json: ${firestoreCfg.edition}`, + ); + } + edition = upperEdition as types.DatabaseEdition; + } + const api = new FirestoreApi(); try { await api.getDatabase(options.projectId, firestoreCfg.database); @@ -40,6 +55,7 @@ async function createDatabase(context: any, options: Options): Promise { databaseId: firestoreCfg.database, locationId: firestoreCfg.location || "nam5", // Default to 'nam5' if location is not specified type: types.DatabaseType.FIRESTORE_NATIVE, + databaseEdition: edition, deleteProtectionState: types.DatabaseDeleteProtectionState.DISABLED, pointInTimeRecoveryEnablement: types.PointInTimeRecoveryEnablement.DISABLED, }; diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index b0c46bee651..dbffa8e335f 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -39,6 +39,7 @@ type DatabaseMultiple = ({ type FirestoreSingle = { database?: string; location?: string; + edition?: string; rules?: string; indexes?: string; } & Deployable; diff --git a/src/firestore/api-types.ts b/src/firestore/api-types.ts index 0a933d2b870..76d70e2c7f0 100644 --- a/src/firestore/api-types.ts +++ b/src/firestore/api-types.ts @@ -147,6 +147,7 @@ export enum DatabaseEdition { export interface DatabaseReq { locationId?: string; type?: DatabaseType; + databaseEdition?: DatabaseEdition; deleteProtectionState?: DatabaseDeleteProtectionState; pointInTimeRecoveryEnablement?: PointInTimeRecoveryEnablement; cmekConfig?: CmekConfig; @@ -157,6 +158,7 @@ export interface CreateDatabaseReq { databaseId: string; locationId: string; type: DatabaseType; + databaseEdition?: DatabaseEdition; deleteProtectionState: DatabaseDeleteProtectionState; pointInTimeRecoveryEnablement: PointInTimeRecoveryEnablement; cmekConfig?: CmekConfig; diff --git a/src/firestore/api.ts b/src/firestore/api.ts index 13564878a44..7abf9f5a081 100644 --- a/src/firestore/api.ts +++ b/src/firestore/api.ts @@ -718,6 +718,7 @@ export class FirestoreApi { const payload: types.DatabaseReq = { locationId: req.locationId, type: req.type, + databaseEdition: req.databaseEdition, deleteProtectionState: req.deleteProtectionState, pointInTimeRecoveryEnablement: req.pointInTimeRecoveryEnablement, cmekConfig: req.cmekConfig, diff --git a/src/firestore/options.ts b/src/firestore/options.ts index 568c50540d2..99024df0269 100644 --- a/src/firestore/options.ts +++ b/src/firestore/options.ts @@ -18,6 +18,7 @@ export interface FirestoreOptions extends Options { type?: types.DatabaseType; deleteProtection?: types.DatabaseDeleteProtectionStateOption; pointInTimeRecoveryEnablement?: types.PointInTimeRecoveryEnablementOption; + edition?: string; // backup schedules backupSchedule?: string;