diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 964672a461e..8e995b565ac 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -4,6 +4,7 @@ "definitions": { "ActiveRuntime": { "enum": [ + "dart3", "nodejs18", "nodejs20", "nodejs22", @@ -913,6 +914,7 @@ }, "runtime": { "enum": [ + "dart3", "nodejs18", "nodejs20", "nodejs22", diff --git a/src/deploy/functions/backend.spec.ts b/src/deploy/functions/backend.spec.ts index 7ef725e0303..ac9cb3b661c 100644 --- a/src/deploy/functions/backend.spec.ts +++ b/src/deploy/functions/backend.spec.ts @@ -94,7 +94,7 @@ describe("Backend", () => { }, }, ], - containerConcurrency: 80, + maxInstanceRequestConcurrency: 80, }, generation: 1, createTime: "2023-01-01T00:00:00Z", diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index a1cbff55c78..00704265383 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -407,6 +407,11 @@ export type Endpoint = TargetIds & // State of the endpoint. state?: EndpointState; + + // Fields for Cloud Run platform (for no-build path) + baseImageUri?: string; + command?: string[]; + args?: string[]; }; export interface RequiredAPI { diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 981daaa7371..66af3bee80d 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -225,9 +225,8 @@ export interface SecretEnvVar { export type MemoryOption = 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768; const allMemoryOptions: MemoryOption[] = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768]; -// Run is an automatic migration from gcfv2 and is not used on the wire. -export type FunctionsPlatform = Exclude; -export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2"]; +export type FunctionsPlatform = backend.FunctionsPlatform; +export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2", "run"]; export type VpcEgressSetting = backend.VpcEgressSettings; export const AllVpcEgressSettings: VpcEgressSetting[] = ["PRIVATE_RANGES_ONLY", "ALL_TRAFFIC"]; export type IngressSetting = backend.IngressSettings; @@ -241,8 +240,8 @@ export type Endpoint = Triggered & { // Defaults to false. If true, the function will be ignored during the deploy process. omit?: Field; - // Defaults to "gcfv2". "Run" will be an additional option defined later - platform?: "gcfv1" | "gcfv2"; + // Defaults to "gcfv2". + platform?: "gcfv1" | "gcfv2" | "run"; // Necessary for the GCF API to determine what code to load with the Functions Framework. // Will become optional once "run" is supported as a platform @@ -287,6 +286,11 @@ export type Endpoint = Triggered & { environmentVariables?: Record> | null; secretEnvironmentVariables?: SecretEnvVar[] | null; labels?: Record> | null; + + // Fields for Cloud Run platform (for no-build path) + baseImageUri?: string; + command?: string[]; + args?: string[]; }; type SecretParam = ReturnType; @@ -503,6 +507,9 @@ export function toBackend( "environmentVariables", "labels", "secretEnvironmentVariables", + "baseImageUri", + "command", + "args", ); r.resolveStrings(bkEndpoint, bdEndpoint, "serviceAccount"); diff --git a/src/deploy/functions/checkIam.ts b/src/deploy/functions/checkIam.ts index 2d1c4607eaf..a3b3586005a 100644 --- a/src/deploy/functions/checkIam.ts +++ b/src/deploy/functions/checkIam.ts @@ -61,7 +61,6 @@ export async function checkServiceAccountIam(projectId: string): Promise { /** * Checks a functions deployment for HTTP function creation, and tests IAM * permissions accordingly. - * * @param context The deploy context. * @param options The command-wide options object. * @param payload The deploy payload. @@ -77,8 +76,10 @@ export async function checkHttpIam( const filters = context.filters || getEndpointFilters(options, context.config!); const wantBackends = Object.values(payload.functions).map(({ wantBackend }) => wantBackend); const httpEndpoints = [...flattenArray(wantBackends.map((b) => backend.allEndpoints(b)))] - .filter(backend.isHttpsTriggered || backend.isDataConnectGraphqlTriggered) - .filter((f) => endpointMatchesAnyFilter(f, filters)); + .filter((f) => backend.isHttpsTriggered(f) || backend.isDataConnectGraphqlTriggered(f)) + .filter((f) => endpointMatchesAnyFilter(f, filters)) + // Services with platform: "run" are not GCFv1 or GCFv2 functions and are handled separately. + .filter((f) => f.platform !== "run"); const existing = await backend.existingBackend(context); const newHttpsEndpoints = httpEndpoints.filter(backend.missingEndpoint(existing)); diff --git a/src/deploy/functions/deploy.ts b/src/deploy/functions/deploy.ts index f9fb40b2c98..e39cd832b94 100644 --- a/src/deploy/functions/deploy.ts +++ b/src/deploy/functions/deploy.ts @@ -15,6 +15,7 @@ import * as experiments from "../../experiments"; import { findEndpoint } from "./backend"; import { deploy as extDeploy } from "../extensions"; import { getProjectNumber } from "../../getProjectNumber"; +import * as path from "path"; setGracefulCleanup(); @@ -79,8 +80,7 @@ export async function uploadSourceV2( ), }; - // Legacy behavior: use the GCF API - if (!experiments.isEnabled("runfunctions")) { + if (!experiments.isEnabled("runfunctions") && !v2Endpoints.some((e) => e.platform === "run")) { if (process.env.GOOGLE_CLOUD_QUOTA_PROJECT) { logLabeledWarning( "functions", @@ -116,7 +116,7 @@ export async function uploadSourceV2( }, }, }); - const objectPath = `${source.functionsSourceV2Hash}.zip`; + const objectPath = `${source.functionsSourceV2Hash}${path.extname(source.functionsSourceV2!)}`; await gcs.upload( uploadOpts, `${bucketName}/${objectPath}`, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 9e49ac55630..d2e700fec8c 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -223,7 +223,7 @@ export async function prepare( ); } - if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2")) { + if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2" || e.platform === "run")) { const schPathSet = new Set(); for (const e of backend.allEndpoints(wantBackend)) { if ( @@ -233,11 +233,16 @@ export async function prepare( schPathSet.add(e.dataConnectGraphqlTrigger.schemaFilePath); } } + const exportType = backend.someEndpoint(wantBackend, (e) => e.platform === "run") + ? "tar.gz" + : "zip"; const packagedSource = await prepareFunctionsUpload( options.config.projectDir, sourceDir, localCfg, [...schPathSet], + undefined, + { exportType }, ); source.functionsSourceV2 = packagedSource?.pathToSource; source.functionsSourceV2Hash = packagedSource?.hash; diff --git a/src/deploy/functions/prepareFunctionsUpload.ts b/src/deploy/functions/prepareFunctionsUpload.ts index 7bc1dc2e5ba..f68cdf8a52d 100644 --- a/src/deploy/functions/prepareFunctionsUpload.ts +++ b/src/deploy/functions/prepareFunctionsUpload.ts @@ -61,13 +61,16 @@ async function packageSource( config: projectConfig.ValidatedSingle, additionalSources: string[], runtimeConfig: any, + options?: { exportType: "zip" | "tar.gz" }, ): Promise { - const tmpFile = tmp.fileSync({ prefix: "firebase-functions-", postfix: ".zip" }).name; + const exportType = options?.exportType || "zip"; + const postfix = `.${exportType}`; + const tmpFile = tmp.fileSync({ prefix: "firebase-functions-", postfix }).name; const fileStream = fs.createWriteStream(tmpFile, { flags: "w", encoding: "binary", }); - const archive = archiver("zip"); + const archive = exportType === "tar.gz" ? archiver("tar", { gzip: true }) : archiver("zip"); const hashes: string[] = []; // We must ignore firebase-debug.log or weird things happen if @@ -154,8 +157,9 @@ export async function prepareFunctionsUpload( config: projectConfig.ValidatedSingle, additionalSources: string[], runtimeConfig?: backend.RuntimeConfigValues, + options?: { exportType: "zip" | "tar.gz" }, ): Promise { - return packageSource(projectDir, sourceDir, config, additionalSources, runtimeConfig); + return packageSource(projectDir, sourceDir, config, additionalSources, runtimeConfig, options); } export function convertToSortedKeyValueArray(config: any): SortedConfig { diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index a1f90d657f2..917e35b2acc 100644 --- a/src/deploy/functions/release/fabricator.spec.ts +++ b/src/deploy/functions/release/fabricator.spec.ts @@ -11,6 +11,7 @@ import * as pollerNS from "../../../operation-poller"; import * as pubsubNS from "../../../gcp/pubsub"; import * as schedulerNS from "../../../gcp/cloudscheduler"; import * as runNS from "../../../gcp/run"; +import * as runV2NS from "../../../gcp/runv2"; import * as cloudtasksNS from "../../../gcp/cloudtasks"; import * as backend from "../backend"; import * as scraper from "./sourceTokenScraper"; @@ -32,6 +33,7 @@ describe("Fabricator", () => { let pubsub: sinon.SinonStubbedInstance; let scheduler: sinon.SinonStubbedInstance; let run: sinon.SinonStubbedInstance; + let runv2: sinon.SinonStubbedInstance; let tasks: sinon.SinonStubbedInstance; let services: sinon.SinonStubbedInstance; let identityPlatform: sinon.SinonStubbedInstance; @@ -44,6 +46,7 @@ describe("Fabricator", () => { pubsub = sinon.stub(pubsubNS); scheduler = sinon.stub(schedulerNS); run = sinon.stub(runNS); + runv2 = sinon.stub(runV2NS); tasks = sinon.stub(cloudtasksNS); services = sinon.stub(servicesNS); identityPlatform = sinon.stub(identityPlatformNS); @@ -53,6 +56,7 @@ describe("Fabricator", () => { scheduler.jobFromEndpoint.restore(); tasks.queueFromEndpoint.restore(); tasks.queueNameForEndpoint.restore(); + runv2.serviceFromEndpoint.restore(); gcf.createFunction.rejects(new Error("unexpected gcf.createFunction")); gcf.updateFunction.rejects(new Error("unexpected gcf.updateFunction")); gcf.deleteFunction.rejects(new Error("unexpected gcf.deleteFunction")); @@ -74,6 +78,10 @@ describe("Fabricator", () => { run.setInvokerUpdate.rejects(new Error("unexpected run.setInvokerUpdate")); run.replaceService.rejects(new Error("unexpected run.replaceService")); run.updateService.rejects(new Error("Unexpected run.updateService")); + runv2.createService.rejects(new Error("unexpected runv2.createService")); + runv2.updateService.rejects(new Error("unexpected runv2.updateService")); + runv2.deleteService.rejects(new Error("unexpected runv2.deleteService")); + runv2.getService.rejects(new Error("unexpected runv2.getService")); poller.pollOperation.rejects(new Error("unexpected poller.pollOperation")); pubsub.createTopic.rejects(new Error("unexpected pubsub.createTopic")); pubsub.deleteTopic.rejects(new Error("unexpected pubsub.deleteTopic")); @@ -1704,4 +1712,103 @@ describe("Fabricator", () => { expect(ep2Result?.error?.message).to.match(/delete function/); }); }); + + describe("createRunService", () => { + it("creates a Cloud Run service with correct configuration", async () => { + runv2.createService.resolves({ uri: "https://service", name: "service" } as any); + run.setInvokerUpdate.resolves(); + + const ep = endpoint( + { httpsTrigger: {} }, + { + platform: "run", + baseImageUri: "gcr.io/base", + command: ["cmd"], + args: ["arg"], + }, + ); + await fab.createRunService(ep); + + expect(runv2.createService).to.have.been.calledWith( + ep.project, + ep.region, + ep.id, + sinon.match({ + template: { + containers: [ + sinon.match({ + image: "scratch", + baseImageUri: "gcr.io/base", + command: ["cmd"], + args: ["arg"], + sourceCode: { + cloudStorageSource: { + bucket: "bucket", + object: "object", + generation: "42", + }, + }, + }), + ], + }, + }), + ); + }); + }); + + describe("updateRunService", () => { + it("updates a Cloud Run service with correct configuration", async () => { + runv2.updateService.resolves({ uri: "https://service", name: "service" } as any); + run.setInvokerUpdate.resolves(); + + const ep = endpoint( + { httpsTrigger: {} }, + { + platform: "run", + baseImageUri: "gcr.io/base-v2", + }, + ); + // Mock update to include the endpoint + const update: planner.EndpointUpdate = { + endpoint: ep, + }; + + await fab.updateRunService(update); + + expect(runv2.updateService).to.have.been.calledWith( + sinon.match({ + name: `projects/${ep.project}/locations/${ep.region}/services/${ep.id}`, + template: { + containers: [ + sinon.match({ + baseImageUri: "gcr.io/base-v2", + }), + ], + }, + }), + ); + }); + }); + + describe("deleteRunService", () => { + it("deletes the Cloud Run service", async () => { + runv2.deleteService.resolves(); + const ep = endpoint({ httpsTrigger: {} }, { platform: "run" }); + + await fab.deleteRunService(ep); + + expect(runv2.deleteService).to.have.been.calledWith(ep.project, ep.region, ep.id); + }); + + it("ignores 404s", async () => { + const err = new Error("Not Found"); + (err as any).status = 404; + runv2.deleteService.rejects(err); + const ep = endpoint({ httpsTrigger: {} }, { platform: "run" }); + + await fab.deleteRunService(ep); + + expect(runv2.deleteService).to.have.been.called; + }); + }); }); diff --git a/src/deploy/functions/release/fabricator.ts b/src/deploy/functions/release/fabricator.ts index b8e30426de0..18a7dd65f67 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -22,6 +22,7 @@ import * as poller from "../../../operation-poller"; import * as pubsub from "../../../gcp/pubsub"; import * as reporter from "./reporter"; import * as run from "../../../gcp/run"; +import * as runV2 from "../../../gcp/runv2"; import * as scheduler from "../../../gcp/cloudscheduler"; import * as utils from "../../../utils"; import * as services from "../services"; @@ -185,9 +186,7 @@ export class Fabricator { } 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, - }); + await this.createRunService(endpoint); } else { assertExhaustive(endpoint.platform); } @@ -212,7 +211,7 @@ export class Fabricator { } 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 }); + await this.updateRunService(update); } else { assertExhaustive(update.endpoint.platform); } @@ -227,7 +226,7 @@ export class Fabricator { } 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 }); + return this.deleteRunService(endpoint); } assertExhaustive(endpoint.platform); } @@ -618,6 +617,130 @@ export class Fabricator { .catch(rethrowAs(endpoint, "delete")); } + async createRunService(endpoint: backend.Endpoint): Promise { + const storageSource = this.sources[endpoint.codebase!]?.storage; + if (!storageSource) { + logger.debug("Precondition failed. Cannot create a Cloud Run function without storage"); + throw new Error("Precondition failed"); + } + const service = runV2.serviceFromEndpoint(endpoint, "scratch"); + const container = service.template.containers![0]; + container.command = endpoint.command; + container.args = endpoint.args; + container.baseImageUri = endpoint.baseImageUri; + container.sourceCode = { + cloudStorageSource: { + bucket: storageSource.bucket, + object: storageSource.object, + generation: storageSource.generation ? String(storageSource.generation) : undefined, + }, + }; + + await this.executor + .run(async () => { + const op = await runV2.createService( + endpoint.project, + endpoint.region, + endpoint.id, + service, + ); + endpoint.uri = op.uri; + endpoint.runServiceId = endpoint.id; + }) + .catch(rethrowAs(endpoint, "create")); + + await this.setInvoker(endpoint); + } + + async updateRunService(update: planner.EndpointUpdate): Promise { + const endpoint = update.endpoint; + const storageSource = this.sources[endpoint.codebase!]?.storage; + if (!storageSource) { + logger.debug("Precondition failed. Cannot update a Cloud Run function without storage"); + throw new Error("Precondition failed"); + } + + const service: Omit = { + name: `projects/${endpoint.project}/locations/${endpoint.region}/services/${endpoint.id}`, + template: { + containers: [ + { + name: "worker", + image: "scratch", + command: endpoint.command, + args: endpoint.args, + baseImageUri: endpoint.baseImageUri, + sourceCode: { + cloudStorageSource: { + bucket: storageSource.bucket, + object: storageSource.object, + generation: storageSource.generation ? String(storageSource.generation) : undefined, + }, + }, + resources: { + limits: { + cpu: String(endpoint.cpu || 1), + memory: `${endpoint.availableMemoryMb || 256}Mi`, + }, + cpuIdle: true, + startupCpuBoost: true, + }, + }, + ], + maxInstanceRequestConcurrency: endpoint.concurrency || 80, + scaling: { + minInstanceCount: endpoint.minInstances || 0, + maxInstanceCount: endpoint.maxInstances || 100, + }, + }, + client: "cli-firebase", + labels: { ...endpoint.labels, "goog-managed-by": "firebase-functions" }, + annotations: {}, + }; + + await this.executor + .run(async () => { + const op = await runV2.updateService(service); + endpoint.uri = op.uri; + endpoint.runServiceId = endpoint.id; + }) + .catch(rethrowAs(endpoint, "update")); + + await this.setInvoker(endpoint); + } + + async deleteRunService(endpoint: backend.Endpoint): Promise { + await this.executor + .run(async () => { + try { + await runV2.deleteService(endpoint.project, endpoint.region, endpoint.id); + } catch (err: any) { + if (err.status === 404) { + return; + } + throw err; + } + }) + .catch(rethrowAs(endpoint, "delete")); + } + + async setInvoker(endpoint: backend.Endpoint): Promise { + if (backend.isHttpsTriggered(endpoint)) { + const invoker = endpoint.httpsTrigger.invoker || ["public"]; + if (!invoker.includes("private")) { + await this.executor + .run(() => + run.setInvokerUpdate( + endpoint.project, + `projects/${endpoint.project}/locations/${endpoint.region}/services/${endpoint.runServiceId}`, + invoker, + ), + ) + .catch(rethrowAs(endpoint, "set invoker")); + } + } + } + async setRunTraits(serviceName: string, endpoint: backend.Endpoint): Promise { await this.functionExecutor .run(async () => { @@ -650,11 +773,6 @@ 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 { - 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); @@ -662,21 +780,26 @@ export class Fabricator { } else if (endpoint.platform === "gcfv2") { await this.upsertScheduleV2(endpoint); return; + } else if (endpoint.platform === "run") { + throw new FirebaseError("Schedule triggers for Cloud Run functions are not supported yet."); } assertExhaustive(endpoint.platform); } else if (backend.isTaskQueueTriggered(endpoint)) { + if (endpoint.platform === "run") { + throw new FirebaseError( + "Task Queue triggers for Cloud Run functions are not supported yet.", + ); + } await this.upsertTaskQueue(endpoint); } else if (backend.isBlockingTriggered(endpoint)) { + if (endpoint.platform === "run") { + throw new FirebaseError("Blocking triggers for Cloud Run functions are not supported yet."); + } await this.registerBlockingTrigger(endpoint); } } async deleteTrigger(endpoint: backend.Endpoint): Promise { - 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); @@ -684,11 +807,21 @@ export class Fabricator { } else if (endpoint.platform === "gcfv2") { await this.deleteScheduleV2(endpoint); return; + } else if (endpoint.platform === "run") { + throw new FirebaseError("Schedule triggers for Cloud Run functions are not supported yet."); } assertExhaustive(endpoint.platform); } else if (backend.isTaskQueueTriggered(endpoint)) { + if (endpoint.platform === "run") { + throw new FirebaseError( + "Task Queue triggers for Cloud Run functions are not supported yet.", + ); + } await this.disableTaskQueue(endpoint); } else if (backend.isBlockingTriggered(endpoint)) { + if (endpoint.platform === "run") { + throw new FirebaseError("Blocking triggers for Cloud Run functions are not supported yet."); + } await this.unregisterBlockingTrigger(endpoint); } // N.B. Like Pub/Sub topics, we don't delete Eventarc channels because we diff --git a/src/deploy/functions/runtimes/dart.ts b/src/deploy/functions/runtimes/dart.ts new file mode 100644 index 00000000000..612d382ad44 --- /dev/null +++ b/src/deploy/functions/runtimes/dart.ts @@ -0,0 +1,58 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import * as yaml from "js-yaml"; +import { DelegateContext, RuntimeDelegate } from "./index"; +import * as discovery from "./discovery"; + +/** + * Create a runtime delegate for the Dart runtime, if applicable. + * @param context runtimes.DelegateContext + * @return Delegate Dart runtime delegate + */ +export async function tryCreateDelegate( + context: DelegateContext, +): Promise { + const yamlPath = path.join(context.sourceDir, "functions.yaml"); + if (!(await fs.pathExists(yamlPath))) { + return undefined; + } + + // If runtime is specified, use it. Otherwise default to "dart3". + // "dart" is often used as a generic alias, map it to "dart3" + let runtime = context.runtime || "dart3"; + if ((runtime as string) === "dart") { + runtime = "dart3" as any; + } + + return { + language: "dart", + runtime: runtime, + bin: "", // No bin needed for no-build + validate: async () => { + // Basic validation that the file is parseable + try { + const content = await fs.readFile(yamlPath, "utf8"); + yaml.load(content); + } catch (e: any) { + throw new Error(`Failed to parse functions.yaml: ${e.message}`); + } + }, + build: async () => { + // No-op for no-build + return Promise.resolve(); + }, + watch: async () => { + return Promise.resolve(async () => { + // No-op + }); + }, + discoverBuild: async () => { + const build = await discovery.detectFromYaml(context.sourceDir, context.projectId, runtime); + if (!build) { + // This should not happen because we checked for existence in tryCreateDelegate + throw new Error("Could not find functions.yaml"); + } + return build; + }, + }; +} diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index 5c56a22e72b..4cbba6102bd 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -635,6 +635,32 @@ describe("buildFromV1Alpha", () => { expect(parsed).to.deep.equal(expected); }); + it("copies no-build fields (baseImageUri, command, args)", () => { + const yaml: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: { + id: { + ...MIN_WIRE_ENDPOINT, + baseImageUri: "gcr.io/base", + command: ["cmd"], + args: ["arg1", "arg2"], + httpsTrigger: {}, + }, + }, + }; + const parsed = v1alpha1.buildFromV1Alpha1(yaml, PROJECT, REGION, RUNTIME); + const expected: build.Build = build.of({ + id: { + ...DEFAULTED_ENDPOINT, + baseImageUri: "gcr.io/base", + command: ["cmd"], + args: ["arg1", "arg2"], + httpsTrigger: {}, + }, + }); + expect(parsed).to.deep.equal(expected); + }); + it("allows some fields of the endpoint to have a Field<> type", () => { const yaml: v1alpha1.WireManifest = { specVersion: "v1alpha1", diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 9e56a9fe338..8f414a2eabb 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -70,6 +70,9 @@ export type WireEndpoint = build.Triggered & entryPoint: string; platform?: build.FunctionsPlatform; secretEnvironmentVariables?: Array | null; + baseImageUri?: string; + command?: string[]; + args?: string[]; }; export type WireExtension = { @@ -166,6 +169,9 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { taskQueueTrigger: "object", blockingTrigger: "object", cpu: (cpu) => cpu === null || isCEL(cpu) || cpu === "gcf_gen1" || typeof cpu === "number", + baseImageUri: "string?", + command: "array?", + args: "array?", }); if (ep.vpc) { assertKeyTypes(prefix + ".vpc", ep.vpc, { @@ -450,6 +456,9 @@ function parseEndpointForBuild( "ingressSettings", "environmentVariables", "serviceAccount", + "baseImageUri", + "command", + "args", ); convertIfPresent(parsed, ep, "secretEnvironmentVariables", (senvs) => { if (!senvs) { diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index cb3aa66d1ab..9ad33973183 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -5,6 +5,8 @@ import * as python from "./python"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; import * as supported from "./supported"; +import * as dart from "./dart"; +import * as experiments from "../../../experiments"; /** * RuntimeDelegate is a language-agnostic strategy for managing @@ -70,7 +72,14 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate]; +const factories: Factory[] = [ + node.tryCreateDelegate, + python.tryCreateDelegate, + (ctx) => + experiments.isEnabled("functionsrunapionly") + ? dart.tryCreateDelegate(ctx) + : Promise.resolve(undefined), +]; /** * Gets the delegate object responsible for discovering, building, and hosting diff --git a/src/deploy/functions/runtimes/supported/types.ts b/src/deploy/functions/runtimes/supported/types.ts index b0591a8c6b6..936b8833fbf 100644 --- a/src/deploy/functions/runtimes/supported/types.ts +++ b/src/deploy/functions/runtimes/supported/types.ts @@ -9,7 +9,7 @@ export type RuntimeStatus = "experimental" | "beta" | "GA" | "deprecated" | "dec type Day = `${number}-${number}-${number}`; /** Supported languages. All Runtime are a language + version. */ -export type Language = "nodejs" | "python"; +export type Language = "nodejs" | "python" | "dart"; /** * Helper type that is more friendlier than string interpolation everywhere. @@ -119,6 +119,12 @@ export const RUNTIMES = runtimes({ deprecationDate: "2029-10-10", decommissionDate: "2030-04-10", }, + dart3: { + friendly: "Dart 3", + status: "experimental", + deprecationDate: "2030-01-01", + decommissionDate: "2030-01-01", + }, }); export type Runtime = keyof typeof RUNTIMES & RuntimeOf; diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index edc48318079..bed1bde4a02 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -60,7 +60,7 @@ describe("runv2", () => { }, }, ], - containerConcurrency: backend.DEFAULT_CONCURRENCY, + maxInstanceRequestConcurrency: backend.DEFAULT_CONCURRENCY, }, client: "cli-firebase", }; @@ -163,7 +163,7 @@ describe("runv2", () => { name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${FUNCTION_ID.toLowerCase()}`, }), ); - expectedServiceInput.template.containerConcurrency = 50; + expectedServiceInput.template.maxInstanceRequestConcurrency = 50; expect(runv2.serviceFromEndpoint(endpoint, IMAGE_URI)).to.deep.equal(expectedServiceInput); }); @@ -402,7 +402,7 @@ describe("runv2", () => { it("should copy concurrency, min/max instances", () => { const service: runv2.Service = JSON.parse(JSON.stringify(BASE_RUN_SERVICE)); - service.template.containerConcurrency = 10; + service.template.maxInstanceRequestConcurrency = 10; service.scaling = { minInstanceCount: 2, maxInstanceCount: 5, diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index e71ad1036a8..1739ad4ddd1 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -46,6 +46,10 @@ export interface Container { // N.B. This defaults to true if resources is not set and must manually be set to true if it is set. cpuIdle?: boolean; // If true, the container will be allowed to idle CPU when not processing requests. }; + baseImageUri?: string; + sourceCode?: { + cloudStorageSource: StorageSource; + }; // Lots more. Most intereeseting is baseImageUri and maybe buildInfo. } export interface RevisionTemplate { @@ -68,7 +72,7 @@ export interface RevisionTemplate { timeout?: proto.Duration; serviceAccount?: string; containers?: Container[]; - containerConcurrency?: number; + maxInstanceRequestConcurrency?: number; } export interface BuildConfig { @@ -109,6 +113,7 @@ export interface Service { invokerIamDisabled?: boolean; // Is this redundant with the Build API? buildConfig?: BuildConfig; + uri?: string; } export type ServiceOutputFields = @@ -155,6 +160,10 @@ export interface SubmitBuildResponse { baseImageWarning?: string; } +/** + * Submits a build to Cloud Build using the v2 API, tracking the long-running operation. + * Used for building source code into container images. + */ export async function submitBuild( projectId: string, location: string, @@ -174,6 +183,10 @@ export async function submitBuild( }); } +/** + * Updates an existing Cloud Run service. + * Tracks the long-running operation until completion. + */ export async function updateService(service: Omit): Promise { const fieldMask = proto.fieldMasks( service, @@ -200,6 +213,68 @@ export async function updateService(service: Omit) return svc; } +/** + * Creates a new Cloud Run service in the specified project and location. + * Tracks the long-running operation until completion. + */ +export async function createService( + projectId: string, + location: string, + serviceId: string, + service: Omit, +): Promise { + // The create API expects the name to be empty or unset, as the parent is in the URL + // and resource ID is a query param. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { name, ...serviceBody } = service; + const res = await client.post, LongRunningOperation>( + `/projects/${projectId}/locations/${location}/services`, + serviceBody, + { + queryParams: { + serviceId, + }, + }, + ); + const svc = await pollOperation({ + apiOrigin: runOrigin(), + apiVersion: API_VERSION, + operationResourceName: res.body.name, + }); + return svc; +} + +/** + * Deletes a Cloud Run service. + * Tracks the long-running operation until completion. + */ +export async function deleteService( + projectId: string, + location: string, + serviceId: string, +): Promise { + const name = `projects/${projectId}/locations/${location}/services/${serviceId}`; + const res = await client.delete>(name); + await pollOperation({ + apiOrigin: runOrigin(), + apiVersion: API_VERSION, + operationResourceName: res.body.name, + }); +} + +/** + * Gets a Cloud Run service details. + */ +export async function getService( + projectId: string, + location: string, + serviceId: string, +): Promise { + const name = `projects/${projectId}/locations/${location}/services/${serviceId}`; + const res = await client.get(name); + return res.body; +} + /** * Lists Cloud Run services in the given project. * @@ -492,6 +567,10 @@ export interface FirebaseFunctionMetadata { // values from the dependent services? But serviceFromEndpoint currently // only returns the service and not the dependent resources, which we will // need for updates. +/** + * Converts a Cloud Run Service definition into a Firebase internal Endpoint representation. + * Handles parsing of environment variables, secrets, and labels to reconstruct the function configuration. + */ export function endpointFromService(service: Omit): backend.Endpoint { const [, /* projects*/ project /* locations*/, , location /* services*/, , svcId] = service.name.split("/"); @@ -541,7 +620,7 @@ export function endpointFromService(service: Omit) }, }), }; - proto.renameIfPresent(endpoint, service.template, "concurrency", "containerConcurrency"); + proto.renameIfPresent(endpoint, service.template, "concurrency", "maxInstanceRequestConcurrency"); proto.renameIfPresent(endpoint, service.labels || {}, "codebase", CODEBASE_LABEL); proto.renameIfPresent(endpoint, service.scaling || {}, "minInstances", "minInstanceCount"); proto.renameIfPresent(endpoint, service.scaling || {}, "maxInstances", "maxInstanceCount"); @@ -563,6 +642,10 @@ export function endpointFromService(service: Omit) return endpoint; } +/** + * Converts a Firebase internal Endpoint representation into a Cloud Run Service definition. + * Used for creating or updating services. + */ export function serviceFromEndpoint( endpoint: backend.Endpoint, image: string, @@ -627,9 +710,9 @@ export function serviceFromEndpoint( }, }, ], - containerConcurrency: endpoint.concurrency || backend.DEFAULT_CONCURRENCY, + maxInstanceRequestConcurrency: endpoint.concurrency || backend.DEFAULT_CONCURRENCY, }; - proto.renameIfPresent(template, endpoint, "containerConcurrency", "concurrency"); + proto.renameIfPresent(template, endpoint, "maxInstanceRequestConcurrency", "concurrency"); const service: Omit = { name: `projects/${endpoint.project}/locations/${endpoint.region}/services/${functionNameToServiceName( @@ -647,6 +730,6 @@ export function serviceFromEndpoint( proto.renameIfPresent(service.scaling, endpoint, "maxInstanceCount", "maxInstances"); } - // TODO: other trigger types, service accounts, concurrency, etc. + // TODO: other trigger types (callable, scheduled, etc), service accounts, timeoutSeconds, VPC, ingress settings return service; }