Skip to content

Commit

Permalink
Added support for CF3v2 functions in the Extensions emulator (#5306)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelgj authored Dec 6, 2022
1 parent 4c92149 commit ff9497e
Show file tree
Hide file tree
Showing 10 changed files with 242 additions and 33 deletions.
2 changes: 2 additions & 0 deletions src/emulator/extensions/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export function checkForUnemulatedTriggerTypes(
return !shouldStart(options, Emulators.AUTH);
case Constants.SERVICE_STORAGE:
return !shouldStart(options, Emulators.STORAGE);
case Constants.SERVICE_EVENTARC:
return !shouldStart(options, Emulators.EVENTARC);
default:
return true;
}
Expand Down
3 changes: 2 additions & 1 deletion src/extensions/billingMigrationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ExtensionSpec } from "./types";
import { logPrefix } from "./extensionsHelper";
import { promptOnce } from "../prompt";
import * as utils from "../utils";
import { getResourceRuntime } from "./utils";

marked.setOptions({
renderer: new TerminalRenderer(),
Expand Down Expand Up @@ -41,7 +42,7 @@ function hasRuntime(spec: ExtensionSpec, runtime: string): boolean {
const specVersion = spec.specVersion || defaultSpecVersion;
const defaultRuntime = defaultRuntimes[specVersion];
const resources = spec.resources || [];
return resources.some((r) => runtime === (r.properties?.runtime || defaultRuntime));
return resources.some((r) => runtime === (getResourceRuntime(r) || defaultRuntime));
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/extensions/displayExtensionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as utils from "../utils";
import { logPrefix } from "./extensionsHelper";
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { Api, ExtensionSpec, Role, Resource } from "./types";
import { Api, ExtensionSpec, Role, Resource, FUNCTIONS_RESOURCE_TYPE } from "./types";
import * as iam from "../gcp/iam";
import { SECRET_ROLE, usesSecrets } from "./secretsUtils";

Expand Down Expand Up @@ -113,7 +113,10 @@ function displayApis(apis: Api[]): string {
}

function usesTasks(spec: ExtensionSpec): boolean {
return spec.resources.some((r: Resource) => r.properties?.taskQueueTrigger !== undefined);
return spec.resources.some(
(r: Resource) =>
r.type === FUNCTIONS_RESOURCE_TYPE && r.properties?.taskQueueTrigger !== undefined
);
}

function impliedRoles(spec: ExtensionSpec): Role[] {
Expand Down
6 changes: 4 additions & 2 deletions src/extensions/emulator/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import * as fs from "fs-extra";
import { ExtensionSpec, Resource } from "../types";
import { FirebaseError } from "../../error";
import { substituteParams } from "../extensionsHelper";
import { getResourceRuntime } from "../utils";
import { parseRuntimeVersion } from "../../emulator/functionsEmulatorUtils";

const SPEC_FILE = "extension.yaml";
const POSTINSTALL_FILE = "POSTINSTALL.md";
const validFunctionTypes = [
"firebaseextensions.v1beta.function",
"firebaseextensions.v1beta.v2function",
"firebaseextensions.v1beta.scheduledFunction",
];

Expand Down Expand Up @@ -95,8 +97,8 @@ export function getFunctionProperties(resources: Resource[]) {
export function getNodeVersion(resources: Resource[]): number {
const invalidRuntimes: string[] = [];
const versions = resources.map((r: Resource) => {
if (r.properties?.runtime) {
const runtimeName = r.properties?.runtime as string;
if (getResourceRuntime(r)) {
const runtimeName = getResourceRuntime(r) as string;
const runtime = parseRuntimeVersion(runtimeName);
if (!runtime) {
invalidRuntimes.push(runtimeName);
Expand Down
101 changes: 78 additions & 23 deletions src/extensions/emulator/triggerHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,93 @@ import {
} from "../../emulator/functionsEmulatorShared";
import { EmulatorLogger } from "../../emulator/emulatorLogger";
import { Emulators } from "../../emulator/types";
import { Resource } from "../../extensions/types";
import {
Resource,
FUNCTIONS_RESOURCE_TYPE,
FUNCTIONS_V2_RESOURCE_TYPE,
} from "../../extensions/types";
import * as backend from "../../deploy/functions/backend";
import * as proto from "../../gcp/proto";
import { FirebaseError } from "../../error";

/**
* Convert a Resource into a ParsedTriggerDefinition
*/
export function functionResourceToEmulatedTriggerDefintion(
resource: Resource
): ParsedTriggerDefinition {
const etd: ParsedTriggerDefinition = {
name: resource.name,
entryPoint: resource.name,
platform: "gcfv1",
};
const properties = resource.properties || {};
proto.convertIfPresent(etd, properties, "timeoutSeconds", "timeout", proto.secondsFromDuration);
proto.convertIfPresent(etd, properties, "regions", "location", (str: string) => [str]);
proto.copyIfPresent(etd, properties, "availableMemoryMb");
if (properties.httpsTrigger) {
etd.httpsTrigger = properties.httpsTrigger;
const resourceType = resource.type;
if (resource.type === FUNCTIONS_RESOURCE_TYPE) {
const etd: ParsedTriggerDefinition = {
name: resource.name,
entryPoint: resource.name,
platform: "gcfv1",
};
const properties = resource.properties || {};
proto.convertIfPresent(etd, properties, "timeoutSeconds", "timeout", proto.secondsFromDuration);
proto.convertIfPresent(etd, properties, "regions", "location", (str: string) => [str]);
proto.copyIfPresent(etd, properties, "availableMemoryMb");
if (properties.httpsTrigger) {
etd.httpsTrigger = properties.httpsTrigger;
}
if (properties.eventTrigger) {
etd.eventTrigger = {
eventType: properties.eventTrigger.eventType,
resource: properties.eventTrigger.resource,
service: getServiceFromEventType(properties.eventTrigger.eventType),
};
} else {
EmulatorLogger.forEmulator(Emulators.FUNCTIONS).log(
"WARN",
`Function '${resource.name} is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.`
);
}
return etd;
}
if (properties.eventTrigger) {
etd.eventTrigger = {
eventType: properties.eventTrigger.eventType,
resource: properties.eventTrigger.resource,
service: getServiceFromEventType(properties.eventTrigger.eventType),
if (resource.type === FUNCTIONS_V2_RESOURCE_TYPE) {
const etd: ParsedTriggerDefinition = {
name: resource.name,
entryPoint: resource.name,
platform: "gcfv2",
};
} else {
EmulatorLogger.forEmulator(Emulators.FUNCTIONS).log(
"WARN",
`Function '${resource.name} is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.`
);
const properties = resource.properties || {};
proto.convertIfPresent(etd, properties, "regions", "location", (str: string) => [str]);
if (properties.serviceConfig) {
proto.copyIfPresent(etd, properties.serviceConfig, "timeoutSeconds");
proto.convertIfPresent(
etd,
properties.serviceConfig,
"availableMemoryMb",
"availableMemory",
(mem: string) => parseInt(mem) as backend.MemoryOptions
);
}
if (properties.eventTrigger) {
etd.eventTrigger = {
eventType: properties.eventTrigger.eventType,
service: getServiceFromEventType(properties.eventTrigger.eventType),
};
proto.copyIfPresent(etd.eventTrigger, properties.eventTrigger, "channel");
if (properties.eventTrigger.eventFilters) {
const eventFilters: Record<string, string> = {};
const eventFilterPathPatterns: Record<string, string> = {};
for (const filter of properties.eventTrigger.eventFilters) {
if (filter.operator === undefined) {
eventFilters[filter.attribute] = filter.value;
} else if (filter.operator === "match-path-pattern") {
eventFilterPathPatterns[filter.attribute] = filter.value;
}
}
etd.eventTrigger.eventFilters = eventFilters;
etd.eventTrigger.eventFilterPathPatterns = eventFilterPathPatterns;
}
} else {
EmulatorLogger.forEmulator(Emulators.FUNCTIONS).log(
"WARN",
`Function '${resource.name} is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.`
);
}
return etd;
}
return etd;
throw new FirebaseError("Unexpected resource type " + resourceType);
}
3 changes: 0 additions & 3 deletions src/extensions/extensionsHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,6 @@ export const AUTOPOULATED_PARAM_PLACEHOLDERS = {
DATABASE_INSTANCE: "project-id-default-rtdb",
DATABASE_URL: "https://project-id-default-rtdb.firebaseio.com",
};
export const resourceTypeToNiceName: Record<string, string> = {
"firebaseextensions.v1beta.function": "Cloud Function",
};
export type ReleaseStage = "stable" | "alpha" | "beta" | "rc";

/**
Expand Down
34 changes: 33 additions & 1 deletion src/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,41 @@ export interface FunctionResourceProperties {
};
}

export const FUNCTIONS_V2_RESOURCE_TYPE = "firebaseextensions.v1beta.v2function";
export interface FunctionV2ResourceProperties {
type: typeof FUNCTIONS_V2_RESOURCE_TYPE;
properties?: {
location?: string;
sourceDirectory?: string;
buildConfig?: {
runtime?: Runtime;
};
serviceConfig?: {
availableMemory?: string;
timeoutSeconds?: number;
minInstanceCount?: number;
maxInstanceCount?: number;
};
eventTrigger?: {
eventType: string;
triggerRegion?: string;
channel?: string;
pubsubTopic?: string;
retryPolicy?: string;
eventFilters?: FunctionV2EventFilter[];
};
};
}

export interface FunctionV2EventFilter {
attribute: string;
value: string;
operator?: string;
}

// Union of all valid property types so we can have a strongly typed "property"
// field depending on the actual value of "type"
type ResourceProperties = FunctionResourceProperties;
type ResourceProperties = FunctionResourceProperties | FunctionV2ResourceProperties;

export type Resource = ResourceProperties & {
name: string;
Expand Down
23 changes: 22 additions & 1 deletion src/extensions/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { promptOnce } from "../prompt";
import { ParamOption } from "./types";
import {
ParamOption,
Resource,
FUNCTIONS_RESOURCE_TYPE,
FUNCTIONS_V2_RESOURCE_TYPE,
} from "./types";
import { RegistryEntry } from "./resolveSource";

// Modified version of the once function from prompt, to return as a joined string.
Expand Down Expand Up @@ -67,3 +72,19 @@ export function formatTimestamp(timestamp: string): string {
const withoutMs = timestamp.split(".")[0];
return withoutMs.replace("T", " ");
}

/**
* Returns the runtime for the resource. The resource may be v1 or v2 function,
* etc, and this utility will do its best to identify the runtime specified for
* this resource.
*/
export function getResourceRuntime(resource: Resource): string | undefined {
switch (resource.type) {
case FUNCTIONS_RESOURCE_TYPE:
return resource.properties?.runtime;
case FUNCTIONS_V2_RESOURCE_TYPE:
return resource.properties?.buildConfig?.runtime;
default:
return undefined;
}
}
7 changes: 7 additions & 0 deletions src/test/emulators/extensions/validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ describe("ExtensionsEmulator validation", () => {
const shouldStartStub = sandbox.stub(controller, "shouldStart");
shouldStartStub.withArgs(sinon.match.any, Emulators.STORAGE).returns(true);
shouldStartStub.withArgs(sinon.match.any, Emulators.DATABASE).returns(true);
shouldStartStub.withArgs(sinon.match.any, Emulators.EVENTARC).returns(true);
shouldStartStub.withArgs(sinon.match.any, Emulators.FIRESTORE).returns(false);
shouldStartStub.withArgs(sinon.match.any, Emulators.AUTH).returns(false);
});
Expand Down Expand Up @@ -220,6 +221,12 @@ describe("ExtensionsEmulator validation", () => {
eventType: "providers/google.firebase.database/eventTypes/ref.write",
},
}),
getTestParsedTriggerDefinition({
eventTrigger: {
eventType: "test.custom.event",
channel: "projects/foo/locations/us-central1/channels/firebase",
},
}),
],
want: [],
},
Expand Down
89 changes: 89 additions & 0 deletions src/test/extensions/emulator/triggerHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,94 @@ describe("triggerHelper", () => {

expect(result).to.eql(expected);
});

it("should handle v2 custom event triggers", () => {
const testResource: Resource = {
name: "test-resource",
entryPoint: "functionName",
type: "firebaseextensions.v1beta.v2function",
properties: {
eventTrigger: {
eventType: "test.custom.event",
channel: "projects/foo/locations/bar/channels/baz",
},
},
};
const expected = {
platform: "gcfv2",
entryPoint: "test-resource",
name: "test-resource",
eventTrigger: {
service: "",
channel: "projects/foo/locations/bar/channels/baz",
eventType: "test.custom.event",
},
};

const result = triggerHelper.functionResourceToEmulatedTriggerDefintion(testResource);

expect(result).to.eql(expected);
});

it("should handle fully packed v2 triggers", () => {
const testResource: Resource = {
name: "test-resource",
entryPoint: "functionName",
type: "firebaseextensions.v1beta.v2function",
properties: {
buildConfig: {
runtime: "node16",
},
location: "us-cental1",
serviceConfig: {
availableMemory: "100MB",
minInstanceCount: 1,
maxInstanceCount: 10,
timeoutSeconds: 66,
},
eventTrigger: {
eventType: "test.custom.event",
channel: "projects/foo/locations/bar/channels/baz",
pubsubTopic: "pubsub.topic",
eventFilters: [
{
attribute: "basic",
value: "attr",
},
{
attribute: "mattern",
value: "patch",
operator: "match-path-pattern",
},
],
retryPolicy: "RETRY",
triggerRegion: "us-cental1",
},
},
};
const expected = {
platform: "gcfv2",
entryPoint: "test-resource",
name: "test-resource",
availableMemoryMb: 100,
timeoutSeconds: 66,
eventTrigger: {
service: "",
channel: "projects/foo/locations/bar/channels/baz",
eventType: "test.custom.event",
eventFilters: {
basic: "attr",
},
eventFilterPathPatterns: {
mattern: "patch",
},
},
regions: ["us-cental1"],
};

const result = triggerHelper.functionResourceToEmulatedTriggerDefintion(testResource);

expect(result).to.eql(expected);
});
});
});

0 comments on commit ff9497e

Please sign in to comment.