Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Improve performance and reliability when deploying multiple 2nd gen functions using single builds. (#6275)
- Fixed issue where the Extensions emulator would error when emualting local extensions with no params. (#6271)
- Improved performance and reliability when deploying multiple 2nd gen functions using single builds. (#6275)
9 changes: 4 additions & 5 deletions src/emulator/extensionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,7 @@ export class ExtensionsEmulator implements EmulatorInstance {
private hasValidSource(args: { path: string; extTarget: string }): boolean {
// TODO(lihes): Source code can technically exist in other than "functions" dir.
// https://source.corp.google.com/piper///depot/google3/firebase/mods/go/worker/fetch_mod_source.go;l=451
const requiredFiles = [
"./extension.yaml",
"./functions/package.json",
"./functions/node_modules",
];
const requiredFiles = ["./extension.yaml", "./functions/package.json"];
// If the directory isn't found, no need to check for files or print errors.
if (!fs.existsSync(args.path)) {
return false;
Expand Down Expand Up @@ -226,10 +222,12 @@ export class ExtensionsEmulator implements EmulatorInstance {
instance: planner.DeploymentInstanceSpec
): Promise<EmulatableBackend> {
const extensionDir = await this.ensureSourceCode(instance);

// TODO: This should find package.json, then use that as functionsDir.
const functionsDir = path.join(extensionDir, "functions");
// TODO(b/213335255): For local extensions, this should include extensionSpec instead of extensionVersion
const env = Object.assign(this.autoPopulatedParams(instance), instance.params);

const { extensionTriggers, runtime, nonSecretEnv, secretEnvVariables } =
await getExtensionFunctionInfo(instance, env);
const emulatableBackend: EmulatableBackend = {
Expand All @@ -248,6 +246,7 @@ export class ExtensionsEmulator implements EmulatorInstance {
} else if (instance.localPath) {
emulatableBackend.extensionSpec = await planner.getExtensionSpec(instance);
}

return emulatableBackend;
}

Expand Down
187 changes: 3 additions & 184 deletions src/extensions/emulator/optionsHelper.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,13 @@
import * as fs from "fs-extra";
import { ParsedTriggerDefinition } from "../../emulator/functionsEmulatorShared";
import * as path from "path";
import * as paramHelper from "../paramHelper";
import * as specHelper from "./specHelper";
import * as localHelper from "../localHelper";
import * as triggerHelper from "./triggerHelper";
import { ExtensionSpec, Param, ParamType, Resource } from "../types";
import { ExtensionSpec, Param, ParamType } from "../types";
import * as extensionsHelper from "../extensionsHelper";
import * as planner from "../../deploy/extensions/planner";
import { Config } from "../../config";
import { FirebaseError } from "../../error";
import { EmulatorLogger } from "../../emulator/emulatorLogger";
import { needProjectId } from "../../projectUtils";
import { Emulators } from "../../emulator/types";
import { SecretEnvVar } from "../../deploy/functions/backend";

/**
* Build firebase options based on the extension configuration.
*/
export async function buildOptions(options: any): Promise<any> {
const extDevDir = localHelper.findExtensionYaml(process.cwd());
options.extDevDir = extDevDir;
const spec = await specHelper.readExtensionYaml(extDevDir);
extensionsHelper.validateSpec(spec);

const params = getParams(options, spec);

extensionsHelper.validateCommandLineParams(params, spec.params);

const functionResources = specHelper.getFunctionResourcesWithParamSubstitution(spec, params);
let testConfig;
if (options.testConfig) {
testConfig = readTestConfigFile(options.testConfig);
checkTestConfig(testConfig, functionResources);
}
options.config = buildConfig(functionResources, testConfig);
options.extDevEnv = params;
const functionEmuTriggerDefs: ParsedTriggerDefinition[] = functionResources.map((r) =>
triggerHelper.functionResourceToEmulatedTriggerDefintion(r)
);
options.extDevTriggers = functionEmuTriggerDefs;
options.extDevRuntime = specHelper.getRuntime(functionResources);
return options;
}

/**
* TODO: Better name? Also, should this be in extensionsEmulator instead?
*/
Expand All @@ -66,8 +30,8 @@ export async function getExtensionFunctionInfo(
});
const runtime = specHelper.getRuntime(functionResources);

const nonSecretEnv = getNonSecretEnv(spec.params, paramValues);
const secretEnvVariables = getSecretEnvVars(spec.params, paramValues);
const nonSecretEnv = getNonSecretEnv(spec.params ?? [], paramValues);
const secretEnvVariables = getSecretEnvVars(spec.params ?? [], paramValues);
return {
extensionTriggers,
runtime,
Expand Down Expand Up @@ -144,148 +108,3 @@ export function getParams(options: any, extensionSpec: ExtensionSpec) {
// Run a substitution to support params that reference other params.
return extensionsHelper.substituteParams<Record<string, string>>(unsubbedParams, unsubbedParams);
}

/**
* Checks and warns if the test config is missing fields
* that are relevant for the extension being emulated.
*/
function checkTestConfig(testConfig: { [key: string]: any }, functionResources: Resource[]) {
const logger = EmulatorLogger.forEmulator(Emulators.FUNCTIONS);
if (!testConfig.functions && functionResources.length) {
logger.log(
"WARN",
"This extension uses functions," +
"but 'firebase.json' provided by --test-config is missing a top-level 'functions' object." +
"Functions will not be emulated."
);
}

if (!testConfig.firestore && shouldEmulateFirestore(functionResources)) {
logger.log(
"WARN",
"This extension interacts with Cloud Firestore," +
"but 'firebase.json' provided by --test-config is missing a top-level 'firestore' object." +
"Cloud Firestore will not be emulated."
);
}

if (!testConfig.database && shouldEmulateDatabase(functionResources)) {
logger.log(
"WARN",
"This extension interacts with Realtime Database," +
"but 'firebase.json' provided by --test-config is missing a top-level 'database' object." +
"Realtime Database will not be emulated."
);
}

if (!testConfig.storage && shouldEmulateStorage(functionResources)) {
logger.log(
"WARN",
"This extension interacts with Cloud Storage," +
"but 'firebase.json' provided by --test-config is missing a top-level 'storage' object." +
"Cloud Storage will not be emulated."
);
}
}

/**
* Reads a test config file.
* @param testConfigPath filepath to a firebase.json style config file.
*/
function readTestConfigFile(testConfigPath: string): { [key: string]: any } {
try {
const buf = fs.readFileSync(path.resolve(testConfigPath));
return JSON.parse(buf.toString());
} catch (err: any) {
throw new FirebaseError(`Error reading --test-config file: ${err.message}\n`, {
original: err,
});
}
}

function buildConfig(
functionResources: Resource[],
testConfig?: { [key: string]: string }
): Config {
const config = new Config(testConfig || {}, { projectDir: process.cwd(), cwd: process.cwd() });

const emulateFunctions = shouldEmulateFunctions(functionResources);
if (!testConfig) {
// If testConfig was provided, don't add any new blocks.
if (emulateFunctions) {
config.set("functions", {});
}
if (shouldEmulateFirestore(functionResources)) {
config.set("firestore", {});
}
if (shouldEmulateDatabase(functionResources)) {
config.set("database", {});
}
if (shouldEmulatePubsub(functionResources)) {
config.set("pubsub", {});
}
if (shouldEmulateStorage(functionResources)) {
config.set("storage", {});
}
}

if (config.src.functions) {
// Switch functions source to what is provided in the extension.yaml
// to match the behavior of deployed extensions.
const sourceDirectory = getFunctionSourceDirectory(functionResources);
config.set("functions.source", sourceDirectory);
}
return config;
}

/**
* Finds the source directory from extension.yaml to use for emulating functions.
* Errors if the extension.yaml contins function resources with different or missing
* values for properties.sourceDirectory.
* @param functionResources An array of function type resources
*/
function getFunctionSourceDirectory(functionResources: Resource[]): string {
let sourceDirectory;
for (const r of functionResources) {
// If not specified, default sourceDirectory to "functions"
const dir = r.properties?.sourceDirectory || "functions";
if (!sourceDirectory) {
sourceDirectory = dir;
} else if (sourceDirectory !== dir) {
throw new FirebaseError(
`Found function resources with different sourceDirectories: '${sourceDirectory}' and '${dir}'. The extensions emulator only supports a single sourceDirectory.`
);
}
}
return sourceDirectory || "functions";
}

function shouldEmulateFunctions(resources: Resource[]): boolean {
return resources.length > 0;
}

function shouldEmulate(emulatorName: string, resources: Resource[]): boolean {
for (const r of resources) {
const eventType: string = r.properties?.eventTrigger?.eventType || "";
if (eventType.includes(emulatorName)) {
return true;
}
}
return false;
}

function shouldEmulateFirestore(resources: Resource[]): boolean {
return shouldEmulate("cloud.firestore", resources);
}

function shouldEmulateDatabase(resources: Resource[]): boolean {
return shouldEmulate("google.firebase.database", resources);
}

function shouldEmulatePubsub(resources: Resource[]): boolean {
return shouldEmulate("google.pubsub", resources);
}

function shouldEmulateStorage(resources: Resource[]): boolean {
return shouldEmulate("google.storage", resources);
}
14 changes: 13 additions & 1 deletion src/extensions/emulator/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@ function wrappedSafeLoad(source: string): any {
export async function readExtensionYaml(directory: string): Promise<ExtensionSpec> {
const extensionYaml = await readFileFromDirectory(directory, SPEC_FILE);
const source = extensionYaml.source;
return wrappedSafeLoad(source);
const spec = wrappedSafeLoad(source);
// Ensure that any omitted array fields are initialized as empty arrays
spec.params = spec.params ?? [];
spec.systemParams = spec.systemParams ?? [];
spec.resources = spec.resources ?? [];
spec.apis = spec.apis ?? [];
spec.roles = spec.roles ?? [];
spec.externalServices = spec.externalServices ?? [];
spec.events = spec.events ?? [];
spec.lifecycleEvents = spec.lifecycleEvents ?? [];
spec.contributors = spec.contributors ?? [];

return spec;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
{
"name": "storage-resize-images",
"vresion": "0.1.18",
"description": "Package file for testing only",
"dependencies": {
"firebase-tools": "file:../../../../../../.."
}
"description": "Package file for testing only"
}
27 changes: 0 additions & 27 deletions src/test/emulators/extensionsEmulator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { expect } from "chai";
import { existsSync } from "node:fs";
import { join } from "node:path";

import * as planner from "../../deploy/extensions/planner";
Expand Down Expand Up @@ -147,30 +146,4 @@ describe("Extensions Emulator", () => {
});
}
});

describe("installAndBuildSourceCode", () => {
const extensionPath = "src/test/emulators/extensions/firebase/storage-resize-images@0.1.18";
it("installs dependecies", () => {
// creating a subclass of ext emulator
// to be able to test private method
class DependencyInstallingExtensionsEmulator extends ExtensionsEmulator {
constructor(extensionPath: string) {
super({
projectId: "test-project",
projectNumber: "1234567",
projectDir: ".",
extensions: {},
aliases: [],
});

this.installAndBuildSourceCode(extensionPath);
}
}
new DependencyInstallingExtensionsEmulator(extensionPath);
const nodeModulesFolderExists = existsSync(
`${extensionPath}/functions/node_modules/firebase-tools`
);
expect(nodeModulesFolderExists).to.be.true;
}).timeout(60_000);
});
});
Loading