From eaaffe17ff5da2c50957338e145ba77fde34d3b9 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 28 Sep 2026 16:53:05 -0400 Subject: [PATCH 01/42] 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 4780e005206..97ff97e0526 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 c6061d37030..38c7a9ef7b9 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 03f1e55bbeca04a4371d4be93ad616dd02ff0d7b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 10:35:24 -0400 Subject: [PATCH 02/42] 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 a7290870374499dd25e6b6db0c149e42e22f03ec Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:53:09 -0400 Subject: [PATCH 03/42] 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 ee94ae44271d136fcb8b2ca55236a087258a1b20 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 13:50:27 -0400 Subject: [PATCH 04/42] 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 5b7a7a6bdf101a44580ef80513b9f20c0af1cc24 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 05/42] 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 c0bbf78c254feb5ab2ecac64b246db7d56cbdf1f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 06/42] 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 466f1a54413096b43fe8e4d8cd310ee7de737e2b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 11:42:24 -0400 Subject: [PATCH 07/42] 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 e33867791fa9027374ddca499202c2646b6b4519 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:44:32 -0400 Subject: [PATCH 08/42] 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 789bf045326a53678ce583db4da1ab9104d0358a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:58:25 -0400 Subject: [PATCH 09/42] 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 4a264610b49e4f1d3b22d2078b6bc8aca5abd595 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:45:50 -0400 Subject: [PATCH 10/42] 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 dcd5eaf759d43b47e9eced08687b157cecca9009 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:08:56 -0400 Subject: [PATCH 11/42] 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 195aa47e881fa8c262520f91b1203b8e339ddc9d Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:18:36 -0400 Subject: [PATCH 12/42] 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 51f48fea1af..9b73db9c81f 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 a99772f87cf..e8fb3dc031d 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"; @@ -127,16 +128,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", @@ -146,6 +148,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 aeb232edc94..c8f47619d43 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -2,6 +2,7 @@ import { statSync } 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"; @@ -104,9 +105,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; @@ -121,6 +122,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 2fa39f79c73a994b978dc152c407661332adc526 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:49:16 -0400 Subject: [PATCH 13/42] 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 69ca644818bcc49852f82f4522065e28119ec903 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:11:24 -0400 Subject: [PATCH 14/42] 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 1086ebead0ad371c85c1556a50111a87dd3ec09d Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 08:16:20 -0400 Subject: [PATCH 15/42] 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 f0b1a7b14ce946b7fbc1db20ca43aa151089cba5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 10:22:32 -0700 Subject: [PATCH 16/42] Rename masterTimeout option to pollTimeoutMs in runv2 --- src/gcp/runv2.spec.ts | 6 +++--- src/gcp/runv2.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index f7c87399f78..c864b3c7bdd 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -686,13 +686,13 @@ describe("runv2", () => { ); }); - it("updateService uses explicit updateMask and masterTimeout when provided", async () => { + it("updateService uses explicit updateMask and pollTimeoutMs 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, + pollTimeoutMs: 60000, }); expect(patchStub).to.have.been.calledOnceWithExactly( @@ -711,7 +711,7 @@ describe("runv2", () => { pollStub.resolves(BASE_RUN_SERVICE); await runv2.createService(PROJECT_ID, LOCATION, SERVICE_ID, BASE_RUN_SERVICE, { - masterTimeout: 60000, + pollTimeoutMs: 60000, }); const expectedBody: Partial = { ...BASE_RUN_SERVICE }; diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 2f5e8071a7d..3ce44515450 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -221,7 +221,7 @@ export async function submitBuild( */ export async function updateService( service: Omit, - opts: { updateMask?: string[]; masterTimeout?: number } = {}, + opts: { updateMask?: string[]; pollTimeoutMs?: number } = {}, ): Promise { const updateMask = opts.updateMask ?? [ ...proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"), @@ -241,7 +241,7 @@ export async function updateService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, - masterTimeout: opts.masterTimeout, + masterTimeout: opts.pollTimeoutMs, }); return svc; } @@ -255,7 +255,7 @@ export async function createService( location: string, serviceId: string, service: Omit, - opts: { masterTimeout?: number } = {}, + opts: { pollTimeoutMs?: 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. @@ -274,7 +274,7 @@ export async function createService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, - masterTimeout: opts.masterTimeout, + masterTimeout: opts.pollTimeoutMs, }); return svc; } From 51e7568a7e916ed82dea9d7612eeebf71aec2c8e Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 10:23:38 -0700 Subject: [PATCH 17/42] Use pollTimeoutMs option when creating and updating Cloud Run services --- src/deploy/run/deploy.ts | 2 +- src/deploy/run/util.spec.ts | 2 +- src/deploy/run/util.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index ad3ec507e2e..f5eb0e75059 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -73,7 +73,7 @@ async function deployService( invokerIamDisabled: true, ingress: "INGRESS_TRAFFIC_ALL", }, - { masterTimeout: SERVICE_OPERATION_TIMEOUT_MS }, + { pollTimeoutMs: SERVICE_OPERATION_TIMEOUT_MS }, ); } diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index 2e11f6263ae..0414815016d 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -116,7 +116,7 @@ describe("run util", () => { }, { updateMask: ["template", "traffic"], - masterTimeout: 600000, + pollTimeoutMs: 600000, }, ); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 819ddec99fd..41b9fe78fa9 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -94,7 +94,7 @@ export function deployRevision( }, { updateMask: ["template", "traffic"], - masterTimeout: SERVICE_OPERATION_TIMEOUT_MS, + pollTimeoutMs: SERVICE_OPERATION_TIMEOUT_MS, }, ); } From 6a3ec2d4972b309740d0e605dba512e10b51c0fb Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 10:35:37 -0700 Subject: [PATCH 18/42] Make updateMask fallback in updateService more readable --- src/gcp/runv2.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 3ce44515450..66d73d97335 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -223,11 +223,17 @@ export async function updateService( service: Omit, opts: { updateMask?: string[]; pollTimeoutMs?: number } = {}, ): Promise { - const updateMask = opts.updateMask ?? [ - ...proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"), + let updateMask = opts.updateMask; + if (!updateMask) { + updateMask = proto.fieldMasks( + service, + /* doNotRecurseIn...*/ "labels", + "annotations", + "tags", + ); // Always update revision name to ensure null generates a new unique revision name. - "template.revision", - ]; + updateMask.push("template.revision"); + } const res = await client.patch, LongRunningOperation>( service.name, service, From fbbcb6f8aa485b7b028d6a7265c56aaefb2ceb5d Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 10:36:54 -0700 Subject: [PATCH 19/42] Add comment explaining default updateMask calculation in updateService --- src/gcp/runv2.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 66d73d97335..2eb6e2a13ea 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -223,6 +223,7 @@ export async function updateService( service: Omit, opts: { updateMask?: string[]; pollTimeoutMs?: number } = {}, ): Promise { + // If no explicit updateMask is provided, infer the mask from the fields set on the service. let updateMask = opts.updateMask; if (!updateMask) { updateMask = proto.fieldMasks( From 9c7fa984c2be183dcce8d21c346c7a8be9a28e22 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 12:39:08 -0700 Subject: [PATCH 20/42] Type Service.ingress as IngressTraffic union in runv2 --- src/gcp/runv2.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 2eb6e2a13ea..15635b83b0b 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -92,6 +92,13 @@ export interface TrafficTarget { tag?: string; } +export type IngressTraffic = + | "INGRESS_TRAFFIC_UNSPECIFIED" + | "INGRESS_TRAFFIC_ALL" + | "INGRESS_TRAFFIC_INTERNAL_ONLY" + | "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" + | "INGRESS_TRAFFIC_NONE"; + // 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 @@ -121,7 +128,7 @@ export interface Service { traffic?: TrafficTarget[]; trafficStatuses?: TrafficTarget[]; invokerIamDisabled?: boolean; - ingress?: string; + ingress?: IngressTraffic; // Is this redundant with the Build API? buildConfig?: BuildConfig; uri?: string; From 6a793742542b1a1ff720fe396cca39d57e400dff Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 12:52:51 -0700 Subject: [PATCH 21/42] Format updateMask call with Prettier and guard template literal in runv2 --- src/gcp/runv2.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 15635b83b0b..cd830500e19 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -217,7 +217,7 @@ export async function submitBuild( }); if (status !== "SUCCESS") { throw new FirebaseError( - `Cloud Build failed with status ${status}. View the build logs at ${cloudBuild.logUrl}`, + `Cloud Build failed with status ${status ?? "UNKNOWN"}. View the build logs at ${cloudBuild.logUrl ?? ""}`, ); } } @@ -233,12 +233,7 @@ export async function updateService( // If no explicit updateMask is provided, infer the mask from the fields set on the service. let updateMask = opts.updateMask; if (!updateMask) { - updateMask = proto.fieldMasks( - service, - /* doNotRecurseIn...*/ "labels", - "annotations", - "tags", - ); + updateMask = proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"); // Always update revision name to ensure null generates a new unique revision name. updateMask.push("template.revision"); } From a4430ad03c5b3b5a893a28b995f96e0b389ed678 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 13:04:29 -0700 Subject: [PATCH 22/42] Improve readability of firebase init run and remove mainContainer --- src/deploy/run/util.spec.ts | 12 +-- src/deploy/run/util.ts | 7 -- src/init/features/run.spec.ts | 58 +++++++++++---- src/init/features/run.ts | 136 +++++++++++++++++++++------------- 4 files changed, 132 insertions(+), 81 deletions(-) diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index f0a11709d16..c7c9b85eba5 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; -import { getExistingService, mainContainer } from "./util"; +import { getExistingService } from "./util"; describe("run util", () => { afterEach(() => sinon.restore()); @@ -23,14 +23,4 @@ describe("run util", () => { 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 index 7e80f6073c8..5976083da0b 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -18,10 +18,3 @@ export async function getExistingService( 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/run.spec.ts b/src/init/features/run.spec.ts index a99772f87cf..808b001a56a 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -150,19 +150,51 @@ describe("init run", () => { }); 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, - ]); + it("sets a single service object when firebase.json has no run config", () => { + const firstService = { + serviceId: "web", + region: "us-central1", + rootDir: "/", + ignore: ["node_modules"], + }; + upsertRunConfig(firstService, config); + expect(config.src.run).to.deep.equal(firstService); + }); + + it("converts to an array when adding a second service", () => { + const firstService = { serviceId: "web", region: "us-central1" }; + const secondService = { serviceId: "api", region: "us-east1" }; + upsertRunConfig(firstService, config); + upsertRunConfig(secondService, config); + expect(config.src.run).to.deep.equal([firstService, secondService]); + }); + + it("updates an existing service in place while preserving existing ignore and localBuild settings", () => { + config.set("run", { + serviceId: "web", + region: "us-central1", + rootDir: "/", + ignore: ["custom-ignore"], + localBuild: true, + }); + + upsertRunConfig( + { + serviceId: "web", + region: "us-east1", + rootDir: "apps/web", + ignore: ["default-ignore"], + }, + config, + ); + + expect(config.src.run).to.deep.equal({ + serviceId: "web", + region: "us-east1", + rootDir: "apps/web", + ignore: ["custom-ignore"], + localBuild: true, + }); }); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index aeb232edc94..dd559383940 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -3,7 +3,7 @@ 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 { getExistingService } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import * as run from "../../gcp/run"; @@ -13,6 +13,9 @@ import { input, select } from "../../prompt"; import { requirePermissions } from "../../requirePermissions"; import { logBullet } from "../../utils"; +const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; +const SERVICE_ID_REGEX = /^[a-z]([-a-z0-9]{0,47}[a-z0-9])?$/; + export interface RunInfo { serviceId: string; region: string; @@ -41,66 +44,91 @@ export async function askQuestions(setup: Setup, config: Config, options: Option 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."); - } + existing = await promptExistingService(projectId); } let serviceId: string; let region: string; if (existing) { - [, , , region, , serviceId] = existing.name.split("/"); + ({ region, serviceId } = parseServiceName(existing.name)); } 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; - }, - }); + serviceId = await promptNewServiceId(projectId, region); } - 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({ + const existingBaseImage = existing?.template.containers?.[0]?.baseImageUri; + const defaultBaseImage = existing ? existingBaseImage : "nodejs22"; + const rawBaseImage = await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: defaultBaseImage, + }); + const baseImage = (rawBaseImage || "").trim(); + + const rootDir = await promptRootDir(config.projectDir); + + setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; +} + +async function promptExistingService(projectId: string): Promise { + const allServices = await runv2.listServices(projectId, /* functionsOnly= */ false); + // Skip services that another product (e.g. Cloud Functions or App Hosting) manages. + const services = allServices.filter((s) => !s.labels?.[runv2.CLIENT_NAME_LABEL]); + if (services.length === 0) { + logBullet("No Cloud Run services to update. Creating a new service instead."); + return undefined; + } + const choices = services.map((service) => { + const { serviceId, region } = parseServiceName(service.name); + return { name: `${serviceId} (${region})`, value: service }; + }); + return select({ + message: "Which service would you like to update?", + choices, + }); +} + +/** + * Extracts the region and service ID from a Cloud Run service resource name + * (projects/{project}/locations/{region}/services/{serviceId}). + */ +function parseServiceName(name: string): { region: string; serviceId: string } { + const parts = name.split("/"); + return { region: parts[3], serviceId: parts[5] }; +} + +async function promptNewServiceId(projectId: string, region: string): Promise { + return input({ + message: "Please enter a unique ID for your service", + validate: async (id: string) => { + if (!SERVICE_ID_REGEX.test(id)) { + return "Use up to 49 lowercase letters, digits, and hyphens, starting with a letter and not ending with a hyphen."; + } + const existing = await getExistingService(projectId, region, id); + if (existing) { + return `A service named ${id} already exists in ${region}.`; + } + return true; + }, + }); +} + +async function promptRootDir(projectDir: string): Promise { + return 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 ( - Boolean(statSync(absPath, { throwIfNoEntry: false })?.isDirectory()) || - `Directory ${absPath} does not exist. Please enter a valid directory.` - ); + validate: (dir: string) => { + const absPath = path.join(projectDir, dir); + const stat = statSync(absPath, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) { + return `Directory ${absPath} does not exist. Please enter a valid directory.`; + } + return true; }, }); - - setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; } /** @@ -116,7 +144,7 @@ export async function actuate(setup: Setup, config: Config): Promise { serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, - ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + ignore: DEFAULT_IGNORE, }, config, ); @@ -128,13 +156,21 @@ export async function actuate(setup: Setup, config: Config): Promise { * 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) { + if (!config.src.run) { + config.set("run", runConfig); + return; + } + const services = Array.isArray(config.src.run) ? [...config.src.run] : [config.src.run]; + const existingIndex = services.findIndex((s) => s.serviceId === runConfig.serviceId); + if (existingIndex === -1) { services.push(runConfig); } else { - const { rootDir, region } = runConfig; - services[i] = { ...runConfig, ...services[i], rootDir, region }; + const existingConfig = services[existingIndex]; + services[existingIndex] = { + ...existingConfig, + ...runConfig, + ignore: existingConfig.ignore ?? runConfig.ignore, + }; } config.set("run", services.length === 1 ? services[0] : services); } From 2915dea05401c90c047c2508fc3a1f97d7a65bc5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 13:12:02 -0700 Subject: [PATCH 23/42] Remove sidecar container from deploy.spec.ts --- src/deploy/run/deploy.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 4162377ea19..c516eec8e14 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -109,7 +109,6 @@ describe("run deploy", () => { serviceAccount: "sa", annotations: { "firebase.google.com/deploy-message": "old", keep: "me" }, containers: [ - { name: "sidecar", image: "side" }, { name: "main", image: "old", @@ -135,7 +134,6 @@ describe("run deploy", () => { 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", From a621639922eaf2044538908f03fa9a6104d61611 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 17:02:12 -0700 Subject: [PATCH 24/42] Use dirExistsSync from fsutils in init run promptRootDir --- src/init/features/run.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index dd559383940..2e89e2ee83c 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,4 +1,3 @@ -import { statSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; @@ -6,6 +5,7 @@ import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; import { getExistingService } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; +import { dirExistsSync } from "../../fsutils"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; @@ -122,8 +122,7 @@ async function promptRootDir(projectDir: string): Promise { default: "/", validate: (dir: string) => { const absPath = path.join(projectDir, dir); - const stat = statSync(absPath, { throwIfNoEntry: false }); - if (!stat?.isDirectory()) { + if (!dirExistsSync(absPath)) { return `Directory ${absPath} does not exist. Please enter a valid directory.`; } return true; From 2736051867bca38eb350374b40183e1b6e5b320a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 18:07:15 -0700 Subject: [PATCH 25/42] Make Cloud Run temp-file cleanup best-effort A failure to delete the temporary source archive is now logged at debug level instead of thrown, so it can't fail a deploy that succeeded or replace the real upload error. Mirrors the cleanup in deploy/apphosting/deploy.ts. --- src/deploy/run/deploy.spec.ts | 19 ++++++++++++++++++- src/deploy/run/deploy.ts | 17 +++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index c516eec8e14..a57e624078f 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -55,7 +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 }); + expect(rmSyncStub).to.have.been.calledWith("src.zip", { recursive: true, 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" }, @@ -88,6 +88,23 @@ describe("run deploy", () => { expect(svc.deployed).to.deep.equal({ uri: "new" }); }); + it("still deploys if the source archive can't be deleted", async () => { + rmSyncStub.restore(); + sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); + + const svc = await deployOne(service()); + + expect(svc.deployed).to.deep.equal({ uri: "new" }); + }); + + it("reports the upload error, not the cleanup error, when both fail", async () => { + rmSyncStub.restore(); + sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); + (gcs.uploadObject as sinon.SinonStub).rejects(new Error("upload failed")); + + await expect(deployOne(service())).to.be.rejectedWith("upload failed"); + }); + it("builds without a base image", async () => { await deployOne(service()); expect(submitBuildStub.firstCall.args[2].buildpackBuild).to.deep.equal({ diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 67f82b553c1..3dab9e49fd3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,10 +1,11 @@ import * as fs from "fs"; import * as path from "path"; -import { FirebaseError } from "../../error"; +import { FirebaseError, getErrMsg } from "../../error"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; +import { logger } from "../../logger"; import { Options } from "../../options"; import { logLabeledBullet } from "../../utils"; import { createSourceDeployArchive } from "../apphosting/util"; @@ -109,7 +110,7 @@ async function uploadSource( ); return { bucket, object }; } finally { - fs.rmSync(archive, { force: true }); + removeTempPath(archive); } } @@ -136,3 +137,15 @@ async function buildImage( }); return imageUri; } + +/** + * Deletes a temporary file or directory. Failures are only logged: a leftover temp file + * shouldn't fail a deploy that succeeded or hide the error from one that didn't. + */ +function removeTempPath(tempPath: string): void { + try { + fs.rmSync(tempPath, { recursive: true, force: true }); + } catch (err: unknown) { + logger.debug(`Failed to clean up ${tempPath}: ${getErrMsg(err)}`); + } +} From c3427e8e13f9fc69cd0c1f293ced7679b83fd3bf Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 7 Oct 2026 10:52:09 -0700 Subject: [PATCH 26/42] Don't default to a base image in init run A base image turns on automatic base image updates, which are off by default. So init no longer suggests nodejs22. An existing service still defaults to its own base image. --- src/init/features/run.spec.ts | 8 ++++---- src/init/features/run.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 808b001a56a..4284018b39a 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -44,9 +44,9 @@ describe("init run", () => { ).to.be.rejectedWith("Cloud Run requires a Firebase project"); }); - it("asks how to create a new service", async () => { + it("asks how to create a new service, with no default base image", async () => { selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-east1"); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); + inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves(""); inputStub.onThirdCall().resolves("/"); const s = setup(); @@ -56,7 +56,7 @@ describe("init run", () => { choices: ["us-central1", "us-east1"], default: "us-central1", }); - expect(inputStub.secondCall.args[0].default).to.equal("nodejs22"); + expect(inputStub.secondCall.args[0].default).to.be.undefined; const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); @@ -64,7 +64,7 @@ describe("init run", () => { expect(s.featureInfo?.run).to.deep.equal({ serviceId: "my-service", region: "us-east1", - baseImage: "nodejs22", + baseImage: "", rootDir: "/", }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 2e89e2ee83c..38362930db6 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -60,11 +60,11 @@ export async function askQuestions(setup: Setup, config: Config, options: Option serviceId = await promptNewServiceId(projectId, region); } - const existingBaseImage = existing?.template.containers?.[0]?.baseImageUri; - const defaultBaseImage = existing ? existingBaseImage : "nodejs22"; + // A base image turns on automatic base image updates, which are off by default. So there's no + // default base image, except that an existing service keeps its own. const rawBaseImage = await input({ - message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: defaultBaseImage, + message: "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", + default: existing?.template.containers?.[0]?.baseImageUri, }); const baseImage = (rawBaseImage || "").trim(); From 11f67e1f5325df069ccfbce44522e6c5df438869 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:58:25 -0400 Subject: [PATCH 27/42] 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 2c9c06e5a27b0cfae1cc6ea84b9f096a40dc955f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:45:50 -0400 Subject: [PATCH 28/42] 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 bd2ff06334559a8e710df747c4186f28e9f22dee Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:08:56 -0400 Subject: [PATCH 29/42] 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 51cca8ba9ba7d31242e8d9f25c04bcff7f2dfc4c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 13:04:29 -0700 Subject: [PATCH 30/42] Improve readability of firebase init run and remove mainContainer --- src/deploy/run/util.spec.ts | 12 +-- src/deploy/run/util.ts | 7 -- src/init/features/run.spec.ts | 58 +++++++++++---- src/init/features/run.ts | 136 +++++++++++++++++++++------------- 4 files changed, 132 insertions(+), 81 deletions(-) diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index f0a11709d16..c7c9b85eba5 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; -import { getExistingService, mainContainer } from "./util"; +import { getExistingService } from "./util"; describe("run util", () => { afterEach(() => sinon.restore()); @@ -23,14 +23,4 @@ describe("run util", () => { 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 index 7e80f6073c8..5976083da0b 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -18,10 +18,3 @@ export async function getExistingService( 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/run.spec.ts b/src/init/features/run.spec.ts index a99772f87cf..808b001a56a 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -150,19 +150,51 @@ describe("init run", () => { }); 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, - ]); + it("sets a single service object when firebase.json has no run config", () => { + const firstService = { + serviceId: "web", + region: "us-central1", + rootDir: "/", + ignore: ["node_modules"], + }; + upsertRunConfig(firstService, config); + expect(config.src.run).to.deep.equal(firstService); + }); + + it("converts to an array when adding a second service", () => { + const firstService = { serviceId: "web", region: "us-central1" }; + const secondService = { serviceId: "api", region: "us-east1" }; + upsertRunConfig(firstService, config); + upsertRunConfig(secondService, config); + expect(config.src.run).to.deep.equal([firstService, secondService]); + }); + + it("updates an existing service in place while preserving existing ignore and localBuild settings", () => { + config.set("run", { + serviceId: "web", + region: "us-central1", + rootDir: "/", + ignore: ["custom-ignore"], + localBuild: true, + }); + + upsertRunConfig( + { + serviceId: "web", + region: "us-east1", + rootDir: "apps/web", + ignore: ["default-ignore"], + }, + config, + ); + + expect(config.src.run).to.deep.equal({ + serviceId: "web", + region: "us-east1", + rootDir: "apps/web", + ignore: ["custom-ignore"], + localBuild: true, + }); }); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index aeb232edc94..dd559383940 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -3,7 +3,7 @@ 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 { getExistingService } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import * as run from "../../gcp/run"; @@ -13,6 +13,9 @@ import { input, select } from "../../prompt"; import { requirePermissions } from "../../requirePermissions"; import { logBullet } from "../../utils"; +const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; +const SERVICE_ID_REGEX = /^[a-z]([-a-z0-9]{0,47}[a-z0-9])?$/; + export interface RunInfo { serviceId: string; region: string; @@ -41,66 +44,91 @@ export async function askQuestions(setup: Setup, config: Config, options: Option 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."); - } + existing = await promptExistingService(projectId); } let serviceId: string; let region: string; if (existing) { - [, , , region, , serviceId] = existing.name.split("/"); + ({ region, serviceId } = parseServiceName(existing.name)); } 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; - }, - }); + serviceId = await promptNewServiceId(projectId, region); } - 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({ + const existingBaseImage = existing?.template.containers?.[0]?.baseImageUri; + const defaultBaseImage = existing ? existingBaseImage : "nodejs22"; + const rawBaseImage = await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: defaultBaseImage, + }); + const baseImage = (rawBaseImage || "").trim(); + + const rootDir = await promptRootDir(config.projectDir); + + setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; +} + +async function promptExistingService(projectId: string): Promise { + const allServices = await runv2.listServices(projectId, /* functionsOnly= */ false); + // Skip services that another product (e.g. Cloud Functions or App Hosting) manages. + const services = allServices.filter((s) => !s.labels?.[runv2.CLIENT_NAME_LABEL]); + if (services.length === 0) { + logBullet("No Cloud Run services to update. Creating a new service instead."); + return undefined; + } + const choices = services.map((service) => { + const { serviceId, region } = parseServiceName(service.name); + return { name: `${serviceId} (${region})`, value: service }; + }); + return select({ + message: "Which service would you like to update?", + choices, + }); +} + +/** + * Extracts the region and service ID from a Cloud Run service resource name + * (projects/{project}/locations/{region}/services/{serviceId}). + */ +function parseServiceName(name: string): { region: string; serviceId: string } { + const parts = name.split("/"); + return { region: parts[3], serviceId: parts[5] }; +} + +async function promptNewServiceId(projectId: string, region: string): Promise { + return input({ + message: "Please enter a unique ID for your service", + validate: async (id: string) => { + if (!SERVICE_ID_REGEX.test(id)) { + return "Use up to 49 lowercase letters, digits, and hyphens, starting with a letter and not ending with a hyphen."; + } + const existing = await getExistingService(projectId, region, id); + if (existing) { + return `A service named ${id} already exists in ${region}.`; + } + return true; + }, + }); +} + +async function promptRootDir(projectDir: string): Promise { + return 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 ( - Boolean(statSync(absPath, { throwIfNoEntry: false })?.isDirectory()) || - `Directory ${absPath} does not exist. Please enter a valid directory.` - ); + validate: (dir: string) => { + const absPath = path.join(projectDir, dir); + const stat = statSync(absPath, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) { + return `Directory ${absPath} does not exist. Please enter a valid directory.`; + } + return true; }, }); - - setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; } /** @@ -116,7 +144,7 @@ export async function actuate(setup: Setup, config: Config): Promise { serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, - ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + ignore: DEFAULT_IGNORE, }, config, ); @@ -128,13 +156,21 @@ export async function actuate(setup: Setup, config: Config): Promise { * 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) { + if (!config.src.run) { + config.set("run", runConfig); + return; + } + const services = Array.isArray(config.src.run) ? [...config.src.run] : [config.src.run]; + const existingIndex = services.findIndex((s) => s.serviceId === runConfig.serviceId); + if (existingIndex === -1) { services.push(runConfig); } else { - const { rootDir, region } = runConfig; - services[i] = { ...runConfig, ...services[i], rootDir, region }; + const existingConfig = services[existingIndex]; + services[existingIndex] = { + ...existingConfig, + ...runConfig, + ignore: existingConfig.ignore ?? runConfig.ignore, + }; } config.set("run", services.length === 1 ? services[0] : services); } From 1ca1eb315028dcdea94d33fcf6139067cc5eac4a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 17:02:12 -0700 Subject: [PATCH 31/42] Use dirExistsSync from fsutils in init run promptRootDir --- src/init/features/run.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index dd559383940..2e89e2ee83c 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,4 +1,3 @@ -import { statSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; @@ -6,6 +5,7 @@ import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; import { getExistingService } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; +import { dirExistsSync } from "../../fsutils"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; @@ -122,8 +122,7 @@ async function promptRootDir(projectDir: string): Promise { default: "/", validate: (dir: string) => { const absPath = path.join(projectDir, dir); - const stat = statSync(absPath, { throwIfNoEntry: false }); - if (!stat?.isDirectory()) { + if (!dirExistsSync(absPath)) { return `Directory ${absPath} does not exist. Please enter a valid directory.`; } return true; From 84f2bbaa620973e62cd34c2ffd8ba392d415679e Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 7 Oct 2026 10:52:09 -0700 Subject: [PATCH 32/42] Don't default to a base image in init run A base image turns on automatic base image updates, which are off by default. So init no longer suggests nodejs22. An existing service still defaults to its own base image. --- src/init/features/run.spec.ts | 8 ++++---- src/init/features/run.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 808b001a56a..4284018b39a 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -44,9 +44,9 @@ describe("init run", () => { ).to.be.rejectedWith("Cloud Run requires a Firebase project"); }); - it("asks how to create a new service", async () => { + it("asks how to create a new service, with no default base image", async () => { selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-east1"); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); + inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves(""); inputStub.onThirdCall().resolves("/"); const s = setup(); @@ -56,7 +56,7 @@ describe("init run", () => { choices: ["us-central1", "us-east1"], default: "us-central1", }); - expect(inputStub.secondCall.args[0].default).to.equal("nodejs22"); + expect(inputStub.secondCall.args[0].default).to.be.undefined; const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); @@ -64,7 +64,7 @@ describe("init run", () => { expect(s.featureInfo?.run).to.deep.equal({ serviceId: "my-service", region: "us-east1", - baseImage: "nodejs22", + baseImage: "", rootDir: "/", }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 2e89e2ee83c..38362930db6 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -60,11 +60,11 @@ export async function askQuestions(setup: Setup, config: Config, options: Option serviceId = await promptNewServiceId(projectId, region); } - const existingBaseImage = existing?.template.containers?.[0]?.baseImageUri; - const defaultBaseImage = existing ? existingBaseImage : "nodejs22"; + // A base image turns on automatic base image updates, which are off by default. So there's no + // default base image, except that an existing service keeps its own. const rawBaseImage = await input({ - message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: defaultBaseImage, + message: "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", + default: existing?.template.containers?.[0]?.baseImageUri, }); const baseImage = (rawBaseImage || "").trim(); From 74f23b782f1edee80898d2ea201c8790759057a1 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 08:11:42 -0700 Subject: [PATCH 33/42] Make upsertRunConfig easier to read Spell out what init changes on an existing service (rootDir and region) instead of merging three spreads. upsertRunConfig now adds the default ignore list itself, so actuate no longer passes it. Behavior is unchanged; added a test for the ignore fallback on existing services. --- src/init/features/run.spec.ts | 36 ++++++++++++++++++++++++--------- src/init/features/run.ts | 38 +++++++++++++++++------------------ 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 4284018b39a..5ac6cf7369d 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -150,23 +150,27 @@ describe("init run", () => { }); describe("upsertRunConfig", () => { + const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; + it("sets a single service object when firebase.json has no run config", () => { - const firstService = { + upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); + expect(config.src.run).to.deep.equal({ serviceId: "web", region: "us-central1", rootDir: "/", - ignore: ["node_modules"], - }; - upsertRunConfig(firstService, config); - expect(config.src.run).to.deep.equal(firstService); + ignore: DEFAULT_IGNORE, + }); }); it("converts to an array when adding a second service", () => { - const firstService = { serviceId: "web", region: "us-central1" }; - const secondService = { serviceId: "api", region: "us-east1" }; + const firstService = { serviceId: "web", region: "us-central1", rootDir: "/" }; + const secondService = { serviceId: "api", region: "us-east1", rootDir: "api" }; upsertRunConfig(firstService, config); upsertRunConfig(secondService, config); - expect(config.src.run).to.deep.equal([firstService, secondService]); + expect(config.src.run).to.deep.equal([ + { ...firstService, ignore: DEFAULT_IGNORE }, + { ...secondService, ignore: DEFAULT_IGNORE }, + ]); }); it("updates an existing service in place while preserving existing ignore and localBuild settings", () => { @@ -183,7 +187,6 @@ describe("init run", () => { serviceId: "web", region: "us-east1", rootDir: "apps/web", - ignore: ["default-ignore"], }, config, ); @@ -196,5 +199,20 @@ describe("init run", () => { localBuild: true, }); }); + + it("adds the default ignore list to an existing service only if it has none", () => { + config.set("run", [ + { serviceId: "web", region: "us-central1" }, + { serviceId: "api", region: "us-central1", ignore: [] }, + ]); + + upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); + upsertRunConfig({ serviceId: "api", region: "us-central1", rootDir: "api" }, config); + + expect(config.src.run).to.deep.equal([ + { serviceId: "web", region: "us-central1", rootDir: "/", ignore: DEFAULT_IGNORE }, + { serviceId: "api", region: "us-central1", rootDir: "api", ignore: [] }, + ]); + }); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 38362930db6..718f1284c85 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -143,7 +143,6 @@ export async function actuate(setup: Setup, config: Config): Promise { serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, - ignore: DEFAULT_IGNORE, }, config, ); @@ -151,25 +150,26 @@ export async function actuate(setup: Setup, config: Config): Promise { } /** - * 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. + * Adds the service to firebase.json, or updates its rootDir and region if it's already there. + * Other settings, like a custom ignore list, are kept. Any service without an ignore list gets + * the default one. Exported for unit testing. */ -export function upsertRunConfig(runConfig: RunSingle, config: Config): void { - if (!config.src.run) { - config.set("run", runConfig); - return; - } - const services = Array.isArray(config.src.run) ? [...config.src.run] : [config.src.run]; - const existingIndex = services.findIndex((s) => s.serviceId === runConfig.serviceId); - if (existingIndex === -1) { - services.push(runConfig); +export function upsertRunConfig( + service: { serviceId: string; rootDir: string; region: string }, + config: Config, +): void { + // "run" can be one service or a list of them. Work with a list. + const entries: RunSingle[] = [config.src.run ?? []].flat(); + + const existing = entries.find((e) => e.serviceId === service.serviceId); + if (existing) { + existing.rootDir = service.rootDir; + existing.region = service.region; + existing.ignore ??= DEFAULT_IGNORE; } else { - const existingConfig = services[existingIndex]; - services[existingIndex] = { - ...existingConfig, - ...runConfig, - ignore: existingConfig.ignore ?? runConfig.ignore, - }; + entries.push({ ...service, ignore: DEFAULT_IGNORE }); } - config.set("run", services.length === 1 ? services[0] : services); + + // Save a single service as an object, not a one-item list. + config.set("run", entries.length === 1 ? entries[0] : entries); } From ada86a42241a9e3fd992bf9a6a6299e7e9645ca9 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 08:11:42 -0700 Subject: [PATCH 34/42] Make upsertRunConfig easier to read Spell out what init changes on an existing service (rootDir and region) instead of merging three spreads. upsertRunConfig now adds the default ignore list itself, so actuate no longer passes it. Behavior is unchanged; added a test for the ignore fallback on existing services. --- src/init/features/run.spec.ts | 36 ++++++++++++++++++++++++--------- src/init/features/run.ts | 38 +++++++++++++++++------------------ 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 4284018b39a..5ac6cf7369d 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -150,23 +150,27 @@ describe("init run", () => { }); describe("upsertRunConfig", () => { + const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; + it("sets a single service object when firebase.json has no run config", () => { - const firstService = { + upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); + expect(config.src.run).to.deep.equal({ serviceId: "web", region: "us-central1", rootDir: "/", - ignore: ["node_modules"], - }; - upsertRunConfig(firstService, config); - expect(config.src.run).to.deep.equal(firstService); + ignore: DEFAULT_IGNORE, + }); }); it("converts to an array when adding a second service", () => { - const firstService = { serviceId: "web", region: "us-central1" }; - const secondService = { serviceId: "api", region: "us-east1" }; + const firstService = { serviceId: "web", region: "us-central1", rootDir: "/" }; + const secondService = { serviceId: "api", region: "us-east1", rootDir: "api" }; upsertRunConfig(firstService, config); upsertRunConfig(secondService, config); - expect(config.src.run).to.deep.equal([firstService, secondService]); + expect(config.src.run).to.deep.equal([ + { ...firstService, ignore: DEFAULT_IGNORE }, + { ...secondService, ignore: DEFAULT_IGNORE }, + ]); }); it("updates an existing service in place while preserving existing ignore and localBuild settings", () => { @@ -183,7 +187,6 @@ describe("init run", () => { serviceId: "web", region: "us-east1", rootDir: "apps/web", - ignore: ["default-ignore"], }, config, ); @@ -196,5 +199,20 @@ describe("init run", () => { localBuild: true, }); }); + + it("adds the default ignore list to an existing service only if it has none", () => { + config.set("run", [ + { serviceId: "web", region: "us-central1" }, + { serviceId: "api", region: "us-central1", ignore: [] }, + ]); + + upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); + upsertRunConfig({ serviceId: "api", region: "us-central1", rootDir: "api" }, config); + + expect(config.src.run).to.deep.equal([ + { serviceId: "web", region: "us-central1", rootDir: "/", ignore: DEFAULT_IGNORE }, + { serviceId: "api", region: "us-central1", rootDir: "api", ignore: [] }, + ]); + }); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 38362930db6..718f1284c85 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -143,7 +143,6 @@ export async function actuate(setup: Setup, config: Config): Promise { serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, - ignore: DEFAULT_IGNORE, }, config, ); @@ -151,25 +150,26 @@ export async function actuate(setup: Setup, config: Config): Promise { } /** - * 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. + * Adds the service to firebase.json, or updates its rootDir and region if it's already there. + * Other settings, like a custom ignore list, are kept. Any service without an ignore list gets + * the default one. Exported for unit testing. */ -export function upsertRunConfig(runConfig: RunSingle, config: Config): void { - if (!config.src.run) { - config.set("run", runConfig); - return; - } - const services = Array.isArray(config.src.run) ? [...config.src.run] : [config.src.run]; - const existingIndex = services.findIndex((s) => s.serviceId === runConfig.serviceId); - if (existingIndex === -1) { - services.push(runConfig); +export function upsertRunConfig( + service: { serviceId: string; rootDir: string; region: string }, + config: Config, +): void { + // "run" can be one service or a list of them. Work with a list. + const entries: RunSingle[] = [config.src.run ?? []].flat(); + + const existing = entries.find((e) => e.serviceId === service.serviceId); + if (existing) { + existing.rootDir = service.rootDir; + existing.region = service.region; + existing.ignore ??= DEFAULT_IGNORE; } else { - const existingConfig = services[existingIndex]; - services[existingIndex] = { - ...existingConfig, - ...runConfig, - ignore: existingConfig.ignore ?? runConfig.ignore, - }; + entries.push({ ...service, ignore: DEFAULT_IGNORE }); } - config.set("run", services.length === 1 ? services[0] : services); + + // Save a single service as an object, not a one-item list. + config.set("run", entries.length === 1 ? entries[0] : entries); } From 54e9f054fb15087f51a9fd480ff3958e2ae103e2 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 08:38:01 -0700 Subject: [PATCH 35/42] Don't add an ignore list to an existing service in init run Re-running init on a service that's already in firebase.json filled in the default ignore list when the entry didn't have one. Init doesn't ask about ignore, so it shouldn't change it: an existing entry now only gets the rootDir and region the user just chose. New services still get the default ignore list. --- src/init/features/run.spec.ts | 9 ++++----- src/init/features/run.ts | 7 +++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 5ac6cf7369d..caa37fc8dfb 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -200,18 +200,17 @@ describe("init run", () => { }); }); - it("adds the default ignore list to an existing service only if it has none", () => { + it("doesn't add an ignore list to an existing service", () => { config.set("run", [ { serviceId: "web", region: "us-central1" }, { serviceId: "api", region: "us-central1", ignore: [] }, ]); - upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); - upsertRunConfig({ serviceId: "api", region: "us-central1", rootDir: "api" }, config); + upsertRunConfig({ serviceId: "web", region: "us-east1", rootDir: "/" }, config); expect(config.src.run).to.deep.equal([ - { serviceId: "web", region: "us-central1", rootDir: "/", ignore: DEFAULT_IGNORE }, - { serviceId: "api", region: "us-central1", rootDir: "api", ignore: [] }, + { serviceId: "web", region: "us-east1", rootDir: "/" }, + { serviceId: "api", region: "us-central1", ignore: [] }, ]); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 718f1284c85..8320a9de3a6 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -150,9 +150,9 @@ export async function actuate(setup: Setup, config: Config): Promise { } /** - * Adds the service to firebase.json, or updates its rootDir and region if it's already there. - * Other settings, like a custom ignore list, are kept. Any service without an ignore list gets - * the default one. Exported for unit testing. + * Adds a new service to firebase.json with the default ignore list. If the service is already + * there, only its rootDir and region change; nothing else in the entry is added or removed. + * Exported for unit testing. */ export function upsertRunConfig( service: { serviceId: string; rootDir: string; region: string }, @@ -165,7 +165,6 @@ export function upsertRunConfig( if (existing) { existing.rootDir = service.rootDir; existing.region = service.region; - existing.ignore ??= DEFAULT_IGNORE; } else { entries.push({ ...service, ignore: DEFAULT_IGNORE }); } From 4a117cb69538f7904f31ce63cccbcb7d9e67a1c4 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 09:06:39 -0700 Subject: [PATCH 36/42] Default init run's root directory to the saved one, matching by service ID and region Pressing Enter now keeps the root directory saved in firebase.json instead of resetting it to /. Cloud Run service IDs are only unique within a region, so entries now match on both; the same ID in another region gets its own entry. --- src/init/features/run.spec.ts | 38 +++++++++++++++++++++++++++++++---- src/init/features/run.ts | 37 ++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index caa37fc8dfb..83b99b14bc5 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -57,6 +57,7 @@ describe("init run", () => { default: "us-central1", }); expect(inputStub.secondCall.args[0].default).to.be.undefined; + expect(inputStub.thirdCall.args[0].default).to.equal("/"); const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); @@ -111,6 +112,23 @@ describe("init run", () => { }); }); + it("defaults the root directory to the one saved for the same service and region", 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); + inputStub.callsFake((o) => Promise.resolve(o.default)); + // The same service ID in another region is a different service, so its entry is skipped. + config.set("run", [ + { serviceId: "web", region: "us-central1", rootDir: "other-region" }, + { serviceId: "web", region: "europe-west1", rootDir: "internal/folder" }, + ]); + const s = setup(); + + await askQuestions(s, config, options); + + expect(s.featureInfo?.run?.rootDir).to.equal("internal/folder"); + }); + it("creates a service if there are none to update", async () => { sinon.stub(runv2, "listServices").resolves([]); selectStub.onFirstCall().resolves("update").onSecondCall().resolves("us-central1"); @@ -185,7 +203,7 @@ describe("init run", () => { upsertRunConfig( { serviceId: "web", - region: "us-east1", + region: "us-central1", rootDir: "apps/web", }, config, @@ -193,7 +211,7 @@ describe("init run", () => { expect(config.src.run).to.deep.equal({ serviceId: "web", - region: "us-east1", + region: "us-central1", rootDir: "apps/web", ignore: ["custom-ignore"], localBuild: true, @@ -206,12 +224,24 @@ describe("init run", () => { { serviceId: "api", region: "us-central1", ignore: [] }, ]); - upsertRunConfig({ serviceId: "web", region: "us-east1", rootDir: "/" }, config); + upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); expect(config.src.run).to.deep.equal([ - { serviceId: "web", region: "us-east1", rootDir: "/" }, + { serviceId: "web", region: "us-central1", rootDir: "/" }, { serviceId: "api", region: "us-central1", ignore: [] }, ]); }); + + it("adds a separate entry for the same service ID in another region", () => { + const saved = { serviceId: "web", region: "us-central1", rootDir: "apps/web", ignore: [] }; + config.set("run", { ...saved }); + + upsertRunConfig({ serviceId: "web", region: "europe-west1", rootDir: "/" }, config); + + expect(config.src.run).to.deep.equal([ + saved, + { serviceId: "web", region: "europe-west1", rootDir: "/", ignore: DEFAULT_IGNORE }, + ]); + }); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 8320a9de3a6..63090ed58df 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -60,6 +60,9 @@ export async function askQuestions(setup: Setup, config: Config, options: Option serviceId = await promptNewServiceId(projectId, region); } + // If firebase.json already has this service, its saved settings are the defaults. + const savedEntry = findRunEntry(getRunEntries(config), serviceId, region); + // A base image turns on automatic base image updates, which are off by default. So there's no // default base image, except that an existing service keeps its own. const rawBaseImage = await input({ @@ -68,7 +71,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); const baseImage = (rawBaseImage || "").trim(); - const rootDir = await promptRootDir(config.projectDir); + const rootDir = await promptRootDir(config.projectDir, savedEntry?.rootDir ?? "/"); setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; } @@ -116,10 +119,10 @@ async function promptNewServiceId(projectId: string, region: string): Promise { +async function promptRootDir(projectDir: string, defaultDir: string): Promise { return input({ message: "Specify your app's root directory relative to your firebase.json directory", - default: "/", + default: defaultDir, validate: (dir: string) => { const absPath = path.join(projectDir, dir); if (!dirExistsSync(absPath)) { @@ -151,20 +154,19 @@ export async function actuate(setup: Setup, config: Config): Promise { /** * Adds a new service to firebase.json with the default ignore list. If the service is already - * there, only its rootDir and region change; nothing else in the entry is added or removed. + * there (same service ID and region), only its rootDir changes; nothing else in the entry is added + * or removed. * Exported for unit testing. */ export function upsertRunConfig( service: { serviceId: string; rootDir: string; region: string }, config: Config, ): void { - // "run" can be one service or a list of them. Work with a list. - const entries: RunSingle[] = [config.src.run ?? []].flat(); + const entries = getRunEntries(config); - const existing = entries.find((e) => e.serviceId === service.serviceId); + const existing = findRunEntry(entries, service.serviceId, service.region); if (existing) { existing.rootDir = service.rootDir; - existing.region = service.region; } else { entries.push({ ...service, ignore: DEFAULT_IGNORE }); } @@ -172,3 +174,22 @@ export function upsertRunConfig( // Save a single service as an object, not a one-item list. config.set("run", entries.length === 1 ? entries[0] : entries); } + +/** + * Returns the Cloud Run services in firebase.json as a list. "run" can be one service or a list. + */ +function getRunEntries(config: Config): RunSingle[] { + return [config.src.run ?? []].flat(); +} + +/** + * Finds a service's firebase.json entry. Cloud Run service IDs are only unique within a region, + * so the region has to match too. + */ +function findRunEntry( + entries: RunSingle[], + serviceId: string, + region: string, +): RunSingle | undefined { + return entries.find((e) => e.serviceId === serviceId && e.region === region); +} From 136598a8e02992bf8965a11d058b4cbafd9928c2 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 09:41:22 -0700 Subject: [PATCH 37/42] Only deploy the service init set up, not same-ID services in other regions Init deploys with --only run:, which matches that ID in every region. Init now also passes the service's region to deploy, which skips services in other regions. firebase deploy and --only run: are unchanged. --- src/deploy/run/args.ts | 5 +++++ src/deploy/run/prepare.spec.ts | 23 +++++++++++++++++++++++ src/deploy/run/prepare.ts | 6 +++++- src/init/features/run.spec.ts | 2 +- src/init/features/run.ts | 3 ++- 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 1e7bc87ed6a..f902796c003 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -14,6 +14,11 @@ export interface Payload { export interface Context { projectId: string; + /** + * Only deploys services in this region. Init sets it so that it deploys just the service it set + * up, even if another region has a service with the same ID. + */ + region?: string; /** Overrides the service's base image. null clears it; undefined keeps the current one. */ baseImage?: string | null; } diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 88ecc5a397f..441eb0053e6 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -66,4 +66,27 @@ describe("run prepare", () => { expect((await prepareOne({}, { baseImage: "nodejs20" })).baseImage).to.equal("nodejs20"); expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; }); + + it("deploys a service ID in every region unless the context names a region", async () => { + const sameIdTwoRegions = { + only: "run:s", + config: { + src: { + run: [ + { serviceId: "s", region: "us-central1" }, + { serviceId: "s", region: "europe-west1" }, + ], + }, + projectDir: "/p", + }, + } as unknown as Options; + const preparedRegions = async (context: Partial) => { + const payload: Payload = {}; + await prepare({ projectId: "p", ...context }, sameIdTwoRegions, payload); + return payload.run!.services.map((svc) => svc.config.region); + }; + + expect(await preparedRegions({})).to.deep.equal(["us-central1", "europe-west1"]); + expect(await preparedRegions({ region: "europe-west1" })).to.deep.equal(["europe-west1"]); + }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 9e90f2c334c..15b11e17178 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -9,7 +9,11 @@ import { getExistingService, getServiceConfigs } 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); + let configs = getServiceConfigs(options); + if (context.region) { + // --only run: matches services with that ID in every region. + configs = configs.filter((c) => c.region === context.region); + } if (!configs.length) { return; } diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 1aed12cdec5..1f454ea0558 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -169,7 +169,7 @@ describe("init run", () => { expect(deployStub).to.have.been.calledWith( ["run"], { projectId: "p", config, only: "run:s" }, - { baseImage: null }, + { region: "r", baseImage: null }, ); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 7e8e862a713..86d6a60ec9d 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -154,7 +154,8 @@ export async function actuate(setup: Setup, config: Config, options: Options): P await deploy( ["run"], { ...options, projectId: setup.projectId, config, only: `run:${info.serviceId}` }, - { baseImage: info.baseImage || null }, + // Pass the region so deploy skips services with the same ID in other regions. + { region: info.region, baseImage: info.baseImage || null }, ); } From c4a99e130394b08468caaccb879740233551e2ee Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 11:12:27 -0700 Subject: [PATCH 38/42] Write firebase.json once, from init, instead of also from init run's actuate Init already writes firebase.json after every feature is set up, so the write in actuate only printed "Wrote configuration info to firebase.json" a second time. Leaving the write to init also means a failed init leaves firebase.json untouched. --- src/init/features/run.spec.ts | 8 ++++---- src/init/features/run.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 83b99b14bc5..3634cef62ed 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -144,12 +144,11 @@ 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); - expect(writeStub).to.not.have.been.called; + expect(config.src.run).to.be.undefined; }); - it("saves the service to firebase.json", async () => { + it("adds the service to the config that init writes to firebase.json", async () => { const writeStub = sinon.stub(config, "writeProjectFile"); const s = setup(); s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; @@ -163,7 +162,8 @@ describe("init run", () => { 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); + // Init writes the file once all features are set up, so a failed init leaves it untouched. + expect(writeStub).to.not.have.been.called; }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 63090ed58df..2fee81f383a 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -134,7 +134,8 @@ async function promptRootDir(projectDir: string, defaultDir: string): Promise { const info = setup.featureInfo?.run; @@ -149,7 +150,6 @@ export async function actuate(setup: Setup, config: Config): Promise { }, config, ); - config.writeProjectFile("firebase.json", config.src); } /** From d46821e7f273f68dfc0b73928ad0b11e73cd2d9f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 12:15:43 -0700 Subject: [PATCH 39/42] Remove the Cloud Run deploy E2E suite scripts/run-deploy-tests ran the real CLI against a real project. Nothing ran it automatically (npm test only covers src/, and CI doesn't list it), and each run left Cloud Build images behind in Artifact Registry. The unit tests cover the logic, and live testing is done by hand. --- package.json | 1 - scripts/run-deploy-tests/run.sh | 15 ---- scripts/run-deploy-tests/tests.ts | 138 ------------------------------ 3 files changed, 154 deletions(-) delete mode 100755 scripts/run-deploy-tests/run.sh delete mode 100644 scripts/run-deploy-tests/tests.ts diff --git a/package.json b/package.json index dcff7cd0ccc..9d437e0e219 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "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 deleted file mode 100755 index 4dc0d0eb01f..00000000000 --- a/scripts/run-deploy-tests/run.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 "======================================" - -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 deleted file mode 100644 index 5a46c9a8bd9..00000000000 --- a/scripts/run-deploy-tests/tests.ts +++ /dev/null @@ -1,138 +0,0 @@ -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 = "directcloudrun"; - 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 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 directcloudrun is not enabled"); - } finally { - process.env.FIREBASE_CLI_EXPERIMENTS = "directcloudrun"; - } - }); - - 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); - }); -}); From f2df542ddfe1e324da2f9789ea4010a0b3691277 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 13:08:04 -0700 Subject: [PATCH 40/42] Let --only run:: pick one Cloud Run service Service IDs are only unique within a region, so firebase.json can list the same ID in more than one region. - run: still deploys that ID in every region it's listed in, and now logs which services it matched when there's more than one. - run:: deploys exactly one service. Init deploys the service it set up this way, which replaces the region it used to pass in the deploy context. - Per-service messages (uploading, building, deployed) include the region. - firebase.json can't list the same service ID and region twice. --- src/deploy/run/args.ts | 5 --- src/deploy/run/deploy.spec.ts | 2 +- src/deploy/run/deploy.ts | 6 +-- src/deploy/run/index.ts | 5 ++- src/deploy/run/prepare.spec.ts | 16 ++++---- src/deploy/run/prepare.ts | 6 +-- src/deploy/run/release.ts | 2 +- src/deploy/run/util.spec.ts | 47 ++++++++++++++++++++- src/deploy/run/util.ts | 75 ++++++++++++++++++++++++++++++---- src/init/features/run.spec.ts | 4 +- src/init/features/run.ts | 7 ++-- 11 files changed, 136 insertions(+), 39 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index f902796c003..1e7bc87ed6a 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -14,11 +14,6 @@ export interface Payload { export interface Context { projectId: string; - /** - * Only deploys services in this region. Init sets it so that it deploys just the service it set - * up, even if another region has a service with the same ID. - */ - region?: 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 index a57e624078f..54f20bad9fa 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -181,7 +181,7 @@ describe("run deploy", () => { expect(logStub).to.have.been.calledOnceWithExactly( "run", - "Deployed service s to https://s-123.run.app", + "Deployed service s in us-central1 to https://s-123.run.app", ); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 3dab9e49fd3..dba5971ada3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -42,7 +42,7 @@ async function deployService( : { containers: [{ name: serviceId, image: "" }] }; const container = template.containers?.[0]; if (!container) { - throw new FirebaseError(`Service ${serviceId} has no containers.`); + throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); } container.image = await buildImage(projectId, svc, source); if (svc.baseImage) { @@ -102,7 +102,7 @@ async function uploadSource( path.join(options.config.projectDir, cfg.rootDir), ); try { - logLabeledBullet("run", `Uploading source for service ${serviceId}...`); + logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, bucketName, @@ -122,7 +122,7 @@ async function buildImage( 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}...`); + logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); await runv2.submitBuild(projectId, region, { storageSource: source, imageUri, diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts index 10b0e3f1346..462e7dce1e0 100644 --- a/src/deploy/run/index.ts +++ b/src/deploy/run/index.ts @@ -4,7 +4,10 @@ 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 help = + "Builds and deploys web apps to Cloud Run services listed in firebase.json. Supports filtering:\n" + + " --only run:serviceId (in every region firebase.json lists it in)\n" + + " --only run:serviceId:region (in one region)"; 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" + diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 441eb0053e6..ddb78521ae9 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -67,9 +67,10 @@ describe("run prepare", () => { expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; }); - it("deploys a service ID in every region unless the context names a region", async () => { + it("only prepares the services that --only selects", async () => { + const payload: Payload = {}; const sameIdTwoRegions = { - only: "run:s", + only: "run:s:europe-west1", config: { src: { run: [ @@ -80,13 +81,10 @@ describe("run prepare", () => { projectDir: "/p", }, } as unknown as Options; - const preparedRegions = async (context: Partial) => { - const payload: Payload = {}; - await prepare({ projectId: "p", ...context }, sameIdTwoRegions, payload); - return payload.run!.services.map((svc) => svc.config.region); - }; - expect(await preparedRegions({})).to.deep.equal(["us-central1", "europe-west1"]); - expect(await preparedRegions({ region: "europe-west1" })).to.deep.equal(["europe-west1"]); + await prepare({ projectId: "p" }, sameIdTwoRegions, payload); + + expect(payload.run!.services.map((svc) => svc.config.region)).to.deep.equal(["europe-west1"]); + expect(getServiceStub).to.have.been.calledOnceWith("p", "europe-west1", "s"); }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 15b11e17178..9e90f2c334c 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -9,11 +9,7 @@ import { getExistingService, getServiceConfigs } 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 { - let configs = getServiceConfigs(options); - if (context.region) { - // --only run: matches services with that ID in every region. - configs = configs.filter((c) => c.region === context.region); - } + const configs = getServiceConfigs(options); if (!configs.length) { return; } diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts index a10967ebe65..e7d3866fc97 100644 --- a/src/deploy/run/release.ts +++ b/src/deploy/run/release.ts @@ -12,7 +12,7 @@ export async function release( for (const { config, deployed } of payload.run?.services || []) { logLabeledSuccess( "run", - `Deployed service ${config.serviceId}${deployed?.uri ? ` to ${deployed.uri}` : ""}`, + `Deployed service ${config.serviceId} in ${config.region}${deployed?.uri ? ` to ${deployed.uri}` : ""}`, ); } } diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index d674d294d1d..f8a32c6da56 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; +import * as utils from "../../utils"; import { copyTemplate, deployRevision, @@ -16,7 +17,13 @@ describe("run util", () => { describe("getServiceConfigs", () => { const a = { serviceId: "a", region: "us-central1" }; const b = { serviceId: "b", region: "us-east1" }; + const aEurope = { serviceId: "a", region: "europe-west1" }; const opts = (run: unknown, only?: string) => ({ config: { src: { run } }, only }) as Options; + let logStub: sinon.SinonStub; + + beforeEach(() => { + logStub = sinon.stub(utils, "logLabeledBullet"); + }); it("returns all services when --only is 'run' or unset", () => { expect(getServiceConfigs(opts([a, b], "run"))).to.deep.equal([a, b]); @@ -26,11 +33,49 @@ describe("run util", () => { it("filters to services named in --only", () => { expect(getServiceConfigs(opts([a, b], "hosting,run:b"))).to.deep.equal([b]); + expect(logStub).not.to.have.been.called; + }); + + it("deploys a service ID in every region it's listed in, and says so", () => { + expect(getServiceConfigs(opts([a, b, aEurope], "run:a"))).to.deep.equal([a, aEurope]); + expect(logStub).to.have.been.calledOnceWithExactly( + "run", + "run:a matches 2 services in firebase.json: a:us-central1, a:europe-west1. " + + "Deploying all of them. To deploy just one, use --only run:a:.", + ); + }); + + it("deploys one service with run::", () => { + expect(getServiceConfigs(opts([a, aEurope], "run:a:europe-west1"))).to.deep.equal([aEurope]); + expect(logStub).not.to.have.been.called; + }); + + it("deploys each service once, in firebase.json order", () => { + expect( + getServiceConfigs(opts([a, b, aEurope], "run:b,run:a:europe-west1,run:a")), + ).to.deep.equal([a, b, aEurope]); }); 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", + "Cloud Run service missing not detected in firebase.json.", + ); + expect(() => getServiceConfigs(opts([a], "run:a:us-east1,run:b"))).to.throw( + "Cloud Run services a:us-east1, b not detected in firebase.json.", + ); + }); + + it("rejects malformed service names", () => { + for (const name of ["", ":us-central1", "a:", "a:us-central1:extra"]) { + expect(() => getServiceConfigs(opts([a], `run:${name}`))).to.throw( + `Invalid Cloud Run service "${name}". Use or :.`, + ); + } + }); + + it("throws if firebase.json lists the same service twice", () => { + expect(() => getServiceConfigs(opts([a, aEurope, { ...a, rootDir: "web" }]))).to.throw( + "Cloud Run service a:us-central1 is listed more than once in firebase.json.", ); }); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index cd18f9d71c7..5b7a0e17671 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -2,15 +2,15 @@ import { FirebaseError, getErrStatus } from "../../error"; import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; -import { cloneDeep } from "../../utils"; +import { cloneDeep, logLabeledBullet } 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. + * Returns every Cloud Run service in firebase.json. Throws if a service is listed twice. */ -export function getServiceConfigs(options: Options): RunSingle[] { +export function getAllServiceConfigs(options: Options): RunSingle[] { const rawConfig = options.config?.src?.run; const configs: RunSingle[] = []; if (Array.isArray(rawConfig)) { @@ -18,18 +18,79 @@ export function getServiceConfigs(options: Options): RunSingle[] { } else if (rawConfig) { configs.push(rawConfig); } + const names = new Set(); + for (const config of configs) { + // Deploying a service without a region fails with its own "missing a region" error. + if (!config.region) { + continue; + } + const name = fullServiceName(config); + if (names.has(name)) { + throw new FirebaseError( + `Cloud Run service ${name} is listed more than once in firebase.json.`, + ); + } + names.add(name); + } + return configs; +} + +/** + * Returns the Cloud Run services in firebase.json that match the --only filter. + * run: matches that service ID in every region it's listed in, and + * run:: matches one service. + */ +export function getServiceConfigs(options: Options): RunSingle[] { + const configs = getAllServiceConfigs(options); 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)); + const selected = new Set(); + const missing: string[] = []; + for (const selector of selectors.filter((s) => s.startsWith("run:"))) { + const name = selector.slice("run:".length); + const matches = findServices(configs, name); + if (!matches.length) { + missing.push(name); + } else if (matches.length > 1) { + logLabeledBullet( + "run", + `${selector} matches ${matches.length} services in firebase.json: ` + + `${matches.map(fullServiceName).join(", ")}. Deploying all of them. ` + + `To deploy just one, use --only ${selector}:.`, + ); + } + matches.forEach((m) => selected.add(m)); + } if (missing.length) { throw new FirebaseError( - `Cloud Run service IDs ${missing.join(",")} not detected in firebase.json`, + `Cloud Run service${missing.length > 1 ? "s" : ""} ${missing.join(", ")} not detected in firebase.json.`, + ); + } + return configs.filter((c) => selected.has(c)); +} + +/** + * Returns the services that `name` refers to. "" matches that service ID in every + * region, and ":" matches one service. Throws if `name` has any other form. + */ +export function findServices(configs: RunSingle[], name: string): RunSingle[] { + const [serviceId, region, ...rest] = name.split(":"); + if (!serviceId || region === "" || rest.length) { + throw new FirebaseError( + `Invalid Cloud Run service "${name}". Use or :.`, ); } - return configs.filter((c) => serviceIds.includes(c.serviceId)); + return configs.filter((c) => c.serviceId === serviceId && (!region || c.region === region)); +} + +/** + * Returns the name that picks out exactly one service, like my-service:us-central1. Service IDs + * are only unique within a region. + */ +export function fullServiceName(config: RunSingle): string { + return `${config.serviceId}:${config.region}`; } /** diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index ffe3626248e..bb2da99bf6a 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -170,8 +170,8 @@ describe("init run", () => { expect(writeStub).to.not.have.been.called; expect(deployStub).to.have.been.calledWith( ["run"], - { projectId: "p", config, only: "run:s" }, - { region: "r", baseImage: null }, + { projectId: "p", config, only: "run:s:r" }, + { baseImage: null }, ); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index cb1c42e8027..92eddf46dbe 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -3,7 +3,7 @@ import { Setup } from ".."; import { Config } from "../../config"; import { deploy } from "../../deploy"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; -import { getExistingService } from "../../deploy/run/util"; +import { fullServiceName, getExistingService } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { dirExistsSync } from "../../fsutils"; @@ -154,9 +154,8 @@ export async function actuate(setup: Setup, config: Config, options: Options): P ); await deploy( ["run"], - { ...options, projectId: setup.projectId, config, only: `run:${info.serviceId}` }, - // Pass the region so deploy skips services with the same ID in other regions. - { region: info.region, baseImage: info.baseImage || null }, + { ...options, projectId: setup.projectId, config, only: `run:${fullServiceName(info)}` }, + { baseImage: info.baseImage || null }, ); } From 83e319f09dde7f4e57b9b5d310db444fa15abbfb Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 11:15:12 -0700 Subject: [PATCH 41/42] Make the Cloud Run deploy code and tests easier to read - deploy.ts reads as four steps: upload the source, build it, prepare the revision, then create the service or roll out a new revision. The revision rules are a pure function, revisionTemplate, with one small test per rule. - Helpers that only deploy.ts uses (deployRevision, toAppHostingConfig, the rollout timeout) moved there from util.ts. util.ts now only picks services from firebase.json and checks whether a service exists. - Stop deleting the local source zip. It's a tmp file, which firebase deploy deletes when it exits; App Hosting relies on the same thing. - Check every service's region once, up front, and stop --only filtering at the first service that isn't in firebase.json. - Tests use realistic names, check only the fields they're about, and drop duplicate coverage. The release test has its own file. - The help text no longer mentions local builds or run:services:update, which later PRs add. - Clearer docstrings and tests for init run's actuate and upsertRunConfig. --- src/deploy/run/args.ts | 3 + src/deploy/run/deploy.spec.ts | 240 +++++++++++++++------------------ src/deploy/run/deploy.ts | 193 ++++++++++++++------------ src/deploy/run/index.ts | 4 +- src/deploy/run/prepare.spec.ts | 92 ++++++------- src/deploy/run/prepare.ts | 7 +- src/deploy/run/release.spec.ts | 36 +++++ src/deploy/run/release.ts | 3 +- src/deploy/run/util.spec.ts | 137 ++++++------------- src/deploy/run/util.ts | 124 +++++------------ src/init/features/run.spec.ts | 67 +++++---- src/init/features/run.ts | 16 ++- 12 files changed, 432 insertions(+), 490 deletions(-) create mode 100644 src/deploy/run/release.spec.ts diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 1e7bc87ed6a..08665317206 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,10 +1,13 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +/** A service being deployed. prepare fills in the first fields, and deploy sets `deployed`. */ export interface ServiceDeploy { config: RunSingle; + /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ existing?: runv2.Service; baseImage?: string; + /** 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 54f20bad9fa..97bdb5fa530 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -8,180 +8,164 @@ import * as getProjectNumber from "../../getProjectNumber"; import { Options } from "../../options"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { deploy } from "./deploy"; +import { deploy, revisionTemplate } from "./deploy"; + +const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; +const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; 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.SinonSpy; + const options = { config: { projectDir: "/project" } } as unknown as Options; + 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"; + let createArchiveStub: 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, "upsertBucket").resolves("source-bucket"); + createArchiveStub = sinon + .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: "bucket", object: src.file, generation: "1" })); - sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - rmSyncStub = sinon.spy(fs, "rmSync"); - sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); + .resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" }); 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); + const deployed = { uri: "https://web.run.app" } as runv2.Service; + createServiceStub = sinon.stub(runv2, "createService").resolves(deployed); + updateServiceStub = sinon.stub(runv2, "updateService").resolves(deployed); 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"); - expect(rmSyncStub).to.have.been.calledWith("src.zip", { recursive: true, 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" }, - imageUri, - buildpackBuild: { - baseImage: "nodejs22", - enableAutomaticUpdates: true, - environmentVariables: { - X_GOOGLE_TARGET_PLATFORM: "fah", - FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", - }, - }, + async function deployOne(svc: ServiceDeploy): Promise { + await deploy({ projectId: "my-project" }, options, { run: { services: [svc] } }); + return svc; + } + + it("uploads the source, builds it, and creates a public service", async () => { + const svc = await deployOne({ config: web, baseImage: NODEJS22 }); + + expect(createArchiveStub).to.have.been.calledWithMatch( + { backendId: "web" }, + "/project/apps/web", + ); + expect(submitBuildStub).to.have.been.calledWithMatch("my-project", "us-central1", { + storageSource: { bucket: "source-bucket", object: "web.zip" }, + imageUri: image, + buildpackBuild: { baseImage: NODEJS22, enableAutomaticUpdates: true }, }); - 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", + expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { + template: { containers: [{ name: "web", image, baseImageUri: NODEJS22 }] }, invokerIamDisabled: true, ingress: "INGRESS_TRAFFIC_ALL", }); - expect(svc.deployed).to.deep.equal({ uri: "new" }); + expect(svc.deployed).to.deep.equal({ uri: "https://web.run.app" }); }); - it("still deploys if the source archive can't be deleted", async () => { - rmSyncStub.restore(); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); - - const svc = await deployOne(service()); + it("builds without a base image if the service doesn't have one", async () => { + await deployOne({ config: web }); - expect(svc.deployed).to.deep.equal({ uri: "new" }); + // Without a base image, Cloud Build can't turn on automatic base image updates. + const { buildpackBuild } = submitBuildStub.firstCall.args[2]; + expect(buildpackBuild).not.to.have.property("baseImage"); + expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); - it("reports the upload error, not the cleanup error, when both fail", async () => { - rmSyncStub.restore(); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); - (gcs.uploadObject as sinon.SinonStub).rejects(new Error("upload failed")); + 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", + template: { revision: "web-001", containers: [{ name: "web", image: "old-image" }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "web-001", percent: 90 }, + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", + revision: "web-000", + tag: "preview", + percent: 10, + }, + ], + } as unknown as runv2.Service; - await expect(deployOne(service())).to.be.rejectedWith("upload failed"); - }); + await deployOne({ config: web, existing }); - 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).not.to.have.been.called; + expect(updateServiceStub).to.have.been.calledOnceWithExactly( + { + name: "projects/my-project/locations/us-central1/services/web", + template: { containers: [{ name: "web", image }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "web-000", tag: "preview" }, + ], }, - }); - expect(createServiceStub.firstCall.args[3].template.containers[0]).not.to.have.property( - "baseImageUri", + { updateMask: ["template", "traffic"], pollTimeoutMs: 10 * 60 * 1000 }, ); }); - it("updates the live revision template of an existing service", async () => { + describe("revisionTemplate", () => { const existing = { - name: "projects/p/locations/us-central1/services/s", + name: "projects/my-project/locations/us-central1/services/web", template: { - revision: "s-1", - serviceAccount: "sa", - annotations: { "firebase.google.com/deploy-message": "old", keep: "me" }, + revision: "web-001", + serviceAccount: "web@my-project.iam.gserviceaccount.com", + annotations: { team: "frontend", [DEPLOY_MESSAGE]: "Last deploy" }, containers: [ { - name: "main", - image: "old", - ports: [{ containerPort: 8080 }], - baseImageUri: "old-base", - env: [ - { name: "B", value: "old" }, - { name: "C", value: "c" }, - ], + name: "web", + image: "old-image", + baseImageUri: NODEJS22, + env: [{ name: "MODE", value: "prod" }], }, ], }, - trafficStatuses: [ - { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", revision: "s-1", percent: 100 }, - ], } as unknown as runv2.Service; - await deployOne(service({ existing })); + it("starts a new service with just the image", () => { + expect(revisionTemplate({ config: web }, image)).to.deep.equal({ + containers: [{ name: "web", image }], + }); + }); - 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: "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" }, - ], - }, - ], + it("keeps the live revision's settings, but not its name", () => { + const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, image); + + expect(template).to.deep.equal({ + serviceAccount: "web@my-project.iam.gserviceaccount.com", + annotations: { team: "frontend" }, + containers: [ + { name: "web", image, baseImageUri: NODEJS22, env: [{ name: "MODE", value: "prod" }] }, + ], + }); + // The live service itself isn't changed. + expect(existing.template.revision).to.equal("web-001"); }); - 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"); + it("sets the base image, or clears it", () => { + const withBaseImage = revisionTemplate( + { config: web, existing, baseImage: "nodejs24" }, + image, + ); + expect(withBaseImage.containers?.[0].baseImageUri).to.equal("nodejs24"); - await release({ projectId: "p" }, options, { - run: { - services: [service({ deployed: { uri: "https://s-123.run.app" } as runv2.Service })], - }, + const withoutBaseImage = revisionTemplate({ config: web, existing }, image); + expect(withoutBaseImage.containers?.[0]).not.to.have.property("baseImageUri"); }); - expect(logStub).to.have.been.calledOnceWithExactly( - "run", - "Deployed service s in us-central1 to https://s-123.run.app", - ); + it("sets the deploy message, or clears the last one", () => { + 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); + expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" }); + }); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index dba5971ada3..5333eaa92b4 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,88 +1,41 @@ import * as fs from "fs"; import * as path from "path"; -import { FirebaseError, getErrMsg } from "../../error"; +import { FirebaseError } from "../../error"; +import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; -import { logger } from "../../logger"; import { Options } from "../../options"; -import { logLabeledBullet } from "../../utils"; +import { cloneDeep, logLabeledBullet } from "../../utils"; import { createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; -import { - copyTemplate, - deployRevision, - SERVICE_OPERATION_TIMEOUT_MS, - toAppHostingConfig, -} from "./util"; 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; -/** - * Builds each service on Cloud Build, uploads it, and deploys it. - */ +/** Builds each service from source on Cloud Build, then rolls it out. */ 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 = template.containers?.[0]; - if (!container) { - throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); - } - container.image = await buildImage(projectId, svc, source); - if (svc.baseImage) { - container.baseImageUri = svc.baseImage; - } else { - delete container.baseImageUri; - } - if (options.message) { - template.annotations = { - ...template.annotations, - [DEPLOY_MESSAGE_ANNOTATION]: options.message as string, - }; - } else if (template.annotations) { - delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; - } - - if (svc.existing) { - return deployRevision(svc.existing, template); + for (const svc of payload.run?.services || []) { + 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 = svc.existing + ? await deployRevision(svc.existing, template) + : await createService(projectId, svc.config, template); } - return runv2.createService( - projectId, - region, - serviceId, - { - name: `projects/${projectId}/locations/${region}/services/${serviceId}`, - template, - client: "cli-firebase", - invokerIamDisabled: true, - ingress: "INGRESS_TRAFFIC_ALL", - }, - { pollTimeoutMs: SERVICE_OPERATION_TIMEOUT_MS }, - ); } +/** 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; + const { serviceId, region } = config; + // Like App Hosting, each region has one source bucket, which deletes uploads after 30 days. const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; const bucketName = await gcs.upsertBucket({ product: "run", @@ -96,24 +49,22 @@ async function uploadSource( }, }); - const cfg = toAppHostingConfig(svc.config); + 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), ); - try { - 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 }; - } finally { - removeTempPath(archive); - } + 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 }; } +/** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ async function buildImage( projectId: string, svc: ServiceDeploy, @@ -130,6 +81,7 @@ 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: { + // 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", }, @@ -139,13 +91,88 @@ async function buildImage( } /** - * Deletes a temporary file or directory. Failures are only logged: a leftover temp file - * shouldn't fail a deploy that succeeded or hide the error from one that didn't. + * Returns the revision to roll out: the live revision's settings (env vars, service account, + * scaling, etc.) with the new image, base image, and deploy message. + * Exported for testing. */ -function removeTempPath(tempPath: string): void { - try { - fs.rmSync(tempPath, { recursive: true, force: true }); - } catch (err: unknown) { - logger.debug(`Failed to clean up ${tempPath}: ${getErrMsg(err)}`); +export function revisionTemplate( + svc: ServiceDeploy, + image: string, + message?: string, +): runv2.RevisionTemplate { + const { serviceId, region } = svc.config; + const template: runv2.RevisionTemplate = svc.existing + ? cloneDeep(svc.existing.template) + : { containers: [{ name: serviceId, image }] }; + // The live template names the live revision. Drop the name so Cloud Run creates a new revision. + delete template.revision; + const container = template.containers?.[0]; + if (!container) { + throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); + } + container.image = image; + if (svc.baseImage) { + container.baseImageUri = svc.baseImage; + } else { + delete container.baseImageUri; } + // The message describes this deploy only, so don't carry over the last one. + if (message) { + template.annotations = { ...template.annotations, [DEPLOY_MESSAGE_ANNOTATION]: message }; + } else if (template.annotations) { + delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; + } + return template; +} + +/** Creates the service with its first revision. */ +function createService( + projectId: string, + config: RunSingle, + template: runv2.RevisionTemplate, +): Promise { + const { serviceId, region } = config; + return runv2.createService( + projectId, + region, + serviceId, + { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + template, + client: "cli-firebase", + // Like an App Hosting backend, the service is public. + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }, + { pollTimeoutMs: ROLLOUT_TIMEOUT_MS }, + ); +} + +/** Rolls out a new revision of an existing service and sends it all traffic. */ +function deployRevision( + service: runv2.Service, + template: runv2.RevisionTemplate, +): Promise { + // Tagged revisions keep their tags (Hosting's pinned rewrites use them), but no traffic. + const tags = (service.traffic || []) + .filter((t) => t.tag) + .map((t) => ({ type: t.type, revision: t.revision, tag: t.tag })); + return runv2.updateService( + { + name: service.name, + template, + traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], + }, + { updateMask: ["template", "traffic"], pollTimeoutMs: ROLLOUT_TIMEOUT_MS }, + ); +} + +/** Adapts a Cloud Run config so that it can reuse App Hosting's archive helper. */ +function toAppHostingConfig(config: RunSingle): AppHostingSingle { + return { + backendId: config.serviceId, + rootDir: config.rootDir || "", + // Leave this undefined (not []) so App Hosting applies its default ignore list. + ignore: config.ignore as string[], + }; } diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts index 462e7dce1e0..745b4322c94 100644 --- a/src/deploy/run/index.ts +++ b/src/deploy/run/index.ts @@ -9,7 +9,7 @@ export const help = " --only run:serviceId (in every region firebase.json lists it in)\n" + " --only run:serviceId:region (in one region)"; export const detailedHelp = - "Deploys Cloud Run services from local source, or from a local build if localBuild is set.\n\n" + + "Deploys Cloud Run services from local source.\n\n" + "Configuration in firebase.json:\n" + "{\n" + ' "run": {\n' + @@ -19,4 +19,4 @@ export const detailedHelp = ' "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."; + "Each deploy reuses the service's current base image."; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index ddb78521ae9..d974c8f6548 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -2,28 +2,32 @@ 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 { Context, Payload, ServiceDeploy } 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 }] }, + const web = { serviceId: "web", region: "us-central1" }; + const liveWeb = { + name: "projects/my-project/locations/us-central1/services/web", + template: { containers: [{ name: "web", image: "old-image", 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 = {}) { + /** Runs prepare on a firebase.json "run" config and returns the services it will deploy. */ + async function prepareRun( + run: unknown, + { context = {}, only }: { context?: Partial; only?: string } = {}, + ): Promise { + const options = { + config: { src: { run }, projectDir: "/project" }, + only, + } as unknown as Options; const payload: Payload = {}; - await prepare({ projectId: "p", ...context }, options(run), payload); - return payload.run!.services[0]; + await prepare({ projectId: "my-project", ...context }, options, payload); + return payload.run?.services ?? []; } beforeEach(() => { @@ -33,58 +37,38 @@ describe("run prepare", () => { 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); + it("does nothing if firebase.json has no Cloud Run services", async () => { + expect(await prepareRun(undefined)).to.deep.equal([]); 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("checks the APIs and finds that a new service doesn't exist yet", async () => { + expect(await prepareRun(web)).to.deep.equal([ + { config: web, existing: undefined, baseImage: undefined }, + ]); + expect(prereqsStub).to.have.been.calledWith("my-project"); + expect(getServiceStub).to.have.been.calledWith("my-project", "us-central1", "web"); }); - 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("reuses an existing service's base image", async () => { + getServiceStub.resolves(liveWeb); + expect(await prepareRun(web)).to.deep.equal([ + { config: web, existing: liveWeb, baseImage: 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; + getServiceStub.resolves(liveWeb); + const [withNewImage] = await prepareRun(web, { context: { baseImage: "nodejs24" } }); + expect(withNewImage.baseImage).to.equal("nodejs24"); + const [cleared] = await prepareRun(web, { context: { baseImage: null } }); + expect(cleared.baseImage).to.be.undefined; }); it("only prepares the services that --only selects", async () => { - const payload: Payload = {}; - const sameIdTwoRegions = { - only: "run:s:europe-west1", - config: { - src: { - run: [ - { serviceId: "s", region: "us-central1" }, - { serviceId: "s", region: "europe-west1" }, - ], - }, - projectDir: "/p", - }, - } as unknown as Options; - - await prepare({ projectId: "p" }, sameIdTwoRegions, payload); - - expect(payload.run!.services.map((svc) => svc.config.region)).to.deep.equal(["europe-west1"]); - expect(getServiceStub).to.have.been.calledOnceWith("p", "europe-west1", "s"); + const webEurope = { serviceId: "web", region: "europe-west1" }; + const services = await prepareRun([web, webEurope], { only: "run:web:europe-west1" }); + expect(services.map((svc) => svc.config)).to.deep.equal([webEurope]); + expect(getServiceStub).to.have.been.calledOnceWith("my-project", "europe-west1", "web"); }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 9e90f2c334c..a9f00503f2b 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,4 +1,3 @@ -import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; @@ -21,11 +20,7 @@ export async function prepare(context: Context, options: Options, payload: Paylo } 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); + const existing = await getExistingService(context.projectId, config.region, config.serviceId); // Base images are sticky: deploys reuse the service's current base image unless told otherwise. const baseImage = context.baseImage === undefined diff --git a/src/deploy/run/release.spec.ts b/src/deploy/run/release.spec.ts new file mode 100644 index 00000000000..c08e8182ac7 --- /dev/null +++ b/src/deploy/run/release.spec.ts @@ -0,0 +1,36 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import * as utils from "../../utils"; +import { release } from "./release"; + +describe("run release", () => { + afterEach(() => sinon.restore()); + + it("logs where each service is deployed", async () => { + const logStub = sinon.stub(utils, "logLabeledSuccess"); + + await release( + { projectId: "my-project" }, + {}, + { + run: { + services: [ + { + config: { serviceId: "web", region: "us-central1" }, + deployed: { uri: "https://web.run.app" } as runv2.Service, + }, + // A service without a URI was still deployed, so it's still logged. + { config: { serviceId: "api", region: "us-east1" }, deployed: {} as runv2.Service }, + ], + }, + }, + ); + + expect(logStub).to.have.been.calledWithExactly( + "run", + "Deployed service web in us-central1 to https://web.run.app", + ); + expect(logStub).to.have.been.calledWithExactly("run", "Deployed service api in us-east1"); + }); +}); diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts index e7d3866fc97..022d58fe97b 100644 --- a/src/deploy/run/release.ts +++ b/src/deploy/run/release.ts @@ -2,7 +2,8 @@ import { logLabeledSuccess } from "../../utils"; import { Payload } from "./args"; /** - * Logs where each deployed service is available. + * Logs where each deployed service is available. The deploy step already rolled them out, so that + * they're live before other products release (e.g. Hosting, whose rewrites can point at them). */ export async function release( _context: unknown, diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index f8a32c6da56..e9778ec1cff 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -3,98 +3,89 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import * as utils from "../../utils"; -import { - copyTemplate, - deployRevision, - getExistingService, - getServiceConfigs, - toAppHostingConfig, -} from "./util"; +import { getExistingService, getServiceConfigs } 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 aEurope = { serviceId: "a", region: "europe-west1" }; - const opts = (run: unknown, only?: string) => ({ config: { src: { run } }, only }) as Options; + const web = { serviceId: "web", region: "us-central1" }; + const webEurope = { serviceId: "web", region: "europe-west1" }; + const api = { serviceId: "api", region: "us-east1" }; + const options = (run: unknown, only?: string) => + ({ config: { src: { run } }, only }) as Options; let logStub: sinon.SinonStub; beforeEach(() => { logStub = sinon.stub(utils, "logLabeledBullet"); }); - 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("returns every service for --only run, or without --only", () => { + expect(getServiceConfigs(options([web, api], "run"))).to.deep.equal([web, api]); + expect(getServiceConfigs(options(web))).to.deep.equal([web]); + expect(getServiceConfigs(options(undefined))).to.deep.equal([]); }); - it("filters to services named in --only", () => { - expect(getServiceConfigs(opts([a, b], "hosting,run:b"))).to.deep.equal([b]); + it("returns the services that --only names", () => { + expect(getServiceConfigs(options([web, api], "hosting,run:api"))).to.deep.equal([api]); expect(logStub).not.to.have.been.called; }); - it("deploys a service ID in every region it's listed in, and says so", () => { - expect(getServiceConfigs(opts([a, b, aEurope], "run:a"))).to.deep.equal([a, aEurope]); + it("returns a service ID in every region it's listed in, and says so", () => { + expect(getServiceConfigs(options([web, api, webEurope], "run:web"))).to.deep.equal([ + web, + webEurope, + ]); expect(logStub).to.have.been.calledOnceWithExactly( "run", - "run:a matches 2 services in firebase.json: a:us-central1, a:europe-west1. " + - "Deploying all of them. To deploy just one, use --only run:a:.", + "run:web matches 2 services in firebase.json: web:us-central1, web:europe-west1. " + + "Deploying all of them. To deploy just one, use --only run:web:.", ); }); - it("deploys one service with run::", () => { - expect(getServiceConfigs(opts([a, aEurope], "run:a:europe-west1"))).to.deep.equal([aEurope]); + it("returns one service for run::", () => { + expect(getServiceConfigs(options([web, webEurope], "run:web:europe-west1"))).to.deep.equal([ + webEurope, + ]); expect(logStub).not.to.have.been.called; }); - it("deploys each service once, in firebase.json order", () => { - expect( - getServiceConfigs(opts([a, b, aEurope], "run:b,run:a:europe-west1,run:a")), - ).to.deep.equal([a, b, aEurope]); + it("returns each service once, in firebase.json order", () => { + const only = "run:api,run:web:europe-west1,run:web"; + expect(getServiceConfigs(options([web, api, webEurope], only))).to.deep.equal([ + web, + api, + webEurope, + ]); }); - it("throws when --only names a service not in firebase.json", () => { - expect(() => getServiceConfigs(opts([a], "run:a,run:missing"))).to.throw( - "Cloud Run service missing not detected in firebase.json.", + it("throws if --only names a service that isn't in firebase.json", () => { + expect(() => getServiceConfigs(options([web], "run:web,run:api"))).to.throw( + "Cloud Run service api not detected in firebase.json.", ); - expect(() => getServiceConfigs(opts([a], "run:a:us-east1,run:b"))).to.throw( - "Cloud Run services a:us-east1, b not detected in firebase.json.", + expect(() => getServiceConfigs(options([web], "run:web:us-east1"))).to.throw( + "Cloud Run service web:us-east1 not detected in firebase.json.", ); }); it("rejects malformed service names", () => { - for (const name of ["", ":us-central1", "a:", "a:us-central1:extra"]) { - expect(() => getServiceConfigs(opts([a], `run:${name}`))).to.throw( + for (const name of ["", ":us-central1", "web:", "web:us-central1:extra"]) { + expect(() => getServiceConfigs(options([web], `run:${name}`))).to.throw( `Invalid Cloud Run service "${name}". Use or :.`, ); } }); - it("throws if firebase.json lists the same service twice", () => { - expect(() => getServiceConfigs(opts([a, aEurope, { ...a, rootDir: "web" }]))).to.throw( - "Cloud Run service a:us-central1 is listed more than once in firebase.json.", + it("throws if a service in firebase.json has no region", () => { + expect(() => getServiceConfigs(options([web, { serviceId: "api" }], "run:web"))).to.throw( + "Cloud Run service api is missing a region 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, - }); + it("throws if firebase.json lists the same service twice", () => { + expect(() => + getServiceConfigs(options([web, webEurope, { ...web, rootDir: "web" }])), + ).to.throw("Cloud Run service web:us-central1 is listed more than once in firebase.json."); }); }); @@ -115,44 +106,4 @@ describe("run util", () => { await expect(getExistingService("p", "r", "s")).to.be.rejected; }); }); - - 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"], - pollTimeoutMs: 600000, - }, - ); - }); - }); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 5b7a0e17671..43e0e5a4fad 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,28 +1,21 @@ import { FirebaseError, getErrStatus } from "../../error"; -import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; +import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; -import { cloneDeep, logLabeledBullet } 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; +import { logLabeledBullet } from "../../utils"; /** - * Returns every Cloud Run service in firebase.json. Throws if a service is listed twice. + * Returns every Cloud Run service in firebase.json. Throws if one has no region or is listed twice. */ export function getAllServiceConfigs(options: Options): RunSingle[] { - const rawConfig = options.config?.src?.run; - const configs: RunSingle[] = []; - if (Array.isArray(rawConfig)) { - configs.push(...rawConfig); - } else if (rawConfig) { - configs.push(rawConfig); - } + // "run" can be one service or a list of them. + const configs: RunSingle[] = [options.config?.src?.run ?? []].flat(); const names = new Set(); for (const config of configs) { - // Deploying a service without a region fails with its own "missing a region" error. if (!config.region) { - continue; + throw new FirebaseError( + `Cloud Run service ${config.serviceId} is missing a region in firebase.json.`, + ); } const name = fullServiceName(config); if (names.has(name)) { @@ -36,39 +29,36 @@ export function getAllServiceConfigs(options: Options): RunSingle[] { } /** - * Returns the Cloud Run services in firebase.json that match the --only filter. - * run: matches that service ID in every region it's listed in, and - * run:: matches one service. + * Returns the services in firebase.json that --only selects: all of them for "run" (or no --only), + * a service ID in every region it's listed in for "run:", and one service for + * "run::". */ export function getServiceConfigs(options: Options): RunSingle[] { const configs = getAllServiceConfigs(options); - const selectors = options.only ? options.only.split(",") : ["run"]; - if (selectors.includes("run")) { + const filters = options.only ? options.only.split(",") : ["run"]; + if (filters.includes("run")) { return configs; } - const selected = new Set(); - const missing: string[] = []; - for (const selector of selectors.filter((s) => s.startsWith("run:"))) { - const name = selector.slice("run:".length); - const matches = findServices(configs, name); - if (!matches.length) { - missing.push(name); - } else if (matches.length > 1) { - logLabeledBullet( - "run", - `${selector} matches ${matches.length} services in firebase.json: ` + - `${matches.map(fullServiceName).join(", ")}. Deploying all of them. ` + - `To deploy just one, use --only ${selector}:.`, - ); - } - matches.forEach((m) => selected.add(m)); - } - if (missing.length) { - throw new FirebaseError( - `Cloud Run service${missing.length > 1 ? "s" : ""} ${missing.join(", ")} not detected in firebase.json.`, - ); - } - return configs.filter((c) => selected.has(c)); + const selected = filters + .filter((f) => f.startsWith("run:")) + .flatMap((filter) => { + const name = filter.slice("run:".length); + const matches = findServices(configs, name); + if (!matches.length) { + throw new FirebaseError(`Cloud Run service ${name} not detected in firebase.json.`); + } + if (matches.length > 1) { + logLabeledBullet( + "run", + `${filter} matches ${matches.length} services in firebase.json: ` + + `${matches.map(fullServiceName).join(", ")}. Deploying all of them. ` + + `To deploy just one, use --only ${filter}:.`, + ); + } + return matches; + }); + // Keep firebase.json's order, and list each service once even if several filters match it. + return configs.filter((c) => selected.includes(c)); } /** @@ -93,18 +83,6 @@ export function fullServiceName(config: RunSingle): string { return `${config.serviceId}:${config.region}`; } -/** - * 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. */ @@ -122,39 +100,3 @@ export async function getExistingService( throw err; } } - -/** - * 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"], - pollTimeoutMs: SERVICE_OPERATION_TIMEOUT_MS, - }, - ); -} diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index bb2da99bf6a..8ff53959e1c 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -11,6 +11,8 @@ import * as prompt from "../../prompt"; import * as requirePermissions from "../../requirePermissions"; import { actuate, askQuestions, upsertRunConfig } from "./run"; +const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; + describe("init run", () => { const options = {} as Options; let config: Config; @@ -144,52 +146,65 @@ describe("init run", () => { }); describe("actuate", () => { + /** Init's answers for a new service. */ + const webSetup = (): Setup => ({ + ...setup(), + featureInfo: { + run: { + serviceId: "web", + region: "us-central1", + baseImage: "nodejs22", + rootDir: "apps/web", + }, + }, + }); + let writeStub: sinon.SinonStub; + let deployStub: sinon.SinonStub; + + beforeEach(() => { + writeStub = sinon.stub(config, "writeProjectFile"); + deployStub = sinon.stub(deploy, "deploy").resolves(); + }); + it("does nothing when featureInfo.run is not set", async () => { - const deployStub = sinon.stub(deploy, "deploy").resolves(); await actuate(setup(), config, options); expect(config.src.run).to.be.undefined; expect(deployStub).to.not.have.been.called; }); - it("adds the service to the config 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, options); + it("adds the service to firebase.json, but not its base image", async () => { + await actuate(webSetup(), config, options); - 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); - // Init writes firebase.json once all features are set up. + expect(config.src.run).to.deep.equal({ + serviceId: "web", + region: "us-central1", + rootDir: "apps/web", + ignore: DEFAULT_IGNORE, + }); + // Init saves firebase.json itself, once every feature is set up. expect(writeStub).to.not.have.been.called; - expect(deployStub).to.have.been.calledWith( + }); + + it("deploys only that service and region, with its base image", async () => { + await actuate(webSetup(), config, options); + + expect(deployStub).to.have.been.calledOnceWith( ["run"], - { projectId: "p", config, only: "run:s:r" }, - { baseImage: null }, + { projectId: "p", config, only: "run:web:us-central1" }, + { baseImage: "nodejs22" }, ); }); it("doesn't write firebase.json if the deploy fails", async () => { - const writeStub = sinon.stub(config, "writeProjectFile"); - sinon.stub(deploy, "deploy").rejects(new Error("build failed")); - const s = setup(); - s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; + deployStub.rejects(new Error("build failed")); - await expect(actuate(s, config, options)).to.be.rejectedWith("build failed"); + await expect(actuate(webSetup(), config, options)).to.be.rejectedWith("build failed"); expect(writeStub).to.not.have.been.called; }); }); describe("upsertRunConfig", () => { - const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; - it("sets a single service object when firebase.json has no run config", () => { upsertRunConfig({ serviceId: "web", region: "us-central1", rootDir: "/" }, config); expect(config.src.run).to.deep.equal({ diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 92eddf46dbe..0ca4beae97c 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -135,9 +135,9 @@ async function promptRootDir(projectDir: string, defaultDir: string): Promise { const info = setup.featureInfo?.run; @@ -152,6 +152,7 @@ export async function actuate(setup: Setup, config: Config, options: Options): P }, config, ); + // Name the region too, so that only this service deploys, not ones with the same ID elsewhere. await deploy( ["run"], { ...options, projectId: setup.projectId, config, only: `run:${fullServiceName(info)}` }, @@ -160,9 +161,12 @@ export async function actuate(setup: Setup, config: Config, options: Options): P } /** - * Adds a new service to firebase.json with the default ignore list. If the service is already - * there (same service ID and region), only its rootDir changes; nothing else in the entry is added - * or removed. + * Adds or updates the service in `config`, the in-memory copy of firebase.json. It doesn't save + * the file; init does that at the end. + * - New service: adds an entry with the default ignore list. + * - Service already listed (same service ID and region): updates rootDir, the only init answer + * saved in firebase.json that can change. The rest of the entry, like a custom ignore list, + * stays as the user left it. * Exported for unit testing. */ export function upsertRunConfig( From 716c4fdacc9a46a644ead9315164636374f16401 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 12:27:45 -0400 Subject: [PATCH 42/42] Address review comments on the Cloud Run deploy - Add mainContainer() so one place decides which container is the main one. - Resolve the sticky base image with an if statement instead of a ternary. - Build the buildpack request with an explicit if instead of a conditional spread. - Explain that svc.baseImage is already resolved by prepare. - Rename createService to createNewService. --- src/deploy/run/args.ts | 4 ++++ src/deploy/run/deploy.ts | 36 ++++++++++++++++++++---------------- src/deploy/run/prepare.ts | 13 +++++++------ src/deploy/run/util.ts | 10 ++++++++++ src/init/features/run.ts | 4 ++-- 5 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 08665317206..ba65fda63b1 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -6,6 +6,10 @@ export interface ServiceDeploy { config: RunSingle; /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ existing?: runv2.Service; + /** + * The base image to build and run on: the service's current one, unless this deploy sets or + * clears it. Undefined means none, so automatic base image updates are off. + */ baseImage?: string; /** The service after the rollout. */ deployed?: runv2.Service; diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 5333eaa92b4..02161c66ff8 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -10,6 +10,7 @@ import { Options } from "../../options"; import { cloneDeep, logLabeledBullet } from "../../utils"; import { createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; +import { mainContainer } from "./util"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ @@ -24,7 +25,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa const template = revisionTemplate(svc, image, options.message as string | undefined); svc.deployed = svc.existing ? await deployRevision(svc.existing, template) - : await createService(projectId, svc.config, template); + : await createNewService(projectId, svc.config, template); } } @@ -74,19 +75,19 @@ async function buildImage( 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} in ${region}...`); - 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: { - // 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", - }, + const buildpackBuild: runv2.BuildpacksBuild = { + environmentVariables: { + // 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", }, - }); + }; + // Only images built for a base image can have their base image updated automatically. + if (svc.baseImage) { + buildpackBuild.baseImage = svc.baseImage; + buildpackBuild.enableAutomaticUpdates = true; + } + await runv2.submitBuild(projectId, region, { storageSource: source, imageUri, buildpackBuild }); return imageUri; } @@ -106,11 +107,14 @@ export function revisionTemplate( : { containers: [{ name: serviceId, image }] }; // The live template names the live revision. Drop the name so Cloud Run creates a new revision. delete template.revision; - const container = template.containers?.[0]; + const container = mainContainer(template); if (!container) { throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); } container.image = image; + // Prepare already resolved svc.baseImage: the service's current base image, unless this deploy + // sets or clears it. A new service has none unless init sets one, so automatic base image + // updates are off by default. if (svc.baseImage) { container.baseImageUri = svc.baseImage; } else { @@ -125,8 +129,8 @@ export function revisionTemplate( return template; } -/** Creates the service with its first revision. */ -function createService( +/** Creates a new service with its first revision. */ +function createNewService( projectId: string, config: RunSingle, template: runv2.RevisionTemplate, diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index a9f00503f2b..7e5e8062855 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -2,7 +2,7 @@ import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; import { prereqs } from "./prereqs"; -import { getExistingService, getServiceConfigs } from "./util"; +import { getExistingService, getServiceConfigs, mainContainer } from "./util"; /** * Reads each service's current state from Cloud Run and resolves its base image. @@ -21,10 +21,11 @@ export async function prepare(context: Context, options: Options, payload: Paylo async function prepareService(context: Context, config: RunSingle): Promise { const existing = await getExistingService(context.projectId, config.region, config.serviceId); - // Base images are sticky: deploys reuse the service's current base image unless told otherwise. - const baseImage = - context.baseImage === undefined - ? existing?.template.containers?.[0]?.baseImageUri - : context.baseImage || undefined; + // Base images are sticky: keep the service's current one unless the context sets a new one or + // clears it (null). + let baseImage = mainContainer(existing?.template)?.baseImageUri; + if (context.baseImage !== undefined) { + baseImage = context.baseImage ?? undefined; + } return { config, existing, baseImage }; } diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 43e0e5a4fad..024ad820f0a 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -100,3 +100,13 @@ export async function getExistingService( throw err; } } + +/** + * Returns the service's main container, the one that receives its traffic. Services that Firebase + * deploys have a single container, so for now this is the first one. + */ +export function mainContainer( + template: runv2.RevisionTemplate | undefined, +): runv2.Container | undefined { + return template?.containers?.[0]; +} diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 0ca4beae97c..33f285fa564 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -3,7 +3,7 @@ import { Setup } from ".."; import { Config } from "../../config"; import { deploy } from "../../deploy"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; -import { fullServiceName, getExistingService } from "../../deploy/run/util"; +import { fullServiceName, getExistingService, mainContainer } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { dirExistsSync } from "../../fsutils"; @@ -68,7 +68,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option // default base image, except that an existing service keeps its own. const rawBaseImage = await input({ message: "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", - default: existing?.template.containers?.[0]?.baseImageUri, + default: mainContainer(existing?.template)?.baseImageUri, }); const baseImage = (rawBaseImage || "").trim();