diff --git a/CHANGELOG.md b/CHANGELOG.md index 12513e6df5a..f9ea57836ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed an issue where missing configurations for Storage, App Hosting, and Data Connect emulators caused blocking startup errors for the entire emulator suite. - Fixed an issue where managed service accounts for declarative security were not deleted when all functions in a codebase were deleted, or caused IAM permission errors on empty codebase deploys. - Configured OneMCP server tools to require a Firebase project by default, with options to opt-out specific tools (such as Developer Knowledge document search). - Fixed a bug where deploying functions with the `dartfunctions` experiment enabled could incorrectly prompt to delete existing GCF v2 functions. diff --git a/src/emulator/controller.spec.ts b/src/emulator/controller.spec.ts index a8bb3529234..0f35f768678 100644 --- a/src/emulator/controller.spec.ts +++ b/src/emulator/controller.spec.ts @@ -10,6 +10,7 @@ function createMockOptions( configValues: { [key: string]: any }, ): Options { const config = { + projectDir: ".", get: (key: string) => configValues[key], has: (key: string) => !!configValues[key], src: { @@ -20,6 +21,7 @@ function createMockOptions( return { only, config, + cwd: process.cwd(), project: "test-project", } as any; } @@ -103,5 +105,55 @@ describe("EmulatorController", () => { }); expect(shouldStart(options, Emulators.FUNCTIONS)).to.be.false; }); + + it("should not start dataconnect emulator if there is no dataconnect service configured", () => { + const options = createMockOptions("dataconnect", { + dataconnect: undefined, + }); + expect(shouldStart(options, Emulators.DATACONNECT)).to.be.false; + }); + + it("should start dataconnect emulator if dataconnect service is configured", () => { + const options = createMockOptions("dataconnect", { + dataconnect: [{ source: "dataconnect" }], + }); + expect(shouldStart(options, Emulators.DATACONNECT)).to.be.true; + }); + + it("should not start extensions emulator if no extensions are configured and functions cannot start", () => { + const options = createMockOptions("extensions", { + extensions: undefined, + functions: {}, + }); + expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.false; + }); + + it("should start extensions emulator if extensions are configured in firebase.json", () => { + const options = createMockOptions("extensions", { + extensions: { "my-ext": "firebase/storage-resize-images@0.1.18" }, + }); + expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.true; + }); + + it("should start extensions emulator if functions emulator can start", () => { + const options = createMockOptions("extensions,functions", { + functions: { source: "functions" }, + }); + expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.true; + }); + + it("should not start apphosting emulator if start command is not set and no lockfile exists", () => { + const options = createMockOptions("apphosting", { + apphosting: { rootDirectory: "./nonexistent-dir" }, + }); + expect(shouldStart(options, Emulators.APPHOSTING)).to.be.false; + }); + + it("should start apphosting emulator if startCommand is configured", () => { + const options = createMockOptions("apphosting", { + apphosting: { startCommand: "npm run dev" }, + }); + expect(shouldStart(options, Emulators.APPHOSTING)).to.be.true; + }); }); }).timeout(2000); diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index cf19e7083ea..de0bcd3ba41 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -200,6 +200,58 @@ export function shouldStart(options: Options, name: Emulators): boolean { return false; } + if ( + name === Emulators.DATACONNECT && + emulatorInTargets && + !readFirebaseJson(options.config).length + ) { + EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled( + "ERROR", + "dataconnect", + `Failed to start Data Connect emulator: No valid Data Connect configuration detected in firebase.json`, + ); + return false; + } + + if ( + name === Emulators.EXTENSIONS && + emulatorInTargets && + !options.config.has("extensions") && + !shouldStart(options, Emulators.FUNCTIONS) + ) { + return false; + } + + if (name === Emulators.APPHOSTING && emulatorInTargets) { + const apphostingEmulatorConfig = options.config.src.emulators?.[Emulators.APPHOSTING]; + const startCommand = + apphostingEmulatorConfig?.startCommand || apphostingEmulatorConfig?.startCommandOverride; + if (!startCommand) { + const rootDirectory = apphostingEmulatorConfig?.rootDirectory; + let backendRoot: string; + try { + backendRoot = resolveProjectPath(options, rootDirectory ?? "./"); + } catch { + backendRoot = path.resolve( + options.config?.projectDir || options.cwd || process.cwd(), + rootDirectory ?? "./", + ); + } + const hasLockfile = + fs.existsSync(path.join(backendRoot, "package-lock.json")) || + fs.existsSync(path.join(backendRoot, "yarn.lock")) || + fs.existsSync(path.join(backendRoot, "pnpm-lock.yaml")); + if (!hasLockfile) { + EmulatorLogger.forEmulator(Emulators.APPHOSTING).logLabeled( + "ERROR", + Emulators.APPHOSTING, + "Failed to start App Hosting emulator: Failed to auto-detect your project's start command. Consider manually setting the start command by setting `firebase.json#emulators.apphosting.startCommand`", + ); + return false; + } + } + } + return emulatorInTargets; } @@ -584,10 +636,6 @@ export async function startAll( } } - if (extensionEmulator) { - await startEmulator(extensionEmulator); - } - const account = getProjectDefaultAccount(options.projectRoot); if (emulatableBackends.length) { @@ -645,6 +693,10 @@ export async function startAll( }); await startEmulator(functionsEmulator); + if (extensionEmulator) { + await startEmulator(extensionEmulator); + } + const eventarcAddr = legacyGetFirstAddr(Emulators.EVENTARC); const eventarcEmulator = new EventarcEmulator({ host: eventarcAddr.host, @@ -893,70 +945,90 @@ export async function startAll( } if (listenForEmulator.dataconnect) { + const dataconnectLogger = EmulatorLogger.forEmulator(Emulators.DATACONNECT); const config = readFirebaseJson(options.config); if (!config.length) { - throw new FirebaseError("No SQL Connect service found in firebase.json"); - } else if (config.length > 1) { - logger.warn( - `TODO: Add support for multiple services in the SQL Connect emulator. Currently emulating first service ${config[0].source}`, + dataconnectLogger.logLabeled( + "ERROR", + "dataconnect", + `Failed to start Data Connect emulator: No valid Data Connect configuration detected in firebase.json`, ); - } + } else { + if (config.length > 1) { + logger.warn( + `TODO: Add support for multiple services in the SQL Connect emulator. Currently emulating first service ${config[0].source}`, + ); + } - const args: DataConnectEmulatorArgs = { - listen: listenForEmulator.dataconnect, - projectId, - auto_download: true, - configDir: config[0].source, - config: options.config, - autoconnectToPostgres: true, - postgresListen: listenForEmulator["dataconnect.postgres"], - enable_output_generated_sdk: true, // TODO: source from arguments - enable_output_schema_extensions: true, - debug: options.debug, - account, - }; + const args: DataConnectEmulatorArgs = { + listen: listenForEmulator.dataconnect, + projectId, + auto_download: true, + configDir: config[0].source, + config: options.config, + autoconnectToPostgres: true, + postgresListen: listenForEmulator["dataconnect.postgres"], + enable_output_generated_sdk: true, // TODO: source from arguments + enable_output_schema_extensions: true, + debug: options.debug, + account, + }; + + if (exportMetadata.dataconnect) { + utils.assertIsString(options.import); + const importDirAbsPath = path.resolve(options.import); + const exportMetadataFilePath = path.resolve( + importDirAbsPath, + exportMetadata.dataconnect.path, + ); + const dataDirectory = options.config.get("emulators.dataconnect.dataDir"); + if (exportMetadataFilePath && dataDirectory) { + dataconnectLogger.logLabeled( + "WARN", + "dataconnect", + "'firebase.json#emulators.dataconnect.dataDir' is set and `--import` flag was passed. " + + "This will overwrite any data saved from previous runs.", + ); + if ( + !options.nonInteractive && + !(await confirm({ + message: `Do you wish to continue and overwrite data in ${dataDirectory}?`, + default: false, + })) + ) { + await cleanShutdown(); + throw new FirebaseError("Command aborted"); + } + } - if (exportMetadata.dataconnect) { - utils.assertIsString(options.import); - const importDirAbsPath = path.resolve(options.import); - const exportMetadataFilePath = path.resolve( - importDirAbsPath, - exportMetadata.dataconnect.path, - ); - const dataDirectory = options.config.get("emulators.dataconnect.dataDir"); - if (exportMetadataFilePath && dataDirectory) { - EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled( - "WARN", + dataconnectLogger.logLabeled( + "BULLET", "dataconnect", - "'firebase.json#emulators.dataconnect.dataDir' is set and `--import` flag was passed. " + - "This will overwrite any data saved from previous runs.", + `Importing data from ${exportMetadataFilePath}`, ); - if ( - !options.nonInteractive && - !(await confirm({ - message: `Do you wish to continue and overwrite data in ${dataDirectory}?`, - default: false, - })) - ) { - await cleanShutdown(); - throw new FirebaseError("Command aborted"); - } + args.importPath = exportMetadataFilePath; + void trackEmulator("emulator_import", { + initiated_by: "start", + emulator_name: Emulators.DATACONNECT, + }); } - EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled( - "BULLET", - "dataconnect", - `Importing data from ${exportMetadataFilePath}`, - ); - args.importPath = exportMetadataFilePath; - void trackEmulator("emulator_import", { - initiated_by: "start", - emulator_name: Emulators.DATACONNECT, - }); + try { + const dataConnectEmulator = new DataConnectEmulator(args); + await startEmulator(dataConnectEmulator); + } catch (err: unknown) { + try { + await EmulatorRegistry.stop(Emulators.DATACONNECT); + } catch { + // Ignore errors stopping failed instance + } + dataconnectLogger.logLabeled( + "ERROR", + "dataconnect", + `Failed to start Data Connect emulator: ${getErrMsg(err)}`, + ); + } } - - const dataConnectEmulator = new DataConnectEmulator(args); - await startEmulator(dataConnectEmulator); } if (listenForEmulator.storage) { @@ -1018,8 +1090,8 @@ export async function startAll( } const apphostingAddr = legacyGetFirstAddr(Emulators.APPHOSTING); + const apphostingLogger = EmulatorLogger.forEmulator(Emulators.APPHOSTING); if (apphostingEmulatorConfig?.startCommandOverride) { - const apphostingLogger = EmulatorLogger.forEmulator(Emulators.APPHOSTING); apphostingLogger.logLabeled( "WARN", Emulators.APPHOSTING, @@ -1037,7 +1109,20 @@ export async function startAll( options, }); - await startEmulator(apphostingEmulator); + try { + await startEmulator(apphostingEmulator); + } catch (err: unknown) { + try { + await EmulatorRegistry.stop(Emulators.APPHOSTING); + } catch { + // Ignore errors stopping failed instance + } + apphostingLogger.logLabeled( + "ERROR", + Emulators.APPHOSTING, + `Failed to start App Hosting emulator: ${getErrMsg(err)}`, + ); + } } if (listenForEmulator.logging) { diff --git a/src/emulator/storage/rules/config.spec.ts b/src/emulator/storage/rules/config.spec.ts index ba190342bca..388ad3d8788 100644 --- a/src/emulator/storage/rules/config.spec.ts +++ b/src/emulator/storage/rules/config.spec.ts @@ -92,20 +92,20 @@ describe("Storage Rules Config", () => { expect(result[2].rules.content).to.contain("allow read, write: if request.auth!=null"); }); - it("should throw FirebaseError when storage config is missing", () => { + it("should use default config when storage config is missing", () => { const config = getOptions({ data: {}, path: resolvePath }); - expect(() => getStorageRulesConfig(PROJECT_ID, config)).to.throw( - FirebaseError, - "Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration", - ); + const result = getStorageRulesConfig(PROJECT_ID, config) as SourceFile; + + expect(result.name).to.contain("templates/emulators/default_storage.rules"); + expect(result.content).to.contain("allow read, write;"); }); - it("should throw FirebaseError when rules file is missing", () => { + it("should use default config when rules file is missing", () => { const config = getOptions({ data: { storage: {} }, path: resolvePath }); - expect(() => getStorageRulesConfig(PROJECT_ID, config)).to.throw( - FirebaseError, - "Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration", - ); + const result = getStorageRulesConfig(PROJECT_ID, config) as SourceFile; + + expect(result.name).to.contain("templates/emulators/default_storage.rules"); + expect(result.content).to.contain("allow read, write;"); }); it("should throw FirebaseError when rules file is invalid", () => { diff --git a/src/emulator/storage/rules/config.ts b/src/emulator/storage/rules/config.ts index 5f333acd9d3..a2977ee1fd6 100644 --- a/src/emulator/storage/rules/config.ts +++ b/src/emulator/storage/rules/config.ts @@ -32,19 +32,33 @@ export function getStorageRulesConfig( "storage", `Detected demo project ID "${projectId}", using a default (open) rules configuration.`, ); - return defaultStorageRules(); + } else { + storageLogger.logLabeled( + "WARN", + "storage", + "Did not find a Storage rules file specified in a firebase.json config file. The emulator will default to allowing all reads and writes. Learn more about this option: https://firebase.google.com/docs/emulator-suite/install_and_configure#security_rules_configuration.", + ); } - throw new FirebaseError( - "Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration", - ); + return defaultStorageRules(); } // No target specified if (!Array.isArray(storageConfig)) { if (!storageConfig.rules) { - throw new FirebaseError( - "Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration", - ); + if (Constants.isDemoProject(projectId)) { + storageLogger.logLabeled( + "BULLET", + "storage", + `Detected demo project ID "${projectId}", using a default (open) rules configuration.`, + ); + } else { + storageLogger.logLabeled( + "WARN", + "storage", + "Did not find a Storage rules file specified in a firebase.json config file. The emulator will default to allowing all reads and writes. Learn more about this option: https://firebase.google.com/docs/emulator-suite/install_and_configure#security_rules_configuration.", + ); + } + return defaultStorageRules(); } return getSourceFile(storageConfig.rules, options); @@ -58,17 +72,21 @@ export function getStorageRulesConfig( } const targets = rc.target(projectId, "storage", targetConfig.target); if (targets.length === 0) { - // Fall back to open if this is a demo project + // Fall back to open if this is a demo project or targets are missing if (Constants.isDemoProject(projectId)) { storageLogger.logLabeled( "BULLET", "storage", `Detected demo project ID "${projectId}", using a default (open) rules configuration. Storage targets in firebase.json will be ignored.`, ); - return defaultStorageRules(); + } else { + storageLogger.logLabeled( + "WARN", + "storage", + `Storage target '${targetConfig.target}' in firebase.json is not configured in .firebaserc. The emulator will default to allowing all reads and writes.`, + ); } - // Otherwise, requireTarget will error out - rc.requireTarget(projectId, "storage", targetConfig.target); + return defaultStorageRules(); } results.push( ...rc.target(projectId, "storage", targetConfig.target).map((resource: string) => {