Skip to content

Cloud Run Functions data model #8767

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions src/deploy/functions/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface Context {
unreachableRegions?: {
gcfV1: string[];
gcfV2: string[];
run: string[];
};

// Tracks metrics about codebase deployments to send to GA4
Expand Down
12 changes: 11 additions & 1 deletion src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@
serviceAccount?: string | null;
}

export type FunctionsPlatform = "gcfv1" | "gcfv2";
export type FunctionsPlatform = "gcfv1" | "gcfv2" | "run";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While the FunctionsPlatform type is correctly updated to include run, the related AllFunctionsPlatforms constant on the next line is not. This will cause parts of the system that iterate over AllFunctionsPlatforms (e.g., in release/planner.ts) to miss Cloud Run functions, which can lead to them not being planned for updates or deletion. Please update AllFunctionsPlatforms to include run to be consistent with the type definition.

export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2"];

export type Triggered =
Expand Down Expand Up @@ -521,7 +521,7 @@
await loadExistingBackend(context);
}
// loadExisting guarantees the validity of existingBackend and unreachableRegions
return context.existingBackend!;

Check warning on line 524 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

async function loadExistingBackend(ctx: Context): Promise<void> {
Expand All @@ -534,6 +534,7 @@
ctx.unreachableRegions = {
gcfV1: [],
gcfV2: [],
run: [],
};
const gcfV1Results = await gcf.listAllFunctions(ctx.projectId);
for (const apiFunction of gcfV1Results.functions) {
Expand All @@ -554,8 +555,8 @@
ctx.existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint;
}
ctx.unreachableRegions.gcfV2 = gcfV2Results.unreachable;
} catch (err: any) {

Check warning on line 558 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (err.status === 404 && err.message?.toLowerCase().includes("method not found")) {

Check warning on line 559 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 559 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value

Check warning on line 559 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 559 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .includes on an `any` value

Check warning on line 559 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .status on an `any` value
return; // customer has preview enabled without allowlist set
}
throw err;
Expand Down Expand Up @@ -623,6 +624,15 @@
"\nCloud Functions in these regions won't be deleted.",
);
}

if (context.unreachableRegions?.run.length) {
utils.logLabeledWarning(
"functions",
"The following Cloud Run regions are currently unreachable:\n" +
context.unreachableRegions.run.join("\n") +
"\nCloud Run services in these regions won't be deleted.",
);
}
}

/** A helper utility for flattening all endpoints in a backend since typing is a bit wonky. */
Expand Down
3 changes: 2 additions & 1 deletion src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
export type MemoryOption = 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
const allMemoryOptions: MemoryOption[] = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];

export type FunctionsPlatform = backend.FunctionsPlatform;
// Run is an automatic migration from gcfv2 and is not used on the wire.
export type FunctionsPlatform = Exclude<backend.FunctionsPlatform, "run">;
export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2"];
export type VpcEgressSetting = backend.VpcEgressSettings;
export const AllVpcEgressSettings: VpcEgressSetting[] = ["PRIVATE_RANGES_ONLY", "ALL_TRAFFIC"];
Expand Down Expand Up @@ -320,7 +321,7 @@
}

