From caaf161be6e40a2ed2dcde583836a71f3dee0a5a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 28 Sep 2026 16:53:05 -0400 Subject: [PATCH 01/28] Create a basic, initial schema for firebase.json --- schema/firebase-config.json | 69 ++++++++++++++++++++++++++++++ src/firebaseConfig.ts | 14 ++++++ src/firebaseConfigValidate.spec.ts | 17 ++++++++ 3 files changed, 100 insertions(+) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 0185d3e7789..a6171d42c19 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1221,6 +1221,62 @@ ], "type": "object" }, + "RunSingle": { + "additionalProperties": false, + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "localBuild": { + "type": "boolean" + }, + "postdeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "predeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "region": { + "type": "string" + }, + "rootDir": { + "type": "string" + }, + "serviceAccount": { + "type": "string" + }, + "serviceId": { + "type": "string" + } + }, + "required": [ + "serviceId" + ], + "type": "object" + }, "StorageSingle": { "additionalProperties": false, "properties": { @@ -2008,6 +2064,19 @@ "remoteconfig": { "$ref": "#/definitions/RemoteConfigConfig" }, + "run": { + "anyOf": [ + { + "$ref": "#/definitions/RunSingle" + }, + { + "items": { + "$ref": "#/definitions/RunSingle" + }, + "type": "array" + } + ] + }, "storage": { "anyOf": [ { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 25934174b9a..18235b97107 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,6 +366,19 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; +export interface RunSingle extends Deployable { + serviceId: string; + region?: string; + rootDir?: string; + ignore?: string[]; + serviceAccount?: string; + localBuild?: boolean; +} + +export type RunMultiple = RunSingle[]; + +export type RunConfig = RunSingle | RunMultiple; + export interface AuthConfig { providers?: { anonymous?: boolean; @@ -392,4 +405,5 @@ export type FirebaseConfig = { dataconnect?: DataConnectConfig; apphosting?: AppHostingConfig; auth?: AuthConfig; + run?: RunConfig; }; diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 327011a577b..56b3d149798 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,23 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a valid run config", () => { + const config: FirebaseConfig = { + run: [ + { + serviceId: "my-service", + region: "us-central1", + rootDir: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { From dd384b6127dfff108583f5d9a94678a27a63061b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 10:35:24 -0400 Subject: [PATCH 02/28] improve testing --- src/firebaseConfigValidate.spec.ts | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 56b3d149798..fc104913d73 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -41,6 +41,92 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a single-object run config", () => { + const config: FirebaseConfig = { + run: { + serviceId: "my-service", + region: "us-central1", + localBuild: true, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + + it("should reject a run config missing serviceId", () => { + const config = { + run: [ + { + region: "us-central1", + rootDir: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "required" && + e.instancePath === "/run/0" && + e.params.missingProperty === "serviceId", + ), + ).to.be.true; + }); + + it("should reject a run config with an unknown field", () => { + const config = { + run: { + serviceId: "my-service", + bananas: true, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "additionalProperties" && + e.instancePath === "/run" && + e.params.additionalProperty === "bananas", + ), + ).to.be.true; + }); + + it("should reject a run config with an incorrect type", () => { + const config = { + run: { + serviceId: "my-service", + localBuild: "yes", + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "type" && + e.instancePath === "/run/localBuild" && + e.params.type === "boolean", + ), + ).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { From c8fbf9cfd7d67285a8e77796735229d16e0a44a5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:53:09 -0400 Subject: [PATCH 03/28] Require region, remove serviceAccount, and register run in Config.KNOWN_PROPS --- schema/firebase-config.json | 4 +- src/config.ts | 1 + src/firebaseConfig.ts | 3 +- src/firebaseConfigValidate.spec.ts | 112 ++++++++++++----------------- 4 files changed, 48 insertions(+), 72 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 97ff97e0526..e646dd371d3 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1265,14 +1265,12 @@ "rootDir": { "type": "string" }, - "serviceAccount": { - "type": "string" - }, "serviceId": { "type": "string" } }, "required": [ + "region", "serviceId" ], "type": "object" diff --git a/src/config.ts b/src/config.ts index ffd9998c88b..cd20b9e1fa0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ export class Config { "apphosting", "auth", "ailogic", + "run", ]; public options: any; diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 38c7a9ef7b9..7137aedc70b 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -368,10 +368,9 @@ export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; export interface RunSingle extends Deployable { serviceId: string; - region?: string; + region: string; rootDir?: string; ignore?: string[]; - serviceAccount?: string; localBuild?: boolean; } diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index fc104913d73..0ee2a2751fe 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,24 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + function expectValidationError( + config: unknown, + keyword: string, + instancePath: string, + params: Record, + ): void { + const validator = getValidator(); + expect(validator(config)).to.be.false; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === keyword && + e.instancePath === instancePath && + Object.entries(params).every(([k, v]) => e.params[k] === v), + ), + ).to.be.true; + } + it("should accept a valid run config", () => { const config: FirebaseConfig = { run: [ @@ -36,9 +54,7 @@ describe("firebaseConfigValidate", () => { }; const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.true; + expect(validator(config)).to.be.true; }); it("should accept a single-object run config", () => { @@ -51,80 +67,42 @@ describe("firebaseConfigValidate", () => { }; const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.true; + expect(validator(config)).to.be.true; }); it("should reject a run config missing serviceId", () => { - const config = { - run: [ - { - region: "us-central1", - rootDir: ".", - }, - ], - }; - - const validator = getValidator(); - const isValid = validator(config); + expectValidationError( + { run: [{ region: "us-central1", rootDir: "." }] }, + "required", + "/run/0", + { + missingProperty: "serviceId", + }, + ); + }); - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "required" && - e.instancePath === "/run/0" && - e.params.missingProperty === "serviceId", - ), - ).to.be.true; + it("should reject a run config missing region", () => { + expectValidationError({ run: { serviceId: "my-service" } }, "required", "/run", { + missingProperty: "region", + }); }); it("should reject a run config with an unknown field", () => { - const config = { - run: { - serviceId: "my-service", - bananas: true, - }, - }; - - const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "additionalProperties" && - e.instancePath === "/run" && - e.params.additionalProperty === "bananas", - ), - ).to.be.true; + expectValidationError( + { run: { serviceId: "my-service", region: "us-central1", bananas: true } }, + "additionalProperties", + "/run", + { additionalProperty: "bananas" }, + ); }); it("should reject a run config with an incorrect type", () => { - const config = { - run: { - serviceId: "my-service", - localBuild: "yes", - }, - }; - - const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "type" && - e.instancePath === "/run/localBuild" && - e.params.type === "boolean", - ), - ).to.be.true; + expectValidationError( + { run: { serviceId: "my-service", region: "us-central1", localBuild: "yes" } }, + "type", + "/run/localBuild", + { type: "boolean" }, + ); }); it("should report an extra top-level field", () => { From 254e3f58d37c3bfd7a84d5cf29ccdfc9ad47aeed Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 04/28] Add direct_cloud_run experiment flag --- src/experiments.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/experiments.ts b/src/experiments.ts index 5558280875f..5f128a19e1f 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -179,6 +179,15 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, + direct_cloud_run: { + shortDescription: "Build and deploy web apps directly to Cloud Run services", + fullDescription: + "Enables `firebase init run`, `firebase deploy --only run`, and the " + + "`firebase run:services:update` command. " + + "These are in preview and may change.", + public: false, + }, + // TODO(joehanley): Delete this once weve scrubbed all references to experiment from docs. dataconnect: { shortDescription: "Deprecated. Previosuly, enabled SQL Connect related features.", From 99b87a53a5da105e48198a8e6675baff36cdc7ff Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 05/28] Update shared GCP clients (runv2, run, artifactregistry) --- src/gcp/artifactregistry.spec.ts | 41 +++++++++ src/gcp/artifactregistry.ts | 30 +++++++ src/gcp/run.spec.ts | 28 ++++++ src/gcp/run.ts | 10 +++ src/gcp/runv2.spec.ts | 143 +++++++++++++++++++++++++++++++ src/gcp/runv2.ts | 74 ++++++++++++---- 6 files changed, 307 insertions(+), 19 deletions(-) diff --git a/src/gcp/artifactregistry.spec.ts b/src/gcp/artifactregistry.spec.ts index c8e9353b925..7cc94830d4d 100644 --- a/src/gcp/artifactregistry.spec.ts +++ b/src/gcp/artifactregistry.spec.ts @@ -140,4 +140,45 @@ describe("artifactRegistry", () => { ); }); }); + + describe("ensureDockerRepository", () => { + it("does nothing if the repository already exists", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(200, { name: REPO_NAME, format: "DOCKER" }); + + await artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("creates the repository and polls the operation when it returns 404", async () => { + const opName = `projects/${PROJECT_ID}/locations/${REGION}/operations/create-repo`; + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(404, { error: { message: "Not Found" } }); + nock(artifactRegistryDomain()) + .post(`/${API_VERSION}/projects/${PROJECT_ID}/locations/${REGION}/repositories`, { + format: "DOCKER", + }) + .query({ repositoryId: REPO }) + .reply(200, { name: opName }); + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${opName}`) + .reply(200, { name: opName, done: true, response: { name: REPO_NAME, format: "DOCKER" } }); + + await artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("rethrows non-404 errors", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(403, { error: { message: "Permission Denied" } }); + + await expect( + artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO), + ).to.be.rejectedWith("Permission Denied"); + expect(nock.isDone()).to.be.true; + }); + }); }); diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index 6c589ad21c2..b5937766c5b 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -3,6 +3,8 @@ import { artifactRegistryDomain } from "../api"; import { assertImplements, DeepOmit, RecursiveKeyOf } from "../metaprogramming"; import * as api from "../ensureApiEnabled"; import * as proto from "./proto"; +import { getErrStatus } from "../error"; +import { pollOperation } from "../operation-poller"; export const API_VERSION = "v1"; @@ -95,3 +97,31 @@ export async function updateRepository(repo: RepositoryInput): Promise { + try { + await getRepository(`projects/${projectId}/locations/${location}/repositories/${repositoryId}`); + return; + } catch (err: unknown) { + if (getErrStatus(err) !== 404) { + throw err; + } + } + const res = await client.post<{ format: string }, Operation>( + `/projects/${projectId}/locations/${location}/repositories`, + { format: "DOCKER" }, + { queryParams: { repositoryId } }, + ); + await pollOperation({ + apiOrigin: artifactRegistryDomain(), + apiVersion: API_VERSION, + operationResourceName: res.body.name, + }); +} diff --git a/src/gcp/run.spec.ts b/src/gcp/run.spec.ts index 3661401fcdc..92fd79e981a 100644 --- a/src/gcp/run.spec.ts +++ b/src/gcp/run.spec.ts @@ -452,4 +452,32 @@ describe("run", () => { expect(run.serviceIsResolved(service)).to.be.true; }); }); + + describe("listLocations", () => { + let getStub: sinon.SinonStub; + + beforeEach(() => { + getStub = sinon.stub(Client.prototype, "get"); + }); + + afterEach(() => { + getStub.restore(); + }); + + it("returns location IDs from the Cloud Run API", async () => { + getStub.resolves({ + body: { locations: [{ locationId: "us-central1" }, { locationId: "europe-west1" }] }, + }); + + const locations = await run.listLocations("my-project"); + expect(locations).to.deep.equal(["us-central1", "europe-west1"]); + expect(getStub).to.have.been.calledOnceWithExactly("/projects/my-project/locations"); + }); + + it("returns an empty array when locations is omitted", async () => { + getStub.resolves({ body: {} }); + + expect(await run.listLocations("my-project")).to.deep.equal([]); + }); + }); }); diff --git a/src/gcp/run.ts b/src/gcp/run.ts index 945aca552cb..73053ab8f99 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -367,3 +367,13 @@ export async function fetchServiceLogs(projectId: string, serviceId: string): Pr }); } } + +/** + * Lists the IDs of the regions that Cloud Run supports (e.g. "us-central1"). + */ +export async function listLocations(projectId: string): Promise { + const res = await client.get<{ locations?: Array<{ locationId: string }> }>( + `/projects/${projectId}/locations`, + ); + return res.body.locations?.map((l) => l.locationId) ?? []; +} diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index de830171f88..f7c87399f78 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -7,6 +7,8 @@ import { latest } from "../deploy/functions/runtimes/supported"; import { CODEBASE_LABEL } from "../functions/constants"; import { Client } from "../apiv2"; import { FirebaseError } from "../error"; +import { logger } from "../logger"; +import * as poller from "../operation-poller"; describe("runv2", () => { const PROJECT_ID = "project-id"; @@ -583,5 +585,146 @@ describe("runv2", () => { { queryParams: {} }, ); }); + + it("should return all services when functionsOnly is false", async () => { + const mockServices = [ + { name: "service1", labels: { "goog-managed-by": "cloud-functions" } }, + { name: "service2", labels: {} }, + ]; + getStub.resolves({ status: 200, body: { services: mockServices } }); + + const services = await runv2.listServices(PROJECT_ID, false); + + expect(services).to.deep.equal(mockServices); + }); + }); + + describe("submitBuild, updateService, and createService", () => { + let sandbox: sinon.SinonSandbox; + let postStub: sinon.SinonStub; + let patchStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + let warnStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + postStub = sandbox.stub(Client.prototype, "post"); + patchStub = sandbox.stub(Client.prototype, "patch"); + pollStub = sandbox.stub(poller, "pollOperation"); + warnStub = sandbox.stub(logger, "warn"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("submitBuild submits a build, logs baseImageWarning, and polls until SUCCESS", async () => { + postStub.resolves({ + body: { + baseImageWarning: "nodejs20 is deprecated", + buildOperation: { + metadata: { + build: { name: "projects/p/locations/r/builds/b1", logUrl: "http://logs" }, + }, + }, + }, + }); + pollStub.callsFake(async (opts: { doneFn?: (b: { status?: string }) => boolean }) => { + expect(opts.doneFn?.({ status: "WORKING" })).to.be.false; + expect(opts.doneFn?.({ status: "SUCCESS" })).to.be.true; + }); + + const build: runv2.Build = { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: { baseImage: "nodejs22" }, + }; + await runv2.submitBuild(PROJECT_ID, LOCATION, build); + + expect(postStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/${LOCATION}/builds:submit`, + build, + ); + expect(warnStub).to.have.been.calledOnceWithExactly("nodejs20 is deprecated"); + expect(pollStub).to.have.been.calledOnce; + }); + + it("submitBuild throws when no build metadata is returned", async () => { + postStub.resolves({ body: { buildOperation: {} } }); + + await expect( + runv2.submitBuild(PROJECT_ID, LOCATION, { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: {}, + }), + ).to.be.rejectedWith("Failed to submit build: no build was returned."); + }); + + it("submitBuild throws when the Cloud Build fails", async () => { + postStub.resolves({ + body: { + buildOperation: { + metadata: { + build: { name: "projects/p/locations/r/builds/b1", logUrl: "http://logs" }, + }, + }, + }, + }); + pollStub.callsFake(async (opts: { doneFn?: (b: { status?: string }) => boolean }) => { + expect(opts.doneFn?.({ status: "FAILURE" })).to.be.true; + }); + + await expect( + runv2.submitBuild(PROJECT_ID, LOCATION, { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: {}, + }), + ).to.be.rejectedWith( + "Cloud Build failed with status FAILURE. View the build logs at http://logs", + ); + }); + + it("updateService uses explicit updateMask and masterTimeout when provided", async () => { + patchStub.resolves({ body: { name: "operations/op1" } }); + pollStub.resolves(BASE_RUN_SERVICE); + + await runv2.updateService(BASE_RUN_SERVICE, { + updateMask: ["template", "traffic"], + masterTimeout: 60000, + }); + + expect(patchStub).to.have.been.calledOnceWithExactly( + BASE_RUN_SERVICE.name, + BASE_RUN_SERVICE, + { queryParams: { updateMask: "template,traffic" } }, + ); + expect(pollStub).to.have.been.calledWithMatch({ + operationResourceName: "operations/op1", + masterTimeout: 60000, + }); + }); + + it("createService strips name from body and passes serviceId query param", async () => { + postStub.resolves({ body: { name: "operations/op2" } }); + pollStub.resolves(BASE_RUN_SERVICE); + + await runv2.createService(PROJECT_ID, LOCATION, SERVICE_ID, BASE_RUN_SERVICE, { + masterTimeout: 60000, + }); + + const expectedBody: Partial = { ...BASE_RUN_SERVICE }; + delete expectedBody.name; + expect(postStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/${LOCATION}/services`, + expectedBody, + { queryParams: { serviceId: SERVICE_ID } }, + ); + expect(pollStub).to.have.been.calledWithMatch({ + operationResourceName: "operations/op2", + masterTimeout: 60000, + }); + }); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 726eb300885..c59cb01a80f 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -36,6 +36,7 @@ export interface Container { command?: string[]; args?: string[]; env?: EnvVar[]; + ports?: Array<{ name?: string; containerPort?: number }>; resources?: { limits?: { cpu?: string; // e.g. "1", "2", "4" @@ -84,6 +85,13 @@ export interface BuildConfig { serviceAccount?: string; } +export interface TrafficTarget { + type?: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" | "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION"; + revision?: string; + percent?: number; + tag?: string; +} + // NOTE: This is a minmal copy of Cloud Run needed for our current API usage. // Add more as needed. // TODO: Can consider a helper where we have a second RecursiveKeysOf field for @@ -110,7 +118,10 @@ export interface Service { etag: string; template: RevisionTemplate; + traffic?: TrafficTarget[]; + trafficStatuses?: TrafficTarget[]; invokerIamDisabled?: boolean; + ingress?: string; // Is this redundant with the Build API? buildConfig?: BuildConfig; uri?: string; @@ -151,17 +162,21 @@ export interface Build { functionTarget?: string; storageSource: StorageSource; imageUri: string; - buildpacksBuild: BuildpacksBuild; + buildpackBuild: BuildpacksBuild; } export interface SubmitBuildResponse { - buildOperation: string; + buildOperation: { + metadata?: { build?: { name: string; logUrl?: string } }; + }; baseImageUri?: string; baseImageWarning?: string; } +const PENDING_BUILD_STATUSES = ["STATUS_UNKNOWN", "PENDING", "QUEUED", "WORKING"]; + /** - * Submits a build to Cloud Build using the v2 API, tracking the long-running operation. + * Submits a build to Cloud Build using the v2 API and waits for the build to finish. * Used for building source code into container images. */ export async function submitBuild( @@ -170,38 +185,55 @@ export async function submitBuild( build: Build, ): Promise { const res = await client.post( - `/projects/${projectId}/locations/${location}/builds`, + `/projects/${projectId}/locations/${location}/builds:submit`, build, ); - if (res.status !== 200) { - throw new FirebaseError(`Failed to submit build: ${res.status} ${res.body}`); + if (res.body.baseImageWarning) { + logger.warn(res.body.baseImageWarning); + } + const cloudBuild = res.body.buildOperation?.metadata?.build; + if (!cloudBuild) { + throw new FirebaseError("Failed to submit build: no build was returned."); } + // The returned operation can't be polled for regional builds, so poll the build itself. + let status: string | undefined; await pollOperation({ apiOrigin: cloudbuildOrigin(), apiVersion: "v1", - operationResourceName: res.body.buildOperation, + operationResourceName: cloudBuild.name, + masterTimeout: 30 * 60 * 1000, + maxBackoff: 10_000, + doneFn: (b: { status?: string }) => { + status = b.status; + return !PENDING_BUILD_STATUSES.includes(b.status ?? "STATUS_UNKNOWN"); + }, }); + if (status !== "SUCCESS") { + throw new FirebaseError( + `Cloud Build failed with status ${status}. View the build logs at ${cloudBuild.logUrl}`, + ); + } } /** * 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, - /* doNotRecurseIn...*/ "labels", - "annotations", - "tags", - ); - // Always update revision name to ensure null generates a new unique revision name. - fieldMask.push("template.revision"); +export async function updateService( + service: Omit, + opts: { updateMask?: string[]; masterTimeout?: number } = {}, +): Promise { + const updateMask = opts.updateMask ?? [ + ...proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"), + // Always update revision name to ensure null generates a new unique revision name. + "template.revision", + ]; const res = await client.patch, LongRunningOperation>( service.name, service, { queryParams: { - updateMask: fieldMask.join(","), + updateMask: updateMask.join(","), }, }, ); @@ -209,6 +241,7 @@ export async function updateService(service: Omit) apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: opts.masterTimeout, }); return svc; } @@ -222,6 +255,7 @@ export async function createService( location: string, serviceId: string, service: Omit, + opts: { masterTimeout?: number } = {}, ): 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. @@ -240,6 +274,7 @@ export async function createService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: opts.masterTimeout, }); return svc; } @@ -278,10 +313,10 @@ export async function getService( /** * Lists Cloud Run services in the given project. * - * This method only returns services with the "goog-managed-by" label set to + * By default, this method only returns services with the "goog-managed-by" label set to * "cloud-functions" or "firebase-functions". */ -export async function listServices(projectId: string): Promise { +export async function listServices(projectId: string, functionsOnly = true): Promise { const allServices: Service[] = []; let pageToken: string | undefined = undefined; @@ -305,6 +340,7 @@ export async function listServices(projectId: string): Promise { if (res.body.services) { for (const service of res.body.services) { if ( + !functionsOnly || service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" || service.labels?.[CLIENT_NAME_LABEL] === "firebase-functions" From e388293abcf76a35a1bc0847f46b2f2db4d5dd19 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:58:25 -0400 Subject: [PATCH 06/28] Add firebase init run interactive setup --- src/commands/init.ts | 8 ++ src/deploy/run/prereqs.ts | 33 +++++++ src/deploy/run/util.spec.ts | 36 ++++++++ src/deploy/run/util.ts | 27 ++++++ src/init/features/index.ts | 1 + src/init/features/run.spec.ts | 167 ++++++++++++++++++++++++++++++++++ src/init/features/run.ts | 138 ++++++++++++++++++++++++++++ src/init/index.ts | 7 ++ 8 files changed, 417 insertions(+) create mode 100644 src/deploy/run/prereqs.ts create mode 100644 src/deploy/run/util.spec.ts create mode 100644 src/deploy/run/util.ts create mode 100644 src/init/features/run.spec.ts create mode 100644 src/init/features/run.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index b69d7363202..6525c331811 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -101,6 +101,14 @@ let choices: { }, ]; +if (isEnabled("direct_cloud_run")) { + choices.push({ + value: "run", + name: "Cloud Run: Create or update a Cloud Run service and deploy your web app to it", + checked: false, + }); +} + if (isEnabled("fdcwebhooks")) { choices.push({ value: "dataconnect:resolver", diff --git a/src/deploy/run/prereqs.ts b/src/deploy/run/prereqs.ts new file mode 100644 index 00000000000..382f95cbacb --- /dev/null +++ b/src/deploy/run/prereqs.ts @@ -0,0 +1,33 @@ +import { ensure } from "../../ensureApiEnabled"; +import * as artifactregistry from "../../gcp/artifactregistry"; + +export const RUN_PERMISSIONS = [ + "run.services.get", + "run.services.create", + "run.services.update", + "run.services.setIamPolicy", + "run.operations.get", + "cloudbuild.builds.create", + "cloudbuild.builds.get", + "storage.buckets.get", + "storage.buckets.list", + "storage.buckets.create", + "storage.buckets.update", + "storage.objects.create", + "artifactregistry.repositories.get", + "artifactregistry.repositories.create", + "artifactregistry.repositories.downloadArtifacts", + "iam.serviceAccounts.actAs", +]; + +/** + * Ensures that the APIs needed to build and deploy Cloud Run services are enabled. + */ +export async function prereqs(projectId: string): Promise { + await Promise.all([ + ensure(projectId, "run.googleapis.com", "run", true), + ensure(projectId, "cloudbuild.googleapis.com", "cloudbuild", true), + ensure(projectId, "storage.googleapis.com", "storage", true), + artifactregistry.ensureApiEnabled(projectId), + ]); +} diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts new file mode 100644 index 00000000000..f0a11709d16 --- /dev/null +++ b/src/deploy/run/util.spec.ts @@ -0,0 +1,36 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import { getExistingService, mainContainer } from "./util"; + +describe("run util", () => { + afterEach(() => sinon.restore()); + + describe("getExistingService", () => { + it("returns the service when it exists", async () => { + const svc = { name: "projects/p/locations/r/services/s" } as runv2.Service; + sinon.stub(runv2, "getService").resolves(svc); + expect(await getExistingService("p", "r", "s")).to.equal(svc); + }); + + it("returns undefined for services that don't exist", async () => { + sinon.stub(runv2, "getService").rejects({ status: 404 }); + expect(await getExistingService("p", "r", "s")).to.be.undefined; + }); + + it("rethrows other errors", async () => { + sinon.stub(runv2, "getService").rejects({ status: 403 }); + await expect(getExistingService("p", "r", "s")).to.be.rejected; + }); + }); + + describe("mainContainer", () => { + it("returns the container with ports, or else the first one", () => { + const sidecar = { name: "sidecar", image: "s" }; + const main = { name: "main", image: "m", ports: [{ containerPort: 8080 }] }; + expect(mainContainer({ containers: [sidecar, main] })).to.equal(main); + expect(mainContainer({ containers: [sidecar] })).to.equal(sidecar); + expect(mainContainer(undefined)).to.be.undefined; + }); + }); +}); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts new file mode 100644 index 00000000000..7e80f6073c8 --- /dev/null +++ b/src/deploy/run/util.ts @@ -0,0 +1,27 @@ +import { getErrStatus } from "../../error"; +import * as runv2 from "../../gcp/runv2"; + +/** + * Gets a Cloud Run service, or undefined if it doesn't exist yet. + */ +export async function getExistingService( + projectId: string, + region: string, + serviceId: string, +): Promise { + try { + return await runv2.getService(projectId, region, serviceId); + } catch (err: unknown) { + if (getErrStatus(err) === 404) { + return undefined; + } + throw err; + } +} + +/** + * Returns the container that serves traffic. + */ +export function mainContainer(template?: runv2.RevisionTemplate): runv2.Container | undefined { + return template?.containers?.find((c) => c.ports?.length) ?? template?.containers?.[0]; +} diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 3d4ace315ec..9686cb09b47 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -61,3 +61,4 @@ export { actuate as agentSkillsActuate, AgentSkillsInfo, } from "./agentSkills"; +export { askQuestions as runAskQuestions, actuate as runActuate, RunInfo } from "./run"; diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts new file mode 100644 index 00000000000..0b2bd812dc5 --- /dev/null +++ b/src/init/features/run.spec.ts @@ -0,0 +1,167 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Setup } from ".."; +import { Config } from "../../config"; +import * as prereqs from "../../deploy/run/prereqs"; +import * as run from "../../gcp/run"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import * as prompt from "../../prompt"; +import * as requirePermissions from "../../requirePermissions"; +import { actuate, askQuestions, upsertRunConfig } from "./run"; + +describe("init run", () => { + const options = {} as Options; + let config: Config; + let selectStub: sinon.SinonStub; + let inputStub: sinon.SinonStub; + + function setup(): Setup { + return { + config: {}, + rcfile: { projects: {}, targets: {}, etags: {} }, + projectId: "p", + instructions: [], + }; + } + + beforeEach(() => { + config = new Config({}, { projectDir: process.cwd(), cwd: process.cwd() }); + sinon.stub(requirePermissions, "requirePermissions").resolves(); + sinon.stub(prereqs, "prereqs").resolves(); + sinon.stub(run, "listLocations").resolves(["us-central1", "us-east1"]); + sinon.stub(runv2, "getService").rejects({ status: 404 }); + selectStub = sinon.stub(prompt, "select"); + inputStub = sinon.stub(prompt, "input"); + }); + + afterEach(() => sinon.restore()); + + describe("askQuestions", () => { + it("throws if no project is selected", async () => { + await expect( + askQuestions({ ...setup(), projectId: undefined }, config, options), + ).to.be.rejectedWith("Cloud Run requires a Firebase project"); + }); + + it("asks how to create a new service", async () => { + selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-east1"); + inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); + inputStub.onThirdCall().resolves("/"); + const s = setup(); + + await askQuestions(s, config, options); + + expect(selectStub.secondCall.args[0]).to.deep.include({ + choices: ["us-central1", "us-east1"], + default: "us-central1", + }); + expect(inputStub.secondCall.args[0].default).to.equal("nodejs22"); + const validateDir = inputStub.thirdCall.args[0].validate; + expect(validateDir(".")).to.be.true; + expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "my-service", + region: "us-east1", + baseImage: "nodejs22", + rootDir: "/", + }); + }); + + it("rejects invalid or taken service IDs", async () => { + selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-central1"); + inputStub.resolves("/"); + await askQuestions(setup(), config, options); + const validate = inputStub.firstCall.args[0].validate; + + expect(await validate("Bad_Id")).to.be.a("string"); + expect(await validate("ok-id")).to.be.true; + (runv2.getService as sinon.SinonStub).resolves({}); + expect(await validate("ok-id")).to.equal( + "A service named ok-id already exists in us-central1.", + ); + }); + + it("updates an existing service", async () => { + const existing = { + name: "projects/p/locations/europe-west1/services/web", + template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs20" }] }, + }; + const managed = { + name: "projects/p/locations/r/services/f", + labels: { "goog-managed-by": "x" }, + }; + sinon.stub(runv2, "listServices").resolves([existing, managed] as unknown as runv2.Service[]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + expect(runv2.listServices).to.have.been.calledWith("p", false); + expect(selectStub.secondCall.args[0].choices).to.deep.equal([ + { name: "web (europe-west1)", value: existing }, + ]); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "web", + region: "europe-west1", + baseImage: "nodejs20", + rootDir: "/", + }); + }); + + it("creates a service if there are none to update", async () => { + sinon.stub(runv2, "listServices").resolves([]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves("us-central1"); + inputStub.onFirstCall().resolves("my-service"); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); + }); + }); + + describe("actuate", () => { + it("does nothing when featureInfo.run is not set", async () => { + const writeStub = sinon.stub(config, "writeProjectFile"); + await actuate(setup(), config); + expect(writeStub).to.not.have.been.called; + }); + + it("saves the service to firebase.json", async () => { + const writeStub = sinon.stub(config, "writeProjectFile"); + const s = setup(); + s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; + + await actuate(s, config); + + const runConfig = { + serviceId: "s", + rootDir: "/", + region: "r", + ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + }; + expect(config.src.run).to.deep.equal(runConfig); + expect(writeStub).to.have.been.calledWith("firebase.json", config.src); + }); + }); + + describe("upsertRunConfig", () => { + it("adds services, and updates them in place without dropping other settings", () => { + const a = { serviceId: "a", region: "r", rootDir: "/", ignore: ["x"] }; + const b = { serviceId: "b", region: "r" }; + upsertRunConfig(a, config); + expect(config.src.run).to.deep.equal(a); + upsertRunConfig(b, config); + expect(config.src.run).to.deep.equal([a, b]); + config.set("run.0.localBuild", true); + upsertRunConfig({ serviceId: "a", region: "r2", rootDir: "web", ignore: ["y"] }, config); + expect(config.src.run).to.deep.equal([ + { serviceId: "a", region: "r2", rootDir: "web", ignore: ["x"], localBuild: true }, + b, + ]); + }); + }); +}); diff --git a/src/init/features/run.ts b/src/init/features/run.ts new file mode 100644 index 00000000000..ab886bf6d04 --- /dev/null +++ b/src/init/features/run.ts @@ -0,0 +1,138 @@ +import { existsSync } from "fs"; +import * as path from "path"; +import { Setup } from ".."; +import { Config } from "../../config"; +import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; +import { getExistingService, mainContainer } from "../../deploy/run/util"; +import { FirebaseError } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; +import * as run from "../../gcp/run"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { input, select } from "../../prompt"; +import { requirePermissions } from "../../requirePermissions"; +import { logBullet } from "../../utils"; + +export interface RunInfo { + serviceId: string; + region: string; + baseImage: string; + rootDir: string; +} + +/** + * Checks product-level setup, then asks which service to create or update and how to deploy it. + */ +export async function askQuestions(setup: Setup, config: Config, options: Options): Promise { + const projectId = setup.projectId; + if (!projectId) { + throw new FirebaseError("Cloud Run requires a Firebase project. Run firebase use --add first."); + } + await requirePermissions({ ...options, projectId }, RUN_PERMISSIONS); + await prereqs(projectId); + + let existing: runv2.Service | undefined; + const action = await select({ + message: "Please select an option", + choices: [ + { name: "Create a new service and deploy", value: "create" }, + { name: "Update an existing service and deploy", value: "update" }, + ], + default: "create", + }); + if (action === "update") { + // Skip services that another product (e.g. Cloud Functions or App Hosting) manages. + const services = (await runv2.listServices(projectId, /* functionsOnly= */ false)).filter( + (s) => !s.labels?.[runv2.CLIENT_NAME_LABEL], + ); + if (services.length) { + existing = await select({ + message: "Which service would you like to update?", + choices: services.map((s) => { + const [, , , location, , id] = s.name.split("/"); + return { name: `${id} (${location})`, value: s }; + }), + }); + } else { + logBullet("No Cloud Run services to update. Creating a new service instead."); + } + } + + let serviceId: string; + let region: string; + if (existing) { + [, , , region, , serviceId] = existing.name.split("/"); + } else { + region = await select({ + message: "Which region should this service be deployed to?", + choices: await run.listLocations(projectId), + default: "us-central1", + }); + serviceId = await input({ + message: "Please enter a unique ID for your service", + validate: async (id) => { + if (!/^[a-z]([-a-z0-9]{0,47}[a-z0-9])?$/.test(id)) { + return "Use up to 49 lowercase letters, digits, and hyphens, starting with a letter and not ending with a hyphen."; + } + if (await getExistingService(projectId, region, id)) { + return `A service named ${id} already exists in ${region}.`; + } + return true; + }, + }); + } + + const baseImage = await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + }); + const rootDir = await input({ + message: "Specify your app's root directory relative to your firebase.json directory", + default: "/", + validate: (dir) => { + const absPath = path.join(config.projectDir, dir); + return ( + existsSync(absPath) || + `Directory ${absPath} does not exist. Please enter a valid directory.` + ); + }, + }); + + setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; +} + +/** + * Adds the service to firebase.json. + */ +export async function actuate(setup: Setup, config: Config): Promise { + const info = setup.featureInfo?.run; + if (!info) { + return; + } + upsertRunConfig( + { + serviceId: info.serviceId, + rootDir: info.rootDir, + region: info.region, + ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + }, + config, + ); + config.writeProjectFile("firebase.json", config.src); +} + +/** + * Adds a service to firebase.json, or updates it in place. Settings that init doesn't ask + * about (e.g. localBuild or a custom ignore list) are kept. Exported for unit testing. + */ +export function upsertRunConfig(runConfig: RunSingle, config: Config): void { + const services = [config.src.run || []].flat(); + const i = services.findIndex((c) => c.serviceId === runConfig.serviceId); + if (i < 0) { + services.push(runConfig); + } else { + const { rootDir, region } = runConfig; + services[i] = { ...runConfig, ...services[i], rootDir, region }; + } + config.set("run", services.length === 1 ? services[0] : services); +} diff --git a/src/init/index.ts b/src/init/index.ts index dda64f48834..ffac29684f5 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -44,6 +44,7 @@ export interface SetupInfo { ailogic?: features.AiLogicInfo; hosting?: features.HostingInfo; auth?: features.AuthInfo; + run?: features.RunInfo; agentSkills?: features.AgentSkillsInfo; } @@ -134,6 +135,12 @@ const featuresList: Feature[] = [ askQuestions: features.agentSkillsAskQuestions, actuate: features.agentSkillsActuate, }, + { + name: "run", + displayName: "Cloud Run", + askQuestions: features.runAskQuestions, + actuate: features.runActuate, + }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); From 2131e749f705fac87b5e544e2db25ac4edc070a6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:18:36 -0400 Subject: [PATCH 07/28] Add firebase deploy --only run for remote Cloud Build source deploys --- package.json | 1 + scripts/run-deploy-tests/run.sh | 15 +++ scripts/run-deploy-tests/tests.ts | 138 ++++++++++++++++++++++++ src/checkValidTargetFilters.ts | 1 + src/commands/deploy.ts | 3 + src/deploy/index.ts | 5 + src/deploy/run/args.ts | 19 ++++ src/deploy/run/deploy.spec.ts | 169 ++++++++++++++++++++++++++++++ src/deploy/run/deploy.ts | 132 +++++++++++++++++++++++ src/deploy/run/index.ts | 19 ++++ src/deploy/run/prepare.spec.ts | 69 ++++++++++++ src/deploy/run/prepare.ts | 35 +++++++ src/deploy/run/release.ts | 15 +++ src/deploy/run/util.spec.ts | 90 +++++++++++++++- src/deploy/run/util.ts | 75 ++++++++++++- src/init/features/run.spec.ts | 13 ++- src/init/features/run.ts | 10 +- 17 files changed, 802 insertions(+), 7 deletions(-) create mode 100755 scripts/run-deploy-tests/run.sh create mode 100644 scripts/run-deploy-tests/tests.ts create mode 100644 src/deploy/run/args.ts create mode 100644 src/deploy/run/deploy.spec.ts create mode 100644 src/deploy/run/deploy.ts create mode 100644 src/deploy/run/index.ts create mode 100644 src/deploy/run/prepare.spec.ts create mode 100644 src/deploy/run/prepare.ts create mode 100644 src/deploy/run/release.ts diff --git a/package.json b/package.json index 18401c0b178..ac4cfcaa877 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test:functions-discover": "bash ./scripts/functions-discover-tests/run.sh", "test:hosting": "bash ./scripts/hosting-tests/run.sh", "test:hosting-rewrites": "bash ./scripts/hosting-tests/rewrites-tests/run.sh", + "test:run-deploy": "bash ./scripts/run-deploy-tests/run.sh", "test:import-export": "bash ./scripts/emulator-import-export-tests/run.sh", "test:triggers-end-to-end": "bash ./scripts/triggers-end-to-end-tests/run.sh", "test:triggers-end-to-end:inspect": "bash ./scripts/triggers-end-to-end-tests/run.sh inspect", diff --git a/scripts/run-deploy-tests/run.sh b/scripts/run-deploy-tests/run.sh new file mode 100755 index 00000000000..3c2bfeeb741 --- /dev/null +++ b/scripts/run-deploy-tests/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e # Immediately exit on failure + +# Globally link the CLI for the testing framework +./scripts/clean-install.sh + +if [ -f "scripts/set-default-credentials.sh" ]; then + source scripts/set-default-credentials.sh +fi + +echo "======================================" +echo "Starting Cloud Run E2E Test Suite" +echo "======================================" + +mocha scripts/run-deploy-tests/tests.ts --timeout 600000 diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts new file mode 100644 index 00000000000..95f4dcb0eb4 --- /dev/null +++ b/scripts/run-deploy-tests/tests.ts @@ -0,0 +1,138 @@ +import { expect } from "chai"; +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import * as cli from "../functions-deploy-tests/cli"; +import * as runv2 from "../../src/gcp/runv2"; +import { requireAuth } from "../../src/requireAuth"; + +const PROJECT = process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || ""; +const REGION = "us-central1"; +const SERVICE_ID = `run-e2e-${Date.now()}`; + +describe("firebase deploy --only run", function (this: Mocha.Suite) { + this.timeout(600_000); + let workDir: string; + + function firebase(cmd: string, ...args: string[]): Promise { + return cli.exec(cmd, PROJECT, [...args, "--non-interactive"], workDir, false); + } + + /** Checks that the service responds, and returns the Node.js version it runs on. */ + async function expectServing(): Promise { + const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const res = await fetch(uri!); + const body = await res.text(); + expect(body).to.match(/^hello from v\d+\./); + return body.replace("hello from ", ""); + } + + function writeFirebaseJson(run: Record = {}): void { + fs.writeJsonSync(path.join(workDir, "firebase.json"), { + run: { serviceId: SERVICE_ID, rootDir: "/", region: REGION, ...run }, + }); + } + + before(async () => { + expect(PROJECT).to.not.be.empty; + process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + await requireAuth({}); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-")); + const pkg = { name: "run-e2e", version: "1.0.0" }; + fs.writeJsonSync(path.join(workDir, "package.json"), { + ...pkg, + scripts: { start: "node index.js" }, + }); + fs.writeJsonSync(path.join(workDir, "package-lock.json"), { + ...pkg, + lockfileVersion: 3, + packages: { "": pkg }, + }); + fs.writeFileSync( + path.join(workDir, "index.js"), + "require('http').createServer((req, res) => res.end('hello from ' + process.version)).listen(process.env.PORT);\n", + ); + writeFirebaseJson(); + }); + + after(async () => { + await runv2.deleteService(PROJECT, REGION, SERVICE_ID).catch(() => undefined); + fs.removeSync(workDir); + }); + + it("requires the direct_cloud_run experiment", async () => { + delete process.env.FIREBASE_CLI_EXPERIMENTS; + try { + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("experiment direct_cloud_run is not enabled"); + } finally { + process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + } + }); + + it("creates a service from source", async () => { + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include("Deploy complete!"); + + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = service.template.containers![0]; + expect(container.baseImageUri).to.be.undefined; + expect(service.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + await expectServing(); + }); + + it("keeps settings changed outside the CLI on deploy", async () => { + // Simulate a console change to a service-level setting. + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const template = { ...service.template, revision: undefined }; + template.containers![0].resources = { limits: { cpu: "1", memory: "1Gi" } }; + await runv2.updateService({ name: service.name, template }, { updateMask: ["template"] }); + + const res = await firebase("deploy", "--only", `run:${SERVICE_ID}`); + expect(res.proc.exitCode).to.equal(0); + const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = after.template.containers![0]; + expect(container.image).not.to.equal(service.template.containers![0].image); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(after.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + await expectServing(); + }); + + it("rejects services that aren't in firebase.json", async () => { + const res = await firebase("deploy", "--only", "run:not-a-service"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("not-a-service not detected in firebase.json"); + }); + + it("fails without a firebase.json", async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-empty-")); + try { + const res = await cli.exec( + "deploy", + PROJECT, + ["--only", "run", "--non-interactive"], + emptyDir, + ); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("Not in a Firebase app directory"); + } finally { + fs.removeSync(emptyDir); + } + }); + + it("fails with a project that doesn't exist", async () => { + const res = await cli.exec( + "deploy", + "invalid-project-id-1234567890", + ["--only", "run", "--non-interactive"], + workDir, + ); + expect(res.proc.exitCode).not.to.equal(0); + }); +}); diff --git a/src/checkValidTargetFilters.ts b/src/checkValidTargetFilters.ts index 427e8deb3e4..c78014fd81f 100644 --- a/src/checkValidTargetFilters.ts +++ b/src/checkValidTargetFilters.ts @@ -32,6 +32,7 @@ const FILTERABLE_TARGETS = new Set([ "dataconnect", "apphosting", "ailogic", + "run", ]); /** diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index e47f6199f69..cd58e2c36c8 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -88,6 +88,9 @@ command if (options.filteredTargets.includes("ailogic")) { experiments.assertEnabled("ailogic", "deploy AI Logic resources"); } + if (options.filteredTargets.includes("run")) { + experiments.assertEnabled("direct_cloud_run", "deploy Cloud Run services"); + } const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { return perms.concat(TARGET_PERMISSIONS[target as (typeof VALID_DEPLOY_TARGETS)[number]]); }, []); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index c7cae13b2c4..950db40ea2f 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -20,6 +20,8 @@ import * as DataConnectTarget from "./dataconnect"; import * as AppHostingTarget from "./apphosting"; import * as AuthTarget from "./auth"; import * as AiLogicTarget from "./ailogic"; +import * as RunTarget from "./run"; +import { RUN_PERMISSIONS } from "./run/prereqs"; import { prepareFrameworks } from "../frameworks"; import { Context as HostingContext } from "./hosting/context"; import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare"; @@ -44,6 +46,7 @@ export const VALID_DEPLOY_TARGETS = [ "apphosting", "auth", "ailogic", + "run", ] as const; export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = { @@ -105,6 +108,7 @@ export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], s // ensureAILogicApiEnabled reads API enablement state via Service Usage. "serviceusage.services.get", ], + run: RUN_PERMISSIONS, }; export const TARGETS = { @@ -119,6 +123,7 @@ export const TARGETS = { apphosting: AppHostingTarget, auth: AuthTarget, ailogic: AiLogicTarget, + run: RunTarget, }; export type DeployOptions = Options & { dryRun?: boolean }; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts new file mode 100644 index 00000000000..1e7bc87ed6a --- /dev/null +++ b/src/deploy/run/args.ts @@ -0,0 +1,19 @@ +import { RunSingle } from "../../firebaseConfig"; +import * as runv2 from "../../gcp/runv2"; + +export interface ServiceDeploy { + config: RunSingle; + existing?: runv2.Service; + baseImage?: string; + deployed?: runv2.Service; +} + +export interface Payload { + run?: { services: ServiceDeploy[] }; +} + +export interface Context { + projectId: string; + /** Overrides the service's base image. null clears it; undefined keeps the current one. */ + baseImage?: string | null; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts new file mode 100644 index 00000000000..b2a0d9bc869 --- /dev/null +++ b/src/deploy/run/deploy.spec.ts @@ -0,0 +1,169 @@ +import { expect } from "chai"; +import * as fs from "fs"; +import * as sinon from "sinon"; +import * as artifactregistry from "../../gcp/artifactregistry"; +import * as runv2 from "../../gcp/runv2"; +import * as gcs from "../../gcp/storage"; +import * as getProjectNumber from "../../getProjectNumber"; +import { Options } from "../../options"; +import * as apphostingUtil from "../apphosting/util"; +import { ServiceDeploy } from "./args"; +import { deploy } from "./deploy"; + +describe("run deploy", () => { + const options = { config: { projectDir: "/p" } } as unknown as Options; + const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; + let sourceArchiveStub: sinon.SinonStub; + let submitBuildStub: sinon.SinonStub; + let createServiceStub: sinon.SinonStub; + let updateServiceStub: sinon.SinonStub; + + function service(overrides: Partial = {}): ServiceDeploy { + return { config, ...overrides }; + } + + async function deployOne(svc: ServiceDeploy, opts = options) { + await deploy({ projectId: "p" }, opts, { run: { services: [svc] } }); + return svc; + } + + beforeEach(() => { + sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); + sinon.stub(gcs, "upsertBucket").resolves("bucket"); + sinon + .stub(gcs, "uploadObject") + .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); + sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); + sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); + sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); + submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); + createServiceStub = sinon + .stub(runv2, "createService") + .resolves({ uri: "new" } as runv2.Service); + updateServiceStub = sinon.stub(runv2, "updateService").resolves({ uri: "up" } as runv2.Service); + sinon.stub(Date, "now").returns(42); + }); + + afterEach(() => sinon.restore()); + + it("builds source and creates a new service", async () => { + const svc = await deployOne(service({ baseImage: "nodejs22" }), { + ...options, + message: "hi", + } as Options); + + expect(sourceArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, "/p/web"); + const imageUri = "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42"; + expect(submitBuildStub).to.have.been.calledWith("p", "us-central1", { + storageSource: { bucket: "bucket", object: "src.zip" }, + imageUri, + buildpackBuild: { + baseImage: "nodejs22", + enableAutomaticUpdates: true, + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }, + }); + expect(createServiceStub).to.have.been.calledWith("p", "us-central1", "s", { + name: "projects/p/locations/us-central1/services/s", + template: { + containers: [ + { + name: "s", + image: imageUri, + baseImageUri: "nodejs22", + }, + ], + annotations: { "firebase.google.com/deploy-message": "hi" }, + }, + client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }); + expect(svc.deployed).to.deep.equal({ uri: "new" }); + }); + + it("builds without a base image", async () => { + await deployOne(service()); + expect(submitBuildStub.firstCall.args[2].buildpackBuild).to.deep.equal({ + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }); + expect(createServiceStub.firstCall.args[3].template.containers[0]).not.to.have.property( + "baseImageUri", + ); + }); + + it("updates the live revision template of an existing service", async () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { + revision: "s-1", + serviceAccount: "sa", + annotations: { "firebase.google.com/deploy-message": "old", keep: "me" }, + containers: [ + { name: "sidecar", image: "side" }, + { + name: "main", + image: "old", + ports: [{ containerPort: 8080 }], + baseImageUri: "old-base", + env: [ + { name: "B", value: "old" }, + { name: "C", value: "c" }, + ], + }, + ], + }, + trafficStatuses: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", revision: "s-1", percent: 100 }, + ], + } as unknown as runv2.Service; + + await deployOne(service({ existing })); + + const [update, updateOpts] = updateServiceStub.firstCall.args; + expect(updateOpts.updateMask).to.deep.equal(["template", "traffic"]); + expect(update.template).to.deep.equal({ + serviceAccount: "sa", + annotations: { keep: "me" }, + containers: [ + { name: "sidecar", image: "side" }, + { + name: "main", + image: "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42", + ports: [{ containerPort: 8080 }], + env: [ + { name: "B", value: "old" }, + { name: "C", value: "c" }, + ], + }, + ], + }); + expect(update.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + expect(existing.template.revision).to.equal("s-1"); + }); + + it("logs the deployed URL of each service during release", async () => { + const { release } = await import("./release"); + const utils = await import("../../utils"); + const logStub = sinon.stub(utils, "logLabeledSuccess"); + + await release({ projectId: "p" }, options, { + run: { + services: [service({ deployed: { uri: "https://s-123.run.app" } as runv2.Service })], + }, + }); + + expect(logStub).to.have.been.calledOnceWithExactly( + "run", + "Deployed service s to https://s-123.run.app", + ); + }); +}); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts new file mode 100644 index 00000000000..3c5e210df28 --- /dev/null +++ b/src/deploy/run/deploy.ts @@ -0,0 +1,132 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as artifactregistry from "../../gcp/artifactregistry"; +import * as runv2 from "../../gcp/runv2"; +import * as gcs from "../../gcp/storage"; +import { getProjectNumber } from "../../getProjectNumber"; +import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; +import { createSourceDeployArchive } from "../apphosting/util"; +import { Context, Payload, ServiceDeploy } from "./args"; +import { + copyTemplate, + deployRevision, + mainContainer, + SERVICE_OPERATION_TIMEOUT_MS, + toAppHostingConfig, +} from "./util"; + +const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; + +/** + * Builds each service on Cloud Build, uploads it, and deploys it. + */ +export async function deploy(context: Context, options: Options, payload: Payload): Promise { + for (const svc of payload.run?.services || []) { + svc.deployed = await deployService(context, options, svc); + } +} + +async function deployService( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + const { serviceId, region } = svc.config; + const source = await uploadSource(projectId, options, svc); + + const template = svc.existing + ? copyTemplate(svc.existing) + : { containers: [{ name: serviceId, image: "" }] }; + const container = mainContainer(template)!; + container.image = await buildImage(projectId, svc, source); + if (svc.baseImage) { + container.baseImageUri = svc.baseImage; + } else { + delete container.baseImageUri; + } + template.annotations = { ...template.annotations }; + if (options.message) { + template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; + } else { + delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; + if (!Object.keys(template.annotations).length) { + delete template.annotations; + } + } + + if (svc.existing) { + return deployRevision(svc.existing, template); + } + return runv2.createService( + projectId, + region, + serviceId, + { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + template, + client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }, + { masterTimeout: SERVICE_OPERATION_TIMEOUT_MS }, + ); +} + +async function uploadSource( + projectId: string, + options: Options, + svc: ServiceDeploy, +): Promise { + const { serviceId, region } = svc.config; + const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; + const bucketName = await gcs.upsertBucket({ + product: "run", + createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, + projectId, + req: { + baseName, + purposeLabel: `run-source-${region.toLowerCase()}`, + location: region, + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, + }, + }); + + const cfg = toAppHostingConfig(svc.config); + const archive = await createSourceDeployArchive( + cfg, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Uploading source for service ${serviceId}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; +} + +async function buildImage( + projectId: string, + svc: ServiceDeploy, + source: runv2.StorageSource, +): Promise { + const { serviceId, region } = svc.config; + await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy"); + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${serviceId}:${Date.now()}`; + logLabeledBullet("run", `Building service ${serviceId}...`); + await runv2.submitBuild(projectId, region, { + storageSource: source, + imageUri, + buildpackBuild: { + // Only images built for a base image can have their base image updated automatically. + ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }, + }); + return imageUri; +} diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts new file mode 100644 index 00000000000..10b0e3f1346 --- /dev/null +++ b/src/deploy/run/index.ts @@ -0,0 +1,19 @@ +import { prepare } from "./prepare"; +import { deploy } from "./deploy"; +import { release } from "./release"; + +export { prepare, deploy, release }; + +export const help = "Builds and deploys web apps to Cloud Run services listed in firebase.json."; +export const detailedHelp = + "Deploys Cloud Run services from local source, or from a local build if localBuild is set.\n\n" + + "Configuration in firebase.json:\n" + + "{\n" + + ' "run": {\n' + + ' "serviceId": "my-service",\n' + + ' "region": "us-central1",\n' + + ' "rootDir": "/",\n' + + ' "ignore": ["node_modules", ".git"]\n' + + " }\n" + + "}\n\n" + + "Base images are configured on the service with firebase run:services:update --base-image and are reused on every deploy."; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts new file mode 100644 index 00000000000..88ecc5a397f --- /dev/null +++ b/src/deploy/run/prepare.spec.ts @@ -0,0 +1,69 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { Context, Payload } from "./args"; +import { prepare } from "./prepare"; +import * as prereqs from "./prereqs"; + +describe("run prepare", () => { + const nodejs22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "i", baseImageUri: nodejs22 }] }, + } as unknown as runv2.Service; + let prereqsStub: sinon.SinonStub; + let getServiceStub: sinon.SinonStub; + + const options = (run: Record): Options => + ({ + config: { src: { run: { serviceId: "s", region: "us-central1", ...run } }, projectDir: "/p" }, + }) as unknown as Options; + + async function prepareOne(run: Record = {}, context: Partial = {}) { + const payload: Payload = {}; + await prepare({ projectId: "p", ...context }, options(run), payload); + return payload.run!.services[0]; + } + + beforeEach(() => { + prereqsStub = sinon.stub(prereqs, "prereqs").resolves(); + getServiceStub = sinon.stub(runv2, "getService").rejects({ status: 404 }); + }); + + afterEach(() => sinon.restore()); + + it("does nothing if no services are being deployed", async () => { + const payload: Payload = {}; + await prepare({ projectId: "p" }, { config: { src: {} } } as unknown as Options, payload); + expect(prereqsStub).not.to.have.been.called; + expect(payload.run).to.be.undefined; + }); + + it("requires a region", async () => { + await expect(prepareOne({ region: undefined })).to.be.rejectedWith( + "Cloud Run service s is missing a region in firebase.json.", + ); + }); + + it("reads the service", async () => { + const svc = await prepareOne(); + expect(prereqsStub).to.have.been.calledWith("p"); + expect(getServiceStub).to.have.been.calledWith("p", "us-central1", "s"); + expect(svc.existing).to.be.undefined; + expect(svc.baseImage).to.be.undefined; + }); + + it("reuses the service's current base image", async () => { + getServiceStub.resolves(existing); + const svc = await prepareOne(); + expect(svc.existing).to.equal(existing); + expect(svc.baseImage).to.equal(nodejs22); + }); + + it("lets the context set or clear the base image", async () => { + getServiceStub.resolves(existing); + expect((await prepareOne({}, { baseImage: "nodejs20" })).baseImage).to.equal("nodejs20"); + expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; + }); +}); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts new file mode 100644 index 00000000000..7291d8a03ce --- /dev/null +++ b/src/deploy/run/prepare.ts @@ -0,0 +1,35 @@ +import { FirebaseError } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; +import { Options } from "../../options"; +import { Context, Payload, ServiceDeploy } from "./args"; +import { prereqs } from "./prereqs"; +import { getExistingService, getServiceConfigs, mainContainer } from "./util"; + +/** + * Reads each service's current state from Cloud Run and resolves its base image. + */ +export async function prepare(context: Context, options: Options, payload: Payload): Promise { + const configs = getServiceConfigs(options); + if (!configs.length) { + return; + } + await prereqs(context.projectId); + payload.run = { services: [] }; + for (const config of configs) { + payload.run.services.push(await prepareService(context, config)); + } +} + +async function prepareService(context: Context, config: RunSingle): Promise { + const { serviceId, region } = config; + if (!region) { + throw new FirebaseError(`Cloud Run service ${serviceId} is missing a region in firebase.json.`); + } + const existing = await getExistingService(context.projectId, region, serviceId); + // Base images are sticky: deploys reuse the service's current base image unless told otherwise. + const baseImage = + context.baseImage === undefined + ? mainContainer(existing?.template)?.baseImageUri + : context.baseImage || undefined; + return { config, existing, baseImage }; +} diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts new file mode 100644 index 00000000000..5c32bc9b075 --- /dev/null +++ b/src/deploy/run/release.ts @@ -0,0 +1,15 @@ +import { logLabeledSuccess } from "../../utils"; +import { Payload } from "./args"; + +/** + * Logs where each deployed service is available. + */ +export async function release( + _context: unknown, + _options: unknown, + payload: Payload, +): Promise { + for (const { config, deployed } of payload.run?.services || []) { + logLabeledSuccess("run", `Deployed service ${config.serviceId} to ${deployed?.uri}`); + } +} diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index f0a11709d16..2e11f6263ae 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -1,11 +1,59 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; -import { getExistingService, mainContainer } from "./util"; +import { Options } from "../../options"; +import { + copyTemplate, + deployRevision, + getExistingService, + getServiceConfigs, + mainContainer, + toAppHostingConfig, +} from "./util"; describe("run util", () => { afterEach(() => sinon.restore()); + describe("getServiceConfigs", () => { + const a = { serviceId: "a", region: "us-central1" }; + const b = { serviceId: "b", region: "us-east1" }; + const opts = (run: unknown, only?: string) => ({ config: { src: { run } }, only }) as Options; + + it("returns all services when --only is 'run' or unset", () => { + expect(getServiceConfigs(opts([a, b], "run"))).to.deep.equal([a, b]); + expect(getServiceConfigs(opts(a))).to.deep.equal([a]); + expect(getServiceConfigs(opts(undefined))).to.deep.equal([]); + }); + + it("filters to services named in --only", () => { + expect(getServiceConfigs(opts([a, b], "hosting,run:b"))).to.deep.equal([b]); + }); + + it("throws when --only names a service not in firebase.json", () => { + expect(() => getServiceConfigs(opts([a], "run:a,run:missing"))).to.throw( + "Cloud Run service IDs missing not detected in firebase.json", + ); + }); + }); + + describe("toAppHostingConfig", () => { + it("adapts RunSingle to AppHostingSingle and defaults rootDir to empty string", () => { + expect( + toAppHostingConfig({ + serviceId: "s", + region: "us-central1", + ignore: ["node_modules"], + localBuild: true, + }), + ).to.deep.equal({ + backendId: "s", + rootDir: "", + ignore: ["node_modules"], + localBuild: true, + }); + }); + }); + describe("getExistingService", () => { it("returns the service when it exists", async () => { const svc = { name: "projects/p/locations/r/services/s" } as runv2.Service; @@ -33,4 +81,44 @@ describe("run util", () => { expect(mainContainer(undefined)).to.be.undefined; }); }); + + describe("copyTemplate and deployRevision", () => { + it("clones the template without the revision name and sends 100% traffic to LATEST", async () => { + const updateStub = sinon.stub(runv2, "updateService").resolves({} as runv2.Service); + const service = { + name: "projects/p/locations/r/services/s", + template: { revision: "s-001", containers: [{ name: "s", image: "old" }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "s-001", percent: 100 }, + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", + revision: "s-001", + tag: "canary", + percent: 0, + }, + ], + } as unknown as runv2.Service; + + const template = copyTemplate(service); + template.containers![0].image = "new"; + await deployRevision(service, template); + + expect(service.template.revision).to.equal("s-001"); + expect(service.template.containers![0].image).to.equal("old"); + expect(updateStub).to.have.been.calledWith( + { + name: "projects/p/locations/r/services/s", + template: { containers: [{ name: "s", image: "new" }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "s-001", tag: "canary" }, + ], + }, + { + updateMask: ["template", "traffic"], + masterTimeout: 600000, + }, + ); + }); + }); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 7e80f6073c8..9cea23cf881 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,5 +1,42 @@ -import { getErrStatus } from "../../error"; +import { FirebaseError, getErrStatus } from "../../error"; +import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { cloneDeep } from "../../utils"; + +/** Rolling out a new revision can take longer than the operation poller's default timeout. */ +export const SERVICE_OPERATION_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Returns the Cloud Run services in firebase.json that match the --only filter. + */ +export function getServiceConfigs(options: Options): RunSingle[] { + const configs = [options.config.src.run || []].flat(); + const selectors = options.only ? options.only.split(",") : ["run"]; + if (selectors.includes("run")) { + return configs; + } + const serviceIds = selectors.filter((s) => s.startsWith("run:")).map((s) => s.slice(4)); + const missing = serviceIds.filter((id) => !configs.some((c) => c.serviceId === id)); + if (missing.length) { + throw new FirebaseError( + `Cloud Run service IDs ${missing.join(",")} not detected in firebase.json`, + ); + } + return configs.filter((c) => serviceIds.includes(c.serviceId)); +} + +/** + * Adapts a Cloud Run config so that it can reuse App Hosting's local build and archive helpers. + */ +export function toAppHostingConfig(config: RunSingle): AppHostingSingle { + return { + backendId: config.serviceId, + rootDir: config.rootDir || "", + ignore: config.ignore as string[], + localBuild: config.localBuild, + }; +} /** * Gets a Cloud Run service, or undefined if it doesn't exist yet. @@ -25,3 +62,39 @@ export async function getExistingService( export function mainContainer(template?: runv2.RevisionTemplate): runv2.Container | undefined { return template?.containers?.find((c) => c.ports?.length) ?? template?.containers?.[0]; } + +/** + * Copies a service's revision template so that updating it creates a new revision. + */ +export function copyTemplate(service: runv2.Service): runv2.RevisionTemplate { + const template = cloneDeep(service.template); + delete template.revision; + return template; +} + +/** + * Deploys a new revision of an existing service and sends it all traffic. + */ +export function deployRevision( + service: runv2.Service, + template: runv2.RevisionTemplate, +): Promise { + const tags = (service.traffic || []) + .filter((t) => t.tag) + .map((t) => { + const tag = { ...t }; + delete tag.percent; + return tag; + }); + return runv2.updateService( + { + name: service.name, + template, + traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], + }, + { + updateMask: ["template", "traffic"], + masterTimeout: SERVICE_OPERATION_TIMEOUT_MS, + }, + ); +} diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b2bd812dc5..0b60f21efd6 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { Setup } from ".."; import { Config } from "../../config"; +import * as deploy from "../../deploy"; import * as prereqs from "../../deploy/run/prereqs"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; @@ -126,16 +127,17 @@ describe("init run", () => { describe("actuate", () => { it("does nothing when featureInfo.run is not set", async () => { const writeStub = sinon.stub(config, "writeProjectFile"); - await actuate(setup(), config); + await actuate(setup(), config, options); expect(writeStub).to.not.have.been.called; }); - it("saves the service to firebase.json", async () => { + it("saves the service to firebase.json and deploys it", async () => { const writeStub = sinon.stub(config, "writeProjectFile"); + const deployStub = sinon.stub(deploy, "deploy").resolves(); const s = setup(); s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; - await actuate(s, config); + await actuate(s, config, options); const runConfig = { serviceId: "s", @@ -145,6 +147,11 @@ describe("init run", () => { }; expect(config.src.run).to.deep.equal(runConfig); expect(writeStub).to.have.been.calledWith("firebase.json", config.src); + expect(deployStub).to.have.been.calledWith( + ["run"], + { projectId: "p", config, only: "run:s" }, + { baseImage: null }, + ); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index ab886bf6d04..e7b8017463a 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; +import { deploy } from "../../deploy"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; import { getExistingService, mainContainer } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; @@ -102,9 +103,9 @@ export async function askQuestions(setup: Setup, config: Config, options: Option } /** - * Adds the service to firebase.json. + * Adds the service to firebase.json and deploys it, since Cloud Run services only change on deploy. */ -export async function actuate(setup: Setup, config: Config): Promise { +export async function actuate(setup: Setup, config: Config, options: Options): Promise { const info = setup.featureInfo?.run; if (!info) { return; @@ -119,6 +120,11 @@ export async function actuate(setup: Setup, config: Config): Promise { config, ); config.writeProjectFile("firebase.json", config.src); + await deploy( + ["run"], + { ...options, projectId: setup.projectId, config, only: `run:${info.serviceId}` }, + { baseImage: info.baseImage || null }, + ); } /** From 595ee570bd2ee64f56798f070116a5f35f6bd9fa Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:23:49 -0400 Subject: [PATCH 08/28] Add firebase run:services:update command --- scripts/run-deploy-tests/tests.ts | 59 +++++++++++++++++++++-- src/commands/index.ts | 5 ++ src/commands/run-services-update.ts | 17 +++++++ src/deploy/run/update.spec.ts | 72 +++++++++++++++++++++++++++++ src/deploy/run/update.ts | 44 ++++++++++++++++++ src/deploy/run/util.ts | 9 ++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/commands/run-services-update.ts create mode 100644 src/deploy/run/update.spec.ts create mode 100644 src/deploy/run/update.ts diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 95f4dcb0eb4..b607db9952e 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -71,6 +71,8 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { } }); + let defaultNodeVersion: string; + it("creates a service from source", async () => { const res = await firebase("deploy", "--only", "run"); expect(res.proc.exitCode).to.equal(0); @@ -82,10 +84,41 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(service.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + defaultNodeVersion = await expectServing(); + }); + + /** + * Runs run:services:update and checks that it rebuilt and deployed the service like deploy + * does. Returns the new main container and the Node.js version the service now runs on. + */ + async function expectUpdated( + ...args: string[] + ): Promise<{ container: runv2.Container; nodeVersion: string }> { + const before = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const res = await firebase("run:services:update", SERVICE_ID, ...args); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include("Deploy complete!"); + + const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = after.template.containers![0]; + const oldContainer = before.template.containers![0]; + expect([container.image, container.sourceCode]).not.to.deep.equal([ + oldContainer.image, + oldContainer.sourceCode, + ]); + expect(after.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + return { container, nodeVersion: await expectServing() }; + } + + it("sets a base image, then rebuilds and deploys", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs22"); + expect(container.baseImageUri).to.include("nodejs22"); + expect(nodeVersion).to.match(/^v22\./); }); - it("keeps settings changed outside the CLI on deploy", async () => { + it("keeps the base image and settings changed outside the CLI on deploy", async () => { // Simulate a console change to a service-level setting. const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); const template = { ...service.template, revision: undefined }; @@ -97,11 +130,31 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); const container = after.template.containers![0]; expect(container.image).not.to.equal(service.template.containers![0].image); + expect(container.baseImageUri).to.include("nodejs22"); expect(container.resources?.limits?.memory).to.equal("1Gi"); expect(after.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + expect(await expectServing()).to.match(/^v22\./); + }); + + it("changes the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs20"); + expect(container.baseImageUri).to.include("nodejs20"); + expect(nodeVersion).to.match(/^v20\./); + }); + + it("clears the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--clear-base-image"); + expect(container).not.to.have.property("baseImageUri"); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(nodeVersion.split(".")[0]).to.equal(defaultNodeVersion.split(".")[0]); + }); + + it("requires a setting to update", async () => { + const res = await firebase("run:services:update", SERVICE_ID); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("Specify a setting to update:"); }); it("rejects services that aren't in firebase.json", async () => { diff --git a/src/commands/index.ts b/src/commands/index.ts index 0878d348906..f4520625b4d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,6 +301,11 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); + if (experiments.isEnabled("direct_cloud_run")) { + client.run = {}; + client.run.services = {}; + client.run.services.update = loadCommand("run-services-update"); + } client.serve = loadCommand("serve"); client.setup = {}; client.setup.emulators = {}; diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts new file mode 100644 index 00000000000..119a730287e --- /dev/null +++ b/src/commands/run-services-update.ts @@ -0,0 +1,17 @@ +import { Command } from "../command"; +import { TARGET_PERMISSIONS } from "../deploy"; +import { updateService } from "../deploy/run/update"; +import { Options } from "../options"; +import { requireConfig } from "../requireConfig"; +import { requirePermissions } from "../requirePermissions"; + +export const command = new Command("run:services:update ") + .description("update the settings of a Cloud Run service, then build and deploy it") + .option( + "--base-image ", + "set the base image (e.g. nodejs22) and enable automatic base image updates", + ) + .option("--clear-base-image", "clear the base image and disable automatic base image updates") + .before(requireConfig) + .before(requirePermissions, TARGET_PERMISSIONS.run) + .action((serviceId: string, options: Options) => updateService(serviceId, options)); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts new file mode 100644 index 00000000000..0b805df910e --- /dev/null +++ b/src/deploy/run/update.spec.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as deployIndex from ".."; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { updateService } from "./update"; + +describe("updateService", () => { + const service = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "i", baseImageUri: "nodejs22" }] }, + } as unknown as runv2.Service; + let getServiceStub: sinon.SinonStub; + let deployStub: sinon.SinonStub; + + function options(opts: Record = {}, run: Record = {}): Options { + return { + project: "p", + config: { src: { run: { serviceId: "s", region: "us-central1", ...run } } }, + ...opts, + } as unknown as Options; + } + + beforeEach(() => { + getServiceStub = sinon.stub(runv2, "getService").resolves(service); + deployStub = sinon.stub(deployIndex, "deploy").resolves(); + }); + + afterEach(() => sinon.restore()); + + it("requires exactly one setting", async () => { + await expect(updateService("s", options())).to.be.rejectedWith( + "--base-image or --clear-base-image", + ); + const both = options({ baseImage: "nodejs20", clearBaseImage: true }); + await expect(updateService("s", both)).to.be.rejectedWith("not both"); + }); + + it("requires the service to be in firebase.json", async () => { + await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( + "Cloud Run service IDs other not detected in firebase.json", + ); + }); + + it("requires the service to exist", async () => { + getServiceStub.rejects({ status: 404 }); + await expect(updateService("s", options({ baseImage: "nodejs22" }))).to.be.rejectedWith( + /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s/, + ); + expect(deployStub).not.to.have.been.called; + }); + + it("builds and deploys the service with the new base image", async () => { + await updateService("s", options({ baseImage: "nodejs20" })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: "nodejs20", + }); + }); + + it("builds and deploys the service without a base image when clearing it", async () => { + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: null, + }); + }); + + it("does nothing when clearing a base image that isn't set", async () => { + getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).not.to.have.been.called; + }); +}); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts new file mode 100644 index 00000000000..65241e9f558 --- /dev/null +++ b/src/deploy/run/update.ts @@ -0,0 +1,44 @@ +import * as clc from "colorette"; +import { deploy } from ".."; +import { FirebaseError } from "../../error"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { logBullet } from "../../utils"; +import { + getExistingService, + getServiceConfigs, + mainContainer, + missingServiceMessage, +} from "./util"; + +/** + * Updates a service's settings, then builds and deploys it like `firebase deploy` does. + * Changing the base image always needs a rebuild: images built for a base image leave out the + * OS and runtime, and images built without one bring their own, which override the base image. + */ +export async function updateService(serviceId: string, options: Options): Promise { + const newBaseImage = options.baseImage as string | undefined; + const clearBaseImage = !!options.clearBaseImage; + if (newBaseImage && clearBaseImage) { + throw new FirebaseError("Use either --base-image or --clear-base-image, not both."); + } + if (!newBaseImage && !clearBaseImage) { + throw new FirebaseError( + "Specify a setting to update: --base-image or --clear-base-image.", + ); + } + + const projectId = needProjectId(options); + const only = `run:${serviceId}`; + const [config] = getServiceConfigs({ ...options, only }); + const existing = await getExistingService(projectId, config.region, serviceId); + if (!existing) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); + return; + } + + await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); +} diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 9cea23cf881..3943f0e18b9 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,3 +1,4 @@ +import * as clc from "colorette"; import { FirebaseError, getErrStatus } from "../../error"; import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; @@ -56,6 +57,14 @@ export async function getExistingService( } } +/** + * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. + */ +export function missingServiceMessage(config: RunSingle): string { + const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; +} + /** * Returns the container that serves traffic. */ From 60f5d65a3e5c1b1b30fc9f6658624fbf22c7103f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:14:00 -0400 Subject: [PATCH 09/28] feat(run): add local build support and build environment variables --- scripts/run-deploy-tests/tests.ts | 84 +++++++++++++++++- src/deploy/apphosting/prepare.ts | 2 +- src/deploy/run/args.ts | 10 +++ src/deploy/run/buildEnv.spec.ts | 88 ++++++++++++++++++ src/deploy/run/buildEnv.ts | 89 +++++++++++++++++++ src/deploy/run/deploy.spec.ts | 143 +++++++++++++++++++++++++++++- src/deploy/run/deploy.ts | 108 +++++++++++++++++++--- src/deploy/run/prepare.spec.ts | 52 +++++++++++ src/deploy/run/prepare.ts | 47 +++++++++- src/deploy/run/update.spec.ts | 5 ++ src/deploy/run/update.ts | 3 + src/deploy/run/util.ts | 4 +- src/init/features/run.spec.ts | 31 ++++++- src/init/features/run.ts | 30 ++++++- 14 files changed, 671 insertions(+), 25 deletions(-) create mode 100644 src/deploy/run/buildEnv.spec.ts create mode 100644 src/deploy/run/buildEnv.ts diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index b607db9952e..f51937a9a80 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -4,11 +4,15 @@ import * as os from "os"; import * as path from "path"; import * as cli from "../functions-deploy-tests/cli"; import * as runv2 from "../../src/gcp/runv2"; +import * as secretManager from "../../src/gcp/secretManager"; +import { BUILD_ENV_ANNOTATION } from "../../src/deploy/run/buildEnv"; import { requireAuth } from "../../src/requireAuth"; const PROJECT = process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || ""; const REGION = "us-central1"; const SERVICE_ID = `run-e2e-${Date.now()}`; +const SECRET_ID = SERVICE_ID; +const SECRET_VALUE = `secret-${Date.now()}`; describe("firebase deploy --only run", function (this: Mocha.Suite) { this.timeout(600_000); @@ -18,6 +22,11 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { return cli.exec(cmd, PROJECT, [...args, "--non-interactive"], workDir, false); } + async function mainContainer(): Promise { + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + return service.template.containers![0]; + } + /** Checks that the service responds, and returns the Node.js version it runs on. */ async function expectServing(): Promise { const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); @@ -27,6 +36,24 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { return body.replace("hello from ", ""); } + /** Returns the value of BUILD_VALUE that the service's last build saw. */ + async function builtValue(): Promise { + const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); + return (await fetch(`${uri!}/build`)).text(); + } + + /** Sets the service's build env annotation, keeping its other annotations. */ + async function setBuildEnv(env: Record): Promise { + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + await runv2.updateService( + { + name: service.name, + annotations: { ...service.annotations, [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + } as unknown as runv2.Service, + { updateMask: ["annotations"] }, + ); + } + function writeFirebaseJson(run: Record = {}): void { fs.writeJsonSync(path.join(workDir, "firebase.json"), { run: { serviceId: SERVICE_ID, rootDir: "/", region: REGION, ...run }, @@ -41,7 +68,10 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const pkg = { name: "run-e2e", version: "1.0.0" }; fs.writeJsonSync(path.join(workDir, "package.json"), { ...pkg, - scripts: { start: "node index.js" }, + scripts: { + start: "node index.js", + build: `node -e "require('fs').writeFileSync('build.txt', process.env.BUILD_VALUE || '')"`, + }, }); fs.writeJsonSync(path.join(workDir, "package-lock.json"), { ...pkg, @@ -50,13 +80,21 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { }); fs.writeFileSync( path.join(workDir, "index.js"), - "require('http').createServer((req, res) => res.end('hello from ' + process.version)).listen(process.env.PORT);\n", + [ + 'const fs = require("fs");', + "require('http').createServer((req, res) => res.end(req.url === '/build'", + " ? (fs.existsSync('build.txt') ? fs.readFileSync('build.txt', 'utf8') : 'not built')", + " : 'hello from ' + process.version)).listen(process.env.PORT);", + ].join("\n"), ); writeFirebaseJson(); + await secretManager.createSecret(PROJECT, SECRET_ID, {}); + await secretManager.addVersion(PROJECT, SECRET_ID, SECRET_VALUE); }); after(async () => { await runv2.deleteService(PROJECT, REGION, SERVICE_ID).catch(() => undefined); + await secretManager.deleteSecret(PROJECT, SECRET_ID).catch(() => undefined); fs.removeSync(workDir); }); @@ -85,6 +123,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); defaultNodeVersion = await expectServing(); + expect(await builtValue()).to.equal(""); }); /** @@ -102,6 +141,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); const container = after.template.containers![0]; const oldContainer = before.template.containers![0]; + // Cloud Build deploys push a new image; local builds upload new source. expect([container.image, container.sourceCode]).not.to.deep.equal([ oldContainer.image, oldContainer.sourceCode, @@ -151,12 +191,52 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(nodeVersion.split(".")[0]).to.equal(defaultNodeVersion.split(".")[0]); }); + it("passes build env from the service to Cloud Build", async () => { + await setBuildEnv({ BUILD_VALUE: "plain" }); + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include(`Using build environment variables from ${BUILD_ENV_ANNOTATION}`); + expect(await builtValue()).to.equal("plain"); + // Deploys don't touch service-level annotations. + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + expect(service.annotations?.[BUILD_ENV_ANNOTATION]).to.equal('{"BUILD_VALUE":"plain"}'); + }); + + it("rejects build secrets on Cloud Build", async () => { + await setBuildEnv({ BUILD_VALUE: { secret: SECRET_ID, version: "1" } }); + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("has build secrets (BUILD_VALUE)"); + }); + it("requires a setting to update", async () => { const res = await firebase("run:services:update", SERVICE_ID); expect(res.proc.exitCode).not.to.equal(0); expect(res.stdout + res.stderr).to.include("Specify a setting to update:"); }); + it("requires a base image for local builds", async () => { + writeFirebaseJson({ localBuild: true }); + const failed = await firebase("deploy", "--only", "run"); + expect(failed.proc.exitCode).not.to.equal(0); + expect(failed.stdout + failed.stderr).to.include("Local builds require a base image"); + }); + + it("sets the base image of a local build, then builds locally with secrets and deploys", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs22"); + expect(container.image).to.equal("scratch"); + expect(container.sourceCode).to.exist; + expect(container.baseImageUri).to.include("nodejs22"); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(nodeVersion).to.match(/^v22\./); + expect(await builtValue()).to.equal(SECRET_VALUE); + + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect((await mainContainer()).baseImageUri).to.include("nodejs22"); + expect(await expectServing()).to.match(/^v22\./); + }); + it("rejects services that aren't in firebase.json", async () => { const res = await firebase("deploy", "--only", "run:not-a-service"); expect(res.proc.exitCode).not.to.equal(0); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 55650cf50e2..b0f76ebb6a2 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -432,7 +432,7 @@ async function ensureAppHostingServiceAgentRoles( * isolated scratch workspace in the `.local_build_` folder that contains exactly the same * source files that would be uploaded to Cloud Build. */ -async function prepareLocalBuildScratchDirectory( +export async function prepareLocalBuildScratchDirectory( rootDir: string, localBuildScratchDir: string, cfg: AppHostingSingle, diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 1e7bc87ed6a..6df96e8b8af 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,10 +1,20 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { BuildEnv } from "./buildEnv"; export interface ServiceDeploy { config: RunSingle; existing?: runv2.Service; baseImage?: string; + /** Build-time environment. Prepare only allows secrets in it for local builds. */ + buildEnv?: BuildEnv; + /** The local build's output, set during deploy. */ + localBuild?: { + scratchDir: string; + outputFiles: string[]; + runCommand?: string; + env?: NonNullable; + }; deployed?: runv2.Service; } diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts new file mode 100644 index 00000000000..00c50090b8f --- /dev/null +++ b/src/deploy/run/buildEnv.spec.ts @@ -0,0 +1,88 @@ +import { expect } from "chai"; +import * as runv2 from "../../gcp/runv2"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; + +describe("run buildEnv", () => { + const withAnnotation = (value: string): runv2.Service => + ({ + name: "projects/p/locations/us-central1/services/s", + annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, + }) as unknown as runv2.Service; + + describe("getBuildEnv", () => { + it("returns {} without a service or annotation", () => { + expect(getBuildEnv(undefined)).to.deep.equal({}); + expect( + getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), + ).to.deep.equal({}); + }); + + it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { + expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); + }); + + it("parses secret references", () => { + const env = { + A: "1", + TOKEN: { secret: "npm-token", version: "3" }, + LATEST: { secret: "s" }, + OTHER: { secret: "projects/q/secrets/t", version: "latest" }, + }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); + }); + + it("rejects invalid JSON and non-objects", () => { + expect(() => getBuildEnv(withAnnotation("{"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, + ); + for (const value of ["[]", "null", '"x"', "1"]) { + expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); + } + }); + + it("rejects invalid values", () => { + const invalid = [ + 1, + null, + ["x"], + {}, + { secret: 1 }, + { secret: "a/b" }, + { secret: "a@1" }, + { secret: "projects/q/secrets/t/versions/1" }, + { secret: "s", version: 1 }, + { secret: "s", version: "one" }, + { secret: "s", extra: "x" }, + ]; + for (const value of invalid) { + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( + 'A must be a string or {"secret": "", "version": ""}.', + ); + } + }); + }); + + it("lists secret names", () => { + expect( + secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), + ).to.deep.equal(["B", "C"]); + }); + + it("converts to local build env, available only to the build", () => { + expect( + toLocalBuildEnv({ + A: "1", + B: { secret: "s" }, + C: { secret: "t", version: "2" }, + D: { secret: "projects/q/secrets/u" }, + E: { secret: "projects/q/secrets/v", version: "4" }, + }), + ).to.deep.equal({ + A: { value: "1", availability: ["BUILD"] }, + B: { secret: "s@latest", availability: ["BUILD"] }, + C: { secret: "t@2", availability: ["BUILD"] }, + D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, + E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, + }); + }); +}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts new file mode 100644 index 00000000000..5ba9d8cb263 --- /dev/null +++ b/src/deploy/run/buildEnv.ts @@ -0,0 +1,89 @@ +import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; +import { EnvMap } from "../../apphosting/yaml"; + +/** + * Prototype: build-time environment variables and secret references, stored on the Cloud Run + * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): + * the same map parses unchanged, and a value can also be a secret reference in the shape of + * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. + * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} + */ +export const BUILD_ENV_ANNOTATION = + "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; + +/** A Secret Manager secret, as a name in the service's project or a full resource name. */ +export interface SecretRef { + secret: string; + /** A version number or "latest". Defaults to "latest". */ + version?: string; +} + +export type BuildEnv = Record; + +const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; +const SECRET_VERSION = /^(?:latest|\d+)$/; + +/** Reads and validates the service's build environment. Returns {} if it has none. */ +export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { + const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; + if (!service || !raw) { + return {}; + } + const invalid = (reason: string): FirebaseError => + new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw invalid("it isn't valid JSON."); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw invalid("it must be a JSON object."); + } + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string") { + continue; + } + const ref = value as Partial | null; + const valid = + typeof ref === "object" && + ref !== null && + !Array.isArray(ref) && + Object.keys(ref).every((k) => k === "secret" || k === "version") && + typeof ref.secret === "string" && + SECRET_NAME.test(ref.secret) && + (ref.version === undefined || + (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); + if (!valid) { + throw invalid( + `${name} must be a string or {"secret": "", "version": ""}.`, + ); + } + } + return parsed as BuildEnv; +} + +/** Names of the variables that are secret references. */ +export function secretNames(env: BuildEnv): string[] { + return Object.entries(env) + .filter(([, value]) => typeof value !== "string") + .map(([name]) => name); +} + +/** Converts the build environment to the form local builds take; they resolve the secrets. */ +export function toLocalBuildEnv(env: BuildEnv): EnvMap { + const result: EnvMap = {}; + for (const [name, value] of Object.entries(env)) { + if (typeof value === "string") { + result[name] = { value, availability: ["BUILD"] }; + } else { + const version = value.version || "latest"; + const secret = value.secret.startsWith("projects/") + ? `${value.secret}/versions/${version}` + : `${value.secret}@${version}`; + result[name] = { secret, availability: ["BUILD"] }; + } + } + return result; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b2a0d9bc869..4f26c4f1ad4 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,11 +1,13 @@ import { expect } from "chai"; import * as fs from "fs"; import * as sinon from "sinon"; +import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import * as getProjectNumber from "../../getProjectNumber"; import { Options } from "../../options"; +import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; import { deploy } from "./deploy"; @@ -14,6 +16,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; + let tarArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -22,7 +25,7 @@ describe("run deploy", () => { return { config, ...overrides }; } - async function deployOne(svc: ServiceDeploy, opts = options) { + async function deployOne(svc: ServiceDeploy, opts = options): Promise { await deploy({ projectId: "p" }, opts, { run: { services: [svc] } }); return svc; } @@ -35,6 +38,9 @@ describe("run deploy", () => { .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); + tarArchiveStub = sinon + .stub(apphostingUtil, "createLocalBuildTarArchive") + .resolves("out.tar.gz"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); createServiceStub = sinon @@ -98,6 +104,19 @@ describe("run deploy", () => { ); }); + it("passes build env to the build", async () => { + await deployOne(service({ baseImage: "nodejs22", buildEnv: { A: "1" } })); + expect(submitBuildStub.firstCall.args[2].buildpackBuild).to.deep.equal({ + baseImage: "nodejs22", + enableAutomaticUpdates: true, + environmentVariables: { + A: "1", + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }); + }); + it("updates the live revision template of an existing service", async () => { const existing = { name: "projects/p/locations/us-central1/services/s", @@ -150,6 +169,128 @@ describe("run deploy", () => { expect(existing.template.revision).to.equal("s-1"); }); + describe("local builds", () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "old" }] }, + } as unknown as runv2.Service; + const localService = (overrides: Partial = {}): ServiceDeploy => + service({ + config: { ...config, localBuild: true }, + existing, + baseImage: "nodejs22", + ...overrides, + }); + let validateNodeStub: sinon.SinonStub; + let localBuildStub: sinon.SinonStub; + let mkdtemp: sinon.SinonSpy; + + beforeEach(() => { + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ + outputFiles: [".next"], + buildConfig: { runCommand: "node server.js" }, + }); + mkdtemp = sinon.spy(fs, "mkdtempSync"); + }); + + it("builds locally and deploys the output without building on Cloud Build", async () => { + await deployOne(localService()); + + const scratchDir = mkdtemp.firstCall.returnValue as string; + expect(validateNodeStub).to.have.been.calledWith( + { runtime: { value: "nodejs22" } }, + "/p/web", + ); + expect(localBuildStub).to.have.been.calledWith( + "p", + scratchDir, + {}, + { + nonInteractive: undefined, + allowLocalBuildSecrets: true, + rootDir: "web", + }, + ); + expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, scratchDir, [ + ".next", + ]); + expect(submitBuildStub).not.to.have.been.called; + expect(updateServiceStub.firstCall.args[0].template.containers[0]).to.deep.equal({ + name: "s", + image: "scratch", + sourceCode: { cloudStorageSource: { bucket: "bucket", object: "out.tar.gz" } }, + command: ["node", "server.js"], + baseImageUri: "nodejs22", + }); + expect(fs.existsSync(scratchDir)).to.be.false; + }); + + it("passes build env and secrets to the local build and merges runtime output env", async () => { + localBuildStub.resolves({ + outputFiles: [".next"], + buildConfig: { + runCommand: "node server.js", + env: [ + { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, + { variable: "CUSTOM", value: "from-build", availability: ["RUNTIME"] }, + ], + }, + }); + const svcWithEnv = { + ...existing, + template: { + containers: [{ name: "s", image: "old", env: [{ name: "CUSTOM", value: "user-val" }] }], + }, + } as unknown as runv2.Service; + + await deployOne( + localService({ + existing: svcWithEnv, + buildEnv: { A: "1", TOKEN: { secret: "t", version: "2" } }, + }), + ); + expect(localBuildStub.firstCall.args[2]).to.deep.equal({ + A: { value: "1", availability: ["BUILD"] }, + TOKEN: { secret: "t@2", availability: ["BUILD"] }, + }); + expect(updateServiceStub.firstCall.args[0].template.containers[0].env).to.deep.equal([ + { name: "CUSTOM", value: "user-val" }, + { name: "NODE_ENV", value: "production" }, + ]); + }); + + it("cleans up and deploys nothing if the local build fails", async () => { + localBuildStub.rejects(new Error("boom")); + await expect(deployOne(localService())).to.be.rejectedWith("boom"); + expect(fs.existsSync(mkdtemp.firstCall.returnValue as string)).to.be.false; + expect(updateServiceStub).not.to.have.been.called; + }); + }); + + it("switches a service from a local build back to a source build", async () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { + containers: [ + { + name: "s", + image: "scratch", + command: ["node", "server.js"], + sourceCode: { cloudStorageSource: { bucket: "b", object: "o" } }, + }, + ], + }, + } as unknown as runv2.Service; + + await deployOne(service({ existing })); + + const container = updateServiceStub.firstCall.args[0].template.containers[0]; + expect(container).not.to.have.property("sourceCode"); + expect(container).not.to.have.property("command"); + }); + it("logs the deployed URL of each service during release", async () => { const { release } = await import("./release"); const utils = await import("../../utils"); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 3c5e210df28..7b0b06dcf9a 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,13 +1,19 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; +import { CLOUD_RUN_SIZE_LIMIT_BYTES } from "../../apphosting/constants"; +import { localBuild, validateLocalBuildNodeVersion } from "../../apphosting/localbuilds"; +import { Backend } from "../../gcp/apphosting"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; import { Options } from "../../options"; import { logLabeledBullet } from "../../utils"; -import { createSourceDeployArchive } from "../apphosting/util"; +import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; +import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; +import { secretNames, toLocalBuildEnv } from "./buildEnv"; import { copyTemplate, deployRevision, @@ -19,11 +25,68 @@ import { const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** - * Builds each service on Cloud Build, uploads it, and deploys it. + * Builds each service (locally or on Cloud Build), uploads it, and deploys it. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { for (const svc of payload.run?.services || []) { - svc.deployed = await deployService(context, options, svc); + try { + if (svc.config.localBuild) { + svc.localBuild = await buildLocally(context, options, svc); + } + svc.deployed = await deployService(context, options, svc); + } finally { + if (svc.localBuild) { + fs.rmSync(svc.localBuild.scratchDir, { recursive: true, force: true }); + } + } + } +} + +async function buildLocally( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise> { + const { config } = svc; + const { serviceId } = config; + const buildEnv = svc.buildEnv || {}; + const cfg = toAppHostingConfig(config); + validateLocalBuildNodeVersion( + { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Starting local build for service ${serviceId}`); + const secrets = secretNames(buildEnv); + if (secrets.length) { + logLabeledBullet( + "run", + `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + + `included in the build output.`, + ); + } + const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); + try { + await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); + const { outputFiles, buildConfig } = await localBuild( + context.projectId, + scratchDir, + toLocalBuildEnv(buildEnv), + { + nonInteractive: options.nonInteractive, + // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. + allowLocalBuildSecrets: true, + rootDir: config.rootDir, + }, + ); + const env = (buildConfig.env || []) + .filter( + (e) => e.value !== undefined && (!e.availability || e.availability.includes("RUNTIME")), + ) + .map((e) => ({ name: e.variable, value: e.value! })); + return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; + } catch (err: unknown) { + fs.rmSync(scratchDir, { recursive: true, force: true }); + throw err; } } @@ -40,7 +103,26 @@ async function deployService( ? copyTemplate(svc.existing) : { containers: [{ name: serviceId, image: "" }] }; const container = mainContainer(template)!; - container.image = await buildImage(projectId, svc, source); + if (svc.localBuild) { + // Cloud Run runs locally built apps directly from source on top of the base image. + container.image = "scratch"; + container.sourceCode = { cloudStorageSource: source }; + container.command = svc.localBuild.runCommand?.split(" "); + if (svc.localBuild.env?.length) { + const existingNames = new Set((container.env || []).map((e) => e.name)); + const newEnv = svc.localBuild.env.filter((e) => !existingNames.has(e.name)); + if (newEnv.length) { + container.env = [...(container.env || []), ...newEnv]; + } + } + } else { + container.image = await buildImage(projectId, svc, source); + if (container.sourceCode) { + // This service was previously deployed from a local build. + delete container.sourceCode; + delete container.command; + } + } if (svc.baseImage) { container.baseImageUri = svc.baseImage; } else { @@ -51,9 +133,6 @@ async function deployService( template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; } else { delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; - if (!Object.keys(template.annotations).length) { - delete template.annotations; - } } if (svc.existing) { @@ -94,15 +173,18 @@ async function uploadSource( }); const cfg = toAppHostingConfig(svc.config); - const archive = await createSourceDeployArchive( - cfg, - path.join(options.config.projectDir, cfg.rootDir), + const archive = svc.localBuild + ? await createLocalBuildTarArchive(cfg, svc.localBuild.scratchDir, svc.localBuild.outputFiles) + : await createSourceDeployArchive(cfg, path.join(options.config.projectDir, cfg.rootDir)); + logLabeledBullet( + "run", + `Uploading ${svc.localBuild ? "built app" : "source"} for service ${serviceId}...`, ); - logLabeledBullet("run", `Uploading source for service ${serviceId}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, bucketName, - gcs.ContentType.ZIP, + svc.localBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP, + svc.localBuild ? CLOUD_RUN_SIZE_LIMIT_BYTES : undefined, ); return { bucket, object }; } @@ -122,7 +204,9 @@ async function buildImage( buildpackBuild: { // Only images built for a base image can have their base image updated automatically. ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), + // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. environmentVariables: { + ...(svc.buildEnv as Record | undefined), X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", }, diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 88ecc5a397f..ff8169aa61f 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload } from "./args"; +import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -66,4 +67,55 @@ describe("run prepare", () => { expect((await prepareOne({}, { baseImage: "nodejs20" })).baseImage).to.equal("nodejs20"); expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; }); + + it("requires a base image for local builds", async () => { + getServiceStub.resolves({ ...existing, template: { containers: [{ name: "s", image: "i" }] } }); + await expect(prepareOne({ localBuild: true })).to.be.rejectedWith( + "Local builds require a base image", + ); + }); + + it("points new local build services to init, which sets a base image", async () => { + await expect(prepareOne({ localBuild: true })).to.be.rejectedWith( + /doesn't exist in us-central1 yet.*which also sets the base image/, + ); + }); + + it("doesn't build locally", async () => { + getServiceStub.resolves(existing); + const svc = await prepareOne({ localBuild: true }); + expect(svc.baseImage).to.equal(nodejs22); + expect(svc).not.to.have.property("localBuild"); + }); + + describe("build env", () => { + const withBuildEnv = (env: Record): runv2.Service => + ({ + ...existing, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }) as unknown as runv2.Service; + + it("passes plain build env to Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1" })); + expect((await prepareOne()).buildEnv).to.deep.equal({ A: "1" }); + }); + + it("leaves build env unset without the annotation", async () => { + getServiceStub.resolves(existing); + expect(await prepareOne()).not.to.have.property("buildEnv"); + }); + + it("rejects build secrets on Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1", TOKEN: { secret: "t" } })); + await expect(prepareOne()).to.be.rejectedWith( + /Service s has build secrets \(TOKEN\).*"localBuild": true/, + ); + }); + + it("keeps build secrets for local builds", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1", TOKEN: { secret: "t", version: "2" } })); + const svc = await prepareOne({ localBuild: true }); + expect(svc.buildEnv).to.deep.equal({ A: "1", TOKEN: { secret: "t", version: "2" } }); + }); + }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 7291d8a03ce..fe63966ad43 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,12 +1,19 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; -import { getExistingService, getServiceConfigs, mainContainer } from "./util"; +import { + getExistingService, + getServiceConfigs, + mainContainer, + missingServiceMessage, +} from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image. + * Reads each service's current state from Cloud Run and resolves its base image and build env. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -31,5 +38,39 @@ async function prepareService(context: Context, config: RunSingle): Promise".`, + ); + } + return svc; } diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 0b805df910e..a2966fa6b34 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -64,6 +64,11 @@ describe("updateService", () => { }); }); + it("can't clear the base image of a locally built service", async () => { + const opts = options({ clearBaseImage: true }, { localBuild: true }); + await expect(updateService("s", opts)).to.be.rejectedWith("local builds need one"); + }); + it("does nothing when clearing a base image that isn't set", async () => { getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); await updateService("s", options({ clearBaseImage: true })); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 65241e9f558..b9712046e4c 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -31,6 +31,9 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; const [config] = getServiceConfigs({ ...options, only }); + if (clearBaseImage && config.localBuild) { + throw new FirebaseError(`Cannot clear the base image of ${serviceId}: local builds need one.`); + } const existing = await getExistingService(projectId, config.region, serviceId); if (!existing) { throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 3943f0e18b9..eeae95ad492 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -61,7 +61,9 @@ export async function getExistingService( * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. */ export function missingServiceMessage(config: RunSingle): string { - const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + const how = config.localBuild + ? `Create it with ${clc.bold("firebase init run")}, which also sets the base image that local builds need.` + : `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; } diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b60f21efd6..e530fa59684 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -122,6 +122,32 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); + + it("enables localBuild and requires a base image when building locally", async () => { + selectStub + .onFirstCall() + .resolves("create") + .onSecondCall() + .resolves("us-central1") + .onThirdCall() + .resolves(true); + inputStub.onFirstCall().resolves("my-service"); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + const validateImg = inputStub.secondCall.args[0].validate; + expect(validateImg("nodejs22")).to.be.true; + expect(validateImg(" ")).to.include("Local builds require a base image"); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "my-service", + region: "us-central1", + baseImage: "nodejs22", + rootDir: "/", + localBuild: true, + }); + }); }); describe("actuate", () => { @@ -135,7 +161,9 @@ describe("init run", () => { const writeStub = sinon.stub(config, "writeProjectFile"); const deployStub = sinon.stub(deploy, "deploy").resolves(); const s = setup(); - s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; + s.featureInfo = { + run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/", localBuild: true }, + }; await actuate(s, config, options); @@ -143,6 +171,7 @@ describe("init run", () => { serviceId: "s", rootDir: "/", region: "r", + localBuild: true, ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], }; expect(config.src.run).to.deep.equal(runConfig); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index e7b8017463a..5c01d3bc279 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -19,6 +19,7 @@ export interface RunInfo { region: string; baseImage: string; rootDir: string; + localBuild?: boolean; } /** @@ -83,9 +84,20 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); } + const localBuild = await select({ + message: "Would you like to build your app locally or remotely?", + choices: [ + { name: "Build remotely on Cloud Build", value: false }, + { name: "Build locally", value: true }, + ], + default: false, + }); const baseImage = await input({ message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + default: + (existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22") || + (localBuild ? "nodejs22" : undefined), + validate: (img) => !localBuild || !!img.trim() || "Local builds require a base image.", }); const rootDir = await input({ message: "Specify your app's root directory relative to your firebase.json directory", @@ -99,7 +111,10 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }, }); - setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; + setup.featureInfo = { + ...setup.featureInfo, + run: { serviceId, region, baseImage, rootDir, ...(localBuild && { localBuild }) }, + }; } /** @@ -115,6 +130,7 @@ export async function actuate(setup: Setup, config: Config, options: Options): P serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, + ...(info.localBuild && { localBuild: true }), ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], }, config, @@ -137,8 +153,14 @@ export function upsertRunConfig(runConfig: RunSingle, config: Config): void { if (i < 0) { services.push(runConfig); } else { - const { rootDir, region } = runConfig; - services[i] = { ...runConfig, ...services[i], rootDir, region }; + const { rootDir, region, localBuild } = runConfig; + services[i] = { + ...runConfig, + ...services[i], + rootDir, + region, + ...(localBuild !== undefined && { localBuild }), + }; } config.set("run", services.length === 1 ? services[0] : services); } From d429b4df5d46df7759746a74e47fe26eb8081638 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:33:48 -0400 Subject: [PATCH 10/28] chore(apphosting): add TODO for bundle.yaml env vars in local builds --- src/apphosting/localbuilds.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index dc17783a5ad..70ad079519d 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -116,6 +116,8 @@ function parseBundleYaml( outputFiles?: { serverApp?: { include?: string[] } }; }; + // TODO: Also merge bundleData.runConfig.environmentVariables (and resource limits) from + // bundle.yaml to match the App Hosting publisher step on remote builds. const runCommand = bundleData?.runConfig?.runCommand ?? defaultRunCommand; const outputFiles = bundleData?.outputFiles?.serverApp?.include ?? []; return { runCommand, outputFiles }; From 29355ca516c7003b89344801f78b84fde4e4104a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:44:32 -0400 Subject: [PATCH 11/28] Rename direct_cloud_run experiment to directcloudrun --- src/experiments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/experiments.ts b/src/experiments.ts index 5f128a19e1f..ad826e9d434 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -179,7 +179,7 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, - direct_cloud_run: { + directcloudrun: { shortDescription: "Build and deploy web apps directly to Cloud Run services", fullDescription: "Enables `firebase init run`, `firebase deploy --only run`, and the " + From f1903095861efb5553c249df964707bb0e554dc8 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:45:50 -0400 Subject: [PATCH 12/28] Use directcloudrun experiment and validate rootDir is a directory --- src/commands/init.ts | 2 +- src/init/features/run.spec.ts | 1 + src/init/features/run.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 6525c331811..41dc110ef1c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -101,7 +101,7 @@ let choices: { }, ]; -if (isEnabled("direct_cloud_run")) { +if (isEnabled("directcloudrun")) { choices.push({ value: "run", name: "Cloud Run: Create or update a Cloud Run service and deploy your web app to it", diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b2bd812dc5..a99772f87cf 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -60,6 +60,7 @@ describe("init run", () => { const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); + expect(validateDir("package.json")).to.include("does not exist"); expect(s.featureInfo?.run).to.deep.equal({ serviceId: "my-service", region: "us-east1", diff --git a/src/init/features/run.ts b/src/init/features/run.ts index ab886bf6d04..51247384090 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,4 +1,4 @@ -import { existsSync } from "fs"; +import { statSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; @@ -92,7 +92,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option validate: (dir) => { const absPath = path.join(config.projectDir, dir); return ( - existsSync(absPath) || + Boolean(statSync(absPath, { throwIfNoEntry: false })?.isDirectory()) || `Directory ${absPath} does not exist. Please enter a valid directory.` ); }, From 9f0562eb4245c86e565938c85bdaabf181e564ec Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:49:16 -0400 Subject: [PATCH 13/28] Clean up temporary source archive, guard release URI, and use directcloudrun --- scripts/run-deploy-tests/run.sh | 2 +- scripts/run-deploy-tests/tests.ts | 8 ++++---- src/commands/deploy.ts | 2 +- src/deploy/run/deploy.spec.ts | 3 +++ src/deploy/run/deploy.ts | 24 ++++++++++++++++-------- src/deploy/run/release.ts | 5 ++++- src/deploy/run/util.ts | 2 +- 7 files changed, 30 insertions(+), 16 deletions(-) diff --git a/scripts/run-deploy-tests/run.sh b/scripts/run-deploy-tests/run.sh index 3c2bfeeb741..4dc0d0eb01f 100755 --- a/scripts/run-deploy-tests/run.sh +++ b/scripts/run-deploy-tests/run.sh @@ -12,4 +12,4 @@ echo "======================================" echo "Starting Cloud Run E2E Test Suite" echo "======================================" -mocha scripts/run-deploy-tests/tests.ts --timeout 600000 +npx mocha scripts/run-deploy-tests/tests.ts --timeout 600000 diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 95f4dcb0eb4..5a46c9a8bd9 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -35,7 +35,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { before(async () => { expect(PROJECT).to.not.be.empty; - process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + process.env.FIREBASE_CLI_EXPERIMENTS = "directcloudrun"; await requireAuth({}); workDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-")); const pkg = { name: "run-e2e", version: "1.0.0" }; @@ -60,14 +60,14 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { fs.removeSync(workDir); }); - it("requires the direct_cloud_run experiment", async () => { + it("requires the directcloudrun experiment", async () => { delete process.env.FIREBASE_CLI_EXPERIMENTS; try { const res = await firebase("deploy", "--only", "run"); expect(res.proc.exitCode).not.to.equal(0); - expect(res.stdout + res.stderr).to.include("experiment direct_cloud_run is not enabled"); + expect(res.stdout + res.stderr).to.include("experiment directcloudrun is not enabled"); } finally { - process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + process.env.FIREBASE_CLI_EXPERIMENTS = "directcloudrun"; } }); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index cd58e2c36c8..1be9797754e 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -89,7 +89,7 @@ command experiments.assertEnabled("ailogic", "deploy AI Logic resources"); } if (options.filteredTargets.includes("run")) { - experiments.assertEnabled("direct_cloud_run", "deploy Cloud Run services"); + experiments.assertEnabled("directcloudrun", "deploy Cloud Run services"); } const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { return perms.concat(TARGET_PERMISSIONS[target as (typeof VALID_DEPLOY_TARGETS)[number]]); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b2a0d9bc869..2690cf25ae0 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -14,6 +14,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; + let rmSyncStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -34,6 +35,7 @@ describe("run deploy", () => { .stub(gcs, "uploadObject") .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); + rmSyncStub = sinon.stub(fs, "rmSync"); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); @@ -53,6 +55,7 @@ describe("run deploy", () => { } as Options); expect(sourceArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, "/p/web"); + expect(rmSyncStub).to.have.been.calledWith("src.zip", { force: true }); const imageUri = "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42"; expect(submitBuildStub).to.have.been.calledWith("p", "us-central1", { storageSource: { bucket: "bucket", object: "src.zip" }, diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 3c5e210df28..e5efc1c3a12 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { FirebaseError } from "../../error"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; @@ -39,7 +40,10 @@ async function deployService( const template = svc.existing ? copyTemplate(svc.existing) : { containers: [{ name: serviceId, image: "" }] }; - const container = mainContainer(template)!; + const container = mainContainer(template); + if (!container) { + throw new FirebaseError(`Service ${serviceId} has no containers.`); + } container.image = await buildImage(projectId, svc, source); if (svc.baseImage) { container.baseImageUri = svc.baseImage; @@ -98,13 +102,17 @@ async function uploadSource( cfg, path.join(options.config.projectDir, cfg.rootDir), ); - logLabeledBullet("run", `Uploading source for service ${serviceId}...`); - const { bucket, object } = await gcs.uploadObject( - { file: archive, stream: fs.createReadStream(archive) }, - bucketName, - gcs.ContentType.ZIP, - ); - return { bucket, object }; + try { + logLabeledBullet("run", `Uploading source for service ${serviceId}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; + } finally { + fs.rmSync(archive, { force: true }); + } } async function buildImage( diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts index 5c32bc9b075..a10967ebe65 100644 --- a/src/deploy/run/release.ts +++ b/src/deploy/run/release.ts @@ -10,6 +10,9 @@ export async function release( payload: Payload, ): Promise { for (const { config, deployed } of payload.run?.services || []) { - logLabeledSuccess("run", `Deployed service ${config.serviceId} to ${deployed?.uri}`); + logLabeledSuccess( + "run", + `Deployed service ${config.serviceId}${deployed?.uri ? ` to ${deployed.uri}` : ""}`, + ); } } diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 9cea23cf881..819ddec99fd 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -11,7 +11,7 @@ export const SERVICE_OPERATION_TIMEOUT_MS = 10 * 60 * 1000; * Returns the Cloud Run services in firebase.json that match the --only filter. */ export function getServiceConfigs(options: Options): RunSingle[] { - const configs = [options.config.src.run || []].flat(); + const configs = [options.config?.src?.run || []].flat(); const selectors = options.only ? options.only.split(",") : ["run"]; if (selectors.includes("run")) { return configs; From d0943936b03205635f2130303cd88c87ef2c1820 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:50:49 -0400 Subject: [PATCH 14/28] Validate service region and check all matching configs in updateService --- src/commands/index.ts | 2 +- src/deploy/run/update.spec.ts | 5 ++++- src/deploy/run/update.ts | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index f4520625b4d..eb2a1639b00 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,7 +301,7 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); - if (experiments.isEnabled("direct_cloud_run")) { + if (experiments.isEnabled("directcloudrun")) { client.run = {}; client.run.services = {}; client.run.services.update = loadCommand("run-services-update"); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 0b805df910e..27529b10f85 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -36,10 +36,13 @@ describe("updateService", () => { await expect(updateService("s", both)).to.be.rejectedWith("not both"); }); - it("requires the service to be in firebase.json", async () => { + it("requires the service to be in firebase.json with a region", async () => { await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( "Cloud Run service IDs other not detected in firebase.json", ); + await expect( + updateService("s", options({ baseImage: "nodejs20" }, { region: "" })), + ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); }); it("requires the service to exist", async () => { diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 65241e9f558..d57386d5513 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,6 +1,7 @@ import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { logBullet } from "../../utils"; @@ -30,12 +31,21 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; - const [config] = getServiceConfigs({ ...options, only }); - const existing = await getExistingService(projectId, config.region, serviceId); - if (!existing) { - throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + const configs = getServiceConfigs({ ...options, only }); + const existing: runv2.Service[] = []; + for (const config of configs) { + if (!config.region) { + throw new FirebaseError( + `Cloud Run service ${serviceId} is missing a region in firebase.json.`, + ); + } + const svc = await getExistingService(projectId, config.region, serviceId); + if (!svc) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + existing.push(svc); } - if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + if (clearBaseImage && existing.every((s) => !mainContainer(s.template)?.baseImageUri)) { logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); return; } From d0eb8fb848b58ddcdf02eff74e3756d8fdda5cd6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:08:56 -0400 Subject: [PATCH 15/28] Trim baseImage input in init run --- src/init/features/run.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 51247384090..aeb232edc94 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -82,10 +82,12 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); } - const baseImage = await input({ - message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", - }); + const baseImage = ( + (await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + })) || "" + ).trim(); const rootDir = await input({ message: "Specify your app's root directory relative to your firebase.json directory", default: "/", From aa0457aa088d2248984b149079520e2caa260d8c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:11:24 -0400 Subject: [PATCH 16/28] Avoid empty template.annotations when deploy message is unset --- src/deploy/run/deploy.spec.ts | 4 ++-- src/deploy/run/deploy.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 2690cf25ae0..4162377ea19 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -14,7 +14,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; - let rmSyncStub: sinon.SinonStub; + let rmSyncStub: sinon.SinonSpy; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -35,7 +35,7 @@ describe("run deploy", () => { .stub(gcs, "uploadObject") .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - rmSyncStub = sinon.stub(fs, "rmSync"); + rmSyncStub = sinon.spy(fs, "rmSync"); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index e5efc1c3a12..10573ea1723 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -50,10 +50,12 @@ async function deployService( } else { delete container.baseImageUri; } - template.annotations = { ...template.annotations }; if (options.message) { - template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; - } else { + template.annotations = { + ...template.annotations, + [DEPLOY_MESSAGE_ANNOTATION]: options.message as string, + }; + } else if (template.annotations) { delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; if (!Object.keys(template.annotations).length) { delete template.annotations; From d07f523058e24cfa28502c2a1e2a4a19a8e8cbe0 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:15:25 -0400 Subject: [PATCH 17/28] Clear localBuild in upsertRunConfig without unused destructuring --- src/init/features/run.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 1f4a5587a1f..aa39e59586e 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -156,14 +156,16 @@ export function upsertRunConfig(runConfig: RunSingle, config: Config): void { services.push(runConfig); } else { const { rootDir, region, localBuild } = runConfig; - const { localBuild: _, ...existing } = services[i]; services[i] = { ...runConfig, - ...existing, + ...services[i], rootDir, region, ...(localBuild && { localBuild }), }; + if (!localBuild) { + delete services[i].localBuild; + } } config.set("run", services.length === 1 ? services[0] : services); } From e7ac3860a4a3d13bbaab8c7c3d3cf859e5b865c1 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 08:16:20 -0400 Subject: [PATCH 18/28] Simplify template.annotations cleanup when deploy message is unset --- src/deploy/run/deploy.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 10573ea1723..ad3ec507e2e 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -57,9 +57,6 @@ async function deployService( }; } else if (template.annotations) { delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; - if (!Object.keys(template.annotations).length) { - delete template.annotations; - } } if (svc.existing) { From 46a8aea2a6fd1687480beb1208659804897a579c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 11:42:24 -0400 Subject: [PATCH 19/28] Add defensive optional chaining in run.ts and runv2.ts --- src/gcp/run.ts | 2 +- src/gcp/runv2.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gcp/run.ts b/src/gcp/run.ts index 73053ab8f99..c1f2c386e28 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -375,5 +375,5 @@ export async function listLocations(projectId: string): Promise { const res = await client.get<{ locations?: Array<{ locationId: string }> }>( `/projects/${projectId}/locations`, ); - return res.body.locations?.map((l) => l.locationId) ?? []; + return res.body?.locations?.map((l) => l.locationId) ?? []; } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index c59cb01a80f..2f5e8071a7d 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -188,10 +188,10 @@ export async function submitBuild( `/projects/${projectId}/locations/${location}/builds:submit`, build, ); - if (res.body.baseImageWarning) { + if (res.body?.baseImageWarning) { logger.warn(res.body.baseImageWarning); } - const cloudBuild = res.body.buildOperation?.metadata?.build; + const cloudBuild = res.body?.buildOperation?.metadata?.build; if (!cloudBuild) { throw new FirebaseError("Failed to submit build: no build was returned."); } @@ -203,9 +203,9 @@ export async function submitBuild( operationResourceName: cloudBuild.name, masterTimeout: 30 * 60 * 1000, maxBackoff: 10_000, - doneFn: (b: { status?: string }) => { - status = b.status; - return !PENDING_BUILD_STATUSES.includes(b.status ?? "STATUS_UNKNOWN"); + doneFn: (b?: { status?: string }) => { + status = b?.status; + return !b?.status || !PENDING_BUILD_STATUSES.includes(b.status); }, }); if (status !== "SUCCESS") { From 6fe842768206886948c3ee1130a7529f8eca013a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 13:50:27 -0400 Subject: [PATCH 20/28] Remove unused Deployable predeploy/postdeploy from RunSingle --- schema/firebase-config.json | 26 -------------------------- src/firebaseConfig.ts | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index e646dd371d3..1338124e049 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1233,32 +1233,6 @@ "localBuild": { "type": "boolean" }, - "postdeploy": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "string" - } - ] - }, - "predeploy": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "string" - } - ] - }, "region": { "type": "string" }, diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 7137aedc70b..71943fd7faa 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,7 +366,7 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; -export interface RunSingle extends Deployable { +export interface RunSingle { serviceId: string; region: string; rootDir?: string; From 1bf57bd68d71f2d91b2c7d9966bf06c0b053ca22 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 18:09:26 -0700 Subject: [PATCH 21/28] Make local build scratch dir cleanup best-effort Route both scratch dir deletions through removeTempPath so a cleanup failure can't fail a successful deploy or replace the real deploy/build error. --- src/deploy/run/deploy.spec.ts | 10 ++++++++++ src/deploy/run/deploy.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index c3b6018a2b2..38bdd314bac 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -285,6 +285,16 @@ describe("run deploy", () => { expect(fs.existsSync(mkdtemp.firstCall.returnValue as string)).to.be.false; expect(updateServiceStub).not.to.have.been.called; }); + + it("reports the local build error, not the cleanup error, when both fail", async () => { + localBuildStub.rejects(new Error("boom")); + rmSyncStub.restore(); + sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); + + await expect(deployOne(localService())).to.be.rejectedWith("boom"); + + fs.rmdirSync(mkdtemp.firstCall.returnValue as string); + }); }); it("switches a service from a local build back to a source build", async () => { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 71338cc85ac..ab6eccd6d8b 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -37,7 +37,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa svc.deployed = await deployService(context, options, svc); } finally { if (svc.localBuild) { - fs.rmSync(svc.localBuild.scratchDir, { recursive: true, force: true }); + removeTempPath(svc.localBuild.scratchDir); } } } @@ -89,7 +89,7 @@ async function buildLocally( .map((e) => ({ name: e.variable, value: e.value! })); return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; } catch (err: unknown) { - fs.rmSync(scratchDir, { recursive: true, force: true }); + removeTempPath(scratchDir); throw err; } } From 5ee1358d4c5aee80a8450c8121913d457cec0b93 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 09:10:12 -0700 Subject: [PATCH 22/28] Default init run's build location to the saved localBuild setting Pressing Enter no longer switches a service that builds locally back to remote builds. --- src/init/features/run.spec.ts | 24 ++++++++++++++++++++++++ src/init/features/run.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 3eda890dff4..d2dd62ad92c 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -156,6 +156,7 @@ describe("init run", () => { await askQuestions(s, config, options); + expect(selectStub.thirdCall.args[0].default).to.be.false; const baseImagePrompt = inputStub.secondCall.args[0]; expect(baseImagePrompt.default).to.be.undefined; expect(baseImagePrompt.validate("nodejs22")).to.be.true; @@ -168,6 +169,29 @@ describe("init run", () => { localBuild: true, }); }); + + it("defaults to building locally if the saved entry for the service does", async () => { + const existing = { + name: "projects/p/locations/europe-west1/services/web", + template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, + }; + sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); + selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); + inputStub.callsFake((o) => Promise.resolve(o.default)); + config.set("run", { + serviceId: "web", + region: "europe-west1", + rootDir: "/", + localBuild: true, + }); + const s = setup(); + + await askQuestions(s, config, options); + + expect(selectStub.thirdCall.args[0].default).to.be.true; + expect(s.featureInfo?.run?.localBuild).to.be.true; + }); }); describe("actuate", () => { diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 24187c4573a..7ebd8238087 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -71,7 +71,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option { name: "Build remotely on Cloud Build", value: false }, { name: "Build locally", value: true }, ], - default: false, + default: savedEntry?.localBuild === true, }); // A base image turns on automatic base image updates, which are off by default. So there's no From ac7deb738bd379311e470d6f5bf6354012a3705a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:34:28 -0700 Subject: [PATCH 23/28] Move build env to its own PR Build-time env vars and secrets (the buildConfig.environmentVariablesAndSecrets annotation) move to bapi_build_env, so this PR only adds local builds. --- src/deploy/run/args.ts | 3 -- src/deploy/run/buildEnv.spec.ts | 88 -------------------------------- src/deploy/run/buildEnv.ts | 89 --------------------------------- src/deploy/run/deploy.spec.ts | 18 +------ src/deploy/run/deploy.ts | 17 +------ src/deploy/run/prepare.spec.ts | 34 ------------- src/deploy/run/prepare.ts | 34 ++----------- 7 files changed, 6 insertions(+), 277 deletions(-) delete mode 100644 src/deploy/run/buildEnv.spec.ts delete mode 100644 src/deploy/run/buildEnv.ts diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index c98333634f8..6848b6acc83 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,6 +1,5 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; -import { BuildEnv } from "./buildEnv"; /** A service being deployed. prepare fills in the first fields, and deploy sets the rest. */ export interface ServiceDeploy { @@ -8,8 +7,6 @@ export interface ServiceDeploy { /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ existing?: runv2.Service; baseImage?: string; - /** Build-time environment. Prepare only allows secrets in it for local builds. */ - buildEnv?: BuildEnv; /** The local build's output. */ localBuild?: { scratchDir: string; diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts deleted file mode 100644 index 00c50090b8f..00000000000 --- a/src/deploy/run/buildEnv.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect } from "chai"; -import * as runv2 from "../../gcp/runv2"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; - -describe("run buildEnv", () => { - const withAnnotation = (value: string): runv2.Service => - ({ - name: "projects/p/locations/us-central1/services/s", - annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, - }) as unknown as runv2.Service; - - describe("getBuildEnv", () => { - it("returns {} without a service or annotation", () => { - expect(getBuildEnv(undefined)).to.deep.equal({}); - expect( - getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), - ).to.deep.equal({}); - }); - - it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { - expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); - }); - - it("parses secret references", () => { - const env = { - A: "1", - TOKEN: { secret: "npm-token", version: "3" }, - LATEST: { secret: "s" }, - OTHER: { secret: "projects/q/secrets/t", version: "latest" }, - }; - expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); - }); - - it("rejects invalid JSON and non-objects", () => { - expect(() => getBuildEnv(withAnnotation("{"))).to.throw( - `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, - ); - for (const value of ["[]", "null", '"x"', "1"]) { - expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); - } - }); - - it("rejects invalid values", () => { - const invalid = [ - 1, - null, - ["x"], - {}, - { secret: 1 }, - { secret: "a/b" }, - { secret: "a@1" }, - { secret: "projects/q/secrets/t/versions/1" }, - { secret: "s", version: 1 }, - { secret: "s", version: "one" }, - { secret: "s", extra: "x" }, - ]; - for (const value of invalid) { - expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( - 'A must be a string or {"secret": "", "version": ""}.', - ); - } - }); - }); - - it("lists secret names", () => { - expect( - secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), - ).to.deep.equal(["B", "C"]); - }); - - it("converts to local build env, available only to the build", () => { - expect( - toLocalBuildEnv({ - A: "1", - B: { secret: "s" }, - C: { secret: "t", version: "2" }, - D: { secret: "projects/q/secrets/u" }, - E: { secret: "projects/q/secrets/v", version: "4" }, - }), - ).to.deep.equal({ - A: { value: "1", availability: ["BUILD"] }, - B: { secret: "s@latest", availability: ["BUILD"] }, - C: { secret: "t@2", availability: ["BUILD"] }, - D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, - E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, - }); - }); -}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts deleted file mode 100644 index 5ba9d8cb263..00000000000 --- a/src/deploy/run/buildEnv.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { FirebaseError } from "../../error"; -import * as runv2 from "../../gcp/runv2"; -import { EnvMap } from "../../apphosting/yaml"; - -/** - * Prototype: build-time environment variables and secret references, stored on the Cloud Run - * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): - * the same map parses unchanged, and a value can also be a secret reference in the shape of - * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. - * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} - */ -export const BUILD_ENV_ANNOTATION = - "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; - -/** A Secret Manager secret, as a name in the service's project or a full resource name. */ -export interface SecretRef { - secret: string; - /** A version number or "latest". Defaults to "latest". */ - version?: string; -} - -export type BuildEnv = Record; - -const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; -const SECRET_VERSION = /^(?:latest|\d+)$/; - -/** Reads and validates the service's build environment. Returns {} if it has none. */ -export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { - const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; - if (!service || !raw) { - return {}; - } - const invalid = (reason: string): FirebaseError => - new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw invalid("it isn't valid JSON."); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw invalid("it must be a JSON object."); - } - for (const [name, value] of Object.entries(parsed)) { - if (typeof value === "string") { - continue; - } - const ref = value as Partial | null; - const valid = - typeof ref === "object" && - ref !== null && - !Array.isArray(ref) && - Object.keys(ref).every((k) => k === "secret" || k === "version") && - typeof ref.secret === "string" && - SECRET_NAME.test(ref.secret) && - (ref.version === undefined || - (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); - if (!valid) { - throw invalid( - `${name} must be a string or {"secret": "", "version": ""}.`, - ); - } - } - return parsed as BuildEnv; -} - -/** Names of the variables that are secret references. */ -export function secretNames(env: BuildEnv): string[] { - return Object.entries(env) - .filter(([, value]) => typeof value !== "string") - .map(([name]) => name); -} - -/** Converts the build environment to the form local builds take; they resolve the secrets. */ -export function toLocalBuildEnv(env: BuildEnv): EnvMap { - const result: EnvMap = {}; - for (const [name, value] of Object.entries(env)) { - if (typeof value === "string") { - result[name] = { value, availability: ["BUILD"] }; - } else { - const version = value.version || "latest"; - const secret = value.secret.startsWith("projects/") - ? `${value.secret}/versions/${version}` - : `${value.secret}@${version}`; - result[name] = { secret, availability: ["BUILD"] }; - } - } - return result; -} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 95c81077101..d55ba00ea89 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -84,13 +84,6 @@ describe("run deploy", () => { expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); - it("passes the service's build env to Cloud Build", async () => { - await deployOne({ config: web, buildEnv: { API_URL: "https://api.example.com" } }); - - const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; - expect(environmentVariables).to.include({ API_URL: "https://api.example.com" }); - }); - it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", @@ -155,8 +148,6 @@ describe("run deploy", () => { scratchDir, {}, { - nonInteractive: undefined, - allowLocalBuildSecrets: true, rootDir: "apps/web", }, ); @@ -174,7 +165,7 @@ describe("run deploy", () => { expect(fs.existsSync(scratchDir)).to.be.false; }); - it("passes build env to the local build, and keeps the env vars the app needs at runtime", async () => { + it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { @@ -185,14 +176,9 @@ describe("run deploy", () => { ], }, }); - const buildEnv = { API_URL: "https://api.example.com", TOKEN: { secret: "t", version: "2" } }; - await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); + await deployOne({ config: localWeb, baseImage: NODEJS22 }); - expect(localBuildStub.firstCall.args[2]).to.deep.equal({ - API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, - TOKEN: { secret: "t@2", availability: ["BUILD"] }, - }); expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ { name: "NODE_ENV", value: "production" }, ]); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 112e8fda9ca..cd003936607 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,7 +16,6 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; -import { secretNames, toLocalBuildEnv } from "./buildEnv"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ @@ -58,32 +57,20 @@ async function buildLocally( ): Promise> { const { config } = svc; const { serviceId, region } = config; - const buildEnv = svc.buildEnv || {}; const cfg = toAppHostingConfig(config); validateLocalBuildNodeVersion( { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); - const secrets = secretNames(buildEnv); - if (secrets.length) { - logLabeledBullet( - "run", - `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + - `included in the build output.`, - ); - } const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); try { await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); const { outputFiles, buildConfig } = await localBuild( context.projectId, scratchDir, - toLocalBuildEnv(buildEnv), + {}, { - nonInteractive: options.nonInteractive, - // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. - allowLocalBuildSecrets: true, rootDir: config.rootDir, }, ); @@ -159,8 +146,6 @@ async function buildImage( // Only images built for a base image can have their base image updated automatically. ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), environmentVariables: { - // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. - ...(svc.buildEnv as Record | undefined), // Use App Hosting's buildpacks, which need to know where to write the output bundle. X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 60019f3e61d..e2b853851d4 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,7 +3,6 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -92,39 +91,6 @@ describe("run prepare", () => { }); }); - describe("build env", () => { - const withBuildEnv = (env: Record): runv2.Service => - ({ - ...liveWeb, - annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, - }) as unknown as runv2.Service; - - it("passes plain build env to Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); - const [svc] = await prepareRun(web); - expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); - }); - - it("leaves build env unset without the annotation", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(web); - expect(svc).not.to.have.property("buildEnv"); - }); - - it("rejects build secrets on Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t" } })); - await expect(prepareRun(web)).to.be.rejectedWith( - /Service web in us-central1 has build secrets \(TOKEN\).*"localBuild": true/, - ); - }); - - it("keeps build secrets for local builds", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t", version: "2" } })); - const [svc] = await prepareRun(localWeb); - expect(svc.buildEnv).to.deep.equal({ TOKEN: { secret: "t", version: "2" } }); - }); - }); - it("only prepares the services that --only selects", async () => { const webEurope = { serviceId: "web", region: "europe-west1" }; const services = await prepareRun([web, webEurope], { only: "run:web:europe-west1" }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index ebf51f4c71f..7f774dc33c0 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,14 +1,12 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; -import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; import { fullServiceName, getExistingService, getServiceConfigs } from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image and build env. + * Reads each service's current state from Cloud Run and resolves its base image. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -30,34 +28,8 @@ async function prepareService(context: Context, config: RunSingle): Promise` : "firebase init run"; @@ -65,5 +37,5 @@ async function prepareService(context: Context, config: RunSingle): Promise Date: Fri, 9 Oct 2026 13:35:45 -0700 Subject: [PATCH 24/28] Make local builds easier to read - deploy() creates the scratch folder and deletes it in one finally. - Explain the App Hosting Backend that the Node.js version check needs. - Init's base image validation is one expression. --- src/deploy/run/deploy.ts | 58 ++++++++++++++++++++-------------------- src/init/features/run.ts | 8 ++---- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index cd003936607..f19621d85aa 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -28,9 +28,13 @@ type RevisionCode = { image: string } | { builtApp: runv2.StorageSource }; export async function deploy(context: Context, options: Options, payload: Payload): Promise { const { projectId } = context; for (const svc of payload.run?.services || []) { + // A local build runs in a scratch copy of the source, which is deleted afterwards. + const scratchDir = svc.config.localBuild + ? fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`)) + : undefined; try { - if (svc.config.localBuild) { - svc.localBuild = await buildLocally(context, options, svc); + if (scratchDir) { + svc.localBuild = await buildLocally(context, options, svc, scratchDir); } const uploaded = await uploadSource(projectId, options, svc); // Cloud Run runs a local build's output as is. Other source is built into an image first. @@ -42,8 +46,8 @@ export async function deploy(context: Context, options: Options, payload: Payloa ? await deployRevision(svc.existing, template) : await createService(projectId, svc.config, template); } finally { - if (svc.localBuild) { - removeTempPath(svc.localBuild.scratchDir); + if (scratchDir) { + removeTempPath(scratchDir); } } } @@ -54,40 +58,36 @@ async function buildLocally( context: Context, options: Options, svc: ServiceDeploy, + scratchDir: string, ): Promise> { const { config } = svc; const { serviceId, region } = config; const cfg = toAppHostingConfig(config); + // App Hosting's check reads the Node.js version from a backend's runtime, e.g. "nodejs22". validateLocalBuildNodeVersion( { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); - const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); - try { - await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); - const { outputFiles, buildConfig } = await localBuild( - context.projectId, - scratchDir, - {}, - { - rootDir: config.rootDir, - }, - ); - // Keep the env vars that the app needs at runtime, not just during the build. - const env = (buildConfig.env || []) - .filter( - (e) => - e.variable && - e.value !== undefined && - (!e.availability || e.availability.includes("RUNTIME")), - ) - .map((e) => ({ name: e.variable, value: e.value! })); - return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; - } catch (err: unknown) { - removeTempPath(scratchDir); - throw err; - } + await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); + const { outputFiles, buildConfig } = await localBuild( + context.projectId, + scratchDir, + {}, + { + rootDir: config.rootDir, + }, + ); + // Keep the env vars that the app needs at runtime, not just during the build. + const env = (buildConfig.env || []) + .filter( + (e) => + e.variable && + e.value !== undefined && + (!e.availability || e.availability.includes("RUNTIME")), + ) + .map((e) => ({ name: e.variable, value: e.value! })); + return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; } /** Uploads the service's source, or its local build's output, to the region's source bucket. */ diff --git a/src/init/features/run.ts b/src/init/features/run.ts index f01a0fa7aa8..39a35616155 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -81,12 +81,8 @@ export async function askQuestions(setup: Setup, config: Config, options: Option ? "Which base image should your app use? (e.g. nodejs20, nodejs22)" : "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", default: existing?.template.containers?.[0]?.baseImageUri, - validate: (img: string) => { - if (localBuild && !img.trim()) { - return "Local builds require a base image."; - } - return true; - }, + validate: (img: string) => + localBuild && !img.trim() ? "Local builds require a base image." : true, }); const baseImage = (rawBaseImage || "").trim(); From fd2a3de4e978559ca3e4f1c78d7be071ec93bc52 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:36:48 -0700 Subject: [PATCH 25/28] Keep localBuild's options on one line --- src/deploy/run/deploy.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f19621d85aa..65f87d61a99 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -74,9 +74,7 @@ async function buildLocally( context.projectId, scratchDir, {}, - { - rootDir: config.rootDir, - }, + { rootDir: config.rootDir }, ); // Keep the env vars that the app needs at runtime, not just during the build. const env = (buildConfig.env || []) From f9b0ab788aea871218d522eb96788281d99e5afe Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 14:06:29 -0700 Subject: [PATCH 26/28] Make the local build tests easier to read - deploy: split the end-to-end local build test, stub the scratch folder instead of touching the filesystem, and keep #11205's upload stub. - prepare: drop a test of an implementation detail. - init: answer prompts with one helper, one behavior per test, and split the upsertRunConfig localBuild test into on and off. --- src/deploy/run/deploy.spec.ts | 144 +++++++++++++++++---------------- src/deploy/run/prepare.spec.ts | 15 ++-- src/init/features/run.spec.ts | 125 +++++++++++----------------- 3 files changed, 124 insertions(+), 160 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index d55ba00ea89..a6e50a83e64 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; import * as fs from "fs"; -import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -21,6 +20,8 @@ describe("run deploy", () => { const web = { serviceId: "web", region: "us-central1", rootDir: "apps/web" }; // Date.now() is stubbed to return 42, which tags the image. const image = "us-central1-docker.pkg.dev/my-project/cloud-run-source-deploy/web:42"; + const scratchDir = "/tmp/run-local-build-web-abc123"; + const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; let createArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; @@ -33,13 +34,9 @@ describe("run deploy", () => { .stub(apphostingUtil, "createSourceDeployArchive") .resolves("/tmp/web.zip"); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - sinon.stub(gcs, "uploadObject").callsFake((src) => - Promise.resolve({ - bucket: "source-bucket", - object: path.basename(src.file), - generation: "1", - }), - ); + sinon + .stub(gcs, "uploadObject") + .resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" }); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); const deployed = { uri: "https://web.run.app" } as runv2.Service; @@ -117,89 +114,92 @@ describe("run deploy", () => { describe("local builds", () => { const localWeb = { ...web, localBuild: true }; + let rmSyncStub: sinon.SinonStub; let validateNodeStub: sinon.SinonStub; let localBuildStub: sinon.SinonStub; let tarArchiveStub: sinon.SinonStub; - let mkdtempSpy: sinon.SinonSpy; beforeEach(() => { - sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + sinon.stub(fs, "mkdtempSync").returns(scratchDir); + rmSyncStub = sinon.stub(fs, "rmSync"); validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ outputFiles: [".next"], - buildConfig: { runCommand: "node server.js" }, + buildConfig: { runCommand: "npm run start" }, }); tarArchiveStub = sinon .stub(apphostingUtil, "createLocalBuildTarArchive") .resolves("/tmp/web.tar.gz"); - mkdtempSpy = sinon.spy(fs, "mkdtempSync"); + (gcs.uploadObject as sinon.SinonStub) + .withArgs(sinon.match({ file: "/tmp/web.tar.gz" })) + .resolves(builtApp); }); - it("builds on this machine and uploads the output, without Cloud Build", async () => { + /** Deploys web with "localBuild": true and returns the container that it rolled out. */ + async function deployLocalBuild(): Promise { await deployOne({ config: localWeb, baseImage: NODEJS22 }); + return createServiceStub.firstCall.args[3].template.containers[0]; + } + + it("builds on this machine, in a scratch folder that it deletes afterwards", async () => { + await deployLocalBuild(); - const scratchDir = mkdtempSpy.firstCall.returnValue as string; expect(validateNodeStub).to.have.been.calledWith( { runtime: { value: "nodejs22" } }, "/project/apps/web", ); - expect(localBuildStub).to.have.been.calledWith( + expect(localBuildStub).to.have.been.calledWithMatch( "my-project", scratchDir, {}, - { - rootDir: "apps/web", - }, + { rootDir: "apps/web" }, ); - expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "web" }, scratchDir, [ - ".next", - ]); + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + }); + + it("uploads the build's output and runs it as is, without Cloud Build", async () => { + const container = await deployLocalBuild(); + + expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]); expect(submitBuildStub).not.to.have.been.called; - expect(createServiceStub.firstCall.args[3].template.containers[0]).to.deep.equal({ - name: "web", + expect(container).to.deep.include({ image: "scratch", - sourceCode: { cloudStorageSource: { bucket: "source-bucket", object: "web.tar.gz" } }, - command: ["node", "server.js"], - baseImageUri: NODEJS22, + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], }); - expect(fs.existsSync(scratchDir)).to.be.false; }); it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { - runCommand: "node server.js", env: [ { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, - { variable: "BUILD_ONLY", value: "x", availability: ["BUILD"] }, + { variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] }, ], }, }); - await deployOne({ config: localWeb, baseImage: NODEJS22 }); + const container = await deployLocalBuild(); - expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ - { name: "NODE_ENV", value: "production" }, - ]); + expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); }); it("cleans up and deploys nothing if the local build fails", async () => { - localBuildStub.rejects(new Error("boom")); + localBuildStub.rejects(new Error("next build failed")); - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); - expect(fs.existsSync(mkdtempSpy.firstCall.returnValue as string)).to.be.false; + expect(rmSyncStub).to.have.been.calledWith(scratchDir); expect(createServiceStub).not.to.have.been.called; }); it("reports the local build error, not the cleanup error, when both fail", async () => { - localBuildStub.rejects(new Error("boom")); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); - - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + localBuildStub.rejects(new Error("next build failed")); + rmSyncStub.throws(new Error("EBUSY: resource busy or locked")); - fs.rmdirSync(mkdtempSpy.firstCall.returnValue as string); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); }); }); @@ -220,7 +220,13 @@ describe("run deploy", () => { ], }, } as unknown as runv2.Service; - const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; + + /** The live web service, running a container with these fields. */ + function webRunning(container: Partial): runv2.Service { + return { + template: { containers: [{ name: "web", image: "old-image", ...container }] }, + } as runv2.Service; + } it("starts a new service with just the image", () => { expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ @@ -254,10 +260,14 @@ describe("run deploy", () => { }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate({ config: web, existing }, { image }, "Fix the login"); + const withMessage = revisionTemplate( + { config: web, existing }, + { image }, + "Fix the login page", + ); expect(withMessage.annotations).to.deep.equal({ team: "frontend", - [DEPLOY_MESSAGE]: "Fix the login", + [DEPLOY_MESSAGE]: "Fix the login page", }); const withoutMessage = revisionTemplate({ config: web, existing }, { image }); @@ -265,51 +275,43 @@ describe("run deploy", () => { }); it("runs a local build's output with the build's start command", () => { - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], runCommand: " node server.js " }; + // The command is split on whitespace, so stray spaces don't become empty arguments. + const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container).to.include({ image: "scratch" }); - expect(container?.sourceCode).to.deep.equal({ cloudStorageSource: builtApp }); - expect(container?.command).to.deep.equal(["node", "server.js"]); + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); it("adds the env vars a local build needs, unless the service already sets them", () => { const env = [ - { name: "MODE", value: "from-build" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]; - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], env }; + const localBuild = { scratchDir, outputFiles: [".next"], env }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container?.env).to.deep.equal([ + expect(template.containers?.[0].env).to.deep.equal([ { name: "MODE", value: "prod" }, { name: "NODE_ENV", value: "production" }, ]); }); it("switches a service from a local build back to an image", () => { - const fromLocalBuild = { - ...existing, - template: { - containers: [ - { - name: "web", - image: "scratch", - command: ["node", "server.js"], - sourceCode: { cloudStorageSource: builtApp }, - }, - ], - }, - } as unknown as runv2.Service; + const fromLocalBuild = webRunning({ + image: "scratch", + command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, + }); - const container = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); - expect(container).to.deep.equal({ name: "web", image }); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); }); }); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index e2b853851d4..0133fb85ae1 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -68,12 +68,14 @@ describe("run prepare", () => { describe("local builds", () => { it("require a base image", async () => { - getServiceStub.resolves({ + const webWithoutBaseImage = { ...liveWeb, template: { containers: [{ name: "web", image: "old-image" }] }, - }); + }; + getServiceStub.resolves(webWithoutBaseImage); + await expect(prepareRun(localWeb)).to.be.rejectedWith( - /Local builds require a base image.*firebase run:services:update web:us-central1 --base-image/, + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', ); }); @@ -82,13 +84,6 @@ describe("run prepare", () => { 'Local builds require a base image. Set one for service web in us-central1 with "firebase init run".', ); }); - - it("are left to the deploy step", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(localWeb); - expect(svc.baseImage).to.equal(nodejs22); - expect(svc).not.to.have.property("localBuild"); - }); }); it("only prepares the services that --only selects", async () => { diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 41d47ca7f46..098dc3b63a9 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -41,6 +41,13 @@ describe("init run", () => { afterEach(() => sinon.restore()); describe("askQuestions", () => { + /** Answers init's prompts, select or input, in the order it asks them. */ + function answerPrompts(...answers: unknown[]): void { + const nextAnswer = (): Promise => Promise.resolve(answers.shift()); + selectStub.callsFake(nextAnswer); + inputStub.callsFake(nextAnswer); + } + it("throws if no project is selected", async () => { await expect( askQuestions({ ...setup(), projectId: undefined }, config, options), @@ -144,55 +151,38 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); - it("enables localBuild and requires a base image when building locally", async () => { - selectStub - .onFirstCall() - .resolves("create") - .onSecondCall() - .resolves("us-central1") - .onThirdCall() - .resolves(true); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); - inputStub.onThirdCall().resolves("/"); - const s = setup(); + it("builds a new service remotely by default", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ false, "", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.false; - const baseImagePrompt = inputStub.secondCall.args[0]; - expect(baseImagePrompt.default).to.be.undefined; - expect(baseImagePrompt.validate("nodejs22")).to.be.true; - expect(baseImagePrompt.validate(" ")).to.include("Local builds require a base image"); - expect(s.featureInfo?.run).to.deep.equal({ - serviceId: "my-service", - region: "us-central1", - baseImage: "nodejs22", - rootDir: "/", - localBuild: true, - }); + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.false; }); - it("defaults to building locally if the saved entry for the service does", async () => { - const existing = { - name: "projects/p/locations/europe-west1/services/web", - template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, - }; + it("defaults to building locally if firebase.json builds the service locally", async () => { + const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); - selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); - selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); - inputStub.callsFake((o) => Promise.resolve(o.default)); - config.set("run", { - serviceId: "web", - region: "europe-west1", - rootDir: "/", - localBuild: true, - }); - const s = setup(); + config.set("run", { serviceId: "web", region: "europe-west1", localBuild: true }); + answerPrompts("update", existing, /* localBuild= */ true, "nodejs22", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.true; - expect(s.featureInfo?.run?.localBuild).to.be.true; + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.true; + }); + + it("saves the choice to build locally, which requires a base image", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ true, "nodejs22", "/"); + const initSetup = setup(); + + await askQuestions(initSetup, config, options); + + const baseImagePrompt = inputStub.secondCall.args[0]; + expect(baseImagePrompt.default).to.be.undefined; + expect(baseImagePrompt.validate(" ")).to.equal("Local builds require a base image."); + expect(baseImagePrompt.validate("nodejs22")).to.be.true; + expect(initSetup.featureInfo?.run).to.include({ localBuild: true }); }); }); @@ -284,45 +274,22 @@ describe("init run", () => { ]); }); - it("updates an existing service in place while preserving custom ignore and updating localBuild", () => { - config.set("run", { - serviceId: "web", - region: "us-central1", - rootDir: "/", - ignore: ["custom-ignore"], - }); + it("turns localBuild on for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"] }); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - ignore: ["custom-ignore"], - }); + upsertRunConfig({ ...web, localBuild: true }, config); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - ignore: ["custom-ignore"], - }); + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"], localBuild: true }); + }); + + it("turns localBuild off for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"], localBuild: true }); + + upsertRunConfig(web, config); + + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"] }); }); it("doesn't add an ignore list to an existing service", () => { From f7b5a179f76954db90ce869387dd57509aa2e655 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 14:50:49 -0400 Subject: [PATCH 27/28] Split Cloud Run deploys into remote and local build paths deploy() now calls deployRemoteBuild or deployLocalBuild. Each path has its own upload function, and local builds get their own revision template, so there are no more RevisionCode unions or repeated localBuild ternaries. The local build's output is passed between functions instead of being stored on ServiceDeploy. The local build guard's fix now uses run:services:update's --region flag. --- src/deploy/run/args.ts | 7 - src/deploy/run/deploy.spec.ts | 76 ++++++----- src/deploy/run/deploy.ts | 228 +++++++++++++++++++++++---------- src/deploy/run/prepare.spec.ts | 2 +- src/deploy/run/prepare.ts | 4 +- 5 files changed, 200 insertions(+), 117 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 435a1163330..ea01f146716 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -15,13 +15,6 @@ export interface ServiceDeploy { * this deploy sets or clears it. Undefined means none, so automatic base image updates are off. */ baseImage?: string; - /** Set by deploy, for local builds only: the local build's output. */ - localBuild?: { - scratchDir: string; - outputFiles: string[]; - runCommand?: string; - env?: NonNullable; - }; /** Set by deploy: the service after the rollout. */ deployed?: runv2.Service; } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index a6e50a83e64..c9bbc450272 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -10,7 +10,7 @@ import { Options } from "../../options"; import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { deploy, revisionTemplate } from "./deploy"; +import { deploy, localBuildTemplate, revisionTemplate } from "./deploy"; const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; @@ -229,13 +229,13 @@ describe("run deploy", () => { } it("starts a new service with just the image", () => { - expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ + expect(revisionTemplate({ config: web }, image)).to.deep.equal({ containers: [{ name: "web", image }], }); }); it("keeps the live revision's settings, but not its name", () => { - const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, { image }); + const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, image); expect(template).to.deep.equal({ serviceAccount: "web@my-project.iam.gserviceaccount.com", @@ -251,67 +251,65 @@ describe("run deploy", () => { it("sets the base image, or clears it", () => { const withBaseImage = revisionTemplate( { config: web, existing, baseImage: "nodejs24" }, - { image }, + image, ); expect(withBaseImage.containers?.[0].baseImageUri).to.equal("nodejs24"); - const withoutBaseImage = revisionTemplate({ config: web, existing }, { image }); + const withoutBaseImage = revisionTemplate({ config: web, existing }, image); expect(withoutBaseImage.containers?.[0]).not.to.have.property("baseImageUri"); }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate( - { config: web, existing }, - { image }, - "Fix the login page", - ); + const withMessage = revisionTemplate({ config: web, existing }, image, "Fix the login page"); expect(withMessage.annotations).to.deep.equal({ team: "frontend", [DEPLOY_MESSAGE]: "Fix the login page", }); - const withoutMessage = revisionTemplate({ config: web, existing }, { image }); + const withoutMessage = revisionTemplate({ config: web, existing }, image); expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" }); }); - it("runs a local build's output with the build's start command", () => { - // The command is split on whitespace, so stray spaces don't become empty arguments. - const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - - const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - - expect(template.containers?.[0]).to.deep.include({ + it("switches a service from a local build back to an image", () => { + const fromLocalBuild = webRunning({ image: "scratch", - sourceCode: { cloudStorageSource: builtApp }, command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, }); - }); - it("adds the env vars a local build needs, unless the service already sets them", () => { - const env = [ - { name: "MODE", value: "development" }, - { name: "NODE_ENV", value: "production" }, - ]; - const localBuild = { scratchDir, outputFiles: [".next"], env }; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, image); - const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - - expect(template.containers?.[0].env).to.deep.equal([ - { name: "MODE", value: "prod" }, - { name: "NODE_ENV", value: "production" }, - ]); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); - it("switches a service from a local build back to an image", () => { - const fromLocalBuild = webRunning({ - image: "scratch", - command: ["npm", "run", "start"], - sourceCode: { cloudStorageSource: builtApp }, + describe("localBuildTemplate", () => { + it("runs a local build's output with the build's start command", () => { + // The command is split on whitespace, so stray spaces don't become empty arguments. + const build = { outputFiles: [".next"], runCommand: " npm run start ", env: [] }; + + const template = localBuildTemplate({ config: web, existing }, build, builtApp); + + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); - const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); + it("adds the env vars a local build needs, unless the service already sets them", () => { + const env = [ + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ]; + const build = { outputFiles: [".next"], env }; - expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); + const template = localBuildTemplate({ config: web, existing }, build, builtApp); + + expect(template.containers?.[0].env).to.deep.equal([ + { name: "MODE", value: "prod" }, + { name: "NODE_ENV", value: "production" }, + ]); + }); }); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 5aa1f01ea03..f6995709216 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -22,45 +22,78 @@ const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; -/** What a new revision runs: an image built on Cloud Build, or a local build's uploaded output. */ -type RevisionCode = { image: string } | { builtApp: runv2.StorageSource }; +/** + * The image of a container that runs a local build's output. Cloud Run runs the output from Cloud + * Storage on the service's base image, so the container doesn't need an image of its own. + */ +const NO_IMAGE = "scratch"; + +/** What a local build produced. */ +export interface LocalBuildOutput { + /** The built files to upload, relative to the scratch directory the build ran in. */ + outputFiles: string[]; + /** The command that starts the app, e.g. "npm run start". */ + runCommand?: string; + /** The env vars the app needs at runtime. */ + env: NonNullable; +} /** Builds each service, locally or on Cloud Build, then rolls it out. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { - const { projectId } = context; for (const svc of payload.run?.services || []) { - // A local build runs in a scratch copy of the source, which is deleted afterwards. - const scratchDir = svc.config.localBuild - ? fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`)) - : undefined; - try { - if (scratchDir) { - svc.localBuild = await buildLocally(context, options, svc, scratchDir); - } - const uploaded = await uploadSource(projectId, options, svc); - // Cloud Run runs a local build's output as is. Other source is built into an image first. - const code = svc.localBuild - ? { builtApp: uploaded } - : { image: await buildImage(projectId, svc, uploaded) }; - const template = revisionTemplate(svc, code, options.message as string | undefined); - svc.deployed = svc.existing - ? await deployRevision(svc.existing, template) - : await createNewService(projectId, svc.config, template); - } finally { - if (scratchDir) { - removeTempPath(scratchDir); - } + if (svc.config.localBuild) { + await deployLocalBuild(context, options, svc); + } else { + await deployRemoteBuild(context, options, svc); } } } +/** Uploads the service's source, builds it into an image on Cloud Build, then rolls it out. */ +async function deployRemoteBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + const source = await uploadSource(projectId, options, svc.config); + const image = await buildImage(projectId, svc, source); + const template = revisionTemplate(svc, image, options.message as string | undefined); + svc.deployed = await rollOut(projectId, svc, template); +} + +/** + * Builds the service on this machine, uploads the output, then rolls it out. Cloud Run runs the + * output as is, so nothing is built on Cloud Build. + */ +async function deployLocalBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + // The build runs in a scratch copy of the source, which is deleted afterwards. + const scratchDir = fs.mkdtempSync( + path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`), + ); + try { + const build = await buildLocally(context, options, svc, scratchDir); + const builtApp = await uploadBuiltApp(projectId, options, svc.config, scratchDir, build); + const message = options.message as string | undefined; + const template = localBuildTemplate(svc, build, builtApp, message); + svc.deployed = await rollOut(projectId, svc, template); + } finally { + removeTempPath(scratchDir); + } +} + /** Builds the service on this machine, in a scratch copy of its source. */ async function buildLocally( context: Context, options: Options, svc: ServiceDeploy, scratchDir: string, -): Promise> { +): Promise { const { config } = svc; const { serviceId, region } = config; const cfg = toAppHostingConfig(config); @@ -86,19 +119,66 @@ async function buildLocally( (!e.availability || e.availability.includes("RUNTIME")), ) .map((e) => ({ name: e.variable, value: e.value! })); - return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; + return { outputFiles, runCommand: buildConfig.runCommand, env }; } -/** Uploads the service's source, or its local build's output, to the region's source bucket. */ +/** Zips the service's source and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, options: Options, - svc: ServiceDeploy, + config: RunSingle, ): Promise { - const { serviceId, region } = svc.config; - // Like App Hosting, each region has one source bucket, which deletes uploads after 30 days. + const { serviceId, region } = config; + const bucketName = await sourceBucket(projectId, options, region); + const cfg = toAppHostingConfig(config); + // The zip is a tmp file, so it's deleted when the CLI exits. + const archive = await createSourceDeployArchive( + cfg, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; +} + +/** Tars a local build's output and uploads it to the region's source bucket. */ +async function uploadBuiltApp( + projectId: string, + options: Options, + config: RunSingle, + scratchDir: string, + build: LocalBuildOutput, +): Promise { + const { serviceId, region } = config; + const bucketName = await sourceBucket(projectId, options, region); + // The tarball is a tmp file, so it's deleted when the CLI exits. + const archive = await createLocalBuildTarArchive( + toAppHostingConfig(config), + scratchDir, + build.outputFiles, + ); + logLabeledBullet("run", `Uploading built app for service ${serviceId} in ${region}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.TAR, + // Cloud Run runs the tarball as is, so it has to fit Cloud Run's size limit. + CLOUD_RUN_SIZE_LIMIT_BYTES, + ); + return { bucket, object }; +} + +/** + * Returns the region's source bucket, creating it if needed. Like App Hosting, each region has one + * source bucket, which deletes uploads after 30 days. + */ +async function sourceBucket(projectId: string, options: Options, region: string): Promise { const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; - const bucketName = await gcs.upsertBucket({ + return gcs.upsertBucket({ product: "run", createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, projectId, @@ -109,23 +189,6 @@ async function uploadSource( lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, }, }); - - const cfg = toAppHostingConfig(svc.config); - // The archive is a tmp file, so it's deleted when the CLI exits. - const archive = svc.localBuild - ? await createLocalBuildTarArchive(cfg, svc.localBuild.scratchDir, svc.localBuild.outputFiles) - : await createSourceDeployArchive(cfg, path.join(options.config.projectDir, cfg.rootDir)); - logLabeledBullet( - "run", - `Uploading ${svc.localBuild ? "built app" : "source"} for service ${serviceId} in ${region}...`, - ); - const { bucket, object } = await gcs.uploadObject( - { file: archive, stream: fs.createReadStream(archive) }, - bucketName, - svc.localBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP, - svc.localBuild ? CLOUD_RUN_SIZE_LIMIT_BYTES : undefined, - ); - return { bucket, object }; } /** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ @@ -156,17 +219,15 @@ async function buildImage( /** * Returns the revision to roll out: the live revision's settings (env vars, service account, - * scaling, etc.) with the new code, base image, and deploy message. + * scaling, etc.) with the new image, base image, and deploy message. * Exported for testing. */ export function revisionTemplate( svc: ServiceDeploy, - code: RevisionCode, + image: string, message?: string, ): runv2.RevisionTemplate { const { serviceId, region } = svc.config; - // Cloud Run runs a local build's output from source, on top of the base image. - const image = "image" in code ? code.image : "scratch"; const template: runv2.RevisionTemplate = svc.existing ? cloneDeep(svc.existing.template) : { containers: [{ name: serviceId, image }] }; @@ -177,22 +238,9 @@ export function revisionTemplate( throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); } container.image = image; - if ("builtApp" in code) { - container.sourceCode = { cloudStorageSource: code.builtApp }; - const cmd = svc.localBuild?.runCommand?.trim(); - if (cmd) { - container.command = cmd.split(/\s+/); - } else { - delete container.command; - } - // Add the env vars the build says the app needs, unless the service already sets them. - const existingNames = new Set((container.env || []).map((e) => e.name)); - const newEnv = (svc.localBuild?.env || []).filter((e) => !existingNames.has(e.name)); - if (newEnv.length) { - container.env = [...(container.env || []), ...newEnv]; - } - } else if (container.sourceCode) { - // The service was last deployed from a local build, which set these. + if (container.sourceCode) { + // The live revision ran a local build's output, with the build's start command (see + // localBuildTemplate). Drop both, so that the new revision runs the new image as built. delete container.sourceCode; delete container.command; } @@ -213,6 +261,50 @@ export function revisionTemplate( return template; } +/** + * Returns the revision to roll out for a local build: revisionTemplate's, but running the build's + * output from Cloud Storage, on the service's base image, with the build's start command. + * Exported for testing. + */ +export function localBuildTemplate( + svc: ServiceDeploy, + build: LocalBuildOutput, + builtApp: runv2.StorageSource, + message?: string, +): runv2.RevisionTemplate { + const template = revisionTemplate(svc, NO_IMAGE, message); + // revisionTemplate has already checked that there's a container. + const container = mainContainer(template)!; + container.sourceCode = { cloudStorageSource: builtApp }; + // Split on any whitespace, so that stray spaces don't become empty arguments. + const command = build.runCommand?.trim(); + if (command) { + container.command = command.split(/\s+/); + } else { + // The build didn't name a start command, so don't keep one from an earlier revision. + delete container.command; + } + // Add the env vars the build says the app needs, unless the service already sets them. + const existingNames = new Set((container.env || []).map((e) => e.name)); + const newEnv = build.env.filter((e) => !existingNames.has(e.name)); + if (newEnv.length) { + container.env = [...(container.env || []), ...newEnv]; + } + return template; +} + +/** Rolls out the new revision: creates the service if it's new, or updates it. */ +function rollOut( + projectId: string, + svc: ServiceDeploy, + template: runv2.RevisionTemplate, +): Promise { + if (svc.existing) { + return deployRevision(svc.existing, template); + } + return createNewService(projectId, svc.config, template); +} + /** Creates a new service with its first revision. */ function createNewService( projectId: string, diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 0133fb85ae1..48f597c28ce 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -75,7 +75,7 @@ describe("run prepare", () => { getServiceStub.resolves(webWithoutBaseImage); await expect(prepareRun(localWeb)).to.be.rejectedWith( - 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web --region us-central1 --base-image ".', ); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 211c7c2f5c0..a713f5ca2d4 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -3,7 +3,7 @@ import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; import { prereqs } from "./prereqs"; -import { fullServiceName, getExistingService, getServiceConfigs, mainContainer } from "./util"; +import { getExistingService, getServiceConfigs, mainContainer } from "./util"; /** * Reads each service's current state from Cloud Run and resolves its base image. @@ -32,7 +32,7 @@ async function prepareService(context: Context, config: RunSingle): Promise` + ? `firebase run:services:update ${serviceId} --region ${region} --base-image ` : "firebase init run"; throw new FirebaseError( `Local builds require a base image. Set one for service ${serviceId} in ${region} with "${fix}".`, From da44ccba9887dc4b7f6693cfa5fb23c459594934 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 16:01:10 -0400 Subject: [PATCH 28/28] Let a local build's env vars replace the service's values The env vars a build reports describe that build's output, like its start command, so they now replace any env var of the same name on the service instead of being skipped. The service's other env vars are kept. --- src/deploy/run/deploy.spec.ts | 26 ++++++++++++++++++-------- src/deploy/run/deploy.ts | 13 ++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index c9bbc450272..a56aa7fda5f 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -296,17 +296,27 @@ describe("run deploy", () => { }); }); - it("adds the env vars a local build needs, unless the service already sets them", () => { - const env = [ - { name: "MODE", value: "development" }, - { name: "NODE_ENV", value: "production" }, - ]; - const build = { outputFiles: [".next"], env }; + it("sets the env vars the build reports, replacing the service's values for the same names", () => { + const service = webRunning({ + env: [ + { name: "MODE", value: "prod" }, + { name: "API_URL", value: "https://api.example.com" }, + ], + }); + const build = { + outputFiles: [".next"], + env: [ + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ], + }; - const template = localBuildTemplate({ config: web, existing }, build, builtApp); + const template = localBuildTemplate({ config: web, existing: service }, build, builtApp); + // API_URL isn't from the build, so it's kept. expect(template.containers?.[0].env).to.deep.equal([ - { name: "MODE", value: "prod" }, + { name: "API_URL", value: "https://api.example.com" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f6995709216..812c7cceb80 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -284,11 +284,14 @@ export function localBuildTemplate( // The build didn't name a start command, so don't keep one from an earlier revision. delete container.command; } - // Add the env vars the build says the app needs, unless the service already sets them. - const existingNames = new Set((container.env || []).map((e) => e.name)); - const newEnv = build.env.filter((e) => !existingNames.has(e.name)); - if (newEnv.length) { - container.env = [...(container.env || []), ...newEnv]; + // Set the runtime env vars that the build reports. Like the start command, they describe this + // build's output, so the build wins: each one replaces any env var of the same name on the + // service, even one set in the console or with gcloud. The service's other env vars are kept. + // A var that an earlier build set stays on the service if this build stops reporting it. + if (build.env.length) { + const buildNames = new Set(build.env.map((e) => e.name)); + const otherEnv = (container.env || []).filter((e) => !buildNames.has(e.name)); + container.env = [...otherEnv, ...build.env]; } return template; }