diff --git a/manual_verify/bin/server.dart b/manual_verify/bin/server.dart new file mode 100644 index 00000000000..1cdc51c6672 --- /dev/null +++ b/manual_verify/bin/server.dart @@ -0,0 +1,9 @@ +import 'dart:io'; +void main() async { + final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final server = await HttpServer.bind('0.0.0.0', port); + print('Server listening on port $port'); + await for (final request in server) { + request.response..statusCode = HttpStatus.ok..write('Hello!')..close(); + } +} diff --git a/manual_verify/bin/server.exe b/manual_verify/bin/server.exe new file mode 100755 index 00000000000..4042491bb80 Binary files /dev/null and b/manual_verify/bin/server.exe differ diff --git a/manual_verify/firebase.json b/manual_verify/firebase.json new file mode 100644 index 00000000000..496f750c85b --- /dev/null +++ b/manual_verify/firebase.json @@ -0,0 +1,5 @@ +{ + "functions": { + "source": "." + } +} diff --git a/manual_verify/functions.yaml b/manual_verify/functions.yaml new file mode 100644 index 00000000000..6f0fd047e7c --- /dev/null +++ b/manual_verify/functions.yaml @@ -0,0 +1,9 @@ +endpoints: + darttest-verified-9: + platform: "run" + region: ["us-west1"] + maxInstances: 10 + httpsTrigger: {} + baseImageUri: "us-west1-docker.pkg.dev/serverless-runtimes/google-22-full/runtimes/go123" + command: ["./bin/server.exe"] + entryPoint: "server" 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 411ed90d466..cb5071051a5 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -386,6 +386,11 @@ export type Endpoint = TargetIds & // State of the endpoint. state?: EndpointState; + + // Fields for "run" platform (no-build) + baseImageUri?: string; + command?: string[]; + args?: string[]; }; export interface RequiredAPI { diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index c786a22c907..d0387347a6f 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -208,8 +208,8 @@ export type MemoryOption = 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 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; @@ -224,7 +224,7 @@ export type Endpoint = Triggered & { omit?: Field; // Defaults to "gcfv2". "Run" will be an additional option defined later - platform?: "gcfv1" | "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 @@ -269,6 +269,11 @@ export type Endpoint = Triggered & { environmentVariables?: Record> | null; secretEnvironmentVariables?: SecretEnvVar[] | null; labels?: Record> | null; + + // Fields for "run" platform (no-build) + baseImageUri?: string; + command?: string[]; + args?: string[]; }; type SecretParam = ReturnType; @@ -485,6 +490,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 211aa1a440c..f43a87c08c8 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. @@ -78,7 +77,8 @@ export async function checkHttpIam( const wantBackends = Object.values(payload.functions).map(({ wantBackend }) => wantBackend); const httpEndpoints = [...flattenArray(wantBackends.map((b) => backend.allEndpoints(b)))] .filter(backend.isHttpsTriggered) - .filter((f) => endpointMatchesAnyFilter(f, filters)); + .filter((f) => endpointMatchesAnyFilter(f, filters)) + .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..4bb002659da 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(); @@ -51,10 +52,16 @@ async function uploadSourceV1( } // Trampoline to allow tests to mock out createStream. +/** + * + */ export function createReadStream(filePath: string): NodeJS.ReadableStream { return fs.createReadStream(filePath); } +/** + * + */ export async function uploadSourceV2( projectId: string, projectNumber: string, @@ -80,7 +87,9 @@ export async function uploadSourceV2( }; // Legacy behavior: use the GCF API - if (!experiments.isEnabled("runfunctions")) { + // We use BYO bucket if the "runfunctions" experiment is enabled OR if we have any platform: run endpoints. + // Otherwise, we use the GCF API. + if (!experiments.isEnabled("runfunctions") && !v2Endpoints.some((e) => e.platform === "run")) { if (process.env.GOOGLE_CLOUD_QUOTA_PROJECT) { logLabeledWarning( "functions", @@ -116,7 +125,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 0cc513dda0b..d19e97678a9 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -97,7 +97,7 @@ export async function prepare( let runtimeConfig: Record = { firebase: firebaseConfig }; const allowFunctionsConfig = experiments.isEnabled("legacyRuntimeConfigCommands"); - const targetedCodebaseConfigs = context.config!.filter((cfg) => codebases.includes(cfg.codebase)); + const targetedCodebaseConfigs = context.config.filter((cfg) => codebases.includes(cfg.codebase)); // Load runtime config if API is enabled and at least one targeted codebase uses it if ( @@ -229,8 +229,14 @@ export async function prepare( ); } - if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2")) { - const packagedSource = await prepareFunctionsUpload(sourceDir, localCfg); + if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2" || e.platform === "run")) { + const configForUpload = shouldUseRuntimeConfig(localCfg) ? runtimeConfig : undefined; + const exportType = backend.someEndpoint(wantBackend, (e) => e.platform === "run") + ? "tar.gz" + : "zip"; + const packagedSource = await prepareFunctionsUpload(sourceDir, localCfg, configForUpload, { + exportType, + }); source.functionsSourceV2 = packagedSource?.pathToSource; source.functionsSourceV2Hash = packagedSource?.hash; } diff --git a/src/deploy/functions/prepareFunctionsUpload.ts b/src/deploy/functions/prepareFunctionsUpload.ts index 64407702c1e..05c38ef2c41 100644 --- a/src/deploy/functions/prepareFunctionsUpload.ts +++ b/src/deploy/functions/prepareFunctionsUpload.ts @@ -24,6 +24,9 @@ interface PackagedSourceInfo { type SortedConfig = string | { key: string; value: SortedConfig }[]; // TODO(inlined): move to a file that's not about uploading source code +/** + * + */ export async function getFunctionsConfig(projectId: string): Promise> { try { return await functionsConfig.materializeAll(projectId); @@ -59,13 +62,16 @@ async function packageSource( sourceDir: string, config: projectConfig.ValidatedSingle, 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 === "tar.gz" ? ".tar.gz" : ".zip"; + 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 @@ -129,14 +135,21 @@ async function packageSource( return { pathToSource: tmpFile, hash }; } +/** + * + */ export async function prepareFunctionsUpload( sourceDir: string, config: projectConfig.ValidatedSingle, runtimeConfig?: backend.RuntimeConfigValues, + options?: { exportType: "zip" | "tar.gz" }, ): Promise { - return packageSource(sourceDir, config, runtimeConfig); + return packageSource(sourceDir, config, runtimeConfig, options); } +/** + * + */ export function convertToSortedKeyValueArray(config: any): SortedConfig { if (typeof config !== "object" || config === null) return config; diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index 01ff014b849..65e377db736 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); @@ -74,6 +77,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")); @@ -1637,4 +1644,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 d0c7d5e1ce3..9cbfda32495 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"; @@ -184,9 +185,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); } @@ -211,7 +210,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); } @@ -226,7 +225,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); } @@ -600,6 +599,155 @@ 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: 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.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 () => { @@ -632,11 +780,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); @@ -644,21 +787,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); @@ -666,11 +814,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/discovery/v1alpha1.spec.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index 5aece13566a..a4e685aa5eb 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -608,6 +608,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 8afaeb1f016..0b5e5f7523b 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -69,6 +69,9 @@ export type WireEndpoint = build.Triggered & entryPoint: string; platform?: build.FunctionsPlatform; secretEnvironmentVariables?: Array | null; + baseImageUri?: string; + command?: string[]; + args?: string[]; }; export type WireExtension = { @@ -164,6 +167,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, { @@ -432,6 +438,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..326b0648ac6 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -5,6 +5,7 @@ import * as python from "./python"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; import * as supported from "./supported"; +import * as nobuild from "./nobuild"; /** * RuntimeDelegate is a language-agnostic strategy for managing @@ -70,7 +71,11 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate]; +const factories: Factory[] = [ + node.tryCreateDelegate, + python.tryCreateDelegate, + nobuild.tryCreateDelegate, +]; /** * Gets the delegate object responsible for discovering, building, and hosting diff --git a/src/deploy/functions/runtimes/nobuild.ts b/src/deploy/functions/runtimes/nobuild.ts new file mode 100644 index 00000000000..e545ba4a799 --- /dev/null +++ b/src/deploy/functions/runtimes/nobuild.ts @@ -0,0 +1,54 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import * as yaml from "js-yaml"; +import { DelegateContext, RuntimeDelegate } from "./index"; +import { buildFromV1Alpha1 } from "./discovery/v1alpha1"; + +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" as any, // "dart" is not yet in supported.Language union, but we added it to types? + runtime: runtime as any, + 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 (config, envs) => { + const content = await fs.readFile(yamlPath, "utf8"); + const parsed = yaml.load(content); + // We pass stub values for project/region as they are often overridden or unused in Build object + // until resolveBackend. + // However, buildFromV1Alpha1 might use them for defaults. + // Using context.projectId. + return buildFromV1Alpha1(parsed, context.projectId, "us-central1", runtime as any); + }, + }; +} 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..8f4f3020295 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", }; @@ -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..0d46f2a62b9 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( diff --git a/verify_no_build.ts b/verify_no_build.ts new file mode 100644 index 00000000000..d54c5582fc0 --- /dev/null +++ b/verify_no_build.ts @@ -0,0 +1,47 @@ + +import { buildFromV1Alpha1 } from "./lib/deploy/functions/runtimes/discovery/v1alpha1"; +import * as build from "./lib/deploy/functions/build"; +import { expect } from "chai"; + +const yaml = { + specVersion: "v1alpha1", + endpoints: { + darttest: { + platform: "run", // --no-build + region: ["us-west1"], + httpsTrigger: {}, // --allow-unauthenticated + baseImageUri: "us-west1-docker.pkg.dev/serverless-runtimes/google-22-full/runtimes/go123", // --base-image + command: ["./bin/server.exe"], // --command + entryPoint: "server", // Required by internal logic even if ignored by no-build? + }, + }, +}; + +console.log("Parsing dummy functions.yaml..."); +try { + // @ts-ignore + const result = buildFromV1Alpha1(yaml, "danielylee-91", "us-west1", "dart"); + console.log("Result endpoints:", JSON.stringify(result.endpoints, null, 2)); + + // @ts-ignore + const endpoint = (result.endpoints as any)["darttest"]; + + if (!endpoint) { + console.error("FAILED: Endpoint not found in result"); + process.exit(1); + } + + console.log("SUCCESS: Endpoint parsed successfully!"); + console.log("Platform:", endpoint.platform); + console.log("Base Image:", endpoint.baseImageUri); + console.log("Command:", endpoint.command); + + if (endpoint.platform !== "run") throw new Error("Wrong platform"); + if (endpoint.baseImageUri !== "us-west1-docker.pkg.dev/serverless-runtimes/google-22-full/runtimes/go123") throw new Error("Wrong base image"); + if (!endpoint.command || endpoint.command[0] !== "./bin/server.exe") throw new Error("Wrong command"); + + console.log("Verification PASSED."); +} catch (e) { + console.error("FAILED:", e); + process.exit(1); +}