// Exported for testing
export function envWithTypes(

Check warning on line 324 in src/deploy/functions/build.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
definedParams: params.Param[],
rawEnvs: Record<string, string>,
): Record<string, params.ParamValue> {
Expand Down Expand Up @@ -466,7 +467,7 @@
// List param, we try resolving a String param instead.
try {
regions = params.resolveList(bdEndpoint.region, paramValues);
} catch (err: any) {

Check warning on line 470 in src/deploy/functions/build.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (err instanceof ExprParseError) {
regions = [params.resolveString(bdEndpoint.region, paramValues)];
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/deploy/functions/ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
const metadata = await metadataCall;
if (e.platform === "gcfv1") {
return `${metadata.projectId}@appspot.gserviceaccount.com`;
} else if (e.platform === "gcfv2") {
} else if (e.platform === "gcfv2" || e.platform === "run") {
return await getDefaultServiceAccount(metadata.projectNumber);
}
assertExhaustive(e.platform);
Expand Down Expand Up @@ -67,7 +67,7 @@

/**
* Checks for various warnings and API enablements needed based on the runtime
* of the deployed functions.

Check warning on line 70 in src/deploy/functions/ensure.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param projectId Project ID upon which to check enablement.
*/
Expand Down
7 changes: 6 additions & 1 deletion src/deploy/functions/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as backend from "./backend";
import { DEFAULT_CODEBASE, ValidatedConfig } from "../../functions/projectConfig";
import { assertExhaustive } from "../../functional";

export interface EndpointFilter {
// If codebase is undefined, match all functions in all codebase that matches the idChunks.
Expand Down Expand Up @@ -130,8 +131,12 @@ export function getEndpointFilters(options: { only?: string }): EndpointFilter[]
export function getHumanFriendlyPlatformName(platform: backend.Endpoint["platform"]): string {
if (platform === "gcfv1") {
return "1st Gen";
} else if (platform === "gcfv2") {
return "2nd Gen";
} else if (platform === "run") {
return "Cloud Run";
}
return "2nd Gen";
assertExhaustive(platform);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ export async function prepare(
let resource: string;
if (endpoint.platform === "gcfv1") {
resource = `projects/${endpoint.project}/locations/${endpoint.region}/functions/${endpoint.id}`;
} else if (endpoint.platform === "gcfv2") {
} else if (endpoint.platform === "gcfv2" || endpoint.platform === "run") {
// N.B. If GCF starts allowing v1's allowable characters in IDs they're
// going to need to have a transform to create a service ID (which has a
// more restrictive character set). We'll need to reimplement that here.
// BUG BUG BUG. This has happened and we need to fix it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This comment indicates a known issue where the function ID to service ID conversion is not happening. It's marked as a BUG BUG BUG, suggesting it's critical and needs to be addressed.

Suggested change
// BUG BUG BUG. This has happened and we need to fix it.
// TODO: Implement function ID to service ID conversion

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the severity of this comment is accurate. This is a WIP and a BUG should be resolved before releasing to the public whereas a TODO can be ignored for years.

resource = `projects/${endpoint.project}/locations/${endpoint.region}/services/${endpoint.id}`;
} else {
assertExhaustive(endpoint.platform);
Expand Down
27 changes: 22 additions & 5 deletions src/deploy/functions/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export function canCalculateMinInstanceCost(endpoint: backend.Endpoint): boolean
const SECONDS_PER_MONTH = 30 * 24 * 60 * 60;

/** The cost of a series of endpoints at 100% idle in a 30d month. */
// BUG BUG BUG!
// This method incorrectly gives a disjoint free tier for GCF v1 and GCF v2 which
// was broken and never fixed when GCF decided to vendor Run usage as the GCF SKU.
// It should be a single free tier that applies to both. This will soon be wrong
// in a _different_ way when GCF v2 un-vendors the SKU and instead v2 and Run should
// share a free tier.
export function monthlyMinInstanceCost(endpoints: backend.Endpoint[]): number {
// Assertion: canCalculateMinInstanceCost
type Usage = {
Expand All @@ -169,6 +175,7 @@ export function monthlyMinInstanceCost(endpoints: backend.Endpoint[]): number {
const usage: Record<backend.FunctionsPlatform, Record<tier, Usage>> = {
gcfv1: { 1: { ram: 0, cpu: 0 }, 2: { ram: 0, cpu: 0 } },
gcfv2: { 1: { ram: 0, cpu: 0 }, 2: { ram: 0, cpu: 0 } },
run: { 1: { ram: 0, cpu: 0 }, 2: { ram: 0, cpu: 0 } },
};

for (const endpoint of endpoints) {
Expand All @@ -188,10 +195,10 @@ export function monthlyMinInstanceCost(endpoints: backend.Endpoint[]): number {
} else {
// V2 is currently fixed at 1vCPU.
const tier = V2_REGION_TO_TIER[endpoint.region];
usage["gcfv2"][tier].ram =
usage["gcfv2"][tier].ram + ramGb * SECONDS_PER_MONTH * endpoint.minInstances;
usage["gcfv2"][tier].cpu =
usage["gcfv2"][tier].cpu +
usage[endpoint.platform][tier].ram =
usage[endpoint.platform][tier].ram + ramGb * SECONDS_PER_MONTH * endpoint.minInstances;
usage[endpoint.platform][tier].cpu =
usage[endpoint.platform][tier].cpu +
(endpoint.cpu as number) * SECONDS_PER_MONTH * endpoint.minInstances;
}
}
Expand All @@ -218,5 +225,15 @@ export function monthlyMinInstanceCost(endpoints: backend.Endpoint[]): number {
v2CpuBill -= V2_FREE_TIER.vCpu * V2_RATES.vCpu[1];
v2CpuBill = Math.max(v2CpuBill, 0);

return v1MemoryBill + v1CpuBill + v2MemoryBill + v2CpuBill;
let runMemoryBill =
usage["run"][1].ram * V2_RATES.memoryGb[1] + usage["run"][2].ram * V2_RATES.memoryGb[2];
runMemoryBill -= V2_FREE_TIER.memoryGb * V2_RATES.memoryGb[1];
runMemoryBill = Math.max(runMemoryBill, 0);

let runCpuBill =
usage["run"][1].cpu * V2_RATES.idleVCpu[1] + usage["run"][2].cpu * V2_RATES.idleVCpu[2];
runCpuBill -= V2_FREE_TIER.vCpu * V2_RATES.vCpu[1];
runCpuBill = Math.max(runCpuBill, 0);

return v1MemoryBill + v1CpuBill + v2MemoryBill + v2CpuBill + runMemoryBill + runCpuBill;
}
25 changes: 22 additions & 3 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ export class Fabricator {
await this.createV1Function(endpoint, scraperV1);
} else if (endpoint.platform === "gcfv2") {
await this.createV2Function(endpoint, scraperV2);
} else if (endpoint.platform === "run") {
throw new FirebaseError("Creating new Cloud Run functions is not supported yet.", {
exit: 1,
});
} else {
assertExhaustive(endpoint.platform);
}
Expand All @@ -206,6 +210,8 @@ export class Fabricator {
await this.updateV1Function(update.endpoint, scraperV1);
} else if (update.endpoint.platform === "gcfv2") {
await this.updateV2Function(update.endpoint, scraperV2);
} else if (update.endpoint.platform === "run") {
throw new FirebaseError("Updating Cloud Run functions is not supported yet.", { exit: 1 });
} else {
assertExhaustive(update.endpoint.platform);
}
Expand All @@ -216,10 +222,13 @@ export class Fabricator {
async deleteEndpoint(endpoint: backend.Endpoint): Promise<void> {
await this.deleteTrigger(endpoint);
if (endpoint.platform === "gcfv1") {
await this.deleteV1Function(endpoint);
} else {
await this.deleteV2Function(endpoint);
return this.deleteV1Function(endpoint);
} else if (endpoint.platform === "gcfv2") {
return this.deleteV2Function(endpoint);
} else if (endpoint.platform === "run") {
throw new FirebaseError("Deleting Cloud Run functions is not supported yet.", { exit: 1 });
}
assertExhaustive(endpoint.platform);
}

async createV1Function(endpoint: backend.Endpoint, scraper: SourceTokenScraper): Promise<void> {
Expand Down Expand Up @@ -623,6 +632,11 @@ export class Fabricator {
// Set/Delete trigger is responsible for wiring up a function with any trigger not owned
// by the GCF API. This includes schedules, task queues, and blocking function triggers.
async setTrigger(endpoint: backend.Endpoint): Promise<void> {
if (endpoint.platform === "run") {
throw new FirebaseError("Setting triggers for Cloud Run functions is not supported yet.", {
exit: 1,
});
}
if (backend.isScheduleTriggered(endpoint)) {
if (endpoint.platform === "gcfv1") {
await this.upsertScheduleV1(endpoint);
Expand All @@ -640,6 +654,11 @@ export class Fabricator {
}

async deleteTrigger(endpoint: backend.Endpoint): Promise<void> {
if (endpoint.platform === "run") {
throw new FirebaseError("Deleting triggers for Cloud Run functions is not supported yet.", {
exit: 1,
});
}
if (backend.isScheduleTriggered(endpoint)) {
if (endpoint.platform === "gcfv1") {
await this.deleteScheduleV1(endpoint);
Expand Down
1 change: 1 addition & 0 deletions src/deploy/hosting/convertConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ describe("convertConfig", () => {
unreachableRegions: {
gcfV1: [],
gcfV2: [],
run: [],
},
};
const deploy: HostingDeploy = {
Expand Down
5 changes: 5 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export const ALL_EXPERIMENTS = experiments({
default: true,
public: true,
},
runfunctions: {
shortDescription:
"Functions created using the V2 API target Cloud Run Functions (not production ready)",
public: false,
},

// Emulator experiments
emulatoruisnapshot: {
Expand Down
4 changes: 4 additions & 0 deletions src/functions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ export async function updateEndpointSecret(
operationResourceName: op.name,
});
return gcfV2.endpointFromFunction(cfn);
} else if (endpoint.platform === "run") {
// This may be tricky because the image has been deleted. How does this work
// with GCF?
throw new FirebaseError("Updating Cloud Run functions is not yet implemented.");
Comment on lines +363 to +365
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This error message is not very informative. Consider adding more context to the error message to help users understand why updating Cloud Run functions is not yet implemented.

Suggested change
// This may be tricky because the image has been deleted. How does this work
// with GCF?
throw new FirebaseError("Updating Cloud Run functions is not yet implemented.");
throw new FirebaseError("Updating secrets for Cloud Run functions is not yet implemented. Please use GCFv2.");

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad suggestion

} else {
assertExhaustive(endpoint.platform);
}
Expand Down
24 changes: 0 additions & 24 deletions src/gcp/cloudfunctionsv2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,30 +62,6 @@ describe("cloudfunctionsv2", () => {
updateTime: new Date(),
};

describe("megabytes", () => {
enum Bytes {
KB = 1e3,
MB = 1e6,
GB = 1e9,
KiB = 1 << 10,
MiB = 1 << 20,
GiB = 1 << 30,
}
it("Should handle decimal SI units", () => {
expect(cloudfunctionsv2.mebibytes("1000k")).to.equal((1000 * Bytes.KB) / Bytes.MiB);
expect(cloudfunctionsv2.mebibytes("1.5M")).to.equal((1.5 * Bytes.MB) / Bytes.MiB);
expect(cloudfunctionsv2.mebibytes("1G")).to.equal(Bytes.GB / Bytes.MiB);
});
it("Should handle binary SI units", () => {
expect(cloudfunctionsv2.mebibytes("1Mi")).to.equal(Bytes.MiB / Bytes.MiB);
expect(cloudfunctionsv2.mebibytes("1Gi")).to.equal(Bytes.GiB / Bytes.MiB);
});
it("Should handle no unit", () => {
expect(cloudfunctionsv2.mebibytes("100000")).to.equal(100000 / Bytes.MiB);
expect(cloudfunctionsv2.mebibytes("1e9")).to.equal(1e9 / Bytes.MiB);
expect(cloudfunctionsv2.mebibytes("1.5E6")).to.equal((1.5 * 1e6) / Bytes.MiB);
});
});
describe("functionFromEndpoint", () => {
it("should guard against version mixing", () => {
expect(() => {
Expand Down
38 changes: 1 addition & 37 deletions src/gcp/cloudfunctionsv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../functions/constants";
import { RequireKeys } from "../metaprogramming";
import { captureRuntimeValidationError } from "./cloudfunctions";
import { mebibytes } from "./k8s";

export const API_VERSION = "v2";

Expand Down Expand Up @@ -207,43 +208,6 @@ interface GenerateUploadUrlResponse {
storageSource: StorageSource;
}

// AvailableMemory suffixes and their byte count.
type MemoryUnit = "" | "k" | "M" | "G" | "T" | "Ki" | "Mi" | "Gi" | "Ti";
const BYTES_PER_UNIT: Record<MemoryUnit, number> = {
"": 1,
k: 1e3,
M: 1e6,
G: 1e9,
T: 1e12,
Ki: 1 << 10,
Mi: 1 << 20,
Gi: 1 << 30,
Ti: 1 << 40,
};

/**
* Returns the float-precision number of Mega(not Mebi)bytes in a
* Kubernetes-style quantity
* Must serve the same results as
* https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/api/resource/quantity.go
*/
export function mebibytes(memory: string): number {
const re = /^([0-9]+(\.[0-9]*)?)(Ki|Mi|Gi|Ti|k|M|G|T|([eE]([0-9]+)))?$/;
const matches = re.exec(memory);
if (!matches) {
throw new Error(`Invalid memory quantity "${memory}""`);
}
const quantity = Number.parseFloat(matches[1]);
let bytes: number;
if (matches[5]) {
bytes = quantity * Math.pow(10, Number.parseFloat(matches[5]));
} else {
const suffix = matches[3] || "";
bytes = quantity * BYTES_PER_UNIT[suffix as MemoryUnit];
}
return bytes / (1 << 20);
}

/**
* Logs an error from a failed function deployment.
* @param func The function that was unsuccessfully deployed.
Expand Down
2 changes: 1 addition & 1 deletion src/gcp/cloudscheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export async function jobFromEndpoint(
scheduled: "true",
},
};
} else if (endpoint.platform === "gcfv2") {
} else if (endpoint.platform === "gcfv2" || endpoint.platform === "run") {
job.timeZone = endpoint.scheduleTrigger.timeZone || DEFAULT_TIME_ZONE_V2;
job.httpTarget = {
uri: endpoint.uri!,
Expand Down
30 changes: 30 additions & 0 deletions src/gcp/k8s.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect } from "chai";
import * as k8s from "./k8s";

describe("megabytes", () => {
enum Bytes {
KB = 1e3,
MB = 1e6,
GB = 1e9,
KiB = 1 << 10,
MiB = 1 << 20,
GiB = 1 << 30,
}

it("Should handle decimal SI units", () => {
expect(k8s.mebibytes("1000k")).to.equal((1000 * Bytes.KB) / Bytes.MiB);
expect(k8s.mebibytes("1.5M")).to.equal((1.5 * Bytes.MB) / Bytes.MiB);
expect(k8s.mebibytes("1G")).to.equal(Bytes.GB / Bytes.MiB);
});

it("Should handle binary SI units", () => {
expect(k8s.mebibytes("1Mi")).to.equal(Bytes.MiB / Bytes.MiB);
expect(k8s.mebibytes("1Gi")).to.equal(Bytes.GiB / Bytes.MiB);
});

it("Should handle no unit", () => {
expect(k8s.mebibytes("100000")).to.equal(100000 / Bytes.MiB);
expect(k8s.mebibytes("1e9")).to.equal(1e9 / Bytes.MiB);
expect(k8s.mebibytes("1.5E6")).to.equal((1.5 * 1e6) / Bytes.MiB);
});
});
Loading
Loading