From caaf161be6e40a2ed2dcde583836a71f3dee0a5a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 28 Sep 2026 16:53:05 -0400 Subject: [PATCH 001/103] Create a basic, initial schema for firebase.json --- schema/firebase-config.json | 69 ++++++++++++++++++++++++++++++ src/firebaseConfig.ts | 14 ++++++ src/firebaseConfigValidate.spec.ts | 17 ++++++++ 3 files changed, 100 insertions(+) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 0185d3e7789..a6171d42c19 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1221,6 +1221,62 @@ ], "type": "object" }, + "RunSingle": { + "additionalProperties": false, + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "localBuild": { + "type": "boolean" + }, + "postdeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "predeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "region": { + "type": "string" + }, + "rootDir": { + "type": "string" + }, + "serviceAccount": { + "type": "string" + }, + "serviceId": { + "type": "string" + } + }, + "required": [ + "serviceId" + ], + "type": "object" + }, "StorageSingle": { "additionalProperties": false, "properties": { @@ -2008,6 +2064,19 @@ "remoteconfig": { "$ref": "#/definitions/RemoteConfigConfig" }, + "run": { + "anyOf": [ + { + "$ref": "#/definitions/RunSingle" + }, + { + "items": { + "$ref": "#/definitions/RunSingle" + }, + "type": "array" + } + ] + }, "storage": { "anyOf": [ { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 25934174b9a..18235b97107 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,6 +366,19 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; +export interface RunSingle extends Deployable { + serviceId: string; + region?: string; + rootDir?: string; + ignore?: string[]; + serviceAccount?: string; + localBuild?: boolean; +} + +export type RunMultiple = RunSingle[]; + +export type RunConfig = RunSingle | RunMultiple; + export interface AuthConfig { providers?: { anonymous?: boolean; @@ -392,4 +405,5 @@ export type FirebaseConfig = { dataconnect?: DataConnectConfig; apphosting?: AppHostingConfig; auth?: AuthConfig; + run?: RunConfig; }; diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 327011a577b..56b3d149798 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,23 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a valid run config", () => { + const config: FirebaseConfig = { + run: [ + { + serviceId: "my-service", + region: "us-central1", + rootDir: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { From dd384b6127dfff108583f5d9a94678a27a63061b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 10:35:24 -0400 Subject: [PATCH 002/103] improve testing --- src/firebaseConfigValidate.spec.ts | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 56b3d149798..fc104913d73 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -41,6 +41,92 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a single-object run config", () => { + const config: FirebaseConfig = { + run: { + serviceId: "my-service", + region: "us-central1", + localBuild: true, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + + it("should reject a run config missing serviceId", () => { + const config = { + run: [ + { + region: "us-central1", + rootDir: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "required" && + e.instancePath === "/run/0" && + e.params.missingProperty === "serviceId", + ), + ).to.be.true; + }); + + it("should reject a run config with an unknown field", () => { + const config = { + run: { + serviceId: "my-service", + bananas: true, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "additionalProperties" && + e.instancePath === "/run" && + e.params.additionalProperty === "bananas", + ), + ).to.be.true; + }); + + it("should reject a run config with an incorrect type", () => { + const config = { + run: { + serviceId: "my-service", + localBuild: "yes", + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === "type" && + e.instancePath === "/run/localBuild" && + e.params.type === "boolean", + ), + ).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { From c8fbf9cfd7d67285a8e77796735229d16e0a44a5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:53:09 -0400 Subject: [PATCH 003/103] Require region, remove serviceAccount, and register run in Config.KNOWN_PROPS --- schema/firebase-config.json | 4 +- src/config.ts | 1 + src/firebaseConfig.ts | 3 +- src/firebaseConfigValidate.spec.ts | 112 ++++++++++++----------------- 4 files changed, 48 insertions(+), 72 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 97ff97e0526..e646dd371d3 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1265,14 +1265,12 @@ "rootDir": { "type": "string" }, - "serviceAccount": { - "type": "string" - }, "serviceId": { "type": "string" } }, "required": [ + "region", "serviceId" ], "type": "object" diff --git a/src/config.ts b/src/config.ts index ffd9998c88b..cd20b9e1fa0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ export class Config { "apphosting", "auth", "ailogic", + "run", ]; public options: any; diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 38c7a9ef7b9..7137aedc70b 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -368,10 +368,9 @@ export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; export interface RunSingle extends Deployable { serviceId: string; - region?: string; + region: string; rootDir?: string; ignore?: string[]; - serviceAccount?: string; localBuild?: boolean; } diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index fc104913d73..0ee2a2751fe 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,24 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + function expectValidationError( + config: unknown, + keyword: string, + instancePath: string, + params: Record, + ): void { + const validator = getValidator(); + expect(validator(config)).to.be.false; + expect( + (validator.errors ?? []).some( + (e) => + e.keyword === keyword && + e.instancePath === instancePath && + Object.entries(params).every(([k, v]) => e.params[k] === v), + ), + ).to.be.true; + } + it("should accept a valid run config", () => { const config: FirebaseConfig = { run: [ @@ -36,9 +54,7 @@ describe("firebaseConfigValidate", () => { }; const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.true; + expect(validator(config)).to.be.true; }); it("should accept a single-object run config", () => { @@ -51,80 +67,42 @@ describe("firebaseConfigValidate", () => { }; const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.true; + expect(validator(config)).to.be.true; }); it("should reject a run config missing serviceId", () => { - const config = { - run: [ - { - region: "us-central1", - rootDir: ".", - }, - ], - }; - - const validator = getValidator(); - const isValid = validator(config); + expectValidationError( + { run: [{ region: "us-central1", rootDir: "." }] }, + "required", + "/run/0", + { + missingProperty: "serviceId", + }, + ); + }); - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "required" && - e.instancePath === "/run/0" && - e.params.missingProperty === "serviceId", - ), - ).to.be.true; + it("should reject a run config missing region", () => { + expectValidationError({ run: { serviceId: "my-service" } }, "required", "/run", { + missingProperty: "region", + }); }); it("should reject a run config with an unknown field", () => { - const config = { - run: { - serviceId: "my-service", - bananas: true, - }, - }; - - const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "additionalProperties" && - e.instancePath === "/run" && - e.params.additionalProperty === "bananas", - ), - ).to.be.true; + expectValidationError( + { run: { serviceId: "my-service", region: "us-central1", bananas: true } }, + "additionalProperties", + "/run", + { additionalProperty: "bananas" }, + ); }); it("should reject a run config with an incorrect type", () => { - const config = { - run: { - serviceId: "my-service", - localBuild: "yes", - }, - }; - - const validator = getValidator(); - const isValid = validator(config); - - expect(isValid).to.be.false; - expect(validator.errors).to.exist; - expect( - (validator.errors ?? []).some( - (e) => - e.keyword === "type" && - e.instancePath === "/run/localBuild" && - e.params.type === "boolean", - ), - ).to.be.true; + expectValidationError( + { run: { serviceId: "my-service", region: "us-central1", localBuild: "yes" } }, + "type", + "/run/localBuild", + { type: "boolean" }, + ); }); it("should report an extra top-level field", () => { From 254e3f58d37c3bfd7a84d5cf29ccdfc9ad47aeed Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 004/103] Add direct_cloud_run experiment flag --- src/experiments.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/experiments.ts b/src/experiments.ts index 5558280875f..5f128a19e1f 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -179,6 +179,15 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, + direct_cloud_run: { + shortDescription: "Build and deploy web apps directly to Cloud Run services", + fullDescription: + "Enables `firebase init run`, `firebase deploy --only run`, and the " + + "`firebase run:services:update` command. " + + "These are in preview and may change.", + public: false, + }, + // TODO(joehanley): Delete this once weve scrubbed all references to experiment from docs. dataconnect: { shortDescription: "Deprecated. Previosuly, enabled SQL Connect related features.", From 99b87a53a5da105e48198a8e6675baff36cdc7ff Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:55:55 -0400 Subject: [PATCH 005/103] Update shared GCP clients (runv2, run, artifactregistry) --- src/gcp/artifactregistry.spec.ts | 41 +++++++++ src/gcp/artifactregistry.ts | 30 +++++++ src/gcp/run.spec.ts | 28 ++++++ src/gcp/run.ts | 10 +++ src/gcp/runv2.spec.ts | 143 +++++++++++++++++++++++++++++++ src/gcp/runv2.ts | 74 ++++++++++++---- 6 files changed, 307 insertions(+), 19 deletions(-) diff --git a/src/gcp/artifactregistry.spec.ts b/src/gcp/artifactregistry.spec.ts index c8e9353b925..7cc94830d4d 100644 --- a/src/gcp/artifactregistry.spec.ts +++ b/src/gcp/artifactregistry.spec.ts @@ -140,4 +140,45 @@ describe("artifactRegistry", () => { ); }); }); + + describe("ensureDockerRepository", () => { + it("does nothing if the repository already exists", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(200, { name: REPO_NAME, format: "DOCKER" }); + + await artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("creates the repository and polls the operation when it returns 404", async () => { + const opName = `projects/${PROJECT_ID}/locations/${REGION}/operations/create-repo`; + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(404, { error: { message: "Not Found" } }); + nock(artifactRegistryDomain()) + .post(`/${API_VERSION}/projects/${PROJECT_ID}/locations/${REGION}/repositories`, { + format: "DOCKER", + }) + .query({ repositoryId: REPO }) + .reply(200, { name: opName }); + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${opName}`) + .reply(200, { name: opName, done: true, response: { name: REPO_NAME, format: "DOCKER" } }); + + await artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("rethrows non-404 errors", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(403, { error: { message: "Permission Denied" } }); + + await expect( + artifactRegistry.ensureDockerRepository(PROJECT_ID, REGION, REPO), + ).to.be.rejectedWith("Permission Denied"); + expect(nock.isDone()).to.be.true; + }); + }); }); diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index 6c589ad21c2..b5937766c5b 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -3,6 +3,8 @@ import { artifactRegistryDomain } from "../api"; import { assertImplements, DeepOmit, RecursiveKeyOf } from "../metaprogramming"; import * as api from "../ensureApiEnabled"; import * as proto from "./proto"; +import { getErrStatus } from "../error"; +import { pollOperation } from "../operation-poller"; export const API_VERSION = "v1"; @@ -95,3 +97,31 @@ export async function updateRepository(repo: RepositoryInput): Promise { + try { + await getRepository(`projects/${projectId}/locations/${location}/repositories/${repositoryId}`); + return; + } catch (err: unknown) { + if (getErrStatus(err) !== 404) { + throw err; + } + } + const res = await client.post<{ format: string }, Operation>( + `/projects/${projectId}/locations/${location}/repositories`, + { format: "DOCKER" }, + { queryParams: { repositoryId } }, + ); + await pollOperation({ + apiOrigin: artifactRegistryDomain(), + apiVersion: API_VERSION, + operationResourceName: res.body.name, + }); +} diff --git a/src/gcp/run.spec.ts b/src/gcp/run.spec.ts index 3661401fcdc..92fd79e981a 100644 --- a/src/gcp/run.spec.ts +++ b/src/gcp/run.spec.ts @@ -452,4 +452,32 @@ describe("run", () => { expect(run.serviceIsResolved(service)).to.be.true; }); }); + + describe("listLocations", () => { + let getStub: sinon.SinonStub; + + beforeEach(() => { + getStub = sinon.stub(Client.prototype, "get"); + }); + + afterEach(() => { + getStub.restore(); + }); + + it("returns location IDs from the Cloud Run API", async () => { + getStub.resolves({ + body: { locations: [{ locationId: "us-central1" }, { locationId: "europe-west1" }] }, + }); + + const locations = await run.listLocations("my-project"); + expect(locations).to.deep.equal(["us-central1", "europe-west1"]); + expect(getStub).to.have.been.calledOnceWithExactly("/projects/my-project/locations"); + }); + + it("returns an empty array when locations is omitted", async () => { + getStub.resolves({ body: {} }); + + expect(await run.listLocations("my-project")).to.deep.equal([]); + }); + }); }); diff --git a/src/gcp/run.ts b/src/gcp/run.ts index 945aca552cb..73053ab8f99 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -367,3 +367,13 @@ export async function fetchServiceLogs(projectId: string, serviceId: string): Pr }); } } + +/** + * Lists the IDs of the regions that Cloud Run supports (e.g. "us-central1"). + */ +export async function listLocations(projectId: string): Promise { + const res = await client.get<{ locations?: Array<{ locationId: string }> }>( + `/projects/${projectId}/locations`, + ); + return res.body.locations?.map((l) => l.locationId) ?? []; +} diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index de830171f88..f7c87399f78 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -7,6 +7,8 @@ import { latest } from "../deploy/functions/runtimes/supported"; import { CODEBASE_LABEL } from "../functions/constants"; import { Client } from "../apiv2"; import { FirebaseError } from "../error"; +import { logger } from "../logger"; +import * as poller from "../operation-poller"; describe("runv2", () => { const PROJECT_ID = "project-id"; @@ -583,5 +585,146 @@ describe("runv2", () => { { queryParams: {} }, ); }); + + it("should return all services when functionsOnly is false", async () => { + const mockServices = [ + { name: "service1", labels: { "goog-managed-by": "cloud-functions" } }, + { name: "service2", labels: {} }, + ]; + getStub.resolves({ status: 200, body: { services: mockServices } }); + + const services = await runv2.listServices(PROJECT_ID, false); + + expect(services).to.deep.equal(mockServices); + }); + }); + + describe("submitBuild, updateService, and createService", () => { + let sandbox: sinon.SinonSandbox; + let postStub: sinon.SinonStub; + let patchStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + let warnStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + postStub = sandbox.stub(Client.prototype, "post"); + patchStub = sandbox.stub(Client.prototype, "patch"); + pollStub = sandbox.stub(poller, "pollOperation"); + warnStub = sandbox.stub(logger, "warn"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("submitBuild submits a build, logs baseImageWarning, and polls until SUCCESS", async () => { + postStub.resolves({ + body: { + baseImageWarning: "nodejs20 is deprecated", + buildOperation: { + metadata: { + build: { name: "projects/p/locations/r/builds/b1", logUrl: "http://logs" }, + }, + }, + }, + }); + pollStub.callsFake(async (opts: { doneFn?: (b: { status?: string }) => boolean }) => { + expect(opts.doneFn?.({ status: "WORKING" })).to.be.false; + expect(opts.doneFn?.({ status: "SUCCESS" })).to.be.true; + }); + + const build: runv2.Build = { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: { baseImage: "nodejs22" }, + }; + await runv2.submitBuild(PROJECT_ID, LOCATION, build); + + expect(postStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/${LOCATION}/builds:submit`, + build, + ); + expect(warnStub).to.have.been.calledOnceWithExactly("nodejs20 is deprecated"); + expect(pollStub).to.have.been.calledOnce; + }); + + it("submitBuild throws when no build metadata is returned", async () => { + postStub.resolves({ body: { buildOperation: {} } }); + + await expect( + runv2.submitBuild(PROJECT_ID, LOCATION, { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: {}, + }), + ).to.be.rejectedWith("Failed to submit build: no build was returned."); + }); + + it("submitBuild throws when the Cloud Build fails", async () => { + postStub.resolves({ + body: { + buildOperation: { + metadata: { + build: { name: "projects/p/locations/r/builds/b1", logUrl: "http://logs" }, + }, + }, + }, + }); + pollStub.callsFake(async (opts: { doneFn?: (b: { status?: string }) => boolean }) => { + expect(opts.doneFn?.({ status: "FAILURE" })).to.be.true; + }); + + await expect( + runv2.submitBuild(PROJECT_ID, LOCATION, { + imageUri: IMAGE_URI, + storageSource: { bucket: "b", object: "o" }, + buildpackBuild: {}, + }), + ).to.be.rejectedWith( + "Cloud Build failed with status FAILURE. View the build logs at http://logs", + ); + }); + + it("updateService uses explicit updateMask and masterTimeout when provided", async () => { + patchStub.resolves({ body: { name: "operations/op1" } }); + pollStub.resolves(BASE_RUN_SERVICE); + + await runv2.updateService(BASE_RUN_SERVICE, { + updateMask: ["template", "traffic"], + masterTimeout: 60000, + }); + + expect(patchStub).to.have.been.calledOnceWithExactly( + BASE_RUN_SERVICE.name, + BASE_RUN_SERVICE, + { queryParams: { updateMask: "template,traffic" } }, + ); + expect(pollStub).to.have.been.calledWithMatch({ + operationResourceName: "operations/op1", + masterTimeout: 60000, + }); + }); + + it("createService strips name from body and passes serviceId query param", async () => { + postStub.resolves({ body: { name: "operations/op2" } }); + pollStub.resolves(BASE_RUN_SERVICE); + + await runv2.createService(PROJECT_ID, LOCATION, SERVICE_ID, BASE_RUN_SERVICE, { + masterTimeout: 60000, + }); + + const expectedBody: Partial = { ...BASE_RUN_SERVICE }; + delete expectedBody.name; + expect(postStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/${LOCATION}/services`, + expectedBody, + { queryParams: { serviceId: SERVICE_ID } }, + ); + expect(pollStub).to.have.been.calledWithMatch({ + operationResourceName: "operations/op2", + masterTimeout: 60000, + }); + }); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 726eb300885..c59cb01a80f 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -36,6 +36,7 @@ export interface Container { command?: string[]; args?: string[]; env?: EnvVar[]; + ports?: Array<{ name?: string; containerPort?: number }>; resources?: { limits?: { cpu?: string; // e.g. "1", "2", "4" @@ -84,6 +85,13 @@ export interface BuildConfig { serviceAccount?: string; } +export interface TrafficTarget { + type?: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" | "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION"; + revision?: string; + percent?: number; + tag?: string; +} + // NOTE: This is a minmal copy of Cloud Run needed for our current API usage. // Add more as needed. // TODO: Can consider a helper where we have a second RecursiveKeysOf field for @@ -110,7 +118,10 @@ export interface Service { etag: string; template: RevisionTemplate; + traffic?: TrafficTarget[]; + trafficStatuses?: TrafficTarget[]; invokerIamDisabled?: boolean; + ingress?: string; // Is this redundant with the Build API? buildConfig?: BuildConfig; uri?: string; @@ -151,17 +162,21 @@ export interface Build { functionTarget?: string; storageSource: StorageSource; imageUri: string; - buildpacksBuild: BuildpacksBuild; + buildpackBuild: BuildpacksBuild; } export interface SubmitBuildResponse { - buildOperation: string; + buildOperation: { + metadata?: { build?: { name: string; logUrl?: string } }; + }; baseImageUri?: string; baseImageWarning?: string; } +const PENDING_BUILD_STATUSES = ["STATUS_UNKNOWN", "PENDING", "QUEUED", "WORKING"]; + /** - * Submits a build to Cloud Build using the v2 API, tracking the long-running operation. + * Submits a build to Cloud Build using the v2 API and waits for the build to finish. * Used for building source code into container images. */ export async function submitBuild( @@ -170,38 +185,55 @@ export async function submitBuild( build: Build, ): Promise { const res = await client.post( - `/projects/${projectId}/locations/${location}/builds`, + `/projects/${projectId}/locations/${location}/builds:submit`, build, ); - if (res.status !== 200) { - throw new FirebaseError(`Failed to submit build: ${res.status} ${res.body}`); + if (res.body.baseImageWarning) { + logger.warn(res.body.baseImageWarning); + } + const cloudBuild = res.body.buildOperation?.metadata?.build; + if (!cloudBuild) { + throw new FirebaseError("Failed to submit build: no build was returned."); } + // The returned operation can't be polled for regional builds, so poll the build itself. + let status: string | undefined; await pollOperation({ apiOrigin: cloudbuildOrigin(), apiVersion: "v1", - operationResourceName: res.body.buildOperation, + operationResourceName: cloudBuild.name, + masterTimeout: 30 * 60 * 1000, + maxBackoff: 10_000, + doneFn: (b: { status?: string }) => { + status = b.status; + return !PENDING_BUILD_STATUSES.includes(b.status ?? "STATUS_UNKNOWN"); + }, }); + if (status !== "SUCCESS") { + throw new FirebaseError( + `Cloud Build failed with status ${status}. View the build logs at ${cloudBuild.logUrl}`, + ); + } } /** * Updates an existing Cloud Run service. * Tracks the long-running operation until completion. */ -export async function updateService(service: Omit): Promise { - const fieldMask = proto.fieldMasks( - service, - /* doNotRecurseIn...*/ "labels", - "annotations", - "tags", - ); - // Always update revision name to ensure null generates a new unique revision name. - fieldMask.push("template.revision"); +export async function updateService( + service: Omit, + opts: { updateMask?: string[]; masterTimeout?: number } = {}, +): Promise { + const updateMask = opts.updateMask ?? [ + ...proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"), + // Always update revision name to ensure null generates a new unique revision name. + "template.revision", + ]; const res = await client.patch, LongRunningOperation>( service.name, service, { queryParams: { - updateMask: fieldMask.join(","), + updateMask: updateMask.join(","), }, }, ); @@ -209,6 +241,7 @@ export async function updateService(service: Omit) apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: opts.masterTimeout, }); return svc; } @@ -222,6 +255,7 @@ export async function createService( location: string, serviceId: string, service: Omit, + opts: { masterTimeout?: number } = {}, ): Promise { // The create API expects the name to be empty or unset, as the parent is in the URL // and resource ID is a query param. @@ -240,6 +274,7 @@ export async function createService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: opts.masterTimeout, }); return svc; } @@ -278,10 +313,10 @@ export async function getService( /** * Lists Cloud Run services in the given project. * - * This method only returns services with the "goog-managed-by" label set to + * By default, this method only returns services with the "goog-managed-by" label set to * "cloud-functions" or "firebase-functions". */ -export async function listServices(projectId: string): Promise { +export async function listServices(projectId: string, functionsOnly = true): Promise { const allServices: Service[] = []; let pageToken: string | undefined = undefined; @@ -305,6 +340,7 @@ export async function listServices(projectId: string): Promise { if (res.body.services) { for (const service of res.body.services) { if ( + !functionsOnly || service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" || service.labels?.[CLIENT_NAME_LABEL] === "firebase-functions" From e388293abcf76a35a1bc0847f46b2f2db4d5dd19 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 21:58:25 -0400 Subject: [PATCH 006/103] Add firebase init run interactive setup --- src/commands/init.ts | 8 ++ src/deploy/run/prereqs.ts | 33 +++++++ src/deploy/run/util.spec.ts | 36 ++++++++ src/deploy/run/util.ts | 27 ++++++ src/init/features/index.ts | 1 + src/init/features/run.spec.ts | 167 ++++++++++++++++++++++++++++++++++ src/init/features/run.ts | 138 ++++++++++++++++++++++++++++ src/init/index.ts | 7 ++ 8 files changed, 417 insertions(+) create mode 100644 src/deploy/run/prereqs.ts create mode 100644 src/deploy/run/util.spec.ts create mode 100644 src/deploy/run/util.ts create mode 100644 src/init/features/run.spec.ts create mode 100644 src/init/features/run.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index b69d7363202..6525c331811 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -101,6 +101,14 @@ let choices: { }, ]; +if (isEnabled("direct_cloud_run")) { + choices.push({ + value: "run", + name: "Cloud Run: Create or update a Cloud Run service and deploy your web app to it", + checked: false, + }); +} + if (isEnabled("fdcwebhooks")) { choices.push({ value: "dataconnect:resolver", diff --git a/src/deploy/run/prereqs.ts b/src/deploy/run/prereqs.ts new file mode 100644 index 00000000000..382f95cbacb --- /dev/null +++ b/src/deploy/run/prereqs.ts @@ -0,0 +1,33 @@ +import { ensure } from "../../ensureApiEnabled"; +import * as artifactregistry from "../../gcp/artifactregistry"; + +export const RUN_PERMISSIONS = [ + "run.services.get", + "run.services.create", + "run.services.update", + "run.services.setIamPolicy", + "run.operations.get", + "cloudbuild.builds.create", + "cloudbuild.builds.get", + "storage.buckets.get", + "storage.buckets.list", + "storage.buckets.create", + "storage.buckets.update", + "storage.objects.create", + "artifactregistry.repositories.get", + "artifactregistry.repositories.create", + "artifactregistry.repositories.downloadArtifacts", + "iam.serviceAccounts.actAs", +]; + +/** + * Ensures that the APIs needed to build and deploy Cloud Run services are enabled. + */ +export async function prereqs(projectId: string): Promise { + await Promise.all([ + ensure(projectId, "run.googleapis.com", "run", true), + ensure(projectId, "cloudbuild.googleapis.com", "cloudbuild", true), + ensure(projectId, "storage.googleapis.com", "storage", true), + artifactregistry.ensureApiEnabled(projectId), + ]); +} diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts new file mode 100644 index 00000000000..f0a11709d16 --- /dev/null +++ b/src/deploy/run/util.spec.ts @@ -0,0 +1,36 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import { getExistingService, mainContainer } from "./util"; + +describe("run util", () => { + afterEach(() => sinon.restore()); + + describe("getExistingService", () => { + it("returns the service when it exists", async () => { + const svc = { name: "projects/p/locations/r/services/s" } as runv2.Service; + sinon.stub(runv2, "getService").resolves(svc); + expect(await getExistingService("p", "r", "s")).to.equal(svc); + }); + + it("returns undefined for services that don't exist", async () => { + sinon.stub(runv2, "getService").rejects({ status: 404 }); + expect(await getExistingService("p", "r", "s")).to.be.undefined; + }); + + it("rethrows other errors", async () => { + sinon.stub(runv2, "getService").rejects({ status: 403 }); + await expect(getExistingService("p", "r", "s")).to.be.rejected; + }); + }); + + describe("mainContainer", () => { + it("returns the container with ports, or else the first one", () => { + const sidecar = { name: "sidecar", image: "s" }; + const main = { name: "main", image: "m", ports: [{ containerPort: 8080 }] }; + expect(mainContainer({ containers: [sidecar, main] })).to.equal(main); + expect(mainContainer({ containers: [sidecar] })).to.equal(sidecar); + expect(mainContainer(undefined)).to.be.undefined; + }); + }); +}); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts new file mode 100644 index 00000000000..7e80f6073c8 --- /dev/null +++ b/src/deploy/run/util.ts @@ -0,0 +1,27 @@ +import { getErrStatus } from "../../error"; +import * as runv2 from "../../gcp/runv2"; + +/** + * Gets a Cloud Run service, or undefined if it doesn't exist yet. + */ +export async function getExistingService( + projectId: string, + region: string, + serviceId: string, +): Promise { + try { + return await runv2.getService(projectId, region, serviceId); + } catch (err: unknown) { + if (getErrStatus(err) === 404) { + return undefined; + } + throw err; + } +} + +/** + * Returns the container that serves traffic. + */ +export function mainContainer(template?: runv2.RevisionTemplate): runv2.Container | undefined { + return template?.containers?.find((c) => c.ports?.length) ?? template?.containers?.[0]; +} diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 3d4ace315ec..9686cb09b47 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -61,3 +61,4 @@ export { actuate as agentSkillsActuate, AgentSkillsInfo, } from "./agentSkills"; +export { askQuestions as runAskQuestions, actuate as runActuate, RunInfo } from "./run"; diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts new file mode 100644 index 00000000000..0b2bd812dc5 --- /dev/null +++ b/src/init/features/run.spec.ts @@ -0,0 +1,167 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Setup } from ".."; +import { Config } from "../../config"; +import * as prereqs from "../../deploy/run/prereqs"; +import * as run from "../../gcp/run"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import * as prompt from "../../prompt"; +import * as requirePermissions from "../../requirePermissions"; +import { actuate, askQuestions, upsertRunConfig } from "./run"; + +describe("init run", () => { + const options = {} as Options; + let config: Config; + let selectStub: sinon.SinonStub; + let inputStub: sinon.SinonStub; + + function setup(): Setup { + return { + config: {}, + rcfile: { projects: {}, targets: {}, etags: {} }, + projectId: "p", + instructions: [], + }; + } + + beforeEach(() => { + config = new Config({}, { projectDir: process.cwd(), cwd: process.cwd() }); + sinon.stub(requirePermissions, "requirePermissions").resolves(); + sinon.stub(prereqs, "prereqs").resolves(); + sinon.stub(run, "listLocations").resolves(["us-central1", "us-east1"]); + sinon.stub(runv2, "getService").rejects({ status: 404 }); + selectStub = sinon.stub(prompt, "select"); + inputStub = sinon.stub(prompt, "input"); + }); + + afterEach(() => sinon.restore()); + + describe("askQuestions", () => { + it("throws if no project is selected", async () => { + await expect( + askQuestions({ ...setup(), projectId: undefined }, config, options), + ).to.be.rejectedWith("Cloud Run requires a Firebase project"); + }); + + it("asks how to create a new service", async () => { + selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-east1"); + inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); + inputStub.onThirdCall().resolves("/"); + const s = setup(); + + await askQuestions(s, config, options); + + expect(selectStub.secondCall.args[0]).to.deep.include({ + choices: ["us-central1", "us-east1"], + default: "us-central1", + }); + expect(inputStub.secondCall.args[0].default).to.equal("nodejs22"); + const validateDir = inputStub.thirdCall.args[0].validate; + expect(validateDir(".")).to.be.true; + expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "my-service", + region: "us-east1", + baseImage: "nodejs22", + rootDir: "/", + }); + }); + + it("rejects invalid or taken service IDs", async () => { + selectStub.onFirstCall().resolves("create").onSecondCall().resolves("us-central1"); + inputStub.resolves("/"); + await askQuestions(setup(), config, options); + const validate = inputStub.firstCall.args[0].validate; + + expect(await validate("Bad_Id")).to.be.a("string"); + expect(await validate("ok-id")).to.be.true; + (runv2.getService as sinon.SinonStub).resolves({}); + expect(await validate("ok-id")).to.equal( + "A service named ok-id already exists in us-central1.", + ); + }); + + it("updates an existing service", async () => { + const existing = { + name: "projects/p/locations/europe-west1/services/web", + template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs20" }] }, + }; + const managed = { + name: "projects/p/locations/r/services/f", + labels: { "goog-managed-by": "x" }, + }; + sinon.stub(runv2, "listServices").resolves([existing, managed] as unknown as runv2.Service[]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + expect(runv2.listServices).to.have.been.calledWith("p", false); + expect(selectStub.secondCall.args[0].choices).to.deep.equal([ + { name: "web (europe-west1)", value: existing }, + ]); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "web", + region: "europe-west1", + baseImage: "nodejs20", + rootDir: "/", + }); + }); + + it("creates a service if there are none to update", async () => { + sinon.stub(runv2, "listServices").resolves([]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves("us-central1"); + inputStub.onFirstCall().resolves("my-service"); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); + }); + }); + + describe("actuate", () => { + it("does nothing when featureInfo.run is not set", async () => { + const writeStub = sinon.stub(config, "writeProjectFile"); + await actuate(setup(), config); + expect(writeStub).to.not.have.been.called; + }); + + it("saves the service to firebase.json", async () => { + const writeStub = sinon.stub(config, "writeProjectFile"); + const s = setup(); + s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; + + await actuate(s, config); + + const runConfig = { + serviceId: "s", + rootDir: "/", + region: "r", + ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + }; + expect(config.src.run).to.deep.equal(runConfig); + expect(writeStub).to.have.been.calledWith("firebase.json", config.src); + }); + }); + + describe("upsertRunConfig", () => { + it("adds services, and updates them in place without dropping other settings", () => { + const a = { serviceId: "a", region: "r", rootDir: "/", ignore: ["x"] }; + const b = { serviceId: "b", region: "r" }; + upsertRunConfig(a, config); + expect(config.src.run).to.deep.equal(a); + upsertRunConfig(b, config); + expect(config.src.run).to.deep.equal([a, b]); + config.set("run.0.localBuild", true); + upsertRunConfig({ serviceId: "a", region: "r2", rootDir: "web", ignore: ["y"] }, config); + expect(config.src.run).to.deep.equal([ + { serviceId: "a", region: "r2", rootDir: "web", ignore: ["x"], localBuild: true }, + b, + ]); + }); + }); +}); diff --git a/src/init/features/run.ts b/src/init/features/run.ts new file mode 100644 index 00000000000..ab886bf6d04 --- /dev/null +++ b/src/init/features/run.ts @@ -0,0 +1,138 @@ +import { existsSync } from "fs"; +import * as path from "path"; +import { Setup } from ".."; +import { Config } from "../../config"; +import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; +import { getExistingService, mainContainer } from "../../deploy/run/util"; +import { FirebaseError } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; +import * as run from "../../gcp/run"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { input, select } from "../../prompt"; +import { requirePermissions } from "../../requirePermissions"; +import { logBullet } from "../../utils"; + +export interface RunInfo { + serviceId: string; + region: string; + baseImage: string; + rootDir: string; +} + +/** + * Checks product-level setup, then asks which service to create or update and how to deploy it. + */ +export async function askQuestions(setup: Setup, config: Config, options: Options): Promise { + const projectId = setup.projectId; + if (!projectId) { + throw new FirebaseError("Cloud Run requires a Firebase project. Run firebase use --add first."); + } + await requirePermissions({ ...options, projectId }, RUN_PERMISSIONS); + await prereqs(projectId); + + let existing: runv2.Service | undefined; + const action = await select({ + message: "Please select an option", + choices: [ + { name: "Create a new service and deploy", value: "create" }, + { name: "Update an existing service and deploy", value: "update" }, + ], + default: "create", + }); + if (action === "update") { + // Skip services that another product (e.g. Cloud Functions or App Hosting) manages. + const services = (await runv2.listServices(projectId, /* functionsOnly= */ false)).filter( + (s) => !s.labels?.[runv2.CLIENT_NAME_LABEL], + ); + if (services.length) { + existing = await select({ + message: "Which service would you like to update?", + choices: services.map((s) => { + const [, , , location, , id] = s.name.split("/"); + return { name: `${id} (${location})`, value: s }; + }), + }); + } else { + logBullet("No Cloud Run services to update. Creating a new service instead."); + } + } + + let serviceId: string; + let region: string; + if (existing) { + [, , , region, , serviceId] = existing.name.split("/"); + } else { + region = await select({ + message: "Which region should this service be deployed to?", + choices: await run.listLocations(projectId), + default: "us-central1", + }); + serviceId = await input({ + message: "Please enter a unique ID for your service", + validate: async (id) => { + if (!/^[a-z]([-a-z0-9]{0,47}[a-z0-9])?$/.test(id)) { + return "Use up to 49 lowercase letters, digits, and hyphens, starting with a letter and not ending with a hyphen."; + } + if (await getExistingService(projectId, region, id)) { + return `A service named ${id} already exists in ${region}.`; + } + return true; + }, + }); + } + + const baseImage = await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + }); + const rootDir = await input({ + message: "Specify your app's root directory relative to your firebase.json directory", + default: "/", + validate: (dir) => { + const absPath = path.join(config.projectDir, dir); + return ( + existsSync(absPath) || + `Directory ${absPath} does not exist. Please enter a valid directory.` + ); + }, + }); + + setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; +} + +/** + * Adds the service to firebase.json. + */ +export async function actuate(setup: Setup, config: Config): Promise { + const info = setup.featureInfo?.run; + if (!info) { + return; + } + upsertRunConfig( + { + serviceId: info.serviceId, + rootDir: info.rootDir, + region: info.region, + ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], + }, + config, + ); + config.writeProjectFile("firebase.json", config.src); +} + +/** + * Adds a service to firebase.json, or updates it in place. Settings that init doesn't ask + * about (e.g. localBuild or a custom ignore list) are kept. Exported for unit testing. + */ +export function upsertRunConfig(runConfig: RunSingle, config: Config): void { + const services = [config.src.run || []].flat(); + const i = services.findIndex((c) => c.serviceId === runConfig.serviceId); + if (i < 0) { + services.push(runConfig); + } else { + const { rootDir, region } = runConfig; + services[i] = { ...runConfig, ...services[i], rootDir, region }; + } + config.set("run", services.length === 1 ? services[0] : services); +} diff --git a/src/init/index.ts b/src/init/index.ts index dda64f48834..ffac29684f5 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -44,6 +44,7 @@ export interface SetupInfo { ailogic?: features.AiLogicInfo; hosting?: features.HostingInfo; auth?: features.AuthInfo; + run?: features.RunInfo; agentSkills?: features.AgentSkillsInfo; } @@ -134,6 +135,12 @@ const featuresList: Feature[] = [ askQuestions: features.agentSkillsAskQuestions, actuate: features.agentSkillsActuate, }, + { + name: "run", + displayName: "Cloud Run", + askQuestions: features.runAskQuestions, + actuate: features.runActuate, + }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); From 2131e749f705fac87b5e544e2db25ac4edc070a6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:18:36 -0400 Subject: [PATCH 007/103] Add firebase deploy --only run for remote Cloud Build source deploys --- package.json | 1 + scripts/run-deploy-tests/run.sh | 15 +++ scripts/run-deploy-tests/tests.ts | 138 ++++++++++++++++++++++++ src/checkValidTargetFilters.ts | 1 + src/commands/deploy.ts | 3 + src/deploy/index.ts | 5 + src/deploy/run/args.ts | 19 ++++ src/deploy/run/deploy.spec.ts | 169 ++++++++++++++++++++++++++++++ src/deploy/run/deploy.ts | 132 +++++++++++++++++++++++ src/deploy/run/index.ts | 19 ++++ src/deploy/run/prepare.spec.ts | 69 ++++++++++++ src/deploy/run/prepare.ts | 35 +++++++ src/deploy/run/release.ts | 15 +++ src/deploy/run/util.spec.ts | 90 +++++++++++++++- src/deploy/run/util.ts | 75 ++++++++++++- src/init/features/run.spec.ts | 13 ++- src/init/features/run.ts | 10 +- 17 files changed, 802 insertions(+), 7 deletions(-) create mode 100755 scripts/run-deploy-tests/run.sh create mode 100644 scripts/run-deploy-tests/tests.ts create mode 100644 src/deploy/run/args.ts create mode 100644 src/deploy/run/deploy.spec.ts create mode 100644 src/deploy/run/deploy.ts create mode 100644 src/deploy/run/index.ts create mode 100644 src/deploy/run/prepare.spec.ts create mode 100644 src/deploy/run/prepare.ts create mode 100644 src/deploy/run/release.ts diff --git a/package.json b/package.json index 18401c0b178..ac4cfcaa877 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test:functions-discover": "bash ./scripts/functions-discover-tests/run.sh", "test:hosting": "bash ./scripts/hosting-tests/run.sh", "test:hosting-rewrites": "bash ./scripts/hosting-tests/rewrites-tests/run.sh", + "test:run-deploy": "bash ./scripts/run-deploy-tests/run.sh", "test:import-export": "bash ./scripts/emulator-import-export-tests/run.sh", "test:triggers-end-to-end": "bash ./scripts/triggers-end-to-end-tests/run.sh", "test:triggers-end-to-end:inspect": "bash ./scripts/triggers-end-to-end-tests/run.sh inspect", diff --git a/scripts/run-deploy-tests/run.sh b/scripts/run-deploy-tests/run.sh new file mode 100755 index 00000000000..3c2bfeeb741 --- /dev/null +++ b/scripts/run-deploy-tests/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e # Immediately exit on failure + +# Globally link the CLI for the testing framework +./scripts/clean-install.sh + +if [ -f "scripts/set-default-credentials.sh" ]; then + source scripts/set-default-credentials.sh +fi + +echo "======================================" +echo "Starting Cloud Run E2E Test Suite" +echo "======================================" + +mocha scripts/run-deploy-tests/tests.ts --timeout 600000 diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts new file mode 100644 index 00000000000..95f4dcb0eb4 --- /dev/null +++ b/scripts/run-deploy-tests/tests.ts @@ -0,0 +1,138 @@ +import { expect } from "chai"; +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import * as cli from "../functions-deploy-tests/cli"; +import * as runv2 from "../../src/gcp/runv2"; +import { requireAuth } from "../../src/requireAuth"; + +const PROJECT = process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || ""; +const REGION = "us-central1"; +const SERVICE_ID = `run-e2e-${Date.now()}`; + +describe("firebase deploy --only run", function (this: Mocha.Suite) { + this.timeout(600_000); + let workDir: string; + + function firebase(cmd: string, ...args: string[]): Promise { + return cli.exec(cmd, PROJECT, [...args, "--non-interactive"], workDir, false); + } + + /** Checks that the service responds, and returns the Node.js version it runs on. */ + async function expectServing(): Promise { + const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const res = await fetch(uri!); + const body = await res.text(); + expect(body).to.match(/^hello from v\d+\./); + return body.replace("hello from ", ""); + } + + function writeFirebaseJson(run: Record = {}): void { + fs.writeJsonSync(path.join(workDir, "firebase.json"), { + run: { serviceId: SERVICE_ID, rootDir: "/", region: REGION, ...run }, + }); + } + + before(async () => { + expect(PROJECT).to.not.be.empty; + process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + await requireAuth({}); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-")); + const pkg = { name: "run-e2e", version: "1.0.0" }; + fs.writeJsonSync(path.join(workDir, "package.json"), { + ...pkg, + scripts: { start: "node index.js" }, + }); + fs.writeJsonSync(path.join(workDir, "package-lock.json"), { + ...pkg, + lockfileVersion: 3, + packages: { "": pkg }, + }); + fs.writeFileSync( + path.join(workDir, "index.js"), + "require('http').createServer((req, res) => res.end('hello from ' + process.version)).listen(process.env.PORT);\n", + ); + writeFirebaseJson(); + }); + + after(async () => { + await runv2.deleteService(PROJECT, REGION, SERVICE_ID).catch(() => undefined); + fs.removeSync(workDir); + }); + + it("requires the direct_cloud_run experiment", async () => { + delete process.env.FIREBASE_CLI_EXPERIMENTS; + try { + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("experiment direct_cloud_run is not enabled"); + } finally { + process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + } + }); + + it("creates a service from source", async () => { + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include("Deploy complete!"); + + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = service.template.containers![0]; + expect(container.baseImageUri).to.be.undefined; + expect(service.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + await expectServing(); + }); + + it("keeps settings changed outside the CLI on deploy", async () => { + // Simulate a console change to a service-level setting. + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const template = { ...service.template, revision: undefined }; + template.containers![0].resources = { limits: { cpu: "1", memory: "1Gi" } }; + await runv2.updateService({ name: service.name, template }, { updateMask: ["template"] }); + + const res = await firebase("deploy", "--only", `run:${SERVICE_ID}`); + expect(res.proc.exitCode).to.equal(0); + const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = after.template.containers![0]; + expect(container.image).not.to.equal(service.template.containers![0].image); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(after.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + await expectServing(); + }); + + it("rejects services that aren't in firebase.json", async () => { + const res = await firebase("deploy", "--only", "run:not-a-service"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("not-a-service not detected in firebase.json"); + }); + + it("fails without a firebase.json", async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-empty-")); + try { + const res = await cli.exec( + "deploy", + PROJECT, + ["--only", "run", "--non-interactive"], + emptyDir, + ); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("Not in a Firebase app directory"); + } finally { + fs.removeSync(emptyDir); + } + }); + + it("fails with a project that doesn't exist", async () => { + const res = await cli.exec( + "deploy", + "invalid-project-id-1234567890", + ["--only", "run", "--non-interactive"], + workDir, + ); + expect(res.proc.exitCode).not.to.equal(0); + }); +}); diff --git a/src/checkValidTargetFilters.ts b/src/checkValidTargetFilters.ts index 427e8deb3e4..c78014fd81f 100644 --- a/src/checkValidTargetFilters.ts +++ b/src/checkValidTargetFilters.ts @@ -32,6 +32,7 @@ const FILTERABLE_TARGETS = new Set([ "dataconnect", "apphosting", "ailogic", + "run", ]); /** diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index e47f6199f69..cd58e2c36c8 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -88,6 +88,9 @@ command if (options.filteredTargets.includes("ailogic")) { experiments.assertEnabled("ailogic", "deploy AI Logic resources"); } + if (options.filteredTargets.includes("run")) { + experiments.assertEnabled("direct_cloud_run", "deploy Cloud Run services"); + } const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { return perms.concat(TARGET_PERMISSIONS[target as (typeof VALID_DEPLOY_TARGETS)[number]]); }, []); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index c7cae13b2c4..950db40ea2f 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -20,6 +20,8 @@ import * as DataConnectTarget from "./dataconnect"; import * as AppHostingTarget from "./apphosting"; import * as AuthTarget from "./auth"; import * as AiLogicTarget from "./ailogic"; +import * as RunTarget from "./run"; +import { RUN_PERMISSIONS } from "./run/prereqs"; import { prepareFrameworks } from "../frameworks"; import { Context as HostingContext } from "./hosting/context"; import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare"; @@ -44,6 +46,7 @@ export const VALID_DEPLOY_TARGETS = [ "apphosting", "auth", "ailogic", + "run", ] as const; export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = { @@ -105,6 +108,7 @@ export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], s // ensureAILogicApiEnabled reads API enablement state via Service Usage. "serviceusage.services.get", ], + run: RUN_PERMISSIONS, }; export const TARGETS = { @@ -119,6 +123,7 @@ export const TARGETS = { apphosting: AppHostingTarget, auth: AuthTarget, ailogic: AiLogicTarget, + run: RunTarget, }; export type DeployOptions = Options & { dryRun?: boolean }; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts new file mode 100644 index 00000000000..1e7bc87ed6a --- /dev/null +++ b/src/deploy/run/args.ts @@ -0,0 +1,19 @@ +import { RunSingle } from "../../firebaseConfig"; +import * as runv2 from "../../gcp/runv2"; + +export interface ServiceDeploy { + config: RunSingle; + existing?: runv2.Service; + baseImage?: string; + deployed?: runv2.Service; +} + +export interface Payload { + run?: { services: ServiceDeploy[] }; +} + +export interface Context { + projectId: string; + /** Overrides the service's base image. null clears it; undefined keeps the current one. */ + baseImage?: string | null; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts new file mode 100644 index 00000000000..b2a0d9bc869 --- /dev/null +++ b/src/deploy/run/deploy.spec.ts @@ -0,0 +1,169 @@ +import { expect } from "chai"; +import * as fs from "fs"; +import * as sinon from "sinon"; +import * as artifactregistry from "../../gcp/artifactregistry"; +import * as runv2 from "../../gcp/runv2"; +import * as gcs from "../../gcp/storage"; +import * as getProjectNumber from "../../getProjectNumber"; +import { Options } from "../../options"; +import * as apphostingUtil from "../apphosting/util"; +import { ServiceDeploy } from "./args"; +import { deploy } from "./deploy"; + +describe("run deploy", () => { + const options = { config: { projectDir: "/p" } } as unknown as Options; + const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; + let sourceArchiveStub: sinon.SinonStub; + let submitBuildStub: sinon.SinonStub; + let createServiceStub: sinon.SinonStub; + let updateServiceStub: sinon.SinonStub; + + function service(overrides: Partial = {}): ServiceDeploy { + return { config, ...overrides }; + } + + async function deployOne(svc: ServiceDeploy, opts = options) { + await deploy({ projectId: "p" }, opts, { run: { services: [svc] } }); + return svc; + } + + beforeEach(() => { + sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); + sinon.stub(gcs, "upsertBucket").resolves("bucket"); + sinon + .stub(gcs, "uploadObject") + .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); + sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); + sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); + sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); + submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); + createServiceStub = sinon + .stub(runv2, "createService") + .resolves({ uri: "new" } as runv2.Service); + updateServiceStub = sinon.stub(runv2, "updateService").resolves({ uri: "up" } as runv2.Service); + sinon.stub(Date, "now").returns(42); + }); + + afterEach(() => sinon.restore()); + + it("builds source and creates a new service", async () => { + const svc = await deployOne(service({ baseImage: "nodejs22" }), { + ...options, + message: "hi", + } as Options); + + expect(sourceArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, "/p/web"); + const imageUri = "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42"; + expect(submitBuildStub).to.have.been.calledWith("p", "us-central1", { + storageSource: { bucket: "bucket", object: "src.zip" }, + imageUri, + buildpackBuild: { + baseImage: "nodejs22", + enableAutomaticUpdates: true, + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }, + }); + expect(createServiceStub).to.have.been.calledWith("p", "us-central1", "s", { + name: "projects/p/locations/us-central1/services/s", + template: { + containers: [ + { + name: "s", + image: imageUri, + baseImageUri: "nodejs22", + }, + ], + annotations: { "firebase.google.com/deploy-message": "hi" }, + }, + client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }); + expect(svc.deployed).to.deep.equal({ uri: "new" }); + }); + + it("builds without a base image", async () => { + await deployOne(service()); + expect(submitBuildStub.firstCall.args[2].buildpackBuild).to.deep.equal({ + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }); + expect(createServiceStub.firstCall.args[3].template.containers[0]).not.to.have.property( + "baseImageUri", + ); + }); + + it("updates the live revision template of an existing service", async () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { + revision: "s-1", + serviceAccount: "sa", + annotations: { "firebase.google.com/deploy-message": "old", keep: "me" }, + containers: [ + { name: "sidecar", image: "side" }, + { + name: "main", + image: "old", + ports: [{ containerPort: 8080 }], + baseImageUri: "old-base", + env: [ + { name: "B", value: "old" }, + { name: "C", value: "c" }, + ], + }, + ], + }, + trafficStatuses: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", revision: "s-1", percent: 100 }, + ], + } as unknown as runv2.Service; + + await deployOne(service({ existing })); + + const [update, updateOpts] = updateServiceStub.firstCall.args; + expect(updateOpts.updateMask).to.deep.equal(["template", "traffic"]); + expect(update.template).to.deep.equal({ + serviceAccount: "sa", + annotations: { keep: "me" }, + containers: [ + { name: "sidecar", image: "side" }, + { + name: "main", + image: "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42", + ports: [{ containerPort: 8080 }], + env: [ + { name: "B", value: "old" }, + { name: "C", value: "c" }, + ], + }, + ], + }); + expect(update.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + expect(existing.template.revision).to.equal("s-1"); + }); + + it("logs the deployed URL of each service during release", async () => { + const { release } = await import("./release"); + const utils = await import("../../utils"); + const logStub = sinon.stub(utils, "logLabeledSuccess"); + + await release({ projectId: "p" }, options, { + run: { + services: [service({ deployed: { uri: "https://s-123.run.app" } as runv2.Service })], + }, + }); + + expect(logStub).to.have.been.calledOnceWithExactly( + "run", + "Deployed service s to https://s-123.run.app", + ); + }); +}); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts new file mode 100644 index 00000000000..3c5e210df28 --- /dev/null +++ b/src/deploy/run/deploy.ts @@ -0,0 +1,132 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as artifactregistry from "../../gcp/artifactregistry"; +import * as runv2 from "../../gcp/runv2"; +import * as gcs from "../../gcp/storage"; +import { getProjectNumber } from "../../getProjectNumber"; +import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; +import { createSourceDeployArchive } from "../apphosting/util"; +import { Context, Payload, ServiceDeploy } from "./args"; +import { + copyTemplate, + deployRevision, + mainContainer, + SERVICE_OPERATION_TIMEOUT_MS, + toAppHostingConfig, +} from "./util"; + +const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; + +/** + * Builds each service on Cloud Build, uploads it, and deploys it. + */ +export async function deploy(context: Context, options: Options, payload: Payload): Promise { + for (const svc of payload.run?.services || []) { + svc.deployed = await deployService(context, options, svc); + } +} + +async function deployService( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + const { serviceId, region } = svc.config; + const source = await uploadSource(projectId, options, svc); + + const template = svc.existing + ? copyTemplate(svc.existing) + : { containers: [{ name: serviceId, image: "" }] }; + const container = mainContainer(template)!; + container.image = await buildImage(projectId, svc, source); + if (svc.baseImage) { + container.baseImageUri = svc.baseImage; + } else { + delete container.baseImageUri; + } + template.annotations = { ...template.annotations }; + if (options.message) { + template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; + } else { + delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; + if (!Object.keys(template.annotations).length) { + delete template.annotations; + } + } + + if (svc.existing) { + return deployRevision(svc.existing, template); + } + return runv2.createService( + projectId, + region, + serviceId, + { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + template, + client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }, + { masterTimeout: SERVICE_OPERATION_TIMEOUT_MS }, + ); +} + +async function uploadSource( + projectId: string, + options: Options, + svc: ServiceDeploy, +): Promise { + const { serviceId, region } = svc.config; + const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; + const bucketName = await gcs.upsertBucket({ + product: "run", + createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, + projectId, + req: { + baseName, + purposeLabel: `run-source-${region.toLowerCase()}`, + location: region, + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, + }, + }); + + const cfg = toAppHostingConfig(svc.config); + const archive = await createSourceDeployArchive( + cfg, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Uploading source for service ${serviceId}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; +} + +async function buildImage( + projectId: string, + svc: ServiceDeploy, + source: runv2.StorageSource, +): Promise { + const { serviceId, region } = svc.config; + await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy"); + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${serviceId}:${Date.now()}`; + logLabeledBullet("run", `Building service ${serviceId}...`); + await runv2.submitBuild(projectId, region, { + storageSource: source, + imageUri, + buildpackBuild: { + // Only images built for a base image can have their base image updated automatically. + ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), + environmentVariables: { + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }, + }); + return imageUri; +} diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts new file mode 100644 index 00000000000..10b0e3f1346 --- /dev/null +++ b/src/deploy/run/index.ts @@ -0,0 +1,19 @@ +import { prepare } from "./prepare"; +import { deploy } from "./deploy"; +import { release } from "./release"; + +export { prepare, deploy, release }; + +export const help = "Builds and deploys web apps to Cloud Run services listed in firebase.json."; +export const detailedHelp = + "Deploys Cloud Run services from local source, or from a local build if localBuild is set.\n\n" + + "Configuration in firebase.json:\n" + + "{\n" + + ' "run": {\n' + + ' "serviceId": "my-service",\n' + + ' "region": "us-central1",\n' + + ' "rootDir": "/",\n' + + ' "ignore": ["node_modules", ".git"]\n' + + " }\n" + + "}\n\n" + + "Base images are configured on the service with firebase run:services:update --base-image and are reused on every deploy."; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts new file mode 100644 index 00000000000..88ecc5a397f --- /dev/null +++ b/src/deploy/run/prepare.spec.ts @@ -0,0 +1,69 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { Context, Payload } from "./args"; +import { prepare } from "./prepare"; +import * as prereqs from "./prereqs"; + +describe("run prepare", () => { + const nodejs22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "i", baseImageUri: nodejs22 }] }, + } as unknown as runv2.Service; + let prereqsStub: sinon.SinonStub; + let getServiceStub: sinon.SinonStub; + + const options = (run: Record): Options => + ({ + config: { src: { run: { serviceId: "s", region: "us-central1", ...run } }, projectDir: "/p" }, + }) as unknown as Options; + + async function prepareOne(run: Record = {}, context: Partial = {}) { + const payload: Payload = {}; + await prepare({ projectId: "p", ...context }, options(run), payload); + return payload.run!.services[0]; + } + + beforeEach(() => { + prereqsStub = sinon.stub(prereqs, "prereqs").resolves(); + getServiceStub = sinon.stub(runv2, "getService").rejects({ status: 404 }); + }); + + afterEach(() => sinon.restore()); + + it("does nothing if no services are being deployed", async () => { + const payload: Payload = {}; + await prepare({ projectId: "p" }, { config: { src: {} } } as unknown as Options, payload); + expect(prereqsStub).not.to.have.been.called; + expect(payload.run).to.be.undefined; + }); + + it("requires a region", async () => { + await expect(prepareOne({ region: undefined })).to.be.rejectedWith( + "Cloud Run service s is missing a region in firebase.json.", + ); + }); + + it("reads the service", async () => { + const svc = await prepareOne(); + expect(prereqsStub).to.have.been.calledWith("p"); + expect(getServiceStub).to.have.been.calledWith("p", "us-central1", "s"); + expect(svc.existing).to.be.undefined; + expect(svc.baseImage).to.be.undefined; + }); + + it("reuses the service's current base image", async () => { + getServiceStub.resolves(existing); + const svc = await prepareOne(); + expect(svc.existing).to.equal(existing); + expect(svc.baseImage).to.equal(nodejs22); + }); + + it("lets the context set or clear the base image", async () => { + getServiceStub.resolves(existing); + expect((await prepareOne({}, { baseImage: "nodejs20" })).baseImage).to.equal("nodejs20"); + expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; + }); +}); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts new file mode 100644 index 00000000000..7291d8a03ce --- /dev/null +++ b/src/deploy/run/prepare.ts @@ -0,0 +1,35 @@ +import { FirebaseError } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; +import { Options } from "../../options"; +import { Context, Payload, ServiceDeploy } from "./args"; +import { prereqs } from "./prereqs"; +import { getExistingService, getServiceConfigs, mainContainer } from "./util"; + +/** + * Reads each service's current state from Cloud Run and resolves its base image. + */ +export async function prepare(context: Context, options: Options, payload: Payload): Promise { + const configs = getServiceConfigs(options); + if (!configs.length) { + return; + } + await prereqs(context.projectId); + payload.run = { services: [] }; + for (const config of configs) { + payload.run.services.push(await prepareService(context, config)); + } +} + +async function prepareService(context: Context, config: RunSingle): Promise { + const { serviceId, region } = config; + if (!region) { + throw new FirebaseError(`Cloud Run service ${serviceId} is missing a region in firebase.json.`); + } + const existing = await getExistingService(context.projectId, region, serviceId); + // Base images are sticky: deploys reuse the service's current base image unless told otherwise. + const baseImage = + context.baseImage === undefined + ? mainContainer(existing?.template)?.baseImageUri + : context.baseImage || undefined; + return { config, existing, baseImage }; +} diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts new file mode 100644 index 00000000000..5c32bc9b075 --- /dev/null +++ b/src/deploy/run/release.ts @@ -0,0 +1,15 @@ +import { logLabeledSuccess } from "../../utils"; +import { Payload } from "./args"; + +/** + * Logs where each deployed service is available. + */ +export async function release( + _context: unknown, + _options: unknown, + payload: Payload, +): Promise { + for (const { config, deployed } of payload.run?.services || []) { + logLabeledSuccess("run", `Deployed service ${config.serviceId} to ${deployed?.uri}`); + } +} diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index f0a11709d16..2e11f6263ae 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -1,11 +1,59 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; -import { getExistingService, mainContainer } from "./util"; +import { Options } from "../../options"; +import { + copyTemplate, + deployRevision, + getExistingService, + getServiceConfigs, + mainContainer, + toAppHostingConfig, +} from "./util"; describe("run util", () => { afterEach(() => sinon.restore()); + describe("getServiceConfigs", () => { + const a = { serviceId: "a", region: "us-central1" }; + const b = { serviceId: "b", region: "us-east1" }; + const opts = (run: unknown, only?: string) => ({ config: { src: { run } }, only }) as Options; + + it("returns all services when --only is 'run' or unset", () => { + expect(getServiceConfigs(opts([a, b], "run"))).to.deep.equal([a, b]); + expect(getServiceConfigs(opts(a))).to.deep.equal([a]); + expect(getServiceConfigs(opts(undefined))).to.deep.equal([]); + }); + + it("filters to services named in --only", () => { + expect(getServiceConfigs(opts([a, b], "hosting,run:b"))).to.deep.equal([b]); + }); + + it("throws when --only names a service not in firebase.json", () => { + expect(() => getServiceConfigs(opts([a], "run:a,run:missing"))).to.throw( + "Cloud Run service IDs missing not detected in firebase.json", + ); + }); + }); + + describe("toAppHostingConfig", () => { + it("adapts RunSingle to AppHostingSingle and defaults rootDir to empty string", () => { + expect( + toAppHostingConfig({ + serviceId: "s", + region: "us-central1", + ignore: ["node_modules"], + localBuild: true, + }), + ).to.deep.equal({ + backendId: "s", + rootDir: "", + ignore: ["node_modules"], + localBuild: true, + }); + }); + }); + describe("getExistingService", () => { it("returns the service when it exists", async () => { const svc = { name: "projects/p/locations/r/services/s" } as runv2.Service; @@ -33,4 +81,44 @@ describe("run util", () => { expect(mainContainer(undefined)).to.be.undefined; }); }); + + describe("copyTemplate and deployRevision", () => { + it("clones the template without the revision name and sends 100% traffic to LATEST", async () => { + const updateStub = sinon.stub(runv2, "updateService").resolves({} as runv2.Service); + const service = { + name: "projects/p/locations/r/services/s", + template: { revision: "s-001", containers: [{ name: "s", image: "old" }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "s-001", percent: 100 }, + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", + revision: "s-001", + tag: "canary", + percent: 0, + }, + ], + } as unknown as runv2.Service; + + const template = copyTemplate(service); + template.containers![0].image = "new"; + await deployRevision(service, template); + + expect(service.template.revision).to.equal("s-001"); + expect(service.template.containers![0].image).to.equal("old"); + expect(updateStub).to.have.been.calledWith( + { + name: "projects/p/locations/r/services/s", + template: { containers: [{ name: "s", image: "new" }] }, + traffic: [ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "s-001", tag: "canary" }, + ], + }, + { + updateMask: ["template", "traffic"], + masterTimeout: 600000, + }, + ); + }); + }); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 7e80f6073c8..9cea23cf881 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,5 +1,42 @@ -import { getErrStatus } from "../../error"; +import { FirebaseError, getErrStatus } from "../../error"; +import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { cloneDeep } from "../../utils"; + +/** Rolling out a new revision can take longer than the operation poller's default timeout. */ +export const SERVICE_OPERATION_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Returns the Cloud Run services in firebase.json that match the --only filter. + */ +export function getServiceConfigs(options: Options): RunSingle[] { + const configs = [options.config.src.run || []].flat(); + const selectors = options.only ? options.only.split(",") : ["run"]; + if (selectors.includes("run")) { + return configs; + } + const serviceIds = selectors.filter((s) => s.startsWith("run:")).map((s) => s.slice(4)); + const missing = serviceIds.filter((id) => !configs.some((c) => c.serviceId === id)); + if (missing.length) { + throw new FirebaseError( + `Cloud Run service IDs ${missing.join(",")} not detected in firebase.json`, + ); + } + return configs.filter((c) => serviceIds.includes(c.serviceId)); +} + +/** + * Adapts a Cloud Run config so that it can reuse App Hosting's local build and archive helpers. + */ +export function toAppHostingConfig(config: RunSingle): AppHostingSingle { + return { + backendId: config.serviceId, + rootDir: config.rootDir || "", + ignore: config.ignore as string[], + localBuild: config.localBuild, + }; +} /** * Gets a Cloud Run service, or undefined if it doesn't exist yet. @@ -25,3 +62,39 @@ export async function getExistingService( export function mainContainer(template?: runv2.RevisionTemplate): runv2.Container | undefined { return template?.containers?.find((c) => c.ports?.length) ?? template?.containers?.[0]; } + +/** + * Copies a service's revision template so that updating it creates a new revision. + */ +export function copyTemplate(service: runv2.Service): runv2.RevisionTemplate { + const template = cloneDeep(service.template); + delete template.revision; + return template; +} + +/** + * Deploys a new revision of an existing service and sends it all traffic. + */ +export function deployRevision( + service: runv2.Service, + template: runv2.RevisionTemplate, +): Promise { + const tags = (service.traffic || []) + .filter((t) => t.tag) + .map((t) => { + const tag = { ...t }; + delete tag.percent; + return tag; + }); + return runv2.updateService( + { + name: service.name, + template, + traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], + }, + { + updateMask: ["template", "traffic"], + masterTimeout: SERVICE_OPERATION_TIMEOUT_MS, + }, + ); +} diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b2bd812dc5..0b60f21efd6 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { Setup } from ".."; import { Config } from "../../config"; +import * as deploy from "../../deploy"; import * as prereqs from "../../deploy/run/prereqs"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; @@ -126,16 +127,17 @@ describe("init run", () => { describe("actuate", () => { it("does nothing when featureInfo.run is not set", async () => { const writeStub = sinon.stub(config, "writeProjectFile"); - await actuate(setup(), config); + await actuate(setup(), config, options); expect(writeStub).to.not.have.been.called; }); - it("saves the service to firebase.json", async () => { + it("saves the service to firebase.json and deploys it", async () => { const writeStub = sinon.stub(config, "writeProjectFile"); + const deployStub = sinon.stub(deploy, "deploy").resolves(); const s = setup(); s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; - await actuate(s, config); + await actuate(s, config, options); const runConfig = { serviceId: "s", @@ -145,6 +147,11 @@ describe("init run", () => { }; expect(config.src.run).to.deep.equal(runConfig); expect(writeStub).to.have.been.calledWith("firebase.json", config.src); + expect(deployStub).to.have.been.calledWith( + ["run"], + { projectId: "p", config, only: "run:s" }, + { baseImage: null }, + ); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index ab886bf6d04..e7b8017463a 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; +import { deploy } from "../../deploy"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; import { getExistingService, mainContainer } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; @@ -102,9 +103,9 @@ export async function askQuestions(setup: Setup, config: Config, options: Option } /** - * Adds the service to firebase.json. + * Adds the service to firebase.json and deploys it, since Cloud Run services only change on deploy. */ -export async function actuate(setup: Setup, config: Config): Promise { +export async function actuate(setup: Setup, config: Config, options: Options): Promise { const info = setup.featureInfo?.run; if (!info) { return; @@ -119,6 +120,11 @@ export async function actuate(setup: Setup, config: Config): Promise { config, ); config.writeProjectFile("firebase.json", config.src); + await deploy( + ["run"], + { ...options, projectId: setup.projectId, config, only: `run:${info.serviceId}` }, + { baseImage: info.baseImage || null }, + ); } /** From 595ee570bd2ee64f56798f070116a5f35f6bd9fa Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:23:49 -0400 Subject: [PATCH 008/103] Add firebase run:services:update command --- scripts/run-deploy-tests/tests.ts | 59 +++++++++++++++++++++-- src/commands/index.ts | 5 ++ src/commands/run-services-update.ts | 17 +++++++ src/deploy/run/update.spec.ts | 72 +++++++++++++++++++++++++++++ src/deploy/run/update.ts | 44 ++++++++++++++++++ src/deploy/run/util.ts | 9 ++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/commands/run-services-update.ts create mode 100644 src/deploy/run/update.spec.ts create mode 100644 src/deploy/run/update.ts diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 95f4dcb0eb4..b607db9952e 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -71,6 +71,8 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { } }); + let defaultNodeVersion: string; + it("creates a service from source", async () => { const res = await firebase("deploy", "--only", "run"); expect(res.proc.exitCode).to.equal(0); @@ -82,10 +84,41 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(service.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + defaultNodeVersion = await expectServing(); + }); + + /** + * Runs run:services:update and checks that it rebuilt and deployed the service like deploy + * does. Returns the new main container and the Node.js version the service now runs on. + */ + async function expectUpdated( + ...args: string[] + ): Promise<{ container: runv2.Container; nodeVersion: string }> { + const before = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const res = await firebase("run:services:update", SERVICE_ID, ...args); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include("Deploy complete!"); + + const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = after.template.containers![0]; + const oldContainer = before.template.containers![0]; + expect([container.image, container.sourceCode]).not.to.deep.equal([ + oldContainer.image, + oldContainer.sourceCode, + ]); + expect(after.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + return { container, nodeVersion: await expectServing() }; + } + + it("sets a base image, then rebuilds and deploys", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs22"); + expect(container.baseImageUri).to.include("nodejs22"); + expect(nodeVersion).to.match(/^v22\./); }); - it("keeps settings changed outside the CLI on deploy", async () => { + it("keeps the base image and settings changed outside the CLI on deploy", async () => { // Simulate a console change to a service-level setting. const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); const template = { ...service.template, revision: undefined }; @@ -97,11 +130,31 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); const container = after.template.containers![0]; expect(container.image).not.to.equal(service.template.containers![0].image); + expect(container.baseImageUri).to.include("nodejs22"); expect(container.resources?.limits?.memory).to.equal("1Gi"); expect(after.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + expect(await expectServing()).to.match(/^v22\./); + }); + + it("changes the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs20"); + expect(container.baseImageUri).to.include("nodejs20"); + expect(nodeVersion).to.match(/^v20\./); + }); + + it("clears the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--clear-base-image"); + expect(container).not.to.have.property("baseImageUri"); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(nodeVersion.split(".")[0]).to.equal(defaultNodeVersion.split(".")[0]); + }); + + it("requires a setting to update", async () => { + const res = await firebase("run:services:update", SERVICE_ID); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("Specify a setting to update:"); }); it("rejects services that aren't in firebase.json", async () => { diff --git a/src/commands/index.ts b/src/commands/index.ts index 0878d348906..f4520625b4d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,6 +301,11 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); + if (experiments.isEnabled("direct_cloud_run")) { + client.run = {}; + client.run.services = {}; + client.run.services.update = loadCommand("run-services-update"); + } client.serve = loadCommand("serve"); client.setup = {}; client.setup.emulators = {}; diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts new file mode 100644 index 00000000000..119a730287e --- /dev/null +++ b/src/commands/run-services-update.ts @@ -0,0 +1,17 @@ +import { Command } from "../command"; +import { TARGET_PERMISSIONS } from "../deploy"; +import { updateService } from "../deploy/run/update"; +import { Options } from "../options"; +import { requireConfig } from "../requireConfig"; +import { requirePermissions } from "../requirePermissions"; + +export const command = new Command("run:services:update ") + .description("update the settings of a Cloud Run service, then build and deploy it") + .option( + "--base-image ", + "set the base image (e.g. nodejs22) and enable automatic base image updates", + ) + .option("--clear-base-image", "clear the base image and disable automatic base image updates") + .before(requireConfig) + .before(requirePermissions, TARGET_PERMISSIONS.run) + .action((serviceId: string, options: Options) => updateService(serviceId, options)); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts new file mode 100644 index 00000000000..0b805df910e --- /dev/null +++ b/src/deploy/run/update.spec.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as deployIndex from ".."; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { updateService } from "./update"; + +describe("updateService", () => { + const service = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "i", baseImageUri: "nodejs22" }] }, + } as unknown as runv2.Service; + let getServiceStub: sinon.SinonStub; + let deployStub: sinon.SinonStub; + + function options(opts: Record = {}, run: Record = {}): Options { + return { + project: "p", + config: { src: { run: { serviceId: "s", region: "us-central1", ...run } } }, + ...opts, + } as unknown as Options; + } + + beforeEach(() => { + getServiceStub = sinon.stub(runv2, "getService").resolves(service); + deployStub = sinon.stub(deployIndex, "deploy").resolves(); + }); + + afterEach(() => sinon.restore()); + + it("requires exactly one setting", async () => { + await expect(updateService("s", options())).to.be.rejectedWith( + "--base-image or --clear-base-image", + ); + const both = options({ baseImage: "nodejs20", clearBaseImage: true }); + await expect(updateService("s", both)).to.be.rejectedWith("not both"); + }); + + it("requires the service to be in firebase.json", async () => { + await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( + "Cloud Run service IDs other not detected in firebase.json", + ); + }); + + it("requires the service to exist", async () => { + getServiceStub.rejects({ status: 404 }); + await expect(updateService("s", options({ baseImage: "nodejs22" }))).to.be.rejectedWith( + /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s/, + ); + expect(deployStub).not.to.have.been.called; + }); + + it("builds and deploys the service with the new base image", async () => { + await updateService("s", options({ baseImage: "nodejs20" })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: "nodejs20", + }); + }); + + it("builds and deploys the service without a base image when clearing it", async () => { + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: null, + }); + }); + + it("does nothing when clearing a base image that isn't set", async () => { + getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).not.to.have.been.called; + }); +}); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts new file mode 100644 index 00000000000..65241e9f558 --- /dev/null +++ b/src/deploy/run/update.ts @@ -0,0 +1,44 @@ +import * as clc from "colorette"; +import { deploy } from ".."; +import { FirebaseError } from "../../error"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { logBullet } from "../../utils"; +import { + getExistingService, + getServiceConfigs, + mainContainer, + missingServiceMessage, +} from "./util"; + +/** + * Updates a service's settings, then builds and deploys it like `firebase deploy` does. + * Changing the base image always needs a rebuild: images built for a base image leave out the + * OS and runtime, and images built without one bring their own, which override the base image. + */ +export async function updateService(serviceId: string, options: Options): Promise { + const newBaseImage = options.baseImage as string | undefined; + const clearBaseImage = !!options.clearBaseImage; + if (newBaseImage && clearBaseImage) { + throw new FirebaseError("Use either --base-image or --clear-base-image, not both."); + } + if (!newBaseImage && !clearBaseImage) { + throw new FirebaseError( + "Specify a setting to update: --base-image or --clear-base-image.", + ); + } + + const projectId = needProjectId(options); + const only = `run:${serviceId}`; + const [config] = getServiceConfigs({ ...options, only }); + const existing = await getExistingService(projectId, config.region, serviceId); + if (!existing) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); + return; + } + + await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); +} diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 9cea23cf881..3943f0e18b9 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,3 +1,4 @@ +import * as clc from "colorette"; import { FirebaseError, getErrStatus } from "../../error"; import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; @@ -56,6 +57,14 @@ export async function getExistingService( } } +/** + * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. + */ +export function missingServiceMessage(config: RunSingle): string { + const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; +} + /** * Returns the container that serves traffic. */ From 60f5d65a3e5c1b1b30fc9f6658624fbf22c7103f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:14:00 -0400 Subject: [PATCH 009/103] feat(run): add local build support and build environment variables --- scripts/run-deploy-tests/tests.ts | 84 +++++++++++++++++- src/deploy/apphosting/prepare.ts | 2 +- src/deploy/run/args.ts | 10 +++ src/deploy/run/buildEnv.spec.ts | 88 ++++++++++++++++++ src/deploy/run/buildEnv.ts | 89 +++++++++++++++++++ src/deploy/run/deploy.spec.ts | 143 +++++++++++++++++++++++++++++- src/deploy/run/deploy.ts | 108 +++++++++++++++++++--- src/deploy/run/prepare.spec.ts | 52 +++++++++++ src/deploy/run/prepare.ts | 47 +++++++++- src/deploy/run/update.spec.ts | 5 ++ src/deploy/run/update.ts | 3 + src/deploy/run/util.ts | 4 +- src/init/features/run.spec.ts | 31 ++++++- src/init/features/run.ts | 30 ++++++- 14 files changed, 671 insertions(+), 25 deletions(-) create mode 100644 src/deploy/run/buildEnv.spec.ts create mode 100644 src/deploy/run/buildEnv.ts diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index b607db9952e..f51937a9a80 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -4,11 +4,15 @@ import * as os from "os"; import * as path from "path"; import * as cli from "../functions-deploy-tests/cli"; import * as runv2 from "../../src/gcp/runv2"; +import * as secretManager from "../../src/gcp/secretManager"; +import { BUILD_ENV_ANNOTATION } from "../../src/deploy/run/buildEnv"; import { requireAuth } from "../../src/requireAuth"; const PROJECT = process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || ""; const REGION = "us-central1"; const SERVICE_ID = `run-e2e-${Date.now()}`; +const SECRET_ID = SERVICE_ID; +const SECRET_VALUE = `secret-${Date.now()}`; describe("firebase deploy --only run", function (this: Mocha.Suite) { this.timeout(600_000); @@ -18,6 +22,11 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { return cli.exec(cmd, PROJECT, [...args, "--non-interactive"], workDir, false); } + async function mainContainer(): Promise { + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + return service.template.containers![0]; + } + /** Checks that the service responds, and returns the Node.js version it runs on. */ async function expectServing(): Promise { const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); @@ -27,6 +36,24 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { return body.replace("hello from ", ""); } + /** Returns the value of BUILD_VALUE that the service's last build saw. */ + async function builtValue(): Promise { + const { uri } = await runv2.getService(PROJECT, REGION, SERVICE_ID); + return (await fetch(`${uri!}/build`)).text(); + } + + /** Sets the service's build env annotation, keeping its other annotations. */ + async function setBuildEnv(env: Record): Promise { + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + await runv2.updateService( + { + name: service.name, + annotations: { ...service.annotations, [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + } as unknown as runv2.Service, + { updateMask: ["annotations"] }, + ); + } + function writeFirebaseJson(run: Record = {}): void { fs.writeJsonSync(path.join(workDir, "firebase.json"), { run: { serviceId: SERVICE_ID, rootDir: "/", region: REGION, ...run }, @@ -41,7 +68,10 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const pkg = { name: "run-e2e", version: "1.0.0" }; fs.writeJsonSync(path.join(workDir, "package.json"), { ...pkg, - scripts: { start: "node index.js" }, + scripts: { + start: "node index.js", + build: `node -e "require('fs').writeFileSync('build.txt', process.env.BUILD_VALUE || '')"`, + }, }); fs.writeJsonSync(path.join(workDir, "package-lock.json"), { ...pkg, @@ -50,13 +80,21 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { }); fs.writeFileSync( path.join(workDir, "index.js"), - "require('http').createServer((req, res) => res.end('hello from ' + process.version)).listen(process.env.PORT);\n", + [ + 'const fs = require("fs");', + "require('http').createServer((req, res) => res.end(req.url === '/build'", + " ? (fs.existsSync('build.txt') ? fs.readFileSync('build.txt', 'utf8') : 'not built')", + " : 'hello from ' + process.version)).listen(process.env.PORT);", + ].join("\n"), ); writeFirebaseJson(); + await secretManager.createSecret(PROJECT, SECRET_ID, {}); + await secretManager.addVersion(PROJECT, SECRET_ID, SECRET_VALUE); }); after(async () => { await runv2.deleteService(PROJECT, REGION, SERVICE_ID).catch(() => undefined); + await secretManager.deleteSecret(PROJECT, SECRET_ID).catch(() => undefined); fs.removeSync(workDir); }); @@ -85,6 +123,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); defaultNodeVersion = await expectServing(); + expect(await builtValue()).to.equal(""); }); /** @@ -102,6 +141,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); const container = after.template.containers![0]; const oldContainer = before.template.containers![0]; + // Cloud Build deploys push a new image; local builds upload new source. expect([container.image, container.sourceCode]).not.to.deep.equal([ oldContainer.image, oldContainer.sourceCode, @@ -151,12 +191,52 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(nodeVersion.split(".")[0]).to.equal(defaultNodeVersion.split(".")[0]); }); + it("passes build env from the service to Cloud Build", async () => { + await setBuildEnv({ BUILD_VALUE: "plain" }); + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include(`Using build environment variables from ${BUILD_ENV_ANNOTATION}`); + expect(await builtValue()).to.equal("plain"); + // Deploys don't touch service-level annotations. + const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); + expect(service.annotations?.[BUILD_ENV_ANNOTATION]).to.equal('{"BUILD_VALUE":"plain"}'); + }); + + it("rejects build secrets on Cloud Build", async () => { + await setBuildEnv({ BUILD_VALUE: { secret: SECRET_ID, version: "1" } }); + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("has build secrets (BUILD_VALUE)"); + }); + it("requires a setting to update", async () => { const res = await firebase("run:services:update", SERVICE_ID); expect(res.proc.exitCode).not.to.equal(0); expect(res.stdout + res.stderr).to.include("Specify a setting to update:"); }); + it("requires a base image for local builds", async () => { + writeFirebaseJson({ localBuild: true }); + const failed = await firebase("deploy", "--only", "run"); + expect(failed.proc.exitCode).not.to.equal(0); + expect(failed.stdout + failed.stderr).to.include("Local builds require a base image"); + }); + + it("sets the base image of a local build, then builds locally with secrets and deploys", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs22"); + expect(container.image).to.equal("scratch"); + expect(container.sourceCode).to.exist; + expect(container.baseImageUri).to.include("nodejs22"); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(nodeVersion).to.match(/^v22\./); + expect(await builtValue()).to.equal(SECRET_VALUE); + + const res = await firebase("deploy", "--only", "run"); + expect(res.proc.exitCode).to.equal(0); + expect((await mainContainer()).baseImageUri).to.include("nodejs22"); + expect(await expectServing()).to.match(/^v22\./); + }); + it("rejects services that aren't in firebase.json", async () => { const res = await firebase("deploy", "--only", "run:not-a-service"); expect(res.proc.exitCode).not.to.equal(0); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 55650cf50e2..b0f76ebb6a2 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -432,7 +432,7 @@ async function ensureAppHostingServiceAgentRoles( * isolated scratch workspace in the `.local_build_` folder that contains exactly the same * source files that would be uploaded to Cloud Build. */ -async function prepareLocalBuildScratchDirectory( +export async function prepareLocalBuildScratchDirectory( rootDir: string, localBuildScratchDir: string, cfg: AppHostingSingle, diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 1e7bc87ed6a..6df96e8b8af 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,10 +1,20 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { BuildEnv } from "./buildEnv"; export interface ServiceDeploy { config: RunSingle; existing?: runv2.Service; baseImage?: string; + /** Build-time environment. Prepare only allows secrets in it for local builds. */ + buildEnv?: BuildEnv; + /** The local build's output, set during deploy. */ + localBuild?: { + scratchDir: string; + outputFiles: string[]; + runCommand?: string; + env?: NonNullable; + }; deployed?: runv2.Service; } diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts new file mode 100644 index 00000000000..00c50090b8f --- /dev/null +++ b/src/deploy/run/buildEnv.spec.ts @@ -0,0 +1,88 @@ +import { expect } from "chai"; +import * as runv2 from "../../gcp/runv2"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; + +describe("run buildEnv", () => { + const withAnnotation = (value: string): runv2.Service => + ({ + name: "projects/p/locations/us-central1/services/s", + annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, + }) as unknown as runv2.Service; + + describe("getBuildEnv", () => { + it("returns {} without a service or annotation", () => { + expect(getBuildEnv(undefined)).to.deep.equal({}); + expect( + getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), + ).to.deep.equal({}); + }); + + it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { + expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); + }); + + it("parses secret references", () => { + const env = { + A: "1", + TOKEN: { secret: "npm-token", version: "3" }, + LATEST: { secret: "s" }, + OTHER: { secret: "projects/q/secrets/t", version: "latest" }, + }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); + }); + + it("rejects invalid JSON and non-objects", () => { + expect(() => getBuildEnv(withAnnotation("{"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, + ); + for (const value of ["[]", "null", '"x"', "1"]) { + expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); + } + }); + + it("rejects invalid values", () => { + const invalid = [ + 1, + null, + ["x"], + {}, + { secret: 1 }, + { secret: "a/b" }, + { secret: "a@1" }, + { secret: "projects/q/secrets/t/versions/1" }, + { secret: "s", version: 1 }, + { secret: "s", version: "one" }, + { secret: "s", extra: "x" }, + ]; + for (const value of invalid) { + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( + 'A must be a string or {"secret": "", "version": ""}.', + ); + } + }); + }); + + it("lists secret names", () => { + expect( + secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), + ).to.deep.equal(["B", "C"]); + }); + + it("converts to local build env, available only to the build", () => { + expect( + toLocalBuildEnv({ + A: "1", + B: { secret: "s" }, + C: { secret: "t", version: "2" }, + D: { secret: "projects/q/secrets/u" }, + E: { secret: "projects/q/secrets/v", version: "4" }, + }), + ).to.deep.equal({ + A: { value: "1", availability: ["BUILD"] }, + B: { secret: "s@latest", availability: ["BUILD"] }, + C: { secret: "t@2", availability: ["BUILD"] }, + D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, + E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, + }); + }); +}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts new file mode 100644 index 00000000000..5ba9d8cb263 --- /dev/null +++ b/src/deploy/run/buildEnv.ts @@ -0,0 +1,89 @@ +import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; +import { EnvMap } from "../../apphosting/yaml"; + +/** + * Prototype: build-time environment variables and secret references, stored on the Cloud Run + * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): + * the same map parses unchanged, and a value can also be a secret reference in the shape of + * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. + * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} + */ +export const BUILD_ENV_ANNOTATION = + "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; + +/** A Secret Manager secret, as a name in the service's project or a full resource name. */ +export interface SecretRef { + secret: string; + /** A version number or "latest". Defaults to "latest". */ + version?: string; +} + +export type BuildEnv = Record; + +const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; +const SECRET_VERSION = /^(?:latest|\d+)$/; + +/** Reads and validates the service's build environment. Returns {} if it has none. */ +export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { + const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; + if (!service || !raw) { + return {}; + } + const invalid = (reason: string): FirebaseError => + new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw invalid("it isn't valid JSON."); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw invalid("it must be a JSON object."); + } + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string") { + continue; + } + const ref = value as Partial | null; + const valid = + typeof ref === "object" && + ref !== null && + !Array.isArray(ref) && + Object.keys(ref).every((k) => k === "secret" || k === "version") && + typeof ref.secret === "string" && + SECRET_NAME.test(ref.secret) && + (ref.version === undefined || + (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); + if (!valid) { + throw invalid( + `${name} must be a string or {"secret": "", "version": ""}.`, + ); + } + } + return parsed as BuildEnv; +} + +/** Names of the variables that are secret references. */ +export function secretNames(env: BuildEnv): string[] { + return Object.entries(env) + .filter(([, value]) => typeof value !== "string") + .map(([name]) => name); +} + +/** Converts the build environment to the form local builds take; they resolve the secrets. */ +export function toLocalBuildEnv(env: BuildEnv): EnvMap { + const result: EnvMap = {}; + for (const [name, value] of Object.entries(env)) { + if (typeof value === "string") { + result[name] = { value, availability: ["BUILD"] }; + } else { + const version = value.version || "latest"; + const secret = value.secret.startsWith("projects/") + ? `${value.secret}/versions/${version}` + : `${value.secret}@${version}`; + result[name] = { secret, availability: ["BUILD"] }; + } + } + return result; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b2a0d9bc869..4f26c4f1ad4 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,11 +1,13 @@ import { expect } from "chai"; import * as fs from "fs"; import * as sinon from "sinon"; +import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import * as getProjectNumber from "../../getProjectNumber"; import { Options } from "../../options"; +import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; import { deploy } from "./deploy"; @@ -14,6 +16,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; + let tarArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -22,7 +25,7 @@ describe("run deploy", () => { return { config, ...overrides }; } - async function deployOne(svc: ServiceDeploy, opts = options) { + async function deployOne(svc: ServiceDeploy, opts = options): Promise { await deploy({ projectId: "p" }, opts, { run: { services: [svc] } }); return svc; } @@ -35,6 +38,9 @@ describe("run deploy", () => { .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); + tarArchiveStub = sinon + .stub(apphostingUtil, "createLocalBuildTarArchive") + .resolves("out.tar.gz"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); createServiceStub = sinon @@ -98,6 +104,19 @@ describe("run deploy", () => { ); }); + it("passes build env to the build", async () => { + await deployOne(service({ baseImage: "nodejs22", buildEnv: { A: "1" } })); + expect(submitBuildStub.firstCall.args[2].buildpackBuild).to.deep.equal({ + baseImage: "nodejs22", + enableAutomaticUpdates: true, + environmentVariables: { + A: "1", + X_GOOGLE_TARGET_PLATFORM: "fah", + FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", + }, + }); + }); + it("updates the live revision template of an existing service", async () => { const existing = { name: "projects/p/locations/us-central1/services/s", @@ -150,6 +169,128 @@ describe("run deploy", () => { expect(existing.template.revision).to.equal("s-1"); }); + describe("local builds", () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "old" }] }, + } as unknown as runv2.Service; + const localService = (overrides: Partial = {}): ServiceDeploy => + service({ + config: { ...config, localBuild: true }, + existing, + baseImage: "nodejs22", + ...overrides, + }); + let validateNodeStub: sinon.SinonStub; + let localBuildStub: sinon.SinonStub; + let mkdtemp: sinon.SinonSpy; + + beforeEach(() => { + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ + outputFiles: [".next"], + buildConfig: { runCommand: "node server.js" }, + }); + mkdtemp = sinon.spy(fs, "mkdtempSync"); + }); + + it("builds locally and deploys the output without building on Cloud Build", async () => { + await deployOne(localService()); + + const scratchDir = mkdtemp.firstCall.returnValue as string; + expect(validateNodeStub).to.have.been.calledWith( + { runtime: { value: "nodejs22" } }, + "/p/web", + ); + expect(localBuildStub).to.have.been.calledWith( + "p", + scratchDir, + {}, + { + nonInteractive: undefined, + allowLocalBuildSecrets: true, + rootDir: "web", + }, + ); + expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, scratchDir, [ + ".next", + ]); + expect(submitBuildStub).not.to.have.been.called; + expect(updateServiceStub.firstCall.args[0].template.containers[0]).to.deep.equal({ + name: "s", + image: "scratch", + sourceCode: { cloudStorageSource: { bucket: "bucket", object: "out.tar.gz" } }, + command: ["node", "server.js"], + baseImageUri: "nodejs22", + }); + expect(fs.existsSync(scratchDir)).to.be.false; + }); + + it("passes build env and secrets to the local build and merges runtime output env", async () => { + localBuildStub.resolves({ + outputFiles: [".next"], + buildConfig: { + runCommand: "node server.js", + env: [ + { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, + { variable: "CUSTOM", value: "from-build", availability: ["RUNTIME"] }, + ], + }, + }); + const svcWithEnv = { + ...existing, + template: { + containers: [{ name: "s", image: "old", env: [{ name: "CUSTOM", value: "user-val" }] }], + }, + } as unknown as runv2.Service; + + await deployOne( + localService({ + existing: svcWithEnv, + buildEnv: { A: "1", TOKEN: { secret: "t", version: "2" } }, + }), + ); + expect(localBuildStub.firstCall.args[2]).to.deep.equal({ + A: { value: "1", availability: ["BUILD"] }, + TOKEN: { secret: "t@2", availability: ["BUILD"] }, + }); + expect(updateServiceStub.firstCall.args[0].template.containers[0].env).to.deep.equal([ + { name: "CUSTOM", value: "user-val" }, + { name: "NODE_ENV", value: "production" }, + ]); + }); + + it("cleans up and deploys nothing if the local build fails", async () => { + localBuildStub.rejects(new Error("boom")); + await expect(deployOne(localService())).to.be.rejectedWith("boom"); + expect(fs.existsSync(mkdtemp.firstCall.returnValue as string)).to.be.false; + expect(updateServiceStub).not.to.have.been.called; + }); + }); + + it("switches a service from a local build back to a source build", async () => { + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { + containers: [ + { + name: "s", + image: "scratch", + command: ["node", "server.js"], + sourceCode: { cloudStorageSource: { bucket: "b", object: "o" } }, + }, + ], + }, + } as unknown as runv2.Service; + + await deployOne(service({ existing })); + + const container = updateServiceStub.firstCall.args[0].template.containers[0]; + expect(container).not.to.have.property("sourceCode"); + expect(container).not.to.have.property("command"); + }); + it("logs the deployed URL of each service during release", async () => { const { release } = await import("./release"); const utils = await import("../../utils"); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 3c5e210df28..7b0b06dcf9a 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,13 +1,19 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; +import { CLOUD_RUN_SIZE_LIMIT_BYTES } from "../../apphosting/constants"; +import { localBuild, validateLocalBuildNodeVersion } from "../../apphosting/localbuilds"; +import { Backend } from "../../gcp/apphosting"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; import { Options } from "../../options"; import { logLabeledBullet } from "../../utils"; -import { createSourceDeployArchive } from "../apphosting/util"; +import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; +import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; +import { secretNames, toLocalBuildEnv } from "./buildEnv"; import { copyTemplate, deployRevision, @@ -19,11 +25,68 @@ import { const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** - * Builds each service on Cloud Build, uploads it, and deploys it. + * Builds each service (locally or on Cloud Build), uploads it, and deploys it. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { for (const svc of payload.run?.services || []) { - svc.deployed = await deployService(context, options, svc); + try { + if (svc.config.localBuild) { + svc.localBuild = await buildLocally(context, options, svc); + } + svc.deployed = await deployService(context, options, svc); + } finally { + if (svc.localBuild) { + fs.rmSync(svc.localBuild.scratchDir, { recursive: true, force: true }); + } + } + } +} + +async function buildLocally( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise> { + const { config } = svc; + const { serviceId } = config; + const buildEnv = svc.buildEnv || {}; + const cfg = toAppHostingConfig(config); + validateLocalBuildNodeVersion( + { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Starting local build for service ${serviceId}`); + const secrets = secretNames(buildEnv); + if (secrets.length) { + logLabeledBullet( + "run", + `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + + `included in the build output.`, + ); + } + const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); + try { + await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); + const { outputFiles, buildConfig } = await localBuild( + context.projectId, + scratchDir, + toLocalBuildEnv(buildEnv), + { + nonInteractive: options.nonInteractive, + // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. + allowLocalBuildSecrets: true, + rootDir: config.rootDir, + }, + ); + const env = (buildConfig.env || []) + .filter( + (e) => e.value !== undefined && (!e.availability || e.availability.includes("RUNTIME")), + ) + .map((e) => ({ name: e.variable, value: e.value! })); + return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; + } catch (err: unknown) { + fs.rmSync(scratchDir, { recursive: true, force: true }); + throw err; } } @@ -40,7 +103,26 @@ async function deployService( ? copyTemplate(svc.existing) : { containers: [{ name: serviceId, image: "" }] }; const container = mainContainer(template)!; - container.image = await buildImage(projectId, svc, source); + if (svc.localBuild) { + // Cloud Run runs locally built apps directly from source on top of the base image. + container.image = "scratch"; + container.sourceCode = { cloudStorageSource: source }; + container.command = svc.localBuild.runCommand?.split(" "); + if (svc.localBuild.env?.length) { + const existingNames = new Set((container.env || []).map((e) => e.name)); + const newEnv = svc.localBuild.env.filter((e) => !existingNames.has(e.name)); + if (newEnv.length) { + container.env = [...(container.env || []), ...newEnv]; + } + } + } else { + container.image = await buildImage(projectId, svc, source); + if (container.sourceCode) { + // This service was previously deployed from a local build. + delete container.sourceCode; + delete container.command; + } + } if (svc.baseImage) { container.baseImageUri = svc.baseImage; } else { @@ -51,9 +133,6 @@ async function deployService( template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; } else { delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; - if (!Object.keys(template.annotations).length) { - delete template.annotations; - } } if (svc.existing) { @@ -94,15 +173,18 @@ async function uploadSource( }); const cfg = toAppHostingConfig(svc.config); - const archive = await createSourceDeployArchive( - cfg, - path.join(options.config.projectDir, cfg.rootDir), + const archive = svc.localBuild + ? await createLocalBuildTarArchive(cfg, svc.localBuild.scratchDir, svc.localBuild.outputFiles) + : await createSourceDeployArchive(cfg, path.join(options.config.projectDir, cfg.rootDir)); + logLabeledBullet( + "run", + `Uploading ${svc.localBuild ? "built app" : "source"} for service ${serviceId}...`, ); - logLabeledBullet("run", `Uploading source for service ${serviceId}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, bucketName, - gcs.ContentType.ZIP, + svc.localBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP, + svc.localBuild ? CLOUD_RUN_SIZE_LIMIT_BYTES : undefined, ); return { bucket, object }; } @@ -122,7 +204,9 @@ async function buildImage( buildpackBuild: { // Only images built for a base image can have their base image updated automatically. ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), + // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. environmentVariables: { + ...(svc.buildEnv as Record | undefined), X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", }, diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 88ecc5a397f..ff8169aa61f 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload } from "./args"; +import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -66,4 +67,55 @@ describe("run prepare", () => { expect((await prepareOne({}, { baseImage: "nodejs20" })).baseImage).to.equal("nodejs20"); expect((await prepareOne({}, { baseImage: null })).baseImage).to.be.undefined; }); + + it("requires a base image for local builds", async () => { + getServiceStub.resolves({ ...existing, template: { containers: [{ name: "s", image: "i" }] } }); + await expect(prepareOne({ localBuild: true })).to.be.rejectedWith( + "Local builds require a base image", + ); + }); + + it("points new local build services to init, which sets a base image", async () => { + await expect(prepareOne({ localBuild: true })).to.be.rejectedWith( + /doesn't exist in us-central1 yet.*which also sets the base image/, + ); + }); + + it("doesn't build locally", async () => { + getServiceStub.resolves(existing); + const svc = await prepareOne({ localBuild: true }); + expect(svc.baseImage).to.equal(nodejs22); + expect(svc).not.to.have.property("localBuild"); + }); + + describe("build env", () => { + const withBuildEnv = (env: Record): runv2.Service => + ({ + ...existing, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }) as unknown as runv2.Service; + + it("passes plain build env to Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1" })); + expect((await prepareOne()).buildEnv).to.deep.equal({ A: "1" }); + }); + + it("leaves build env unset without the annotation", async () => { + getServiceStub.resolves(existing); + expect(await prepareOne()).not.to.have.property("buildEnv"); + }); + + it("rejects build secrets on Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1", TOKEN: { secret: "t" } })); + await expect(prepareOne()).to.be.rejectedWith( + /Service s has build secrets \(TOKEN\).*"localBuild": true/, + ); + }); + + it("keeps build secrets for local builds", async () => { + getServiceStub.resolves(withBuildEnv({ A: "1", TOKEN: { secret: "t", version: "2" } })); + const svc = await prepareOne({ localBuild: true }); + expect(svc.buildEnv).to.deep.equal({ A: "1", TOKEN: { secret: "t", version: "2" } }); + }); + }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 7291d8a03ce..fe63966ad43 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,12 +1,19 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; -import { getExistingService, getServiceConfigs, mainContainer } from "./util"; +import { + getExistingService, + getServiceConfigs, + mainContainer, + missingServiceMessage, +} from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image. + * Reads each service's current state from Cloud Run and resolves its base image and build env. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -31,5 +38,39 @@ async function prepareService(context: Context, config: RunSingle): Promise".`, + ); + } + return svc; } diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 0b805df910e..a2966fa6b34 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -64,6 +64,11 @@ describe("updateService", () => { }); }); + it("can't clear the base image of a locally built service", async () => { + const opts = options({ clearBaseImage: true }, { localBuild: true }); + await expect(updateService("s", opts)).to.be.rejectedWith("local builds need one"); + }); + it("does nothing when clearing a base image that isn't set", async () => { getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); await updateService("s", options({ clearBaseImage: true })); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 65241e9f558..b9712046e4c 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -31,6 +31,9 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; const [config] = getServiceConfigs({ ...options, only }); + if (clearBaseImage && config.localBuild) { + throw new FirebaseError(`Cannot clear the base image of ${serviceId}: local builds need one.`); + } const existing = await getExistingService(projectId, config.region, serviceId); if (!existing) { throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 3943f0e18b9..eeae95ad492 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -61,7 +61,9 @@ export async function getExistingService( * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. */ export function missingServiceMessage(config: RunSingle): string { - const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + const how = config.localBuild + ? `Create it with ${clc.bold("firebase init run")}, which also sets the base image that local builds need.` + : `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; } diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b60f21efd6..e530fa59684 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -122,6 +122,32 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); + + it("enables localBuild and requires a base image when building locally", async () => { + selectStub + .onFirstCall() + .resolves("create") + .onSecondCall() + .resolves("us-central1") + .onThirdCall() + .resolves(true); + inputStub.onFirstCall().resolves("my-service"); + inputStub.callsFake((o) => Promise.resolve(o.default)); + const s = setup(); + + await askQuestions(s, config, options); + + const validateImg = inputStub.secondCall.args[0].validate; + expect(validateImg("nodejs22")).to.be.true; + expect(validateImg(" ")).to.include("Local builds require a base image"); + expect(s.featureInfo?.run).to.deep.equal({ + serviceId: "my-service", + region: "us-central1", + baseImage: "nodejs22", + rootDir: "/", + localBuild: true, + }); + }); }); describe("actuate", () => { @@ -135,7 +161,9 @@ describe("init run", () => { const writeStub = sinon.stub(config, "writeProjectFile"); const deployStub = sinon.stub(deploy, "deploy").resolves(); const s = setup(); - s.featureInfo = { run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/" } }; + s.featureInfo = { + run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/", localBuild: true }, + }; await actuate(s, config, options); @@ -143,6 +171,7 @@ describe("init run", () => { serviceId: "s", rootDir: "/", region: "r", + localBuild: true, ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], }; expect(config.src.run).to.deep.equal(runConfig); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index e7b8017463a..5c01d3bc279 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -19,6 +19,7 @@ export interface RunInfo { region: string; baseImage: string; rootDir: string; + localBuild?: boolean; } /** @@ -83,9 +84,20 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); } + const localBuild = await select({ + message: "Would you like to build your app locally or remotely?", + choices: [ + { name: "Build remotely on Cloud Build", value: false }, + { name: "Build locally", value: true }, + ], + default: false, + }); const baseImage = await input({ message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + default: + (existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22") || + (localBuild ? "nodejs22" : undefined), + validate: (img) => !localBuild || !!img.trim() || "Local builds require a base image.", }); const rootDir = await input({ message: "Specify your app's root directory relative to your firebase.json directory", @@ -99,7 +111,10 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }, }); - setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; + setup.featureInfo = { + ...setup.featureInfo, + run: { serviceId, region, baseImage, rootDir, ...(localBuild && { localBuild }) }, + }; } /** @@ -115,6 +130,7 @@ export async function actuate(setup: Setup, config: Config, options: Options): P serviceId: info.serviceId, rootDir: info.rootDir, region: info.region, + ...(info.localBuild && { localBuild: true }), ignore: ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"], }, config, @@ -137,8 +153,14 @@ export function upsertRunConfig(runConfig: RunSingle, config: Config): void { if (i < 0) { services.push(runConfig); } else { - const { rootDir, region } = runConfig; - services[i] = { ...runConfig, ...services[i], rootDir, region }; + const { rootDir, region, localBuild } = runConfig; + services[i] = { + ...runConfig, + ...services[i], + rootDir, + region, + ...(localBuild !== undefined && { localBuild }), + }; } config.set("run", services.length === 1 ? services[0] : services); } From 25b3c53366cf0519933b88007e944ebd21b4b777 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:19:56 -0400 Subject: [PATCH 010/103] feat(run): add Firebase Web App linking and SDK auto-initialization --- scripts/run-deploy-tests/tests.ts | 18 +++++ src/commands/run-services-update.ts | 2 + src/deploy/apphosting/prepare.ts | 2 + src/deploy/run/args.ts | 5 ++ src/deploy/run/deploy.spec.ts | 44 ++++++++++++ src/deploy/run/deploy.ts | 23 ++++++- src/deploy/run/prepare.spec.ts | 103 ++++++++++++++++++++++++++++ src/deploy/run/prepare.ts | 73 +++++++++++++++++--- src/deploy/run/update.spec.ts | 38 ++++++++-- src/deploy/run/update.ts | 28 +++++++- src/deploy/run/util.spec.ts | 15 ++++ src/deploy/run/util.ts | 7 +- src/init/features/run.spec.ts | 24 ++++++- src/init/features/run.ts | 24 ++++++- 14 files changed, 382 insertions(+), 24 deletions(-) diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index f51937a9a80..bb40620889b 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -6,6 +6,8 @@ import * as cli from "../functions-deploy-tests/cli"; import * as runv2 from "../../src/gcp/runv2"; import * as secretManager from "../../src/gcp/secretManager"; import { BUILD_ENV_ANNOTATION } from "../../src/deploy/run/buildEnv"; +import { FIREBASE_APP_ANNOTATION } from "../../src/deploy/run/util"; +import { AppPlatform, createWebApp, listFirebaseApps } from "../../src/management/apps"; import { requireAuth } from "../../src/requireAuth"; const PROJECT = process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || ""; @@ -237,6 +239,22 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(await expectServing()).to.match(/^v22\./); }); + it("links and clears a Firebase Web App", async () => { + const apps = await listFirebaseApps(PROJECT, AppPlatform.WEB); + const appId = + apps[0]?.appId ?? (await createWebApp(PROJECT, { displayName: SERVICE_ID })).appId; + + const { container } = await expectUpdated("--app", appId); + const linked = await runv2.getService(PROJECT, REGION, SERVICE_ID); + expect(linked.annotations?.[FIREBASE_APP_ANNOTATION]).to.equal(appId); + expect(container.env?.find((e) => e.name === "FIREBASE_CONFIG")?.value).to.include(PROJECT); + + const { container: clearedContainer } = await expectUpdated("--clear-app"); + const cleared = await runv2.getService(PROJECT, REGION, SERVICE_ID); + expect(cleared.annotations?.[FIREBASE_APP_ANNOTATION]).to.be.undefined; + expect(clearedContainer.env?.find((e) => e.name === "FIREBASE_CONFIG")).to.be.undefined; + }); + it("rejects services that aren't in firebase.json", async () => { const res = await firebase("deploy", "--only", "run:not-a-service"); expect(res.proc.exitCode).not.to.equal(0); diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts index 119a730287e..2dbd01fccbd 100644 --- a/src/commands/run-services-update.ts +++ b/src/commands/run-services-update.ts @@ -12,6 +12,8 @@ export const command = new Command("run:services:update ") "set the base image (e.g. nodejs22) and enable automatic base image updates", ) .option("--clear-base-image", "clear the base image and disable automatic base image updates") + .option("--app ", "associate a Firebase Web App ID for SDK auto-initialization") + .option("--clear-app", "disassociate the Firebase Web App and disable SDK auto-initialization") .before(requireConfig) .before(requirePermissions, TARGET_PERMISSIONS.run) .action((serviceId: string, options: Options) => updateService(serviceId, options)); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index b0f76ebb6a2..4bc9abb8b9b 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -318,6 +318,8 @@ export async function injectAutoInitEnvVars( )) as WebConfig; // We inject autoinit env vars into the build and runtime env vars. + // TODO: Match FAH remote builds by only injecting FIREBASE_CONFIG (and not + // FIREBASE_WEBAPP_CONFIG) into runtimeEnv. const autoinitVars = getAutoinitEnvVars(webappConfig); for (const [envVarName, envVarValue] of Object.entries(autoinitVars)) { buildEnv[cfg.backendId][envVarName] ??= { value: envVarValue }; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 6df96e8b8af..ec17c72bc27 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -6,6 +6,9 @@ export interface ServiceDeploy { config: RunSingle; existing?: runv2.Service; baseImage?: string; + appId?: string; + /** Runtime FIREBASE_CONFIG JSON for the linked Firebase Web App. */ + firebaseConfig?: string; /** Build-time environment. Prepare only allows secrets in it for local builds. */ buildEnv?: BuildEnv; /** The local build's output, set during deploy. */ @@ -26,4 +29,6 @@ export interface Context { projectId: string; /** Overrides the service's base image. null clears it; undefined keeps the current one. */ baseImage?: string | null; + /** Overrides the service's Firebase Web App ID. null clears it; undefined keeps the current one. */ + appId?: string | null; } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 4f26c4f1ad4..d1e5ccfaca7 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -169,6 +169,50 @@ describe("run deploy", () => { expect(existing.template.revision).to.equal("s-1"); }); + it("sets and clears the linked Firebase Web App and runtime FIREBASE_CONFIG", async () => { + await deployOne(service({ appId: "new-app", firebaseConfig: '{"projectId":"p"}' })); + expect(createServiceStub.firstCall.args[3].annotations).to.deep.equal({ + "firebase.google.com/app-id": "new-app", + }); + expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: '{"projectId":"p"}' }, + ]); + + const existing = { + name: "projects/p/locations/us-central1/services/s", + annotations: { keep: "1", "firebase.google.com/app-id": "old-app" }, + template: { + containers: [ + { + name: "s", + image: "old", + env: [{ name: "FIREBASE_CONFIG", value: '{"projectId":"old"}' }], + }, + ], + }, + } as unknown as runv2.Service; + + await deployOne(service({ existing, appId: "new-app", firebaseConfig: '{"projectId":"p"}' })); + expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ + keep: "1", + "firebase.google.com/app-id": "new-app", + }); + expect(updateServiceStub.firstCall.args[1].updateMask).to.deep.equal([ + "annotations", + "template", + "traffic", + ]); + expect(updateServiceStub.firstCall.args[0].template.containers[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: '{"projectId":"p"}' }, + ]); + + await deploy({ projectId: "p", appId: null }, options, { + run: { services: [service({ existing })] }, + }); + expect(updateServiceStub.secondCall.args[0].annotations).to.deep.equal({ keep: "1" }); + expect(updateServiceStub.secondCall.args[0].template.containers[0]).not.to.have.property("env"); + }); + describe("local builds", () => { const existing = { name: "projects/p/locations/us-central1/services/s", diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 7b0b06dcf9a..ab485a18948 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -17,6 +17,7 @@ import { secretNames, toLocalBuildEnv } from "./buildEnv"; import { copyTemplate, deployRevision, + FIREBASE_APP_ANNOTATION, mainContainer, SERVICE_OPERATION_TIMEOUT_MS, toAppHostingConfig, @@ -128,6 +129,15 @@ async function deployService( } else { delete container.baseImageUri; } + if (svc.firebaseConfig) { + const env = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); + container.env = [...env, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; + } else if (context.appId === null && container.env) { + container.env = container.env.filter((e) => e.name !== "FIREBASE_CONFIG"); + if (!container.env.length) { + delete container.env; + } + } template.annotations = { ...template.annotations }; if (options.message) { template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; @@ -135,8 +145,18 @@ async function deployService( delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; } + let annotations: Record | undefined; + if (svc.appId !== undefined || context.appId === null) { + annotations = { ...svc.existing?.annotations }; + if (svc.appId) { + annotations[FIREBASE_APP_ANNOTATION] = svc.appId; + } else { + delete annotations[FIREBASE_APP_ANNOTATION]; + } + } + if (svc.existing) { - return deployRevision(svc.existing, template); + return deployRevision(svc.existing, template, annotations); } return runv2.createService( projectId, @@ -144,6 +164,7 @@ async function deployService( serviceId, { name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + ...(annotations && Object.keys(annotations).length && { annotations }), template, client: "cli-firebase", invokerIamDisabled: true, diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index ff8169aa61f..6c645df9666 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -1,11 +1,13 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import { Context, Payload } from "./args"; import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; +import { FIREBASE_APP_ANNOTATION } from "./util"; describe("run prepare", () => { const nodejs22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; @@ -118,4 +120,105 @@ describe("run prepare", () => { expect(svc.buildEnv).to.deep.equal({ A: "1", TOKEN: { secret: "t", version: "2" } }); }); }); + + describe("sdk autoinit", () => { + const webConfig = { + projectId: "p", + appId: "1:1:web:a", + apiKey: "k", + storageBucket: "p.appspot.com", + }; + const firebaseConfig = JSON.stringify({ storageBucket: "p.appspot.com", projectId: "p" }); + const webappConfig = JSON.stringify(webConfig); + let getAppConfigStub: sinon.SinonStub; + + beforeEach(() => { + getAppConfigStub = sinon.stub(managementApps, "getAppConfig").resolves(webConfig); + }); + + it("reuses the service's current appId and resolves build and runtime config", async () => { + getServiceStub.resolves({ + ...existing, + annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, + }); + const svc = await prepareOne(); + expect(getAppConfigStub).to.have.been.calledWith("1:1:web:a", managementApps.AppPlatform.WEB); + expect(svc.appId).to.equal("1:1:web:a"); + expect(svc.firebaseConfig).to.equal(firebaseConfig); + expect(svc.buildEnv).to.deep.equal({ + FIREBASE_WEBAPP_CONFIG: webappConfig, + FIREBASE_CONFIG: firebaseConfig, + }); + }); + + it("lets the context set or clear the appId", async () => { + getServiceStub.resolves({ + ...existing, + annotations: { [FIREBASE_APP_ANNOTATION]: "old-app" }, + }); + expect((await prepareOne({}, { appId: "1:1:web:a" })).appId).to.equal("1:1:web:a"); + const cleared = await prepareOne({}, { appId: null }); + expect(cleared.appId).to.be.undefined; + expect(cleared.firebaseConfig).to.be.undefined; + expect(cleared.buildEnv).to.be.undefined; + }); + + it("lets user buildEnv and container env override autoinit vars", async () => { + getServiceStub.resolves({ + ...existing, + annotations: { + [FIREBASE_APP_ANNOTATION]: "1:1:web:a", + [BUILD_ENV_ANNOTATION]: JSON.stringify({ FIREBASE_WEBAPP_CONFIG: "custom-webapp" }), + }, + template: { + containers: [ + { + name: "s", + image: "i", + baseImageUri: nodejs22, + env: [{ name: "FIREBASE_CONFIG", value: "custom-runtime" }], + }, + ], + }, + }); + const svc = await prepareOne({}, { appId: "1:1:web:a" }); + expect(svc.firebaseConfig).to.equal("custom-runtime"); + expect(svc.buildEnv).to.deep.equal({ + FIREBASE_WEBAPP_CONFIG: "custom-webapp", + FIREBASE_CONFIG: "custom-runtime", + }); + }); + + it("preserves secret-backed FIREBASE_CONFIG on the container without overwriting it", async () => { + getServiceStub.resolves({ + ...existing, + annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, + template: { + containers: [ + { + name: "s", + image: "i", + env: [ + { + name: "FIREBASE_CONFIG", + valueSource: { secretKeyRef: { secret: "sec", version: "1" } }, + }, + ], + }, + ], + }, + }); + const svc = await prepareOne(); + expect(svc.firebaseConfig).to.be.undefined; + expect(svc.buildEnv).to.deep.equal({ FIREBASE_WEBAPP_CONFIG: webappConfig }); + }); + + it("warns and continues without autoinit if getAppConfig fails", async () => { + getAppConfigStub.rejects(new Error("boom")); + const svc = await prepareOne({}, { appId: "1:1:web:a" }); + expect(svc.appId).to.equal("1:1:web:a"); + expect(svc.firebaseConfig).to.be.undefined; + expect(svc.buildEnv).to.be.undefined; + }); + }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index fe63966ad43..ea3e2455da4 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,11 +1,16 @@ +import { getAutoinitEnvVars } from "../../apphosting/utils"; import { FirebaseError } from "../../error"; +import { WebConfig } from "../../fetchWebSetup"; import { RunSingle } from "../../firebaseConfig"; +import * as runv2 from "../../gcp/runv2"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; -import { logLabeledBullet } from "../../utils"; +import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; +import { BUILD_ENV_ANNOTATION, BuildEnv, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; import { + FIREBASE_APP_ANNOTATION, getExistingService, getServiceConfigs, mainContainer, @@ -38,19 +43,30 @@ async function prepareService(context: Context, config: RunSingle): Promise | undefined> { + if (!appId) { + return undefined; + } + try { + const webappConfig = (await managementApps.getAppConfig( + appId, + managementApps.AppPlatform.WEB, + )) as WebConfig; + const autoinitVars = getAutoinitEnvVars(webappConfig); + if (appId === existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { + for (const env of mainContainer(existing?.template)?.env || []) { + if (env.name in autoinitVars) { + if ("value" in env && env.value !== undefined) { + autoinitVars[env.name] = env.value; + } else { + delete autoinitVars[env.name]; + } + } + } + } + return Object.keys(autoinitVars).length ? autoinitVars : undefined; + } catch { + logLabeledWarning( + "run", + `Unable to lookup details for Firebase Web App ${appId} on service ${serviceId}. Firebase SDK autoinit will not be available.`, + ); + return undefined; + } +} diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index a2966fa6b34..735dca308b7 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -28,12 +28,14 @@ describe("updateService", () => { afterEach(() => sinon.restore()); - it("requires exactly one setting", async () => { + it("requires at least one valid setting", async () => { await expect(updateService("s", options())).to.be.rejectedWith( - "--base-image or --clear-base-image", + "--base-image , --clear-base-image, --app , or --clear-app", ); - const both = options({ baseImage: "nodejs20", clearBaseImage: true }); - await expect(updateService("s", both)).to.be.rejectedWith("not both"); + const bothBase = options({ baseImage: "nodejs20", clearBaseImage: true }); + await expect(updateService("s", bothBase)).to.be.rejectedWith("not both"); + const bothApp = options({ app: "app-1", clearApp: true }); + await expect(updateService("s", bothApp)).to.be.rejectedWith("not both"); }); it("requires the service to be in firebase.json", async () => { @@ -74,4 +76,32 @@ describe("updateService", () => { await updateService("s", options({ clearBaseImage: true })); expect(deployStub).not.to.have.been.called; }); + + it("links and clears a Firebase Web App", async () => { + await updateService("s", options({ app: "1:1:web:a" })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + appId: "1:1:web:a", + }); + + getServiceStub.resolves({ + ...service, + annotations: { "firebase.google.com/app-id": "1:1:web:a" }, + }); + await updateService("s", options({ clearApp: true })); + expect(deployStub).to.have.been.calledWith(["run"], sinon.match({ only: "run:s" }), { + appId: null, + }); + }); + + it("does nothing when clearing an app that isn't linked", async () => { + await updateService("s", options({ clearApp: true })); + expect(deployStub).not.to.have.been.called; + }); + + it("still updates baseImage when combined with a no-op --clear-app", async () => { + await updateService("s", options({ baseImage: "nodejs20", clearApp: true })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: "nodejs20", + }); + }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index b9712046e4c..b782ceb49ce 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -5,6 +5,7 @@ import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { logBullet } from "../../utils"; import { + FIREBASE_APP_ANNOTATION, getExistingService, getServiceConfigs, mainContainer, @@ -19,12 +20,17 @@ import { export async function updateService(serviceId: string, options: Options): Promise { const newBaseImage = options.baseImage as string | undefined; const clearBaseImage = !!options.clearBaseImage; + const newAppId = options.app as string | undefined; + const clearApp = !!options.clearApp; if (newBaseImage && clearBaseImage) { throw new FirebaseError("Use either --base-image or --clear-base-image, not both."); } - if (!newBaseImage && !clearBaseImage) { + if (newAppId && clearApp) { + throw new FirebaseError("Use either --app or --clear-app, not both."); + } + if (!newBaseImage && !clearBaseImage && !newAppId && !clearApp) { throw new FirebaseError( - "Specify a setting to update: --base-image or --clear-base-image.", + "Specify a setting to update: --base-image , --clear-base-image, --app , or --clear-app.", ); } @@ -38,10 +44,26 @@ export async function updateService(serviceId: string, options: Options): Promis if (!existing) { throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); } + let updateBaseImage = Boolean(newBaseImage || clearBaseImage); if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); + updateBaseImage = false; + } + let updateApp = Boolean(newAppId || clearApp); + if (clearApp && !existing.annotations?.[FIREBASE_APP_ANNOTATION]) { + logBullet(`Service ${clc.bold(serviceId)} does not have a linked Firebase Web App.`); + updateApp = false; + } + if (!updateBaseImage && !updateApp) { return; } - await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); + await deploy( + ["run"], + { ...options, only }, + { + ...(updateBaseImage && { baseImage: newBaseImage ?? null }), + ...(updateApp && { appId: newAppId ?? null }), + }, + ); } diff --git a/src/deploy/run/util.spec.ts b/src/deploy/run/util.spec.ts index 2e11f6263ae..6c60904b88f 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -120,5 +120,20 @@ describe("run util", () => { }, ); }); + + it("includes annotations in updateMask when annotations are passed", async () => { + const updateStub = sinon.stub(runv2, "updateService").resolves({} as runv2.Service); + const service = { + name: "projects/p/locations/r/services/s", + template: { containers: [{ name: "s", image: "i" }] }, + } as unknown as runv2.Service; + + await deployRevision(service, service.template, { "firebase.google.com/app-id": "app" }); + + expect(updateStub).to.have.been.calledWithMatch( + { annotations: { "firebase.google.com/app-id": "app" } }, + { updateMask: ["annotations", "template", "traffic"] }, + ); + }); }); }); diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index eeae95ad492..bfaeffa318c 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -8,6 +8,9 @@ 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; +/** Annotation on the Cloud Run Service storing the linked Firebase Web App ID (go/crff-autoinit). */ +export const FIREBASE_APP_ANNOTATION = "firebase.google.com/app-id"; + /** * Returns the Cloud Run services in firebase.json that match the --only filter. */ @@ -89,6 +92,7 @@ export function copyTemplate(service: runv2.Service): runv2.RevisionTemplate { export function deployRevision( service: runv2.Service, template: runv2.RevisionTemplate, + annotations?: Record, ): Promise { const tags = (service.traffic || []) .filter((t) => t.tag) @@ -100,11 +104,12 @@ export function deployRevision( return runv2.updateService( { name: service.name, + ...(annotations && { annotations }), template, traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], }, { - updateMask: ["template", "traffic"], + updateMask: [...(annotations ? ["annotations"] : []), "template", "traffic"], masterTimeout: SERVICE_OPERATION_TIMEOUT_MS, }, ); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index e530fa59684..14daeba090e 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { Setup } from ".."; +import { webApps } from "../../apphosting/app"; import { Config } from "../../config"; import * as deploy from "../../deploy"; import * as prereqs from "../../deploy/run/prereqs"; @@ -16,6 +17,7 @@ describe("init run", () => { let config: Config; let selectStub: sinon.SinonStub; let inputStub: sinon.SinonStub; + let getOrCreateWebAppStub: sinon.SinonStub; function setup(): Setup { return { @@ -32,6 +34,9 @@ describe("init run", () => { sinon.stub(prereqs, "prereqs").resolves(); sinon.stub(run, "listLocations").resolves(["us-central1", "us-east1"]); sinon.stub(runv2, "getService").rejects({ status: 404 }); + getOrCreateWebAppStub = sinon + .stub(webApps, "getOrCreateWebApp") + .resolves({ name: "my-service", id: "1:1:web:a" }); selectStub = sinon.stub(prompt, "select"); inputStub = sinon.stub(prompt, "input"); }); @@ -57,6 +62,7 @@ describe("init run", () => { choices: ["us-central1", "us-east1"], default: "us-central1", }); + expect(getOrCreateWebAppStub).to.have.been.calledWith("p", null, "my-service"); expect(inputStub.secondCall.args[0].default).to.equal("nodejs22"); const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; @@ -66,6 +72,7 @@ describe("init run", () => { region: "us-east1", baseImage: "nodejs22", rootDir: "/", + appId: "1:1:web:a", }); }); @@ -83,9 +90,10 @@ describe("init run", () => { ); }); - it("updates an existing service", async () => { + it("updates an existing service and reuses its linked Web App", async () => { const existing = { name: "projects/p/locations/europe-west1/services/web", + annotations: { "firebase.google.com/app-id": "existing-app" }, template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs20" }] }, }; const managed = { @@ -100,6 +108,7 @@ describe("init run", () => { await askQuestions(s, config, options); expect(runv2.listServices).to.have.been.calledWith("p", false); + expect(getOrCreateWebAppStub).not.to.have.been.called; expect(selectStub.secondCall.args[0].choices).to.deep.equal([ { name: "web (europe-west1)", value: existing }, ]); @@ -108,6 +117,7 @@ describe("init run", () => { region: "europe-west1", baseImage: "nodejs20", rootDir: "/", + appId: "existing-app", }); }); @@ -146,6 +156,7 @@ describe("init run", () => { baseImage: "nodejs22", rootDir: "/", localBuild: true, + appId: "1:1:web:a", }); }); }); @@ -162,7 +173,14 @@ describe("init run", () => { const deployStub = sinon.stub(deploy, "deploy").resolves(); const s = setup(); s.featureInfo = { - run: { serviceId: "s", region: "r", baseImage: "", rootDir: "/", localBuild: true }, + run: { + serviceId: "s", + region: "r", + baseImage: "", + rootDir: "/", + localBuild: true, + appId: "1:1:web:a", + }, }; await actuate(s, config, options); @@ -179,7 +197,7 @@ describe("init run", () => { expect(deployStub).to.have.been.calledWith( ["run"], { projectId: "p", config, only: "run:s" }, - { baseImage: null }, + { baseImage: null, appId: "1:1:web:a" }, ); }); }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 5c01d3bc279..da4256c3e58 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,10 +1,11 @@ import { existsSync } from "fs"; import * as path from "path"; import { Setup } from ".."; +import { webApps } from "../../apphosting/app"; import { Config } from "../../config"; import { deploy } from "../../deploy"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; -import { getExistingService, mainContainer } from "../../deploy/run/util"; +import { FIREBASE_APP_ANNOTATION, getExistingService, mainContainer } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import * as run from "../../gcp/run"; @@ -20,6 +21,7 @@ export interface RunInfo { baseImage: string; rootDir: string; localBuild?: boolean; + appId?: string; } /** @@ -84,6 +86,12 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); } + let appId = existing?.annotations?.[FIREBASE_APP_ANNOTATION]; + if (!appId) { + const webApp = await webApps.getOrCreateWebApp(projectId, null, serviceId); + appId = webApp?.id; + } + const localBuild = await select({ message: "Would you like to build your app locally or remotely?", choices: [ @@ -113,7 +121,14 @@ export async function askQuestions(setup: Setup, config: Config, options: Option setup.featureInfo = { ...setup.featureInfo, - run: { serviceId, region, baseImage, rootDir, ...(localBuild && { localBuild }) }, + run: { + serviceId, + region, + baseImage, + rootDir, + ...(localBuild && { localBuild }), + ...(appId && { appId }), + }, }; } @@ -139,7 +154,10 @@ 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 }, + { + baseImage: info.baseImage || null, + ...(info.appId !== undefined && { appId: info.appId || null }), + }, ); } From d429b4df5d46df7759746a74e47fe26eb8081638 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:33:48 -0400 Subject: [PATCH 011/103] chore(apphosting): add TODO for bundle.yaml env vars in local builds --- src/apphosting/localbuilds.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index dc17783a5ad..70ad079519d 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -116,6 +116,8 @@ function parseBundleYaml( outputFiles?: { serverApp?: { include?: string[] } }; }; + // TODO: Also merge bundleData.runConfig.environmentVariables (and resource limits) from + // bundle.yaml to match the App Hosting publisher step on remote builds. const runCommand = bundleData?.runConfig?.runCommand ?? defaultRunCommand; const outputFiles = bundleData?.outputFiles?.serverApp?.include ?? []; return { runCommand, outputFiles }; From 29355ca516c7003b89344801f78b84fde4e4104a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:44:32 -0400 Subject: [PATCH 012/103] Rename direct_cloud_run experiment to directcloudrun --- src/experiments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/experiments.ts b/src/experiments.ts index 5f128a19e1f..ad826e9d434 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -179,7 +179,7 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, - direct_cloud_run: { + directcloudrun: { shortDescription: "Build and deploy web apps directly to Cloud Run services", fullDescription: "Enables `firebase init run`, `firebase deploy --only run`, and the " + From f1903095861efb5553c249df964707bb0e554dc8 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:45:50 -0400 Subject: [PATCH 013/103] Use directcloudrun experiment and validate rootDir is a directory --- src/commands/init.ts | 2 +- src/init/features/run.spec.ts | 1 + src/init/features/run.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 6525c331811..41dc110ef1c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -101,7 +101,7 @@ let choices: { }, ]; -if (isEnabled("direct_cloud_run")) { +if (isEnabled("directcloudrun")) { choices.push({ value: "run", name: "Cloud Run: Create or update a Cloud Run service and deploy your web app to it", diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 0b2bd812dc5..a99772f87cf 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -60,6 +60,7 @@ describe("init run", () => { const validateDir = inputStub.thirdCall.args[0].validate; expect(validateDir(".")).to.be.true; expect(validateDir("nonexistent-dir-xyz")).to.include("does not exist"); + expect(validateDir("package.json")).to.include("does not exist"); expect(s.featureInfo?.run).to.deep.equal({ serviceId: "my-service", region: "us-east1", diff --git a/src/init/features/run.ts b/src/init/features/run.ts index ab886bf6d04..51247384090 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,4 +1,4 @@ -import { existsSync } from "fs"; +import { statSync } from "fs"; import * as path from "path"; import { Setup } from ".."; import { Config } from "../../config"; @@ -92,7 +92,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option validate: (dir) => { const absPath = path.join(config.projectDir, dir); return ( - existsSync(absPath) || + Boolean(statSync(absPath, { throwIfNoEntry: false })?.isDirectory()) || `Directory ${absPath} does not exist. Please enter a valid directory.` ); }, From 9f0562eb4245c86e565938c85bdaabf181e564ec Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:49:16 -0400 Subject: [PATCH 014/103] Clean up temporary source archive, guard release URI, and use directcloudrun --- scripts/run-deploy-tests/run.sh | 2 +- scripts/run-deploy-tests/tests.ts | 8 ++++---- src/commands/deploy.ts | 2 +- src/deploy/run/deploy.spec.ts | 3 +++ src/deploy/run/deploy.ts | 24 ++++++++++++++++-------- src/deploy/run/release.ts | 5 ++++- src/deploy/run/util.ts | 2 +- 7 files changed, 30 insertions(+), 16 deletions(-) diff --git a/scripts/run-deploy-tests/run.sh b/scripts/run-deploy-tests/run.sh index 3c2bfeeb741..4dc0d0eb01f 100755 --- a/scripts/run-deploy-tests/run.sh +++ b/scripts/run-deploy-tests/run.sh @@ -12,4 +12,4 @@ echo "======================================" echo "Starting Cloud Run E2E Test Suite" echo "======================================" -mocha scripts/run-deploy-tests/tests.ts --timeout 600000 +npx mocha scripts/run-deploy-tests/tests.ts --timeout 600000 diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 95f4dcb0eb4..5a46c9a8bd9 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -35,7 +35,7 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { before(async () => { expect(PROJECT).to.not.be.empty; - process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + process.env.FIREBASE_CLI_EXPERIMENTS = "directcloudrun"; await requireAuth({}); workDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-e2e-")); const pkg = { name: "run-e2e", version: "1.0.0" }; @@ -60,14 +60,14 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { fs.removeSync(workDir); }); - it("requires the direct_cloud_run experiment", async () => { + it("requires the directcloudrun experiment", async () => { delete process.env.FIREBASE_CLI_EXPERIMENTS; try { const res = await firebase("deploy", "--only", "run"); expect(res.proc.exitCode).not.to.equal(0); - expect(res.stdout + res.stderr).to.include("experiment direct_cloud_run is not enabled"); + expect(res.stdout + res.stderr).to.include("experiment directcloudrun is not enabled"); } finally { - process.env.FIREBASE_CLI_EXPERIMENTS = "direct_cloud_run"; + process.env.FIREBASE_CLI_EXPERIMENTS = "directcloudrun"; } }); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index cd58e2c36c8..1be9797754e 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -89,7 +89,7 @@ command experiments.assertEnabled("ailogic", "deploy AI Logic resources"); } if (options.filteredTargets.includes("run")) { - experiments.assertEnabled("direct_cloud_run", "deploy Cloud Run services"); + experiments.assertEnabled("directcloudrun", "deploy Cloud Run services"); } const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { return perms.concat(TARGET_PERMISSIONS[target as (typeof VALID_DEPLOY_TARGETS)[number]]); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b2a0d9bc869..2690cf25ae0 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -14,6 +14,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; + let rmSyncStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -34,6 +35,7 @@ describe("run deploy", () => { .stub(gcs, "uploadObject") .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); + rmSyncStub = sinon.stub(fs, "rmSync"); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); @@ -53,6 +55,7 @@ describe("run deploy", () => { } as Options); expect(sourceArchiveStub).to.have.been.calledWithMatch({ backendId: "s" }, "/p/web"); + expect(rmSyncStub).to.have.been.calledWith("src.zip", { force: true }); const imageUri = "us-central1-docker.pkg.dev/p/cloud-run-source-deploy/s:42"; expect(submitBuildStub).to.have.been.calledWith("p", "us-central1", { storageSource: { bucket: "bucket", object: "src.zip" }, diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 3c5e210df28..e5efc1c3a12 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { FirebaseError } from "../../error"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; @@ -39,7 +40,10 @@ async function deployService( const template = svc.existing ? copyTemplate(svc.existing) : { containers: [{ name: serviceId, image: "" }] }; - const container = mainContainer(template)!; + const container = mainContainer(template); + if (!container) { + throw new FirebaseError(`Service ${serviceId} has no containers.`); + } container.image = await buildImage(projectId, svc, source); if (svc.baseImage) { container.baseImageUri = svc.baseImage; @@ -98,13 +102,17 @@ async function uploadSource( cfg, path.join(options.config.projectDir, cfg.rootDir), ); - logLabeledBullet("run", `Uploading source for service ${serviceId}...`); - const { bucket, object } = await gcs.uploadObject( - { file: archive, stream: fs.createReadStream(archive) }, - bucketName, - gcs.ContentType.ZIP, - ); - return { bucket, object }; + try { + logLabeledBullet("run", `Uploading source for service ${serviceId}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; + } finally { + fs.rmSync(archive, { force: true }); + } } async function buildImage( diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts index 5c32bc9b075..a10967ebe65 100644 --- a/src/deploy/run/release.ts +++ b/src/deploy/run/release.ts @@ -10,6 +10,9 @@ export async function release( payload: Payload, ): Promise { for (const { config, deployed } of payload.run?.services || []) { - logLabeledSuccess("run", `Deployed service ${config.serviceId} to ${deployed?.uri}`); + logLabeledSuccess( + "run", + `Deployed service ${config.serviceId}${deployed?.uri ? ` to ${deployed.uri}` : ""}`, + ); } } diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 9cea23cf881..819ddec99fd 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -11,7 +11,7 @@ export const SERVICE_OPERATION_TIMEOUT_MS = 10 * 60 * 1000; * Returns the Cloud Run services in firebase.json that match the --only filter. */ export function getServiceConfigs(options: Options): RunSingle[] { - const configs = [options.config.src.run || []].flat(); + const configs = [options.config?.src?.run || []].flat(); const selectors = options.only ? options.only.split(",") : ["run"]; if (selectors.includes("run")) { return configs; From d0943936b03205635f2130303cd88c87ef2c1820 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:50:49 -0400 Subject: [PATCH 015/103] Validate service region and check all matching configs in updateService --- src/commands/index.ts | 2 +- src/deploy/run/update.spec.ts | 5 ++++- src/deploy/run/update.ts | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index f4520625b4d..eb2a1639b00 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,7 +301,7 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); - if (experiments.isEnabled("direct_cloud_run")) { + if (experiments.isEnabled("directcloudrun")) { client.run = {}; client.run.services = {}; client.run.services.update = loadCommand("run-services-update"); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 0b805df910e..27529b10f85 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -36,10 +36,13 @@ describe("updateService", () => { await expect(updateService("s", both)).to.be.rejectedWith("not both"); }); - it("requires the service to be in firebase.json", async () => { + it("requires the service to be in firebase.json with a region", async () => { await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( "Cloud Run service IDs other not detected in firebase.json", ); + await expect( + updateService("s", options({ baseImage: "nodejs20" }, { region: "" })), + ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); }); it("requires the service to exist", async () => { diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 65241e9f558..d57386d5513 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,6 +1,7 @@ import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { logBullet } from "../../utils"; @@ -30,12 +31,21 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; - const [config] = getServiceConfigs({ ...options, only }); - const existing = await getExistingService(projectId, config.region, serviceId); - if (!existing) { - throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + const configs = getServiceConfigs({ ...options, only }); + const existing: runv2.Service[] = []; + for (const config of configs) { + if (!config.region) { + throw new FirebaseError( + `Cloud Run service ${serviceId} is missing a region in firebase.json.`, + ); + } + const svc = await getExistingService(projectId, config.region, serviceId); + if (!svc) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + existing.push(svc); } - if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + if (clearBaseImage && existing.every((s) => !mainContainer(s.template)?.baseImageUri)) { logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); return; } From d0eb8fb848b58ddcdf02eff74e3756d8fdda5cd6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:08:56 -0400 Subject: [PATCH 016/103] Trim baseImage input in init run --- src/init/features/run.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 51247384090..aeb232edc94 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -82,10 +82,12 @@ export async function askQuestions(setup: Setup, config: Config, options: Option }); } - const baseImage = await input({ - message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", - default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", - }); + const baseImage = ( + (await input({ + message: "Which base image should your app use? (e.g. nodejs20, nodejs22)", + default: existing ? mainContainer(existing.template)?.baseImageUri : "nodejs22", + })) || "" + ).trim(); const rootDir = await input({ message: "Specify your app's root directory relative to your firebase.json directory", default: "/", From aa0457aa088d2248984b149079520e2caa260d8c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:11:24 -0400 Subject: [PATCH 017/103] Avoid empty template.annotations when deploy message is unset --- src/deploy/run/deploy.spec.ts | 4 ++-- src/deploy/run/deploy.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 2690cf25ae0..4162377ea19 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -14,7 +14,7 @@ describe("run deploy", () => { const options = { config: { projectDir: "/p" } } as unknown as Options; const config = { serviceId: "s", region: "us-central1", rootDir: "web" }; let sourceArchiveStub: sinon.SinonStub; - let rmSyncStub: sinon.SinonStub; + let rmSyncStub: sinon.SinonSpy; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let updateServiceStub: sinon.SinonStub; @@ -35,7 +35,7 @@ describe("run deploy", () => { .stub(gcs, "uploadObject") .callsFake((src) => Promise.resolve({ bucket: "bucket", object: src.file, generation: "1" })); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - rmSyncStub = sinon.stub(fs, "rmSync"); + rmSyncStub = sinon.spy(fs, "rmSync"); sourceArchiveStub = sinon.stub(apphostingUtil, "createSourceDeployArchive").resolves("src.zip"); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index e5efc1c3a12..10573ea1723 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -50,10 +50,12 @@ async function deployService( } else { delete container.baseImageUri; } - template.annotations = { ...template.annotations }; if (options.message) { - template.annotations[DEPLOY_MESSAGE_ANNOTATION] = options.message as string; - } else { + template.annotations = { + ...template.annotations, + [DEPLOY_MESSAGE_ANNOTATION]: options.message as string, + }; + } else if (template.annotations) { delete template.annotations[DEPLOY_MESSAGE_ANNOTATION]; if (!Object.keys(template.annotations).length) { delete template.annotations; From d07f523058e24cfa28502c2a1e2a4a19a8e8cbe0 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 07:15:25 -0400 Subject: [PATCH 018/103] Clear localBuild in upsertRunConfig without unused destructuring --- src/init/features/run.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 1f4a5587a1f..aa39e59586e 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -156,14 +156,16 @@ export function upsertRunConfig(runConfig: RunSingle, config: Config): void { services.push(runConfig); } else { const { rootDir, region, localBuild } = runConfig; - const { localBuild: _, ...existing } = services[i]; services[i] = { ...runConfig, - ...existing, + ...services[i], rootDir, region, ...(localBuild && { localBuild }), }; + if (!localBuild) { + delete services[i].localBuild; + } } config.set("run", services.length === 1 ? services[0] : services); } From e7ac3860a4a3d13bbaab8c7c3d3cf859e5b865c1 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 08:16:20 -0400 Subject: [PATCH 019/103] 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 4ee2069cdd024c045e2f949266c51227edbac36f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 08:27:22 -0400 Subject: [PATCH 020/103] Use Object.prototype.hasOwnProperty.call when checking autoinitVars keys --- src/deploy/run/prepare.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 626516f607f..0c62e4d416a 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -117,7 +117,7 @@ async function resolveAutoInitEnv( const autoinitVars = getAutoinitEnvVars(webappConfig); if (appId === existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { for (const env of mainContainer(existing?.template)?.env || []) { - if (env.name in autoinitVars) { + if (Object.prototype.hasOwnProperty.call(autoinitVars, env.name)) { if ("value" in env && env.value !== undefined) { autoinitVars[env.name] = env.value; } else { From 26f92e5b2674e73bb03e337b1d568141a1da693f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 10:44:07 -0400 Subject: [PATCH 021/103] Ensure runtime service account has roles/firebase.sdkAdminServiceAgent for SDK autoinit --- src/deploy/run/prepare.spec.ts | 52 ++++++++++++++++++++++++++++++++ src/deploy/run/prepare.ts | 54 +++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 7971aed6a5a..18381a6dd5f 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -1,6 +1,9 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import * as computeEngine from "../../gcp/computeEngine"; +import * as resourceManager from "../../gcp/resourceManager"; import * as runv2 from "../../gcp/runv2"; +import * as getProjectNumber from "../../getProjectNumber"; import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import { Context, Payload } from "./args"; @@ -122,6 +125,8 @@ describe("run prepare", () => { }); describe("sdk autoinit", () => { + const defaultSa = "123-compute@developer.gserviceaccount.com"; + const adminSdkRole = "roles/firebase.sdkAdminServiceAgent"; const webConfig = { projectId: "p", appId: "1:1:web:a", @@ -131,9 +136,17 @@ describe("run prepare", () => { const firebaseConfig = JSON.stringify({ storageBucket: "p.appspot.com", projectId: "p" }); const webappConfig = JSON.stringify(webConfig); let getAppConfigStub: sinon.SinonStub; + let hasRolesStub: sinon.SinonStub; + let addRolesStub: sinon.SinonStub; beforeEach(() => { getAppConfigStub = sinon.stub(managementApps, "getAppConfig").resolves(webConfig); + sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); + sinon.stub(computeEngine, "getDefaultServiceAccount").resolves(defaultSa); + hasRolesStub = sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); + addRolesStub = sinon + .stub(resourceManager, "addServiceAccountToRoles") + .resolves({ bindings: [] }); }); it("reuses the service's current appId and resolves build and runtime config", async () => { @@ -149,6 +162,43 @@ describe("run prepare", () => { FIREBASE_WEBAPP_CONFIG: webappConfig, FIREBASE_CONFIG: firebaseConfig, }); + expect(hasRolesStub).to.have.been.calledWith("p", defaultSa, [adminSdkRole], true); + expect(addRolesStub).not.to.have.been.called; + }); + + it("grants roles/firebase.sdkAdminServiceAgent when missing on the service account", async () => { + hasRolesStub.resolves(false); + await prepareOne({}, { appId: "1:1:web:a" }); + expect(addRolesStub).to.have.been.calledOnceWithExactly("p", defaultSa, [adminSdkRole], true); + }); + + it("checks and grants roles/firebase.sdkAdminServiceAgent on a custom service account", async () => { + hasRolesStub.resolves(false); + getServiceStub.resolves({ + ...existing, + annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, + template: { ...existing.template, serviceAccount: "custom@p.iam.gserviceaccount.com" }, + }); + await prepareOne(); + expect(hasRolesStub).to.have.been.calledWith( + "p", + "custom@p.iam.gserviceaccount.com", + [adminSdkRole], + true, + ); + expect(addRolesStub).to.have.been.calledOnceWithExactly( + "p", + "custom@p.iam.gserviceaccount.com", + [adminSdkRole], + true, + ); + }); + + it("warns and continues if granting roles/firebase.sdkAdminServiceAgent fails with 403", async () => { + hasRolesStub.resolves(false); + addRolesStub.rejects({ status: 403 }); + const svc = await prepareOne({}, { appId: "1:1:web:a" }); + expect(svc.appId).to.equal("1:1:web:a"); }); it("lets the context set or clear the appId", async () => { @@ -223,6 +273,7 @@ describe("run prepare", () => { expect(svc.appId).to.equal("1:1:web:a"); expect(svc.firebaseConfig).to.be.undefined; expect(svc.buildEnv).to.be.undefined; + expect(hasRolesStub).not.to.have.been.called; }); it("throws if getAppConfig fails when explicitly setting a new appId", async () => { @@ -230,6 +281,7 @@ describe("run prepare", () => { await expect(prepareOne({}, { appId: "bad-app" })).to.be.rejectedWith( "Unable to lookup details for Firebase Web App bad-app on service s.", ); + expect(hasRolesStub).not.to.have.been.called; }); }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 0c62e4d416a..c4f5e08a689 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,8 +1,11 @@ import { getAutoinitEnvVars } from "../../apphosting/utils"; -import { FirebaseError } from "../../error"; +import { FirebaseError, getErrStatus } from "../../error"; import { WebConfig } from "../../fetchWebSetup"; import { RunSingle } from "../../firebaseConfig"; +import { getDefaultServiceAccount } from "../../gcp/computeEngine"; +import * as resourceManager from "../../gcp/resourceManager"; import * as runv2 from "../../gcp/runv2"; +import { getProjectNumber } from "../../getProjectNumber"; import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; @@ -17,6 +20,8 @@ import { missingServiceMessage, } from "./util"; +const ADMIN_SDK_ROLE = "roles/firebase.sdkAdminServiceAgent"; + /** * Reads each service's current state from Cloud Run and resolves its base image and build env. */ @@ -55,6 +60,9 @@ async function prepareService(context: Context, config: RunSingle): Promise { + const serviceAccount = + existing?.template?.serviceAccount || + (await getDefaultServiceAccount(await getProjectNumber(context))); + try { + if ( + await resourceManager.serviceAccountHasRoles( + context.projectId, + serviceAccount, + [ADMIN_SDK_ROLE], + /* skipAccountLookup= */ true, + ) + ) { + return; + } + logLabeledWarning( + "run", + `Service account ${serviceAccount} is missing role ${ADMIN_SDK_ROLE} required for Firebase Admin SDK auto-initialization. Granting ${ADMIN_SDK_ROLE} to ${serviceAccount}...`, + ); + await resourceManager.addServiceAccountToRoles( + context.projectId, + serviceAccount, + [ADMIN_SDK_ROLE], + /* skipAccountLookup= */ true, + ); + } catch (err: unknown) { + if (getErrStatus(err) === 403) { + logLabeledWarning( + "run", + `Failed to grant ${ADMIN_SDK_ROLE} to ${serviceAccount}. Make sure you have the resourcemanager.projects.setIamPolicy permission.`, + ); + } else { + throw err; + } + } +} From beea973193b7193d38718ce0ab15537223b62b10 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 11:23:19 -0400 Subject: [PATCH 022/103] Use ternary instead of numeric length in object spreads --- src/deploy/run/deploy.ts | 2 +- src/deploy/run/prepare.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f8b8d7b27f5..18f117273b2 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -178,7 +178,7 @@ async function deployService( serviceId, { name: `projects/${projectId}/locations/${region}/services/${serviceId}`, - ...(annotations && Object.keys(annotations).length && { annotations }), + ...(annotations && Object.keys(annotations).length > 0 ? { annotations } : {}), template, client: "cli-firebase", invokerIamDisabled: true, diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index c4f5e08a689..a699f15d013 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -78,7 +78,7 @@ async function prepareService(context: Context, config: RunSingle): Promise 0 ? { buildEnv } : {}), }; if (!config.localBuild) { const secrets = secretNames(buildEnv); From 46a8aea2a6fd1687480beb1208659804897a579c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 11:42:24 -0400 Subject: [PATCH 023/103] Add defensive optional chaining in run.ts and runv2.ts --- src/gcp/run.ts | 2 +- src/gcp/runv2.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gcp/run.ts b/src/gcp/run.ts index 73053ab8f99..c1f2c386e28 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -375,5 +375,5 @@ export async function listLocations(projectId: string): Promise { const res = await client.get<{ locations?: Array<{ locationId: string }> }>( `/projects/${projectId}/locations`, ); - return res.body.locations?.map((l) => l.locationId) ?? []; + return res.body?.locations?.map((l) => l.locationId) ?? []; } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index c59cb01a80f..2f5e8071a7d 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -188,10 +188,10 @@ export async function submitBuild( `/projects/${projectId}/locations/${location}/builds:submit`, build, ); - if (res.body.baseImageWarning) { + if (res.body?.baseImageWarning) { logger.warn(res.body.baseImageWarning); } - const cloudBuild = res.body.buildOperation?.metadata?.build; + const cloudBuild = res.body?.buildOperation?.metadata?.build; if (!cloudBuild) { throw new FirebaseError("Failed to submit build: no build was returned."); } @@ -203,9 +203,9 @@ export async function submitBuild( operationResourceName: cloudBuild.name, masterTimeout: 30 * 60 * 1000, maxBackoff: 10_000, - doneFn: (b: { status?: string }) => { - status = b.status; - return !PENDING_BUILD_STATUSES.includes(b.status ?? "STATUS_UNKNOWN"); + doneFn: (b?: { status?: string }) => { + status = b?.status; + return !b?.status || !PENDING_BUILD_STATUSES.includes(b.status); }, }); if (status !== "SUCCESS") { From 6fe842768206886948c3ee1130a7529f8eca013a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 13:50:27 -0400 Subject: [PATCH 024/103] 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 eaaffe17ff5da2c50957338e145ba77fde34d3b9 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 28 Sep 2026 16:53:05 -0400 Subject: [PATCH 025/103] 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 026/103] 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 027/103] 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 028/103] 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 029/103] 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 030/103] 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 031/103] 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 032/103] 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 033/103] 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 034/103] 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 035/103] 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 036/103] 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 037/103] 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 038/103] 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 039/103] 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 bc390c74eb9bc144cb67e2e424f739d37f2b86a5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:23:49 -0400 Subject: [PATCH 040/103] Add firebase run:services:update command --- scripts/run-deploy-tests/tests.ts | 59 +++++++++++++++++++++-- src/commands/index.ts | 5 ++ src/commands/run-services-update.ts | 17 +++++++ src/deploy/run/update.spec.ts | 72 +++++++++++++++++++++++++++++ src/deploy/run/update.ts | 44 ++++++++++++++++++ src/deploy/run/util.ts | 9 ++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/commands/run-services-update.ts create mode 100644 src/deploy/run/update.spec.ts create mode 100644 src/deploy/run/update.ts diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 5a46c9a8bd9..af95c23c43d 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -71,6 +71,8 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { } }); + let defaultNodeVersion: string; + it("creates a service from source", async () => { const res = await firebase("deploy", "--only", "run"); expect(res.proc.exitCode).to.equal(0); @@ -82,10 +84,41 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { expect(service.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + defaultNodeVersion = await expectServing(); + }); + + /** + * Runs run:services:update and checks that it rebuilt and deployed the service like deploy + * does. Returns the new main container and the Node.js version the service now runs on. + */ + async function expectUpdated( + ...args: string[] + ): Promise<{ container: runv2.Container; nodeVersion: string }> { + const before = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const res = await firebase("run:services:update", SERVICE_ID, ...args); + expect(res.proc.exitCode).to.equal(0); + expect(res.stdout).to.include("Deploy complete!"); + + const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); + const container = after.template.containers![0]; + const oldContainer = before.template.containers![0]; + expect([container.image, container.sourceCode]).not.to.deep.equal([ + oldContainer.image, + oldContainer.sourceCode, + ]); + expect(after.traffic).to.deep.equal([ + { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, + ]); + return { container, nodeVersion: await expectServing() }; + } + + it("sets a base image, then rebuilds and deploys", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs22"); + expect(container.baseImageUri).to.include("nodejs22"); + expect(nodeVersion).to.match(/^v22\./); }); - it("keeps settings changed outside the CLI on deploy", async () => { + it("keeps the base image and settings changed outside the CLI on deploy", async () => { // Simulate a console change to a service-level setting. const service = await runv2.getService(PROJECT, REGION, SERVICE_ID); const template = { ...service.template, revision: undefined }; @@ -97,11 +130,31 @@ describe("firebase deploy --only run", function (this: Mocha.Suite) { const after = await runv2.getService(PROJECT, REGION, SERVICE_ID); const container = after.template.containers![0]; expect(container.image).not.to.equal(service.template.containers![0].image); + expect(container.baseImageUri).to.include("nodejs22"); expect(container.resources?.limits?.memory).to.equal("1Gi"); expect(after.traffic).to.deep.equal([ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ]); - await expectServing(); + expect(await expectServing()).to.match(/^v22\./); + }); + + it("changes the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--base-image", "nodejs20"); + expect(container.baseImageUri).to.include("nodejs20"); + expect(nodeVersion).to.match(/^v20\./); + }); + + it("clears the base image", async () => { + const { container, nodeVersion } = await expectUpdated("--clear-base-image"); + expect(container).not.to.have.property("baseImageUri"); + expect(container.resources?.limits?.memory).to.equal("1Gi"); + expect(nodeVersion.split(".")[0]).to.equal(defaultNodeVersion.split(".")[0]); + }); + + it("requires a setting to update", async () => { + const res = await firebase("run:services:update", SERVICE_ID); + expect(res.proc.exitCode).not.to.equal(0); + expect(res.stdout + res.stderr).to.include("Specify a setting to update:"); }); it("rejects services that aren't in firebase.json", async () => { diff --git a/src/commands/index.ts b/src/commands/index.ts index 0878d348906..f4520625b4d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,6 +301,11 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); + if (experiments.isEnabled("direct_cloud_run")) { + client.run = {}; + client.run.services = {}; + client.run.services.update = loadCommand("run-services-update"); + } client.serve = loadCommand("serve"); client.setup = {}; client.setup.emulators = {}; diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts new file mode 100644 index 00000000000..119a730287e --- /dev/null +++ b/src/commands/run-services-update.ts @@ -0,0 +1,17 @@ +import { Command } from "../command"; +import { TARGET_PERMISSIONS } from "../deploy"; +import { updateService } from "../deploy/run/update"; +import { Options } from "../options"; +import { requireConfig } from "../requireConfig"; +import { requirePermissions } from "../requirePermissions"; + +export const command = new Command("run:services:update ") + .description("update the settings of a Cloud Run service, then build and deploy it") + .option( + "--base-image ", + "set the base image (e.g. nodejs22) and enable automatic base image updates", + ) + .option("--clear-base-image", "clear the base image and disable automatic base image updates") + .before(requireConfig) + .before(requirePermissions, TARGET_PERMISSIONS.run) + .action((serviceId: string, options: Options) => updateService(serviceId, options)); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts new file mode 100644 index 00000000000..0b805df910e --- /dev/null +++ b/src/deploy/run/update.spec.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as deployIndex from ".."; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { updateService } from "./update"; + +describe("updateService", () => { + const service = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "i", baseImageUri: "nodejs22" }] }, + } as unknown as runv2.Service; + let getServiceStub: sinon.SinonStub; + let deployStub: sinon.SinonStub; + + function options(opts: Record = {}, run: Record = {}): Options { + return { + project: "p", + config: { src: { run: { serviceId: "s", region: "us-central1", ...run } } }, + ...opts, + } as unknown as Options; + } + + beforeEach(() => { + getServiceStub = sinon.stub(runv2, "getService").resolves(service); + deployStub = sinon.stub(deployIndex, "deploy").resolves(); + }); + + afterEach(() => sinon.restore()); + + it("requires exactly one setting", async () => { + await expect(updateService("s", options())).to.be.rejectedWith( + "--base-image or --clear-base-image", + ); + const both = options({ baseImage: "nodejs20", clearBaseImage: true }); + await expect(updateService("s", both)).to.be.rejectedWith("not both"); + }); + + it("requires the service to be in firebase.json", async () => { + await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( + "Cloud Run service IDs other not detected in firebase.json", + ); + }); + + it("requires the service to exist", async () => { + getServiceStub.rejects({ status: 404 }); + await expect(updateService("s", options({ baseImage: "nodejs22" }))).to.be.rejectedWith( + /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s/, + ); + expect(deployStub).not.to.have.been.called; + }); + + it("builds and deploys the service with the new base image", async () => { + await updateService("s", options({ baseImage: "nodejs20" })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: "nodejs20", + }); + }); + + it("builds and deploys the service without a base image when clearing it", async () => { + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: null, + }); + }); + + it("does nothing when clearing a base image that isn't set", async () => { + getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); + await updateService("s", options({ clearBaseImage: true })); + expect(deployStub).not.to.have.been.called; + }); +}); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts new file mode 100644 index 00000000000..65241e9f558 --- /dev/null +++ b/src/deploy/run/update.ts @@ -0,0 +1,44 @@ +import * as clc from "colorette"; +import { deploy } from ".."; +import { FirebaseError } from "../../error"; +import { Options } from "../../options"; +import { needProjectId } from "../../projectUtils"; +import { logBullet } from "../../utils"; +import { + getExistingService, + getServiceConfigs, + mainContainer, + missingServiceMessage, +} from "./util"; + +/** + * Updates a service's settings, then builds and deploys it like `firebase deploy` does. + * Changing the base image always needs a rebuild: images built for a base image leave out the + * OS and runtime, and images built without one bring their own, which override the base image. + */ +export async function updateService(serviceId: string, options: Options): Promise { + const newBaseImage = options.baseImage as string | undefined; + const clearBaseImage = !!options.clearBaseImage; + if (newBaseImage && clearBaseImage) { + throw new FirebaseError("Use either --base-image or --clear-base-image, not both."); + } + if (!newBaseImage && !clearBaseImage) { + throw new FirebaseError( + "Specify a setting to update: --base-image or --clear-base-image.", + ); + } + + const projectId = needProjectId(options); + const only = `run:${serviceId}`; + const [config] = getServiceConfigs({ ...options, only }); + const existing = await getExistingService(projectId, config.region, serviceId); + if (!existing) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); + return; + } + + await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); +} diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 819ddec99fd..3e47415fd12 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,3 +1,4 @@ +import * as clc from "colorette"; import { FirebaseError, getErrStatus } from "../../error"; import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; @@ -56,6 +57,14 @@ export async function getExistingService( } } +/** + * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. + */ +export function missingServiceMessage(config: RunSingle): string { + const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; +} + /** * Returns the container that serves traffic. */ From 9a8b7ca28af6528919cf7bcb1e6d2ed869475971 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 30 Sep 2026 00:50:49 -0400 Subject: [PATCH 041/103] Validate service region and check all matching configs in updateService --- src/commands/index.ts | 2 +- src/deploy/run/update.spec.ts | 5 ++++- src/deploy/run/update.ts | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index f4520625b4d..eb2a1639b00 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -301,7 +301,7 @@ export function load(client: CLIClient): CLIClient { client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get"); client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list"); client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); - if (experiments.isEnabled("direct_cloud_run")) { + if (experiments.isEnabled("directcloudrun")) { client.run = {}; client.run.services = {}; client.run.services.update = loadCommand("run-services-update"); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 0b805df910e..27529b10f85 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -36,10 +36,13 @@ describe("updateService", () => { await expect(updateService("s", both)).to.be.rejectedWith("not both"); }); - it("requires the service to be in firebase.json", async () => { + it("requires the service to be in firebase.json with a region", async () => { await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( "Cloud Run service IDs other not detected in firebase.json", ); + await expect( + updateService("s", options({ baseImage: "nodejs20" }, { region: "" })), + ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); }); it("requires the service to exist", async () => { diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 65241e9f558..d57386d5513 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,6 +1,7 @@ import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { logBullet } from "../../utils"; @@ -30,12 +31,21 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; - const [config] = getServiceConfigs({ ...options, only }); - const existing = await getExistingService(projectId, config.region, serviceId); - if (!existing) { - throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + const configs = getServiceConfigs({ ...options, only }); + const existing: runv2.Service[] = []; + for (const config of configs) { + if (!config.region) { + throw new FirebaseError( + `Cloud Run service ${serviceId} is missing a region in firebase.json.`, + ); + } + const svc = await getExistingService(projectId, config.region, serviceId); + if (!svc) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); + } + existing.push(svc); } - if (clearBaseImage && !mainContainer(existing.template)?.baseImageUri) { + if (clearBaseImage && existing.every((s) => !mainContainer(s.template)?.baseImageUri)) { logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); return; } From f0b1a7b14ce946b7fbc1db20ca43aa151089cba5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 10:22:32 -0700 Subject: [PATCH 042/103] 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 043/103] 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 044/103] 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 045/103] 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 046/103] 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 047/103] 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 d3d1f3a5bbeac4ece4f2b818aaede2bafa623192 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 12:54:11 -0700 Subject: [PATCH 048/103] Fix Policy stub type in prepare.spec.ts --- src/deploy/run/prepare.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 18381a6dd5f..2478fa8eaa1 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -146,7 +146,7 @@ describe("run prepare", () => { hasRolesStub = sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); addRolesStub = sinon .stub(resourceManager, "addServiceAccountToRoles") - .resolves({ bindings: [] }); + .resolves({ bindings: [], etag: "", version: 3 }); }); it("reuses the service's current appId and resolves build and runtime config", async () => { From a4430ad03c5b3b5a893a28b995f96e0b389ed678 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 13:04:29 -0700 Subject: [PATCH 049/103] 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 050/103] 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 051/103] 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 052/103] 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 1bf57bd68d71f2d91b2c7d9966bf06c0b053ca22 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 18:09:26 -0700 Subject: [PATCH 053/103] Make local build scratch dir cleanup best-effort Route both scratch dir deletions through removeTempPath so a cleanup failure can't fail a successful deploy or replace the real deploy/build error. --- src/deploy/run/deploy.spec.ts | 10 ++++++++++ src/deploy/run/deploy.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index c3b6018a2b2..38bdd314bac 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -285,6 +285,16 @@ describe("run deploy", () => { expect(fs.existsSync(mkdtemp.firstCall.returnValue as string)).to.be.false; expect(updateServiceStub).not.to.have.been.called; }); + + it("reports the local build error, not the cleanup error, when both fail", async () => { + localBuildStub.rejects(new Error("boom")); + rmSyncStub.restore(); + sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); + + await expect(deployOne(localService())).to.be.rejectedWith("boom"); + + fs.rmdirSync(mkdtemp.firstCall.returnValue as string); + }); }); it("switches a service from a local build back to a source build", async () => { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 71338cc85ac..ab6eccd6d8b 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -37,7 +37,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa svc.deployed = await deployService(context, options, svc); } finally { if (svc.localBuild) { - fs.rmSync(svc.localBuild.scratchDir, { recursive: true, force: true }); + removeTempPath(svc.localBuild.scratchDir); } } } @@ -89,7 +89,7 @@ async function buildLocally( .map((e) => ({ name: e.variable, value: e.value! })); return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; } catch (err: unknown) { - fs.rmSync(scratchDir, { recursive: true, force: true }); + removeTempPath(scratchDir); throw err; } } From d122b9e2bfcc29859e47d60733ee3b9611d9354f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 2 Oct 2026 18:29:38 -0700 Subject: [PATCH 054/103] Fail clearly when SDK autoinit can't be set up, and simplify it - A Firebase Web App that can't be looked up now fails the deploy whether it's newly or already linked, instead of deploying without autoinit. The error includes the cause and how to switch or unlink the app. - Split resolveAutoInitEnv into flat, single-purpose helpers and use getError for the original error. - run:services:update --app no longer rebuilds when the service already uses that app. - Suggest asking an admin when granting the Admin SDK role fails. - Document that FIREBASE_CONFIG is the Admin SDK's runtime config and why prepare resolves it. --- src/deploy/run/args.ts | 7 ++- src/deploy/run/deploy.ts | 2 + src/deploy/run/prepare.spec.ts | 16 +++--- src/deploy/run/prepare.ts | 95 ++++++++++++++++++---------------- src/deploy/run/update.spec.ts | 14 +++++ src/deploy/run/update.ts | 4 ++ 6 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index ec17c72bc27..a6e51614e0d 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -6,8 +6,13 @@ export interface ServiceDeploy { config: RunSingle; existing?: runv2.Service; baseImage?: string; + /** The linked Firebase Web App. Deploy saves it on the service so later deploys reuse it. */ appId?: string; - /** Runtime FIREBASE_CONFIG JSON for the linked Firebase Web App. */ + /** + * Value for the container's FIREBASE_CONFIG env var, which the Admin SDK reads to + * auto-initialize. Resolved in prepare so deploy doesn't look up the app again. The client SDK's + * FIREBASE_WEBAPP_CONFIG is only needed at build time, so it's only in buildEnv. + */ firebaseConfig?: string; /** Build-time environment. Prepare only allows secrets in it for local builds. */ buildEnv?: BuildEnv; diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 2afcb475913..acedc22b866 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -141,6 +141,8 @@ async function deployService( } else { delete container.baseImageUri; } + // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's + // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. if (svc.firebaseConfig) { const env = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); container.env = [...env, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 2478fa8eaa1..0432dfbc910 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -6,6 +6,7 @@ import * as runv2 from "../../gcp/runv2"; import * as getProjectNumber from "../../getProjectNumber"; import * as managementApps from "../../management/apps"; import { Options } from "../../options"; +import * as utils from "../../utils"; import { Context, Payload } from "./args"; import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; @@ -195,10 +196,12 @@ describe("run prepare", () => { }); it("warns and continues if granting roles/firebase.sdkAdminServiceAgent fails with 403", async () => { + const warnStub = sinon.stub(utils, "logLabeledWarning"); hasRolesStub.resolves(false); addRolesStub.rejects({ status: 403 }); const svc = await prepareOne({}, { appId: "1:1:web:a" }); expect(svc.appId).to.equal("1:1:web:a"); + expect(warnStub).to.have.been.calledWithMatch("run", /or ask an admin to grant this role/); }); it("lets the context set or clear the appId", async () => { @@ -263,23 +266,22 @@ describe("run prepare", () => { expect(svc.buildEnv).to.deep.equal({ FIREBASE_WEBAPP_CONFIG: webappConfig }); }); - it("warns and continues without autoinit if getAppConfig fails for an existing annotation", async () => { + it("fails if the linked Firebase Web App can't be looked up", async () => { getServiceStub.resolves({ ...existing, annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, }); getAppConfigStub.rejects(new Error("boom")); - const svc = await prepareOne(); - expect(svc.appId).to.equal("1:1:web:a"); - expect(svc.firebaseConfig).to.be.undefined; - expect(svc.buildEnv).to.be.undefined; + await expect(prepareOne()).to.be.rejectedWith( + "Unable to look up Firebase Web App 1:1:web:a for service s: boom", + ); expect(hasRolesStub).not.to.have.been.called; }); - it("throws if getAppConfig fails when explicitly setting a new appId", async () => { + it("fails if a newly linked Firebase Web App can't be looked up", async () => { getAppConfigStub.rejects(new Error("boom")); await expect(prepareOne({}, { appId: "bad-app" })).to.be.rejectedWith( - "Unable to lookup details for Firebase Web App bad-app on service s.", + "Unable to look up Firebase Web App bad-app for service s: boom", ); expect(hasRolesStub).not.to.have.been.called; }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 01caabf0017..4af0b4ff69d 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,5 +1,5 @@ import { getAutoinitEnvVars } from "../../apphosting/utils"; -import { FirebaseError, getErrStatus } from "../../error"; +import { FirebaseError, getErrMsg, getError, getErrStatus } from "../../error"; import { WebConfig } from "../../fetchWebSetup"; import { RunSingle } from "../../firebaseConfig"; import { getDefaultServiceAccount } from "../../gcp/computeEngine"; @@ -53,13 +53,9 @@ async function prepareService(context: Context, config: RunSingle): Promise = {}; + if (appId) { + autoInitEnv = await resolveAutoInitEnv(serviceId, appId, existing); await ensureAutoInitIam(context, existing); } const userBuildEnv = getBuildEnv(existing); @@ -76,7 +72,7 @@ async function prepareService(context: Context, config: RunSingle): Promise 0 ? { buildEnv } : {}), }; if (!config.localBuild) { @@ -104,48 +100,59 @@ async function prepareService(context: Context, config: RunSingle): Promise | undefined> { - if (!appId) { - return undefined; +): Promise> { + const autoInitEnv = getAutoinitEnvVars(await getWebAppConfig(serviceId, appId)); + // Env vars set on the container take precedence, unless the service is being linked to a + // different app: then they were meant for the old one. + if (appId === existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { + applyContainerOverrides(autoInitEnv, existing.template.containers?.[0]?.env); } + return autoInitEnv; +} + +/** + * Fetches the Firebase Web App's config. Fails instead of deploying a service that can't + * auto-initialize Firebase SDKs. + */ +async function getWebAppConfig(serviceId: string, appId: string): Promise { try { - const webappConfig = (await managementApps.getAppConfig( - appId, - managementApps.AppPlatform.WEB, - )) as WebConfig; - const autoinitVars = getAutoinitEnvVars(webappConfig); - if (appId === existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { - for (const env of existing?.template.containers?.[0]?.env || []) { - if (Object.prototype.hasOwnProperty.call(autoinitVars, env.name)) { - if ("value" in env && env.value !== undefined) { - autoinitVars[env.name] = env.value; - } else { - delete autoinitVars[env.name]; - } - } - } - } - return Object.keys(autoinitVars).length ? autoinitVars : undefined; + return (await managementApps.getAppConfig(appId, managementApps.AppPlatform.WEB)) as WebConfig; } catch (err: unknown) { - if (requireValidApp) { - throw new FirebaseError( - `Unable to lookup details for Firebase Web App ${appId} on service ${serviceId}.`, - { original: err instanceof Error ? err : undefined }, - ); - } - logLabeledWarning( - "run", - `Unable to lookup details for Firebase Web App ${appId} on service ${serviceId}. Firebase SDK autoinit will not be available.`, + throw new FirebaseError( + `Unable to look up Firebase Web App ${appId} for service ${serviceId}: ${getErrMsg(err)}\n` + + `To use a different app, run "firebase run:services:update ${serviceId} --app ". ` + + `To deploy without Firebase SDK auto-initialization, run ` + + `"firebase run:services:update ${serviceId} --clear-app".`, + { original: getError(err) }, ); - return undefined; + } +} + +/** + * Lets env vars set on the container override the matching auto-init vars. A secret-backed var + * can't be read here, so it's dropped and the container keeps its secret. + */ +function applyContainerOverrides( + autoInitEnv: Record, + containerEnv: runv2.Container["env"] = [], +): void { + for (const env of containerEnv) { + if (!Object.prototype.hasOwnProperty.call(autoInitEnv, env.name)) { + continue; + } + if ("value" in env) { + autoInitEnv[env.name] = env.value; + } else { + delete autoInitEnv[env.name]; + } } } @@ -185,7 +192,7 @@ async function ensureAutoInitIam( if (getErrStatus(err) === 403) { logLabeledWarning( "run", - `Failed to grant ${ADMIN_SDK_ROLE} to ${serviceAccount}. Make sure you have the resourcemanager.projects.setIamPolicy permission.`, + `Failed to grant ${ADMIN_SDK_ROLE} to ${serviceAccount}. Make sure you have the resourcemanager.projects.setIamPolicy permission, or ask an admin to grant this role.`, ); } else { throw err; diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index f127dba3143..583d159fdee 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -107,4 +107,18 @@ describe("updateService", () => { baseImage: "nodejs20", }); }); + + it("does nothing when linking the app that's already linked", async () => { + getServiceStub.resolves({ + ...service, + annotations: { "firebase.google.com/app-id": "1:1:web:a" }, + }); + await updateService("s", options({ app: "1:1:web:a" })); + expect(deployStub).not.to.have.been.called; + + await updateService("s", options({ app: "1:1:web:a", baseImage: "nodejs20" })); + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: "nodejs20", + }); + }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 4e6049c19c0..2f65e9fdcac 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -65,6 +65,10 @@ export async function updateService(serviceId: string, options: Options): Promis logBullet(`Service ${clc.bold(serviceId)} does not have a linked Firebase Web App.`); updateApp = false; } + if (newAppId && existing.every((s) => s.annotations?.[FIREBASE_APP_ANNOTATION] === newAppId)) { + logBullet(`Service ${clc.bold(serviceId)} is already linked to Firebase Web App ${newAppId}.`); + updateApp = false; + } if (!updateBaseImage && !updateApp) { return; } From 512f9f27df2d5cc0cc73478837a3e39d503c20da Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Sun, 4 Oct 2026 13:21:19 -0700 Subject: [PATCH 055/103] Always refresh FIREBASE_CONFIG from the linked app Deploy writes FIREBASE_CONFIG onto the container, and the next deploy treated any value on the container as a user override. So the CLI's own first value won forever: linking an app and later enabling Storage never gave the service a storageBucket. While an app is linked, deploy now always writes the app's current config, replacing whatever is on the container, including secret-backed values. Build-time overrides still go through the build env annotation. --- src/deploy/run/deploy.ts | 6 +++-- src/deploy/run/prepare.spec.ts | 41 ++++++++++++---------------------- src/deploy/run/prepare.ts | 41 +--------------------------------- 3 files changed, 19 insertions(+), 69 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index acedc22b866..6c59c44e5b0 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -141,8 +141,10 @@ async function deployService( } else { delete container.baseImageUri; } - // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's - // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. + // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. While an app is linked, it's + // always rewritten from the app's current config, so changes like enabling Storage reach the + // service. The client SDK's FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set + // on the container. if (svc.firebaseConfig) { const env = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); container.env = [...env, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 0432dfbc910..3c17458d4ad 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -216,54 +216,41 @@ describe("run prepare", () => { expect(cleared.buildEnv).to.be.undefined; }); - it("lets user buildEnv and container env override autoinit vars", async () => { + it("always uses the linked app's current config, not the one on the container", async () => { getServiceStub.resolves({ ...existing, - annotations: { - [FIREBASE_APP_ANNOTATION]: "1:1:web:a", - [BUILD_ENV_ANNOTATION]: JSON.stringify({ FIREBASE_WEBAPP_CONFIG: "custom-webapp" }), - }, + annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, template: { containers: [ { name: "s", image: "i", baseImageUri: nodejs22, - env: [{ name: "FIREBASE_CONFIG", value: "custom-runtime" }], + env: [{ name: "FIREBASE_CONFIG", value: '{"projectId":"p"}' }], }, ], }, }); - const svc = await prepareOne({}, { appId: "1:1:web:a" }); - expect(svc.firebaseConfig).to.equal("custom-runtime"); + const svc = await prepareOne(); + expect(svc.firebaseConfig).to.equal(firebaseConfig); expect(svc.buildEnv).to.deep.equal({ - FIREBASE_WEBAPP_CONFIG: "custom-webapp", - FIREBASE_CONFIG: "custom-runtime", + FIREBASE_WEBAPP_CONFIG: webappConfig, + FIREBASE_CONFIG: firebaseConfig, }); }); - it("preserves secret-backed FIREBASE_CONFIG on the container without overwriting it", async () => { + it("lets the build env annotation override autoinit vars at build time only", async () => { + const userBuildEnv = { FIREBASE_WEBAPP_CONFIG: "custom-webapp", FIREBASE_CONFIG: "custom" }; getServiceStub.resolves({ ...existing, - annotations: { [FIREBASE_APP_ANNOTATION]: "1:1:web:a" }, - template: { - containers: [ - { - name: "s", - image: "i", - env: [ - { - name: "FIREBASE_CONFIG", - valueSource: { secretKeyRef: { secret: "sec", version: "1" } }, - }, - ], - }, - ], + annotations: { + [FIREBASE_APP_ANNOTATION]: "1:1:web:a", + [BUILD_ENV_ANNOTATION]: JSON.stringify(userBuildEnv), }, }); const svc = await prepareOne(); - expect(svc.firebaseConfig).to.be.undefined; - expect(svc.buildEnv).to.deep.equal({ FIREBASE_WEBAPP_CONFIG: webappConfig }); + expect(svc.buildEnv).to.deep.equal(userBuildEnv); + expect(svc.firebaseConfig).to.equal(firebaseConfig); }); it("fails if the linked Firebase Web App can't be looked up", async () => { diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 4af0b4ff69d..3ed81dbcf3f 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -55,7 +55,7 @@ async function prepareService(context: Context, config: RunSingle): Promise = {}; if (appId) { - autoInitEnv = await resolveAutoInitEnv(serviceId, appId, existing); + autoInitEnv = getAutoinitEnvVars(await getWebAppConfig(serviceId, appId)); await ensureAutoInitIam(context, existing); } const userBuildEnv = getBuildEnv(existing); @@ -99,25 +99,6 @@ async function prepareService(context: Context, config: RunSingle): Promise> { - const autoInitEnv = getAutoinitEnvVars(await getWebAppConfig(serviceId, appId)); - // Env vars set on the container take precedence, unless the service is being linked to a - // different app: then they were meant for the old one. - if (appId === existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { - applyContainerOverrides(autoInitEnv, existing.template.containers?.[0]?.env); - } - return autoInitEnv; -} - /** * Fetches the Firebase Web App's config. Fails instead of deploying a service that can't * auto-initialize Firebase SDKs. @@ -136,26 +117,6 @@ async function getWebAppConfig(serviceId: string, appId: string): Promise, - containerEnv: runv2.Container["env"] = [], -): void { - for (const env of containerEnv) { - if (!Object.prototype.hasOwnProperty.call(autoInitEnv, env.name)) { - continue; - } - if ("value" in env) { - autoInitEnv[env.name] = env.value; - } else { - delete autoInitEnv[env.name]; - } - } -} - /** * Ensures the service's runtime service account has the IAM role needed for Firebase Admin SDK * auto-initialization. From f5ba7ec2a4938a9ad5c6db2822e680557a0d5be4 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Sun, 4 Oct 2026 13:56:20 -0700 Subject: [PATCH 056/103] Always rebuild in run:services:update, even if nothing changes Clearing a base image that isn't set no longer skips the deploy. run:services:update always rebuilds and deploys, the same as firebase deploy, instead of trying to detect no-op updates. --- src/deploy/run/update.spec.ts | 6 ++++-- src/deploy/run/update.ts | 15 +++------------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 27529b10f85..6e1f912f147 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -67,9 +67,11 @@ describe("updateService", () => { }); }); - it("does nothing when clearing a base image that isn't set", async () => { + it("still rebuilds and deploys when clearing a base image that isn't set", async () => { getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); await updateService("s", options({ clearBaseImage: true })); - expect(deployStub).not.to.have.been.called; + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + baseImage: null, + }); }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index c50c013e677..aa6d4f2ff7b 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,14 +1,12 @@ -import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; -import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { logBullet } from "../../utils"; import { getExistingService, getServiceConfigs, missingServiceMessage } from "./util"; /** - * Updates a service's settings, then builds and deploys it like `firebase deploy` does. + * Updates a service's settings, then builds and deploys it like `firebase deploy` does. It always + * rebuilds and deploys, even if the service already has those settings. * Changing the base image always needs a rebuild: images built for a base image leave out the * OS and runtime, and images built without one bring their own, which override the base image. */ @@ -27,22 +25,15 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; const configs = getServiceConfigs({ ...options, only }); - const existing: runv2.Service[] = []; for (const config of configs) { if (!config.region) { throw new FirebaseError( `Cloud Run service ${serviceId} is missing a region in firebase.json.`, ); } - const svc = await getExistingService(projectId, config.region, serviceId); - if (!svc) { + if (!(await getExistingService(projectId, config.region, serviceId))) { throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); } - existing.push(svc); - } - if (clearBaseImage && existing.every((s) => !s.template.containers?.[0]?.baseImageUri)) { - logBullet(`Service ${clc.bold(serviceId)} does not have a base image.`); - return; } await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); From f0427c91be57c2f512d84431bbde37d31168e377 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Sun, 4 Oct 2026 13:59:43 -0700 Subject: [PATCH 057/103] Always rebuild for --app and --clear-app too run:services:update no longer skips the deploy when the app is already linked or there's no app to clear, so every setting behaves the same. Clearing the app now only removes FIREBASE_CONFIG from a service that was linked, since the CLI set it there; on a service that was never linked, it's the user's own value. --- src/deploy/run/deploy.spec.ts | 12 ++++++++++++ src/deploy/run/deploy.ts | 4 +++- src/deploy/run/update.spec.ts | 20 ++++++++++---------- src/deploy/run/update.ts | 32 +++++--------------------------- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 4a2abfa69df..7c6adb10645 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -231,6 +231,18 @@ describe("run deploy", () => { expect(updateServiceStub.secondCall.args[0].template.containers[0]).not.to.have.property("env"); }); + it("keeps a FIREBASE_CONFIG the user set when clearing the app of a service that wasn't linked", async () => { + const env = [{ name: "FIREBASE_CONFIG", value: '{"projectId":"mine"}' }]; + const existing = { + name: "projects/p/locations/us-central1/services/s", + template: { containers: [{ name: "s", image: "old", env }] }, + } as unknown as runv2.Service; + await deploy({ projectId: "p", appId: null }, options, { + run: { services: [service({ existing })] }, + }); + expect(updateServiceStub.firstCall.args[0].template.containers[0].env).to.deep.equal(env); + }); + describe("local builds", () => { const existing = { name: "projects/p/locations/us-central1/services/s", diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 6c59c44e5b0..43896362843 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -148,7 +148,9 @@ async function deployService( if (svc.firebaseConfig) { const env = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); container.env = [...env, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; - } else if (context.appId === null && container.env) { + } else if (svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] && container.env) { + // The service is being unlinked. Remove the FIREBASE_CONFIG that was set for its app, but + // leave one alone on a service that was never linked: the user set that one. container.env = container.env.filter((e) => e.name !== "FIREBASE_CONFIG"); if (!container.env.length) { delete container.env; diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 631e05b2fd9..2d8b460404d 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -98,29 +98,29 @@ describe("updateService", () => { }); }); - it("does nothing when clearing an app that isn't linked", async () => { + it("still rebuilds and deploys when clearing an app that isn't linked", async () => { await updateService("s", options({ clearApp: true })); - expect(deployStub).not.to.have.been.called; - }); - - it("still updates baseImage when combined with a no-op --clear-app", async () => { - await updateService("s", options({ baseImage: "nodejs20", clearApp: true })); expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { - baseImage: "nodejs20", + appId: null, }); }); - it("does nothing when linking the app that's already linked", async () => { + it("still rebuilds and deploys when linking the app that's already linked", async () => { getServiceStub.resolves({ ...service, annotations: { "firebase.google.com/app-id": "1:1:web:a" }, }); await updateService("s", options({ app: "1:1:web:a" })); - expect(deployStub).not.to.have.been.called; + expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { + appId: "1:1:web:a", + }); + }); - await updateService("s", options({ app: "1:1:web:a", baseImage: "nodejs20" })); + it("updates the base image and the app together", async () => { + await updateService("s", options({ baseImage: "nodejs20", clearApp: true })); expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { baseImage: "nodejs20", + appId: null, }); }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 2b22e40aaa8..e5ccfd5fa0e 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,16 +1,8 @@ -import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; -import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { logBullet } from "../../utils"; -import { - FIREBASE_APP_ANNOTATION, - getExistingService, - getServiceConfigs, - missingServiceMessage, -} from "./util"; +import { getExistingService, getServiceConfigs, missingServiceMessage } from "./util"; /** * Updates a service's settings, then builds and deploys it like `firebase deploy` does. It always @@ -29,7 +21,9 @@ export async function updateService(serviceId: string, options: Options): Promis if (newAppId && clearApp) { throw new FirebaseError("Use either --app or --clear-app, not both."); } - if (!newBaseImage && !clearBaseImage && !newAppId && !clearApp) { + const updateBaseImage = Boolean(newBaseImage || clearBaseImage); + const updateApp = Boolean(newAppId || clearApp); + if (!updateBaseImage && !updateApp) { throw new FirebaseError( "Specify a setting to update: --base-image , --clear-base-image, --app , or --clear-app.", ); @@ -38,7 +32,6 @@ export async function updateService(serviceId: string, options: Options): Promis const projectId = needProjectId(options); const only = `run:${serviceId}`; const configs = getServiceConfigs({ ...options, only }); - const existing: runv2.Service[] = []; for (const config of configs) { if (!config.region) { throw new FirebaseError( @@ -50,24 +43,9 @@ export async function updateService(serviceId: string, options: Options): Promis `Cannot clear the base image of ${serviceId}: local builds need one.`, ); } - const svc = await getExistingService(projectId, config.region, serviceId); - if (!svc) { + if (!(await getExistingService(projectId, config.region, serviceId))) { throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); } - existing.push(svc); - } - const updateBaseImage = Boolean(newBaseImage || clearBaseImage); - let updateApp = Boolean(newAppId || clearApp); - if (clearApp && existing.every((s) => !s.annotations?.[FIREBASE_APP_ANNOTATION])) { - logBullet(`Service ${clc.bold(serviceId)} does not have a linked Firebase Web App.`); - updateApp = false; - } - if (newAppId && existing.every((s) => s.annotations?.[FIREBASE_APP_ANNOTATION] === newAppId)) { - logBullet(`Service ${clc.bold(serviceId)} is already linked to Firebase Web App ${newAppId}.`); - updateApp = false; - } - if (!updateBaseImage && !updateApp) { - return; } await deploy( From c3427e8e13f9fc69cd0c1f293ced7679b83fd3bf Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 7 Oct 2026 10:52:09 -0700 Subject: [PATCH 058/103] 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 059/103] 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 060/103] 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 061/103] 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 062/103] 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 063/103] 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 064/103] 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 065/103] 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 066/103] 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 067/103] 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 068/103] 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 5ee1358d4c5aee80a8450c8121913d457cec0b93 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 09:10:12 -0700 Subject: [PATCH 069/103] Default init run's build location to the saved localBuild setting Pressing Enter no longer switches a service that builds locally back to remote builds. --- src/init/features/run.spec.ts | 24 ++++++++++++++++++++++++ src/init/features/run.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 3eda890dff4..d2dd62ad92c 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -156,6 +156,7 @@ describe("init run", () => { await askQuestions(s, config, options); + expect(selectStub.thirdCall.args[0].default).to.be.false; const baseImagePrompt = inputStub.secondCall.args[0]; expect(baseImagePrompt.default).to.be.undefined; expect(baseImagePrompt.validate("nodejs22")).to.be.true; @@ -168,6 +169,29 @@ describe("init run", () => { localBuild: true, }); }); + + it("defaults to building locally if the saved entry for the service does", async () => { + const existing = { + name: "projects/p/locations/europe-west1/services/web", + template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, + }; + sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); + selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); + selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); + inputStub.callsFake((o) => Promise.resolve(o.default)); + config.set("run", { + serviceId: "web", + region: "europe-west1", + rootDir: "/", + localBuild: true, + }); + const s = setup(); + + await askQuestions(s, config, options); + + expect(selectStub.thirdCall.args[0].default).to.be.true; + expect(s.featureInfo?.run?.localBuild).to.be.true; + }); }); describe("actuate", () => { diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 24187c4573a..7ebd8238087 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -71,7 +71,7 @@ export async function askQuestions(setup: Setup, config: Config, options: Option { name: "Build remotely on Cloud Build", value: false }, { name: "Build locally", value: true }, ], - default: false, + default: savedEntry?.localBuild === true, }); // A base image turns on automatic base image updates, which are off by default. So there's no From 136598a8e02992bf8965a11d058b4cbafd9928c2 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 09:41:22 -0700 Subject: [PATCH 070/103] 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 071/103] 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 072/103] 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 073/103] 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 4037b81aaea08f2fbc159b4073de35171d76e9bc Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 13:10:57 -0700 Subject: [PATCH 074/103] Make run:services:update change exactly one service firebase run:services:update now also accepts :. - If firebase.json lists the service ID once, the region is optional. - If it lists the ID in more than one region, the command fails and lists the : names to choose from. - It deploys only run::, so it never redeploys the same ID in another region. The "doesn't exist yet" message also suggests the command that deploys just that service and region. --- src/commands/run-services-update.ts | 7 ++- src/deploy/run/update.spec.ts | 69 ++++++++++++++++++++++++----- src/deploy/run/update.ts | 60 +++++++++++++++++++------ src/deploy/run/util.ts | 2 +- 4 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts index 119a730287e..0d6d8dde7db 100644 --- a/src/commands/run-services-update.ts +++ b/src/commands/run-services-update.ts @@ -6,7 +6,10 @@ import { requireConfig } from "../requireConfig"; import { requirePermissions } from "../requirePermissions"; export const command = new Command("run:services:update ") - .description("update the settings of a Cloud Run service, then build and deploy it") + .description( + "update the settings of a Cloud Run service, then build and deploy it. " + + "If firebase.json lists the service ID in more than one region, use :", + ) .option( "--base-image ", "set the base image (e.g. nodejs22) and enable automatic base image updates", @@ -14,4 +17,4 @@ export const command = new Command("run:services:update ") .option("--clear-base-image", "clear the base image and disable automatic base image updates") .before(requireConfig) .before(requirePermissions, TARGET_PERMISSIONS.run) - .action((serviceId: string, options: Options) => updateService(serviceId, options)); + .action((name: string, options: Options) => updateService(name, options)); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 6e1f912f147..52d9f66ac41 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -38,8 +38,11 @@ describe("updateService", () => { it("requires the service to be in firebase.json with a region", async () => { await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( - "Cloud Run service IDs other not detected in firebase.json", + "Cloud Run service other not detected in firebase.json.", ); + await expect( + updateService("s:us-east1", options({ baseImage: "nodejs20" })), + ).to.be.rejectedWith("Cloud Run service s:us-east1 not detected in firebase.json."); await expect( updateService("s", options({ baseImage: "nodejs20" }, { region: "" })), ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); @@ -48,30 +51,76 @@ describe("updateService", () => { it("requires the service to exist", async () => { getServiceStub.rejects({ status: 404 }); await expect(updateService("s", options({ baseImage: "nodejs22" }))).to.be.rejectedWith( - /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s/, + /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s:us-central1/, ); expect(deployStub).not.to.have.been.called; }); it("builds and deploys the service with the new base image", async () => { await updateService("s", options({ baseImage: "nodejs20" })); - expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { - baseImage: "nodejs20", - }); + expect(deployStub).to.have.been.calledOnceWith( + ["run"], + sinon.match({ only: "run:s:us-central1" }), + { baseImage: "nodejs20" }, + ); }); it("builds and deploys the service without a base image when clearing it", async () => { await updateService("s", options({ clearBaseImage: true })); - expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { - baseImage: null, - }); + expect(deployStub).to.have.been.calledOnceWith( + ["run"], + sinon.match({ only: "run:s:us-central1" }), + { baseImage: null }, + ); }); it("still rebuilds and deploys when clearing a base image that isn't set", async () => { getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); await updateService("s", options({ clearBaseImage: true })); - expect(deployStub).to.have.been.calledOnceWith(["run"], sinon.match({ only: "run:s" }), { - baseImage: null, + expect(deployStub).to.have.been.calledOnceWith( + ["run"], + sinon.match({ only: "run:s:us-central1" }), + { baseImage: null }, + ); + }); + + describe("when firebase.json lists the service ID more than once", () => { + const twoRegions = { + src: { + run: [ + { serviceId: "s", region: "us-central1" }, + { serviceId: "s", region: "europe-west1" }, + ], + }, + }; + + it("asks for the region", async () => { + await expect( + updateService("s", options({ baseImage: "nodejs20", config: twoRegions })), + ).to.be.rejectedWith( + "s matches 2 services in firebase.json: s:us-central1, s:europe-west1. " + + "Run the command again with one of them, e.g. firebase run:services:update s:us-central1", + ); + expect(deployStub).not.to.have.been.called; + }); + + it("updates only the region it's given", async () => { + await updateService("s:europe-west1", options({ baseImage: "nodejs20", config: twoRegions })); + expect(getServiceStub).to.have.been.calledOnceWith("p", "europe-west1", "s"); + expect(deployStub).to.have.been.calledOnceWith( + ["run"], + sinon.match({ only: "run:s:europe-west1" }), + { baseImage: "nodejs20" }, + ); + }); + + it("reports a missing region instead of listing the matches", async () => { + const noRegion = { + src: { run: [{ serviceId: "s" }, { serviceId: "s", region: "us-central1" }] }, + }; + await expect( + updateService("s", options({ baseImage: "nodejs20", config: noRegion })), + ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); }); }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index aa6d4f2ff7b..4631b8a8c79 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,16 +1,25 @@ import { deploy } from ".."; import { FirebaseError } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { getExistingService, getServiceConfigs, missingServiceMessage } from "./util"; +import { + findServices, + fullServiceName, + getAllServiceConfigs, + getExistingService, + missingServiceMessage, +} from "./util"; /** * Updates a service's settings, then builds and deploys it like `firebase deploy` does. It always * rebuilds and deploys, even if the service already has those settings. * Changing the base image always needs a rebuild: images built for a base image leave out the * OS and runtime, and images built without one bring their own, which override the base image. + * `name` is a service ID, or : to pick one region of a service ID that + * firebase.json lists in more than one. */ -export async function updateService(serviceId: string, options: Options): Promise { +export async function updateService(name: string, options: Options): Promise { const newBaseImage = options.baseImage as string | undefined; const clearBaseImage = !!options.clearBaseImage; if (newBaseImage && clearBaseImage) { @@ -23,18 +32,41 @@ export async function updateService(serviceId: string, options: Options): Promis } const projectId = needProjectId(options); - const only = `run:${serviceId}`; - const configs = getServiceConfigs({ ...options, only }); - for (const config of configs) { - if (!config.region) { - throw new FirebaseError( - `Cloud Run service ${serviceId} is missing a region in firebase.json.`, - ); - } - if (!(await getExistingService(projectId, config.region, serviceId))) { - throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); - } + const config = findServiceToUpdate(name, options); + if (!(await getExistingService(projectId, config.region, config.serviceId))) { + throw new FirebaseError(`${missingServiceMessage(config)} Then you can update it.`); } - await deploy(["run"], { ...options, only }, { baseImage: newBaseImage ?? null }); + await deploy( + ["run"], + { ...options, only: `run:${fullServiceName(config)}` }, + { baseImage: newBaseImage ?? null }, + ); +} + +/** + * Returns the service in firebase.json that `name` refers to. Updates change one service at a + * time, so if firebase.json lists the service ID in more than one region, `name` has to include + * the region. + */ +function findServiceToUpdate(name: string, options: Options): RunSingle { + const matches = findServices(getAllServiceConfigs(options), name); + if (!matches.length) { + throw new FirebaseError(`Cloud Run service ${name} not detected in firebase.json.`); + } + // Check this first: the error below lists each match's region. + const noRegion = matches.find((c) => !c.region); + if (noRegion) { + throw new FirebaseError( + `Cloud Run service ${noRegion.serviceId} is missing a region in firebase.json.`, + ); + } + if (matches.length > 1) { + const names = matches.map(fullServiceName); + throw new FirebaseError( + `${name} matches ${matches.length} services in firebase.json: ${names.join(", ")}. ` + + `Run the command again with one of them, e.g. firebase run:services:update ${names[0]}`, + ); + } + return matches[0]; } diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 0830bbf102a..821121cd48b 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -128,7 +128,7 @@ export async function getExistingService( * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. */ export function missingServiceMessage(config: RunSingle): string { - const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${config.serviceId}`)}.`; + const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${fullServiceName(config)}`)}.`; return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; } From 83e319f09dde7f4e57b9b5d310db444fa15abbfb Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 11:15:12 -0700 Subject: [PATCH 075/103] 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 84494af9eee8b737101c995dcd67f4b75f1f4907 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:29:39 -0700 Subject: [PATCH 076/103] Make run:services:update easier to read - Read the flags into a settings object and pass it straight to deploy. - Move the missing-service message into update.ts, its only caller. - Tests: realistic names, an update() helper that reads like the CLI call, one expectDeployed() helper, and one test for the always-rebuild decision. --- src/deploy/run/update.spec.ts | 124 ++++++++++++++-------------------- src/deploy/run/update.ts | 53 ++++++++------- src/deploy/run/util.ts | 9 --- 3 files changed, 78 insertions(+), 108 deletions(-) diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 2c075deb3b5..86df8984c09 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -6,112 +6,88 @@ import { Options } from "../../options"; import { updateService } from "./update"; describe("updateService", () => { - const service = { - name: "projects/p/locations/us-central1/services/s", - template: { containers: [{ name: "s", image: "i", baseImageUri: "nodejs22" }] }, - } as unknown as runv2.Service; + const web = { serviceId: "web", region: "us-central1" }; + const webEurope = { serviceId: "web", region: "europe-west1" }; let getServiceStub: sinon.SinonStub; let deployStub: sinon.SinonStub; - function options(opts: Record = {}, run: Record = {}): Options { - return { - project: "p", - config: { src: { run: { serviceId: "s", region: "us-central1", ...run } } }, - ...opts, - } as unknown as Options; - } - beforeEach(() => { - getServiceStub = sinon.stub(runv2, "getService").resolves(service); + getServiceStub = sinon.stub(runv2, "getService").resolves({ + name: "projects/my-project/locations/us-central1/services/web", + } as runv2.Service); deployStub = sinon.stub(deployIndex, "deploy").resolves(); }); afterEach(() => sinon.restore()); - it("requires exactly one setting", async () => { - await expect(updateService("s", options())).to.be.rejectedWith( + /** Runs `firebase run:services:update ` with these flags and firebase.json "run" config. */ + function update(name: string, flags: Record, run: unknown = web): Promise { + const options = { project: "my-project", config: { src: { run } }, ...flags }; + return updateService(name, options as unknown as Options); + } + + /** Asserts that update deployed only `only`, with these settings. */ + function expectDeployed(only: string, settings: Record): void { + expect(deployStub).to.have.been.calledOnceWith( + ["run"], + sinon.match({ only }), + sinon.match(settings), + ); + } + + it("requires --base-image or --clear-base-image, but not both", async () => { + await expect(update("web", {})).to.be.rejectedWith( "--base-image or --clear-base-image", ); - const both = options({ baseImage: "nodejs20", clearBaseImage: true }); - await expect(updateService("s", both)).to.be.rejectedWith("not both"); + await expect(update("web", { baseImage: "nodejs22", clearBaseImage: true })).to.be.rejectedWith( + "Use either --base-image or --clear-base-image, not both.", + ); }); - it("requires the service to be in firebase.json with a region", async () => { - await expect(updateService("other", options({ baseImage: "nodejs20" }))).to.be.rejectedWith( - "Cloud Run service other not detected in firebase.json.", + it("requires the service to be in firebase.json", async () => { + await expect(update("api", { baseImage: "nodejs22" })).to.be.rejectedWith( + "Cloud Run service api not detected in firebase.json.", + ); + await expect(update("web:europe-west1", { baseImage: "nodejs22" })).to.be.rejectedWith( + "Cloud Run service web:europe-west1 not detected in firebase.json.", ); - await expect( - updateService("s:us-east1", options({ baseImage: "nodejs20" })), - ).to.be.rejectedWith("Cloud Run service s:us-east1 not detected in firebase.json."); - await expect( - updateService("s", options({ baseImage: "nodejs20" }, { region: "" })), - ).to.be.rejectedWith("Cloud Run service s is missing a region in firebase.json."); }); it("requires the service to exist", async () => { getServiceStub.rejects({ status: 404 }); - await expect(updateService("s", options({ baseImage: "nodejs22" }))).to.be.rejectedWith( - /service s doesn't exist in us-central1 yet.*firebase deploy --only run:s:us-central1/, + + await expect(update("web", { baseImage: "nodejs22" })).to.be.rejectedWith( + /web doesn't exist in us-central1 yet.*firebase deploy --only run:web:us-central1/, ); expect(deployStub).not.to.have.been.called; }); - it("builds and deploys the service with the new base image", async () => { - await updateService("s", options({ baseImage: "nodejs20" })); - expect(deployStub).to.have.been.calledOnceWith( - ["run"], - sinon.match({ only: "run:s:us-central1" }), - { baseImage: "nodejs20" }, - ); - }); + it("deploys the service with the new base image", async () => { + await update("web", { baseImage: "nodejs22" }); - it("builds and deploys the service without a base image when clearing it", async () => { - await updateService("s", options({ clearBaseImage: true })); - expect(deployStub).to.have.been.calledOnceWith( - ["run"], - sinon.match({ only: "run:s:us-central1" }), - { baseImage: null }, - ); + expectDeployed("run:web:us-central1", { baseImage: "nodejs22" }); }); - it("still rebuilds and deploys when clearing a base image that isn't set", async () => { - getServiceStub.resolves({ ...service, template: { containers: [{ name: "s", image: "i" }] } }); - await updateService("s", options({ clearBaseImage: true })); - expect(deployStub).to.have.been.calledOnceWith( - ["run"], - sinon.match({ only: "run:s:us-central1" }), - { baseImage: null }, - ); + it("clears the base image, and deploys even if there's none to clear", async () => { + await update("web", { clearBaseImage: true }); + + expectDeployed("run:web:us-central1", { baseImage: null }); }); - describe("when firebase.json lists the service ID more than once", () => { - const twoRegions = { - src: { - run: [ - { serviceId: "s", region: "us-central1" }, - { serviceId: "s", region: "europe-west1" }, - ], - }, - }; - - it("asks for the region", async () => { - await expect( - updateService("s", options({ baseImage: "nodejs20", config: twoRegions })), - ).to.be.rejectedWith( - "s matches 2 services in firebase.json: s:us-central1, s:europe-west1. " + - "Run the command again with one of them, e.g. firebase run:services:update s:us-central1", + describe("when firebase.json lists the service in two regions", () => { + it("asks which one to update", async () => { + await expect(update("web", { baseImage: "nodejs22" }, [web, webEurope])).to.be.rejectedWith( + "web matches 2 services in firebase.json: web:us-central1, web:europe-west1. " + + "Run the command again with one of them, e.g. firebase run:services:update web:us-central1", ); expect(deployStub).not.to.have.been.called; }); it("updates only the region it's given", async () => { - await updateService("s:europe-west1", options({ baseImage: "nodejs20", config: twoRegions })); - expect(getServiceStub).to.have.been.calledOnceWith("p", "europe-west1", "s"); - expect(deployStub).to.have.been.calledOnceWith( - ["run"], - sinon.match({ only: "run:s:europe-west1" }), - { baseImage: "nodejs20" }, - ); + await update("web:europe-west1", { baseImage: "nodejs22" }, [web, webEurope]); + + expect(getServiceStub).to.have.been.calledOnceWith("my-project", "europe-west1", "web"); + expectDeployed("run:web:europe-west1", { baseImage: "nodejs22" }); }); }); }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index a7d2617e268..80a0f4ae65c 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -1,31 +1,24 @@ +import * as clc from "colorette"; import { deploy } from ".."; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { - findServices, - fullServiceName, - getAllServiceConfigs, - getExistingService, - missingServiceMessage, -} from "./util"; +import { findServices, fullServiceName, getAllServiceConfigs, getExistingService } from "./util"; /** - * Updates a service's settings, then builds and deploys it like `firebase deploy` does. It always - * rebuilds and deploys, even if the service already has those settings. - * Changing the base image always needs a rebuild: images built for a base image leave out the - * OS and runtime, and images built without one bring their own, which override the base image. - * `name` is a service ID, or : to pick one region of a service ID that - * firebase.json lists in more than one. + * Changes a service's settings by deploying it with them, like `firebase deploy` does. It always + * rebuilds, even if nothing changed: a new base image needs a new build, because images built for + * a base image leave out the OS and runtime that other images bring. + * `name` is , or : if firebase.json lists the ID in several regions. */ export async function updateService(name: string, options: Options): Promise { - const newBaseImage = options.baseImage as string | undefined; - const clearBaseImage = !!options.clearBaseImage; - if (newBaseImage && clearBaseImage) { - throw new FirebaseError("Use either --base-image or --clear-base-image, not both."); - } - if (!newBaseImage && !clearBaseImage) { + // Deploy reads each setting as an override: a value sets it, null clears it, and undefined keeps + // the service's current value. + const settings = { + baseImage: readFlagPair(options.baseImage, options.clearBaseImage, "base-image"), + }; + if (Object.values(settings).every((value) => value === undefined)) { throw new FirebaseError( "Specify a setting to update: --base-image or --clear-base-image.", ); @@ -34,14 +27,24 @@ export async function updateService(name: string, options: Options): Promise ` / `--clear-` pair as a setting override. */ +function readFlagPair(value: unknown, clear: unknown, flag: string): string | null | undefined { + if (value && clear) { + throw new FirebaseError(`Use either --${flag} or --clear-${flag}, not both.`); + } + return clear ? null : (value as string | undefined); } /** diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index b90d80dc111..43e0e5a4fad 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,4 +1,3 @@ -import * as clc from "colorette"; import { FirebaseError, getErrStatus } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; @@ -101,11 +100,3 @@ export async function getExistingService( throw err; } } - -/** - * Explains how to create a service that is in firebase.json, but not in Cloud Run yet. - */ -export function missingServiceMessage(config: RunSingle): string { - const how = `Create it with ${clc.bold("firebase init run")} or ${clc.bold(`firebase deploy --only run:${fullServiceName(config)}`)}.`; - return `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ${how}`; -} From ac7deb738bd379311e470d6f5bf6354012a3705a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:34:28 -0700 Subject: [PATCH 077/103] Move build env to its own PR Build-time env vars and secrets (the buildConfig.environmentVariablesAndSecrets annotation) move to bapi_build_env, so this PR only adds local builds. --- src/deploy/run/args.ts | 3 -- src/deploy/run/buildEnv.spec.ts | 88 -------------------------------- src/deploy/run/buildEnv.ts | 89 --------------------------------- src/deploy/run/deploy.spec.ts | 18 +------ src/deploy/run/deploy.ts | 17 +------ src/deploy/run/prepare.spec.ts | 34 ------------- src/deploy/run/prepare.ts | 34 ++----------- 7 files changed, 6 insertions(+), 277 deletions(-) delete mode 100644 src/deploy/run/buildEnv.spec.ts delete mode 100644 src/deploy/run/buildEnv.ts diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index c98333634f8..6848b6acc83 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,6 +1,5 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; -import { BuildEnv } from "./buildEnv"; /** A service being deployed. prepare fills in the first fields, and deploy sets the rest. */ export interface ServiceDeploy { @@ -8,8 +7,6 @@ export interface ServiceDeploy { /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ existing?: runv2.Service; baseImage?: string; - /** Build-time environment. Prepare only allows secrets in it for local builds. */ - buildEnv?: BuildEnv; /** The local build's output. */ localBuild?: { scratchDir: string; diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts deleted file mode 100644 index 00c50090b8f..00000000000 --- a/src/deploy/run/buildEnv.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect } from "chai"; -import * as runv2 from "../../gcp/runv2"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; - -describe("run buildEnv", () => { - const withAnnotation = (value: string): runv2.Service => - ({ - name: "projects/p/locations/us-central1/services/s", - annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, - }) as unknown as runv2.Service; - - describe("getBuildEnv", () => { - it("returns {} without a service or annotation", () => { - expect(getBuildEnv(undefined)).to.deep.equal({}); - expect( - getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), - ).to.deep.equal({}); - }); - - it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { - expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); - }); - - it("parses secret references", () => { - const env = { - A: "1", - TOKEN: { secret: "npm-token", version: "3" }, - LATEST: { secret: "s" }, - OTHER: { secret: "projects/q/secrets/t", version: "latest" }, - }; - expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); - }); - - it("rejects invalid JSON and non-objects", () => { - expect(() => getBuildEnv(withAnnotation("{"))).to.throw( - `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, - ); - for (const value of ["[]", "null", '"x"', "1"]) { - expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); - } - }); - - it("rejects invalid values", () => { - const invalid = [ - 1, - null, - ["x"], - {}, - { secret: 1 }, - { secret: "a/b" }, - { secret: "a@1" }, - { secret: "projects/q/secrets/t/versions/1" }, - { secret: "s", version: 1 }, - { secret: "s", version: "one" }, - { secret: "s", extra: "x" }, - ]; - for (const value of invalid) { - expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( - 'A must be a string or {"secret": "", "version": ""}.', - ); - } - }); - }); - - it("lists secret names", () => { - expect( - secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), - ).to.deep.equal(["B", "C"]); - }); - - it("converts to local build env, available only to the build", () => { - expect( - toLocalBuildEnv({ - A: "1", - B: { secret: "s" }, - C: { secret: "t", version: "2" }, - D: { secret: "projects/q/secrets/u" }, - E: { secret: "projects/q/secrets/v", version: "4" }, - }), - ).to.deep.equal({ - A: { value: "1", availability: ["BUILD"] }, - B: { secret: "s@latest", availability: ["BUILD"] }, - C: { secret: "t@2", availability: ["BUILD"] }, - D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, - E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, - }); - }); -}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts deleted file mode 100644 index 5ba9d8cb263..00000000000 --- a/src/deploy/run/buildEnv.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { FirebaseError } from "../../error"; -import * as runv2 from "../../gcp/runv2"; -import { EnvMap } from "../../apphosting/yaml"; - -/** - * Prototype: build-time environment variables and secret references, stored on the Cloud Run - * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): - * the same map parses unchanged, and a value can also be a secret reference in the shape of - * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. - * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} - */ -export const BUILD_ENV_ANNOTATION = - "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; - -/** A Secret Manager secret, as a name in the service's project or a full resource name. */ -export interface SecretRef { - secret: string; - /** A version number or "latest". Defaults to "latest". */ - version?: string; -} - -export type BuildEnv = Record; - -const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; -const SECRET_VERSION = /^(?:latest|\d+)$/; - -/** Reads and validates the service's build environment. Returns {} if it has none. */ -export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { - const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; - if (!service || !raw) { - return {}; - } - const invalid = (reason: string): FirebaseError => - new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw invalid("it isn't valid JSON."); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw invalid("it must be a JSON object."); - } - for (const [name, value] of Object.entries(parsed)) { - if (typeof value === "string") { - continue; - } - const ref = value as Partial | null; - const valid = - typeof ref === "object" && - ref !== null && - !Array.isArray(ref) && - Object.keys(ref).every((k) => k === "secret" || k === "version") && - typeof ref.secret === "string" && - SECRET_NAME.test(ref.secret) && - (ref.version === undefined || - (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); - if (!valid) { - throw invalid( - `${name} must be a string or {"secret": "", "version": ""}.`, - ); - } - } - return parsed as BuildEnv; -} - -/** Names of the variables that are secret references. */ -export function secretNames(env: BuildEnv): string[] { - return Object.entries(env) - .filter(([, value]) => typeof value !== "string") - .map(([name]) => name); -} - -/** Converts the build environment to the form local builds take; they resolve the secrets. */ -export function toLocalBuildEnv(env: BuildEnv): EnvMap { - const result: EnvMap = {}; - for (const [name, value] of Object.entries(env)) { - if (typeof value === "string") { - result[name] = { value, availability: ["BUILD"] }; - } else { - const version = value.version || "latest"; - const secret = value.secret.startsWith("projects/") - ? `${value.secret}/versions/${version}` - : `${value.secret}@${version}`; - result[name] = { secret, availability: ["BUILD"] }; - } - } - return result; -} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 95c81077101..d55ba00ea89 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -84,13 +84,6 @@ describe("run deploy", () => { expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); - it("passes the service's build env to Cloud Build", async () => { - await deployOne({ config: web, buildEnv: { API_URL: "https://api.example.com" } }); - - const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; - expect(environmentVariables).to.include({ API_URL: "https://api.example.com" }); - }); - it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", @@ -155,8 +148,6 @@ describe("run deploy", () => { scratchDir, {}, { - nonInteractive: undefined, - allowLocalBuildSecrets: true, rootDir: "apps/web", }, ); @@ -174,7 +165,7 @@ describe("run deploy", () => { expect(fs.existsSync(scratchDir)).to.be.false; }); - it("passes build env to the local build, and keeps the env vars the app needs at runtime", async () => { + it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { @@ -185,14 +176,9 @@ describe("run deploy", () => { ], }, }); - const buildEnv = { API_URL: "https://api.example.com", TOKEN: { secret: "t", version: "2" } }; - await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); + await deployOne({ config: localWeb, baseImage: NODEJS22 }); - expect(localBuildStub.firstCall.args[2]).to.deep.equal({ - API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, - TOKEN: { secret: "t@2", availability: ["BUILD"] }, - }); expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ { name: "NODE_ENV", value: "production" }, ]); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 112e8fda9ca..cd003936607 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,7 +16,6 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; -import { secretNames, toLocalBuildEnv } from "./buildEnv"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ @@ -58,32 +57,20 @@ async function buildLocally( ): Promise> { const { config } = svc; const { serviceId, region } = config; - const buildEnv = svc.buildEnv || {}; const cfg = toAppHostingConfig(config); validateLocalBuildNodeVersion( { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); - const secrets = secretNames(buildEnv); - if (secrets.length) { - logLabeledBullet( - "run", - `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + - `included in the build output.`, - ); - } const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); try { await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); const { outputFiles, buildConfig } = await localBuild( context.projectId, scratchDir, - toLocalBuildEnv(buildEnv), + {}, { - nonInteractive: options.nonInteractive, - // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. - allowLocalBuildSecrets: true, rootDir: config.rootDir, }, ); @@ -159,8 +146,6 @@ async function buildImage( // Only images built for a base image can have their base image updated automatically. ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), environmentVariables: { - // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. - ...(svc.buildEnv as Record | undefined), // Use App Hosting's buildpacks, which need to know where to write the output bundle. X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 60019f3e61d..e2b853851d4 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,7 +3,6 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -92,39 +91,6 @@ describe("run prepare", () => { }); }); - describe("build env", () => { - const withBuildEnv = (env: Record): runv2.Service => - ({ - ...liveWeb, - annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, - }) as unknown as runv2.Service; - - it("passes plain build env to Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); - const [svc] = await prepareRun(web); - expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); - }); - - it("leaves build env unset without the annotation", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(web); - expect(svc).not.to.have.property("buildEnv"); - }); - - it("rejects build secrets on Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t" } })); - await expect(prepareRun(web)).to.be.rejectedWith( - /Service web in us-central1 has build secrets \(TOKEN\).*"localBuild": true/, - ); - }); - - it("keeps build secrets for local builds", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t", version: "2" } })); - const [svc] = await prepareRun(localWeb); - expect(svc.buildEnv).to.deep.equal({ TOKEN: { secret: "t", version: "2" } }); - }); - }); - it("only prepares the services that --only selects", async () => { const webEurope = { serviceId: "web", region: "europe-west1" }; const services = await prepareRun([web, webEurope], { only: "run:web:europe-west1" }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index ebf51f4c71f..7f774dc33c0 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,14 +1,12 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; -import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; import { fullServiceName, getExistingService, getServiceConfigs } from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image and build env. + * Reads each service's current state from Cloud Run and resolves its base image. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -30,34 +28,8 @@ async function prepareService(context: Context, config: RunSingle): Promise` : "firebase init run"; @@ -65,5 +37,5 @@ async function prepareService(context: Context, config: RunSingle): Promise Date: Fri, 9 Oct 2026 13:35:45 -0700 Subject: [PATCH 078/103] Make local builds easier to read - deploy() creates the scratch folder and deletes it in one finally. - Explain the App Hosting Backend that the Node.js version check needs. - Init's base image validation is one expression. --- src/deploy/run/deploy.ts | 58 ++++++++++++++++++++-------------------- src/init/features/run.ts | 8 ++---- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index cd003936607..f19621d85aa 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -28,9 +28,13 @@ type RevisionCode = { image: string } | { builtApp: runv2.StorageSource }; export async function deploy(context: Context, options: Options, payload: Payload): Promise { const { projectId } = context; for (const svc of payload.run?.services || []) { + // A local build runs in a scratch copy of the source, which is deleted afterwards. + const scratchDir = svc.config.localBuild + ? fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`)) + : undefined; try { - if (svc.config.localBuild) { - svc.localBuild = await buildLocally(context, options, svc); + if (scratchDir) { + svc.localBuild = await buildLocally(context, options, svc, scratchDir); } const uploaded = await uploadSource(projectId, options, svc); // Cloud Run runs a local build's output as is. Other source is built into an image first. @@ -42,8 +46,8 @@ export async function deploy(context: Context, options: Options, payload: Payloa ? await deployRevision(svc.existing, template) : await createService(projectId, svc.config, template); } finally { - if (svc.localBuild) { - removeTempPath(svc.localBuild.scratchDir); + if (scratchDir) { + removeTempPath(scratchDir); } } } @@ -54,40 +58,36 @@ async function buildLocally( context: Context, options: Options, svc: ServiceDeploy, + scratchDir: string, ): Promise> { const { config } = svc; const { serviceId, region } = config; const cfg = toAppHostingConfig(config); + // App Hosting's check reads the Node.js version from a backend's runtime, e.g. "nodejs22". validateLocalBuildNodeVersion( { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); - const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${serviceId}-`)); - try { - await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); - const { outputFiles, buildConfig } = await localBuild( - context.projectId, - scratchDir, - {}, - { - rootDir: config.rootDir, - }, - ); - // Keep the env vars that the app needs at runtime, not just during the build. - const env = (buildConfig.env || []) - .filter( - (e) => - e.variable && - e.value !== undefined && - (!e.availability || e.availability.includes("RUNTIME")), - ) - .map((e) => ({ name: e.variable, value: e.value! })); - return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; - } catch (err: unknown) { - removeTempPath(scratchDir); - throw err; - } + await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); + const { outputFiles, buildConfig } = await localBuild( + context.projectId, + scratchDir, + {}, + { + rootDir: config.rootDir, + }, + ); + // Keep the env vars that the app needs at runtime, not just during the build. + const env = (buildConfig.env || []) + .filter( + (e) => + e.variable && + e.value !== undefined && + (!e.availability || e.availability.includes("RUNTIME")), + ) + .map((e) => ({ name: e.variable, value: e.value! })); + return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; } /** Uploads the service's source, or its local build's output, to the region's source bucket. */ diff --git a/src/init/features/run.ts b/src/init/features/run.ts index f01a0fa7aa8..39a35616155 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -81,12 +81,8 @@ export async function askQuestions(setup: Setup, config: Config, options: Option ? "Which base image should your app use? (e.g. nodejs20, nodejs22)" : "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", default: existing?.template.containers?.[0]?.baseImageUri, - validate: (img: string) => { - if (localBuild && !img.trim()) { - return "Local builds require a base image."; - } - return true; - }, + validate: (img: string) => + localBuild && !img.trim() ? "Local builds require a base image." : true, }); const baseImage = (rawBaseImage || "").trim(); From fd2a3de4e978559ca3e4f1c78d7be071ec93bc52 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:36:48 -0700 Subject: [PATCH 079/103] Keep localBuild's options on one line --- src/deploy/run/deploy.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f19621d85aa..65f87d61a99 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -74,9 +74,7 @@ async function buildLocally( context.projectId, scratchDir, {}, - { - rootDir: config.rootDir, - }, + { rootDir: config.rootDir }, ); // Keep the env vars that the app needs at runtime, not just during the build. const env = (buildConfig.env || []) From e39e3727936b986a5ff91d58c2ef4f84c9ab74ad Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:38:07 -0700 Subject: [PATCH 080/103] Support build-time env vars and secrets for Cloud Run Reads the firebase.google.com/buildConfig.environmentVariablesAndSecrets annotation on the service. Plain values go to Cloud Build and local builds; secret references only work in local builds, which read them with the deployer's credentials. --- src/deploy/run/args.ts | 3 ++ src/deploy/run/buildEnv.spec.ts | 88 ++++++++++++++++++++++++++++++++ src/deploy/run/buildEnv.ts | 89 +++++++++++++++++++++++++++++++++ src/deploy/run/deploy.spec.ts | 21 +++++++- src/deploy/run/deploy.ts | 16 +++++- src/deploy/run/prepare.spec.ts | 28 +++++++++++ src/deploy/run/prepare.ts | 27 +++++++++- 7 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/deploy/run/buildEnv.spec.ts create mode 100644 src/deploy/run/buildEnv.ts diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 6848b6acc83..c98333634f8 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,5 +1,6 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { BuildEnv } from "./buildEnv"; /** A service being deployed. prepare fills in the first fields, and deploy sets the rest. */ export interface ServiceDeploy { @@ -7,6 +8,8 @@ export interface ServiceDeploy { /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ existing?: runv2.Service; baseImage?: string; + /** Build-time environment. Prepare only allows secrets in it for local builds. */ + buildEnv?: BuildEnv; /** The local build's output. */ localBuild?: { scratchDir: string; diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts new file mode 100644 index 00000000000..00c50090b8f --- /dev/null +++ b/src/deploy/run/buildEnv.spec.ts @@ -0,0 +1,88 @@ +import { expect } from "chai"; +import * as runv2 from "../../gcp/runv2"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; + +describe("run buildEnv", () => { + const withAnnotation = (value: string): runv2.Service => + ({ + name: "projects/p/locations/us-central1/services/s", + annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, + }) as unknown as runv2.Service; + + describe("getBuildEnv", () => { + it("returns {} without a service or annotation", () => { + expect(getBuildEnv(undefined)).to.deep.equal({}); + expect( + getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), + ).to.deep.equal({}); + }); + + it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { + expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); + }); + + it("parses secret references", () => { + const env = { + A: "1", + TOKEN: { secret: "npm-token", version: "3" }, + LATEST: { secret: "s" }, + OTHER: { secret: "projects/q/secrets/t", version: "latest" }, + }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); + }); + + it("rejects invalid JSON and non-objects", () => { + expect(() => getBuildEnv(withAnnotation("{"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, + ); + for (const value of ["[]", "null", '"x"', "1"]) { + expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); + } + }); + + it("rejects invalid values", () => { + const invalid = [ + 1, + null, + ["x"], + {}, + { secret: 1 }, + { secret: "a/b" }, + { secret: "a@1" }, + { secret: "projects/q/secrets/t/versions/1" }, + { secret: "s", version: 1 }, + { secret: "s", version: "one" }, + { secret: "s", extra: "x" }, + ]; + for (const value of invalid) { + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( + 'A must be a string or {"secret": "", "version": ""}.', + ); + } + }); + }); + + it("lists secret names", () => { + expect( + secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), + ).to.deep.equal(["B", "C"]); + }); + + it("converts to local build env, available only to the build", () => { + expect( + toLocalBuildEnv({ + A: "1", + B: { secret: "s" }, + C: { secret: "t", version: "2" }, + D: { secret: "projects/q/secrets/u" }, + E: { secret: "projects/q/secrets/v", version: "4" }, + }), + ).to.deep.equal({ + A: { value: "1", availability: ["BUILD"] }, + B: { secret: "s@latest", availability: ["BUILD"] }, + C: { secret: "t@2", availability: ["BUILD"] }, + D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, + E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, + }); + }); +}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts new file mode 100644 index 00000000000..5ba9d8cb263 --- /dev/null +++ b/src/deploy/run/buildEnv.ts @@ -0,0 +1,89 @@ +import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; +import { EnvMap } from "../../apphosting/yaml"; + +/** + * Prototype: build-time environment variables and secret references, stored on the Cloud Run + * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): + * the same map parses unchanged, and a value can also be a secret reference in the shape of + * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. + * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} + */ +export const BUILD_ENV_ANNOTATION = + "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; + +/** A Secret Manager secret, as a name in the service's project or a full resource name. */ +export interface SecretRef { + secret: string; + /** A version number or "latest". Defaults to "latest". */ + version?: string; +} + +export type BuildEnv = Record; + +const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; +const SECRET_VERSION = /^(?:latest|\d+)$/; + +/** Reads and validates the service's build environment. Returns {} if it has none. */ +export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { + const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; + if (!service || !raw) { + return {}; + } + const invalid = (reason: string): FirebaseError => + new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw invalid("it isn't valid JSON."); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw invalid("it must be a JSON object."); + } + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string") { + continue; + } + const ref = value as Partial | null; + const valid = + typeof ref === "object" && + ref !== null && + !Array.isArray(ref) && + Object.keys(ref).every((k) => k === "secret" || k === "version") && + typeof ref.secret === "string" && + SECRET_NAME.test(ref.secret) && + (ref.version === undefined || + (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); + if (!valid) { + throw invalid( + `${name} must be a string or {"secret": "", "version": ""}.`, + ); + } + } + return parsed as BuildEnv; +} + +/** Names of the variables that are secret references. */ +export function secretNames(env: BuildEnv): string[] { + return Object.entries(env) + .filter(([, value]) => typeof value !== "string") + .map(([name]) => name); +} + +/** Converts the build environment to the form local builds take; they resolve the secrets. */ +export function toLocalBuildEnv(env: BuildEnv): EnvMap { + const result: EnvMap = {}; + for (const [name, value] of Object.entries(env)) { + if (typeof value === "string") { + result[name] = { value, availability: ["BUILD"] }; + } else { + const version = value.version || "latest"; + const secret = value.secret.startsWith("projects/") + ? `${value.secret}/versions/${version}` + : `${value.secret}@${version}`; + result[name] = { secret, availability: ["BUILD"] }; + } + } + return result; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index d55ba00ea89..1dfa6e36cc1 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -84,6 +84,13 @@ describe("run deploy", () => { expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); + it("passes the service's build env to Cloud Build", async () => { + await deployOne({ config: web, buildEnv: { API_URL: "https://api.example.com" } }); + + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ API_URL: "https://api.example.com" }); + }); + it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", @@ -143,7 +150,7 @@ describe("run deploy", () => { { runtime: { value: "nodejs22" } }, "/project/apps/web", ); - expect(localBuildStub).to.have.been.calledWith( + expect(localBuildStub).to.have.been.calledWithMatch( "my-project", scratchDir, {}, @@ -184,6 +191,18 @@ describe("run deploy", () => { ]); }); + it("passes the build env, secrets included, to the local build", async () => { + const buildEnv = { API_URL: "https://api.example.com", TOKEN: { secret: "t", version: "2" } }; + + await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); + + expect(localBuildStub.firstCall.args[2]).to.deep.equal({ + API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, + TOKEN: { secret: "t@2", availability: ["BUILD"] }, + }); + expect(localBuildStub.firstCall.args[3]).to.include({ allowLocalBuildSecrets: true }); + }); + it("cleans up and deploys nothing if the local build fails", async () => { localBuildStub.rejects(new Error("boom")); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 65f87d61a99..18da9d332b7 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,6 +16,7 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; +import { secretNames, toLocalBuildEnv } from "./buildEnv"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ @@ -69,12 +70,21 @@ async function buildLocally( path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); + const secrets = secretNames(svc.buildEnv || {}); + if (secrets.length) { + logLabeledBullet( + "run", + `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + + `included in the build output.`, + ); + } await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); const { outputFiles, buildConfig } = await localBuild( context.projectId, scratchDir, - {}, - { rootDir: config.rootDir }, + toLocalBuildEnv(svc.buildEnv || {}), + // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. + { rootDir: config.rootDir, allowLocalBuildSecrets: true }, ); // Keep the env vars that the app needs at runtime, not just during the build. const env = (buildConfig.env || []) @@ -144,6 +154,8 @@ async function buildImage( // Only images built for a base image can have their base image updated automatically. ...(svc.baseImage && { baseImage: svc.baseImage, enableAutomaticUpdates: true }), environmentVariables: { + // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. + ...(svc.buildEnv as Record | undefined), // Use App Hosting's buildpacks, which need to know where to write the output bundle. X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index e2b853851d4..e577b1039df 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -91,6 +92,33 @@ describe("run prepare", () => { }); }); + describe("build env", () => { + const withBuildEnv = (env: Record): runv2.Service => + ({ + ...liveWeb, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }) as unknown as runv2.Service; + + it("reads the build env from the service's annotation", async () => { + getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); + const [svc] = await prepareRun(web); + expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); + }); + + it("rejects build secrets on Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t" } })); + await expect(prepareRun(web)).to.be.rejectedWith( + /Service web in us-central1 has build secrets \(TOKEN\).*"localBuild": true/, + ); + }); + + it("keeps build secrets for local builds", async () => { + getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t", version: "2" } })); + const [svc] = await prepareRun(localWeb); + expect(svc.buildEnv).to.deep.equal({ TOKEN: { secret: "t", version: "2" } }); + }); + }); + it("only prepares the services that --only selects", async () => { const webEurope = { serviceId: "web", region: "europe-west1" }; const services = await prepareRun([web, webEurope], { only: "run:web:europe-west1" }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 7f774dc33c0..293729dbab6 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,12 +1,14 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; import { fullServiceName, getExistingService, getServiceConfigs } from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image. + * Reads each service's current state from Cloud Run and resolves its base image and build env. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -37,5 +39,26 @@ async function prepareService(context: Context, config: RunSingle): Promise Date: Fri, 9 Oct 2026 14:06:29 -0700 Subject: [PATCH 081/103] Make the build env tests easier to read Realistic names (API_URL, NPM_TOKEN, npm-token) and shorter fixtures. --- src/deploy/run/buildEnv.spec.ts | 85 +++++++++-------- src/deploy/run/deploy.spec.ts | 158 +++++++++++++++++--------------- src/deploy/run/prepare.spec.ts | 41 ++++----- src/init/features/run.spec.ts | 125 ++++++++++--------------- 4 files changed, 195 insertions(+), 214 deletions(-) diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts index 00c50090b8f..e403ffde742 100644 --- a/src/deploy/run/buildEnv.spec.ts +++ b/src/deploy/run/buildEnv.spec.ts @@ -3,39 +3,39 @@ import * as runv2 from "../../gcp/runv2"; import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; describe("run buildEnv", () => { - const withAnnotation = (value: string): runv2.Service => - ({ - name: "projects/p/locations/us-central1/services/s", - annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, - }) as unknown as runv2.Service; + const web = { name: "projects/my-project/locations/us-central1/services/web" } as runv2.Service; + /** The web service, with this build env annotation. */ + const withAnnotation = (value: string): runv2.Service => ({ + ...web, + annotations: { [BUILD_ENV_ANNOTATION]: value }, + }); describe("getBuildEnv", () => { it("returns {} without a service or annotation", () => { expect(getBuildEnv(undefined)).to.deep.equal({}); - expect( - getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), - ).to.deep.equal({}); + expect(getBuildEnv({ ...web, annotations: { team: "frontend" } })).to.deep.equal({}); }); it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { - expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); + const env = { API_URL: "https://api.example.com", BASE_PATH: "" }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); it("parses secret references", () => { const env = { - A: "1", - TOKEN: { secret: "npm-token", version: "3" }, - LATEST: { secret: "s" }, - OTHER: { secret: "projects/q/secrets/t", version: "latest" }, + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token", version: "latest" }, }; expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); it("rejects invalid JSON and non-objects", () => { - expect(() => getBuildEnv(withAnnotation("{"))).to.throw( - `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, + expect(() => getBuildEnv(withAnnotation("API_URL=https://api.example.com"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/my-project/locations/us-central1/services/web: it isn't valid JSON.`, ); - for (const value of ["[]", "null", '"x"', "1"]) { + for (const value of ["[]", "null", '"https://api.example.com"', "1"]) { expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); } }); @@ -44,45 +44,54 @@ describe("run buildEnv", () => { const invalid = [ 1, null, - ["x"], + ["npm-token"], {}, { secret: 1 }, - { secret: "a/b" }, - { secret: "a@1" }, - { secret: "projects/q/secrets/t/versions/1" }, - { secret: "s", version: 1 }, - { secret: "s", version: "one" }, - { secret: "s", extra: "x" }, + { secret: "secrets/npm-token" }, + { secret: "npm-token@3" }, + { secret: "projects/shared-secrets/secrets/npm-token/versions/3" }, + { secret: "npm-token", version: 3 }, + { secret: "npm-token", version: "v3" }, + { secret: "npm-token", project: "shared-secrets" }, ]; for (const value of invalid) { - expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( - 'A must be a string or {"secret": "", "version": ""}.', + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ NPM_TOKEN: value })))).to.throw( + 'NPM_TOKEN must be a string or {"secret": "", "version": ""}.', ); } }); }); it("lists secret names", () => { - expect( - secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), - ).to.deep.equal(["B", "C"]); + const env = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + }; + expect(secretNames(env)).to.deep.equal(["NPM_TOKEN", "SENTRY_AUTH_TOKEN"]); }); it("converts to local build env, available only to the build", () => { expect( toLocalBuildEnv({ - A: "1", - B: { secret: "s" }, - C: { secret: "t", version: "2" }, - D: { secret: "projects/q/secrets/u" }, - E: { secret: "projects/q/secrets/v", version: "4" }, + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token" }, + CMS_TOKEN: { secret: "projects/shared-secrets/secrets/cms-token", version: "4" }, }), ).to.deep.equal({ - A: { value: "1", availability: ["BUILD"] }, - B: { secret: "s@latest", availability: ["BUILD"] }, - C: { secret: "t@2", availability: ["BUILD"] }, - D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, - E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, + API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, + NPM_TOKEN: { secret: "npm-token@latest", availability: ["BUILD"] }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2", availability: ["BUILD"] }, + GITHUB_TOKEN: { + secret: "projects/shared-secrets/secrets/github-token/versions/latest", + availability: ["BUILD"], + }, + CMS_TOKEN: { + secret: "projects/shared-secrets/secrets/cms-token/versions/4", + availability: ["BUILD"], + }, }); }); }); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 1dfa6e36cc1..17ce8ce12b8 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; import * as fs from "fs"; -import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -21,6 +20,8 @@ describe("run deploy", () => { const web = { serviceId: "web", region: "us-central1", rootDir: "apps/web" }; // Date.now() is stubbed to return 42, which tags the image. const image = "us-central1-docker.pkg.dev/my-project/cloud-run-source-deploy/web:42"; + const scratchDir = "/tmp/run-local-build-web-abc123"; + const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; let createArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; @@ -33,13 +34,9 @@ describe("run deploy", () => { .stub(apphostingUtil, "createSourceDeployArchive") .resolves("/tmp/web.zip"); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - sinon.stub(gcs, "uploadObject").callsFake((src) => - Promise.resolve({ - bucket: "source-bucket", - object: path.basename(src.file), - generation: "1", - }), - ); + sinon + .stub(gcs, "uploadObject") + .resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" }); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); const deployed = { uri: "https://web.run.app" } as runv2.Service; @@ -124,28 +121,37 @@ describe("run deploy", () => { describe("local builds", () => { const localWeb = { ...web, localBuild: true }; + let rmSyncStub: sinon.SinonStub; let validateNodeStub: sinon.SinonStub; let localBuildStub: sinon.SinonStub; let tarArchiveStub: sinon.SinonStub; - let mkdtempSpy: sinon.SinonSpy; beforeEach(() => { - sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + sinon.stub(fs, "mkdtempSync").returns(scratchDir); + rmSyncStub = sinon.stub(fs, "rmSync"); validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ outputFiles: [".next"], - buildConfig: { runCommand: "node server.js" }, + buildConfig: { runCommand: "npm run start" }, }); tarArchiveStub = sinon .stub(apphostingUtil, "createLocalBuildTarArchive") .resolves("/tmp/web.tar.gz"); - mkdtempSpy = sinon.spy(fs, "mkdtempSync"); + (gcs.uploadObject as sinon.SinonStub) + .withArgs(sinon.match({ file: "/tmp/web.tar.gz" })) + .resolves(builtApp); }); - it("builds on this machine and uploads the output, without Cloud Build", async () => { + /** Deploys web with "localBuild": true and returns the container that it rolled out. */ + async function deployLocalBuild(): Promise { await deployOne({ config: localWeb, baseImage: NODEJS22 }); + return createServiceStub.firstCall.args[3].template.containers[0]; + } + + it("builds on this machine, in a scratch folder that it deletes afterwards", async () => { + await deployLocalBuild(); - const scratchDir = mkdtempSpy.firstCall.returnValue as string; expect(validateNodeStub).to.have.been.calledWith( { runtime: { value: "nodejs22" } }, "/project/apps/web", @@ -154,71 +160,69 @@ describe("run deploy", () => { "my-project", scratchDir, {}, - { - rootDir: "apps/web", - }, + { rootDir: "apps/web" }, ); - expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "web" }, scratchDir, [ - ".next", - ]); + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + }); + + it("uploads the build's output and runs it as is, without Cloud Build", async () => { + const container = await deployLocalBuild(); + + expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]); expect(submitBuildStub).not.to.have.been.called; - expect(createServiceStub.firstCall.args[3].template.containers[0]).to.deep.equal({ - name: "web", + expect(container).to.deep.include({ image: "scratch", - sourceCode: { cloudStorageSource: { bucket: "source-bucket", object: "web.tar.gz" } }, - command: ["node", "server.js"], - baseImageUri: NODEJS22, + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], }); - expect(fs.existsSync(scratchDir)).to.be.false; }); it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { - runCommand: "node server.js", env: [ { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, - { variable: "BUILD_ONLY", value: "x", availability: ["BUILD"] }, + { variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] }, ], }, }); - await deployOne({ config: localWeb, baseImage: NODEJS22 }); + const container = await deployLocalBuild(); - expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ - { name: "NODE_ENV", value: "production" }, - ]); + expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); }); it("passes the build env, secrets included, to the local build", async () => { - const buildEnv = { API_URL: "https://api.example.com", TOKEN: { secret: "t", version: "2" } }; + const buildEnv = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token", version: "2" }, + }; await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); - expect(localBuildStub.firstCall.args[2]).to.deep.equal({ - API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, - TOKEN: { secret: "t@2", availability: ["BUILD"] }, - }); - expect(localBuildStub.firstCall.args[3]).to.include({ allowLocalBuildSecrets: true }); + expect(localBuildStub).to.have.been.calledWithMatch( + "my-project", + scratchDir, + { API_URL: { value: "https://api.example.com" }, NPM_TOKEN: { secret: "npm-token@2" } }, + { allowLocalBuildSecrets: true }, + ); }); it("cleans up and deploys nothing if the local build fails", async () => { - localBuildStub.rejects(new Error("boom")); + localBuildStub.rejects(new Error("next build failed")); - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); - expect(fs.existsSync(mkdtempSpy.firstCall.returnValue as string)).to.be.false; + expect(rmSyncStub).to.have.been.calledWith(scratchDir); expect(createServiceStub).not.to.have.been.called; }); it("reports the local build error, not the cleanup error, when both fail", async () => { - localBuildStub.rejects(new Error("boom")); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); + localBuildStub.rejects(new Error("next build failed")); + rmSyncStub.throws(new Error("EBUSY: resource busy or locked")); - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); - - fs.rmdirSync(mkdtempSpy.firstCall.returnValue as string); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); }); }); @@ -239,7 +243,13 @@ describe("run deploy", () => { ], }, } as unknown as runv2.Service; - const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; + + /** The live web service, running a container with these fields. */ + function webRunning(container: Partial): runv2.Service { + return { + template: { containers: [{ name: "web", image: "old-image", ...container }] }, + } as runv2.Service; + } it("starts a new service with just the image", () => { expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ @@ -273,10 +283,14 @@ describe("run deploy", () => { }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate({ config: web, existing }, { image }, "Fix the login"); + const withMessage = revisionTemplate( + { config: web, existing }, + { image }, + "Fix the login page", + ); expect(withMessage.annotations).to.deep.equal({ team: "frontend", - [DEPLOY_MESSAGE]: "Fix the login", + [DEPLOY_MESSAGE]: "Fix the login page", }); const withoutMessage = revisionTemplate({ config: web, existing }, { image }); @@ -284,51 +298,43 @@ describe("run deploy", () => { }); it("runs a local build's output with the build's start command", () => { - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], runCommand: " node server.js " }; + // The command is split on whitespace, so stray spaces don't become empty arguments. + const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container).to.include({ image: "scratch" }); - expect(container?.sourceCode).to.deep.equal({ cloudStorageSource: builtApp }); - expect(container?.command).to.deep.equal(["node", "server.js"]); + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); it("adds the env vars a local build needs, unless the service already sets them", () => { const env = [ - { name: "MODE", value: "from-build" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]; - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], env }; + const localBuild = { scratchDir, outputFiles: [".next"], env }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container?.env).to.deep.equal([ + expect(template.containers?.[0].env).to.deep.equal([ { name: "MODE", value: "prod" }, { name: "NODE_ENV", value: "production" }, ]); }); it("switches a service from a local build back to an image", () => { - const fromLocalBuild = { - ...existing, - template: { - containers: [ - { - name: "web", - image: "scratch", - command: ["node", "server.js"], - sourceCode: { cloudStorageSource: builtApp }, - }, - ], - }, - } as unknown as runv2.Service; + const fromLocalBuild = webRunning({ + image: "scratch", + command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, + }); - const container = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); - expect(container).to.deep.equal({ name: "web", image }); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); }); }); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index e577b1039df..9933c4de7a4 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,7 +3,7 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION } from "./buildEnv"; +import { BUILD_ENV_ANNOTATION, BuildEnv } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -15,6 +15,11 @@ describe("run prepare", () => { name: "projects/my-project/locations/us-central1/services/web", template: { containers: [{ name: "web", image: "old-image", baseImageUri: nodejs22 }] }, } as unknown as runv2.Service; + /** The live web service, with this build env annotation. */ + const withBuildEnv = (env: BuildEnv): runv2.Service => ({ + ...liveWeb, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }); let prereqsStub: sinon.SinonStub; let getServiceStub: sinon.SinonStub; @@ -69,12 +74,14 @@ describe("run prepare", () => { describe("local builds", () => { it("require a base image", async () => { - getServiceStub.resolves({ + const webWithoutBaseImage = { ...liveWeb, template: { containers: [{ name: "web", image: "old-image" }] }, - }); + }; + getServiceStub.resolves(webWithoutBaseImage); + await expect(prepareRun(localWeb)).to.be.rejectedWith( - /Local builds require a base image.*firebase run:services:update web:us-central1 --base-image/, + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', ); }); @@ -83,39 +90,31 @@ describe("run prepare", () => { 'Local builds require a base image. Set one for service web in us-central1 with "firebase init run".', ); }); - - it("are left to the deploy step", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(localWeb); - expect(svc.baseImage).to.equal(nodejs22); - expect(svc).not.to.have.property("localBuild"); - }); }); describe("build env", () => { - const withBuildEnv = (env: Record): runv2.Service => - ({ - ...liveWeb, - annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, - }) as unknown as runv2.Service; - it("reads the build env from the service's annotation", async () => { getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); + const [svc] = await prepareRun(web); + expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); }); it("rejects build secrets on Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t" } })); + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + await expect(prepareRun(web)).to.be.rejectedWith( - /Service web in us-central1 has build secrets \(TOKEN\).*"localBuild": true/, + /Service web in us-central1 has build secrets \(NPM_TOKEN\).*"localBuild": true/, ); }); it("keeps build secrets for local builds", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t", version: "2" } })); + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + const [svc] = await prepareRun(localWeb); - expect(svc.buildEnv).to.deep.equal({ TOKEN: { secret: "t", version: "2" } }); + + expect(svc.buildEnv).to.deep.equal({ NPM_TOKEN: { secret: "npm-token" } }); }); }); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 41d47ca7f46..098dc3b63a9 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -41,6 +41,13 @@ describe("init run", () => { afterEach(() => sinon.restore()); describe("askQuestions", () => { + /** Answers init's prompts, select or input, in the order it asks them. */ + function answerPrompts(...answers: unknown[]): void { + const nextAnswer = (): Promise => Promise.resolve(answers.shift()); + selectStub.callsFake(nextAnswer); + inputStub.callsFake(nextAnswer); + } + it("throws if no project is selected", async () => { await expect( askQuestions({ ...setup(), projectId: undefined }, config, options), @@ -144,55 +151,38 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); - it("enables localBuild and requires a base image when building locally", async () => { - selectStub - .onFirstCall() - .resolves("create") - .onSecondCall() - .resolves("us-central1") - .onThirdCall() - .resolves(true); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); - inputStub.onThirdCall().resolves("/"); - const s = setup(); + it("builds a new service remotely by default", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ false, "", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.false; - const baseImagePrompt = inputStub.secondCall.args[0]; - expect(baseImagePrompt.default).to.be.undefined; - expect(baseImagePrompt.validate("nodejs22")).to.be.true; - expect(baseImagePrompt.validate(" ")).to.include("Local builds require a base image"); - expect(s.featureInfo?.run).to.deep.equal({ - serviceId: "my-service", - region: "us-central1", - baseImage: "nodejs22", - rootDir: "/", - localBuild: true, - }); + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.false; }); - it("defaults to building locally if the saved entry for the service does", async () => { - const existing = { - name: "projects/p/locations/europe-west1/services/web", - template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, - }; + it("defaults to building locally if firebase.json builds the service locally", async () => { + const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); - selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); - selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); - inputStub.callsFake((o) => Promise.resolve(o.default)); - config.set("run", { - serviceId: "web", - region: "europe-west1", - rootDir: "/", - localBuild: true, - }); - const s = setup(); + config.set("run", { serviceId: "web", region: "europe-west1", localBuild: true }); + answerPrompts("update", existing, /* localBuild= */ true, "nodejs22", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.true; - expect(s.featureInfo?.run?.localBuild).to.be.true; + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.true; + }); + + it("saves the choice to build locally, which requires a base image", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ true, "nodejs22", "/"); + const initSetup = setup(); + + await askQuestions(initSetup, config, options); + + const baseImagePrompt = inputStub.secondCall.args[0]; + expect(baseImagePrompt.default).to.be.undefined; + expect(baseImagePrompt.validate(" ")).to.equal("Local builds require a base image."); + expect(baseImagePrompt.validate("nodejs22")).to.be.true; + expect(initSetup.featureInfo?.run).to.include({ localBuild: true }); }); }); @@ -284,45 +274,22 @@ describe("init run", () => { ]); }); - it("updates an existing service in place while preserving custom ignore and updating localBuild", () => { - config.set("run", { - serviceId: "web", - region: "us-central1", - rootDir: "/", - ignore: ["custom-ignore"], - }); + it("turns localBuild on for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"] }); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - ignore: ["custom-ignore"], - }); + upsertRunConfig({ ...web, localBuild: true }, config); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - ignore: ["custom-ignore"], - }); + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"], localBuild: true }); + }); + + it("turns localBuild off for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"], localBuild: true }); + + upsertRunConfig(web, config); + + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"] }); }); it("doesn't add an ignore list to an existing service", () => { From f9b0ab788aea871218d522eb96788281d99e5afe Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 14:06:29 -0700 Subject: [PATCH 082/103] Make the local build tests easier to read - deploy: split the end-to-end local build test, stub the scratch folder instead of touching the filesystem, and keep #11205's upload stub. - prepare: drop a test of an implementation detail. - init: answer prompts with one helper, one behavior per test, and split the upsertRunConfig localBuild test into on and off. --- src/deploy/run/deploy.spec.ts | 144 +++++++++++++++++---------------- src/deploy/run/prepare.spec.ts | 15 ++-- src/init/features/run.spec.ts | 125 +++++++++++----------------- 3 files changed, 124 insertions(+), 160 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index d55ba00ea89..a6e50a83e64 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; import * as fs from "fs"; -import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -21,6 +20,8 @@ describe("run deploy", () => { const web = { serviceId: "web", region: "us-central1", rootDir: "apps/web" }; // Date.now() is stubbed to return 42, which tags the image. const image = "us-central1-docker.pkg.dev/my-project/cloud-run-source-deploy/web:42"; + const scratchDir = "/tmp/run-local-build-web-abc123"; + const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; let createArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; @@ -33,13 +34,9 @@ describe("run deploy", () => { .stub(apphostingUtil, "createSourceDeployArchive") .resolves("/tmp/web.zip"); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - sinon.stub(gcs, "uploadObject").callsFake((src) => - Promise.resolve({ - bucket: "source-bucket", - object: path.basename(src.file), - generation: "1", - }), - ); + sinon + .stub(gcs, "uploadObject") + .resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" }); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); const deployed = { uri: "https://web.run.app" } as runv2.Service; @@ -117,89 +114,92 @@ describe("run deploy", () => { describe("local builds", () => { const localWeb = { ...web, localBuild: true }; + let rmSyncStub: sinon.SinonStub; let validateNodeStub: sinon.SinonStub; let localBuildStub: sinon.SinonStub; let tarArchiveStub: sinon.SinonStub; - let mkdtempSpy: sinon.SinonSpy; beforeEach(() => { - sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + sinon.stub(fs, "mkdtempSync").returns(scratchDir); + rmSyncStub = sinon.stub(fs, "rmSync"); validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ outputFiles: [".next"], - buildConfig: { runCommand: "node server.js" }, + buildConfig: { runCommand: "npm run start" }, }); tarArchiveStub = sinon .stub(apphostingUtil, "createLocalBuildTarArchive") .resolves("/tmp/web.tar.gz"); - mkdtempSpy = sinon.spy(fs, "mkdtempSync"); + (gcs.uploadObject as sinon.SinonStub) + .withArgs(sinon.match({ file: "/tmp/web.tar.gz" })) + .resolves(builtApp); }); - it("builds on this machine and uploads the output, without Cloud Build", async () => { + /** Deploys web with "localBuild": true and returns the container that it rolled out. */ + async function deployLocalBuild(): Promise { await deployOne({ config: localWeb, baseImage: NODEJS22 }); + return createServiceStub.firstCall.args[3].template.containers[0]; + } + + it("builds on this machine, in a scratch folder that it deletes afterwards", async () => { + await deployLocalBuild(); - const scratchDir = mkdtempSpy.firstCall.returnValue as string; expect(validateNodeStub).to.have.been.calledWith( { runtime: { value: "nodejs22" } }, "/project/apps/web", ); - expect(localBuildStub).to.have.been.calledWith( + expect(localBuildStub).to.have.been.calledWithMatch( "my-project", scratchDir, {}, - { - rootDir: "apps/web", - }, + { rootDir: "apps/web" }, ); - expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "web" }, scratchDir, [ - ".next", - ]); + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + }); + + it("uploads the build's output and runs it as is, without Cloud Build", async () => { + const container = await deployLocalBuild(); + + expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]); expect(submitBuildStub).not.to.have.been.called; - expect(createServiceStub.firstCall.args[3].template.containers[0]).to.deep.equal({ - name: "web", + expect(container).to.deep.include({ image: "scratch", - sourceCode: { cloudStorageSource: { bucket: "source-bucket", object: "web.tar.gz" } }, - command: ["node", "server.js"], - baseImageUri: NODEJS22, + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], }); - expect(fs.existsSync(scratchDir)).to.be.false; }); it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { - runCommand: "node server.js", env: [ { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, - { variable: "BUILD_ONLY", value: "x", availability: ["BUILD"] }, + { variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] }, ], }, }); - await deployOne({ config: localWeb, baseImage: NODEJS22 }); + const container = await deployLocalBuild(); - expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ - { name: "NODE_ENV", value: "production" }, - ]); + expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); }); it("cleans up and deploys nothing if the local build fails", async () => { - localBuildStub.rejects(new Error("boom")); + localBuildStub.rejects(new Error("next build failed")); - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); - expect(fs.existsSync(mkdtempSpy.firstCall.returnValue as string)).to.be.false; + expect(rmSyncStub).to.have.been.calledWith(scratchDir); expect(createServiceStub).not.to.have.been.called; }); it("reports the local build error, not the cleanup error, when both fail", async () => { - localBuildStub.rejects(new Error("boom")); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); - - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + localBuildStub.rejects(new Error("next build failed")); + rmSyncStub.throws(new Error("EBUSY: resource busy or locked")); - fs.rmdirSync(mkdtempSpy.firstCall.returnValue as string); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); }); }); @@ -220,7 +220,13 @@ describe("run deploy", () => { ], }, } as unknown as runv2.Service; - const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; + + /** The live web service, running a container with these fields. */ + function webRunning(container: Partial): runv2.Service { + return { + template: { containers: [{ name: "web", image: "old-image", ...container }] }, + } as runv2.Service; + } it("starts a new service with just the image", () => { expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ @@ -254,10 +260,14 @@ describe("run deploy", () => { }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate({ config: web, existing }, { image }, "Fix the login"); + const withMessage = revisionTemplate( + { config: web, existing }, + { image }, + "Fix the login page", + ); expect(withMessage.annotations).to.deep.equal({ team: "frontend", - [DEPLOY_MESSAGE]: "Fix the login", + [DEPLOY_MESSAGE]: "Fix the login page", }); const withoutMessage = revisionTemplate({ config: web, existing }, { image }); @@ -265,51 +275,43 @@ describe("run deploy", () => { }); it("runs a local build's output with the build's start command", () => { - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], runCommand: " node server.js " }; + // The command is split on whitespace, so stray spaces don't become empty arguments. + const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container).to.include({ image: "scratch" }); - expect(container?.sourceCode).to.deep.equal({ cloudStorageSource: builtApp }); - expect(container?.command).to.deep.equal(["node", "server.js"]); + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); it("adds the env vars a local build needs, unless the service already sets them", () => { const env = [ - { name: "MODE", value: "from-build" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]; - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], env }; + const localBuild = { scratchDir, outputFiles: [".next"], env }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container?.env).to.deep.equal([ + expect(template.containers?.[0].env).to.deep.equal([ { name: "MODE", value: "prod" }, { name: "NODE_ENV", value: "production" }, ]); }); it("switches a service from a local build back to an image", () => { - const fromLocalBuild = { - ...existing, - template: { - containers: [ - { - name: "web", - image: "scratch", - command: ["node", "server.js"], - sourceCode: { cloudStorageSource: builtApp }, - }, - ], - }, - } as unknown as runv2.Service; + const fromLocalBuild = webRunning({ + image: "scratch", + command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, + }); - const container = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); - expect(container).to.deep.equal({ name: "web", image }); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); }); }); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index e2b853851d4..0133fb85ae1 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -68,12 +68,14 @@ describe("run prepare", () => { describe("local builds", () => { it("require a base image", async () => { - getServiceStub.resolves({ + const webWithoutBaseImage = { ...liveWeb, template: { containers: [{ name: "web", image: "old-image" }] }, - }); + }; + getServiceStub.resolves(webWithoutBaseImage); + await expect(prepareRun(localWeb)).to.be.rejectedWith( - /Local builds require a base image.*firebase run:services:update web:us-central1 --base-image/, + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', ); }); @@ -82,13 +84,6 @@ describe("run prepare", () => { 'Local builds require a base image. Set one for service web in us-central1 with "firebase init run".', ); }); - - it("are left to the deploy step", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(localWeb); - expect(svc.baseImage).to.equal(nodejs22); - expect(svc).not.to.have.property("localBuild"); - }); }); it("only prepares the services that --only selects", async () => { diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 41d47ca7f46..098dc3b63a9 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -41,6 +41,13 @@ describe("init run", () => { afterEach(() => sinon.restore()); describe("askQuestions", () => { + /** Answers init's prompts, select or input, in the order it asks them. */ + function answerPrompts(...answers: unknown[]): void { + const nextAnswer = (): Promise => Promise.resolve(answers.shift()); + selectStub.callsFake(nextAnswer); + inputStub.callsFake(nextAnswer); + } + it("throws if no project is selected", async () => { await expect( askQuestions({ ...setup(), projectId: undefined }, config, options), @@ -144,55 +151,38 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); - it("enables localBuild and requires a base image when building locally", async () => { - selectStub - .onFirstCall() - .resolves("create") - .onSecondCall() - .resolves("us-central1") - .onThirdCall() - .resolves(true); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); - inputStub.onThirdCall().resolves("/"); - const s = setup(); + it("builds a new service remotely by default", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ false, "", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.false; - const baseImagePrompt = inputStub.secondCall.args[0]; - expect(baseImagePrompt.default).to.be.undefined; - expect(baseImagePrompt.validate("nodejs22")).to.be.true; - expect(baseImagePrompt.validate(" ")).to.include("Local builds require a base image"); - expect(s.featureInfo?.run).to.deep.equal({ - serviceId: "my-service", - region: "us-central1", - baseImage: "nodejs22", - rootDir: "/", - localBuild: true, - }); + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.false; }); - it("defaults to building locally if the saved entry for the service does", async () => { - const existing = { - name: "projects/p/locations/europe-west1/services/web", - template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, - }; + it("defaults to building locally if firebase.json builds the service locally", async () => { + const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); - selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); - selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); - inputStub.callsFake((o) => Promise.resolve(o.default)); - config.set("run", { - serviceId: "web", - region: "europe-west1", - rootDir: "/", - localBuild: true, - }); - const s = setup(); + config.set("run", { serviceId: "web", region: "europe-west1", localBuild: true }); + answerPrompts("update", existing, /* localBuild= */ true, "nodejs22", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.true; - expect(s.featureInfo?.run?.localBuild).to.be.true; + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.true; + }); + + it("saves the choice to build locally, which requires a base image", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ true, "nodejs22", "/"); + const initSetup = setup(); + + await askQuestions(initSetup, config, options); + + const baseImagePrompt = inputStub.secondCall.args[0]; + expect(baseImagePrompt.default).to.be.undefined; + expect(baseImagePrompt.validate(" ")).to.equal("Local builds require a base image."); + expect(baseImagePrompt.validate("nodejs22")).to.be.true; + expect(initSetup.featureInfo?.run).to.include({ localBuild: true }); }); }); @@ -284,45 +274,22 @@ describe("init run", () => { ]); }); - it("updates an existing service in place while preserving custom ignore and updating localBuild", () => { - config.set("run", { - serviceId: "web", - region: "us-central1", - rootDir: "/", - ignore: ["custom-ignore"], - }); + it("turns localBuild on for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"] }); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - ignore: ["custom-ignore"], - }); + upsertRunConfig({ ...web, localBuild: true }, config); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - ignore: ["custom-ignore"], - }); + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"], localBuild: true }); + }); + + it("turns localBuild off for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"], localBuild: true }); + + upsertRunConfig(web, config); + + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"] }); }); it("doesn't add an ignore list to an existing service", () => { From 0a39c6450e5f73b136cfb1a1d3149fdb14118016 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 14:06:40 -0700 Subject: [PATCH 083/103] Make the SDK auto-init tests easier to read - Realistic app IDs, small fixture helpers, and no prettier-mangled calls. - Drop two tests that repeated others, and test that prepare rejects a service it can't build before looking up the app or touching IAM. --- src/deploy/run/buildEnv.spec.ts | 85 ++++++------ src/deploy/run/deploy.spec.ts | 224 ++++++++++++++++---------------- src/deploy/run/prepare.spec.ts | 156 ++++++++-------------- src/init/features/run.spec.ts | 145 ++++++++------------- 4 files changed, 272 insertions(+), 338 deletions(-) diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts index 00c50090b8f..e403ffde742 100644 --- a/src/deploy/run/buildEnv.spec.ts +++ b/src/deploy/run/buildEnv.spec.ts @@ -3,39 +3,39 @@ import * as runv2 from "../../gcp/runv2"; import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; describe("run buildEnv", () => { - const withAnnotation = (value: string): runv2.Service => - ({ - name: "projects/p/locations/us-central1/services/s", - annotations: { other: "x", [BUILD_ENV_ANNOTATION]: value }, - }) as unknown as runv2.Service; + const web = { name: "projects/my-project/locations/us-central1/services/web" } as runv2.Service; + /** The web service, with this build env annotation. */ + const withAnnotation = (value: string): runv2.Service => ({ + ...web, + annotations: { [BUILD_ENV_ANNOTATION]: value }, + }); describe("getBuildEnv", () => { it("returns {} without a service or annotation", () => { expect(getBuildEnv(undefined)).to.deep.equal({}); - expect( - getBuildEnv({ annotations: { other: "x" } } as unknown as runv2.Service), - ).to.deep.equal({}); + expect(getBuildEnv({ ...web, annotations: { team: "frontend" } })).to.deep.equal({}); }); it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { - expect(getBuildEnv(withAnnotation('{"A":"1","B":""}'))).to.deep.equal({ A: "1", B: "" }); + const env = { API_URL: "https://api.example.com", BASE_PATH: "" }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); it("parses secret references", () => { const env = { - A: "1", - TOKEN: { secret: "npm-token", version: "3" }, - LATEST: { secret: "s" }, - OTHER: { secret: "projects/q/secrets/t", version: "latest" }, + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token", version: "latest" }, }; expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); it("rejects invalid JSON and non-objects", () => { - expect(() => getBuildEnv(withAnnotation("{"))).to.throw( - `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/p/locations/us-central1/services/s: it isn't valid JSON.`, + expect(() => getBuildEnv(withAnnotation("API_URL=https://api.example.com"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/my-project/locations/us-central1/services/web: it isn't valid JSON.`, ); - for (const value of ["[]", "null", '"x"', "1"]) { + for (const value of ["[]", "null", '"https://api.example.com"', "1"]) { expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); } }); @@ -44,45 +44,54 @@ describe("run buildEnv", () => { const invalid = [ 1, null, - ["x"], + ["npm-token"], {}, { secret: 1 }, - { secret: "a/b" }, - { secret: "a@1" }, - { secret: "projects/q/secrets/t/versions/1" }, - { secret: "s", version: 1 }, - { secret: "s", version: "one" }, - { secret: "s", extra: "x" }, + { secret: "secrets/npm-token" }, + { secret: "npm-token@3" }, + { secret: "projects/shared-secrets/secrets/npm-token/versions/3" }, + { secret: "npm-token", version: 3 }, + { secret: "npm-token", version: "v3" }, + { secret: "npm-token", project: "shared-secrets" }, ]; for (const value of invalid) { - expect(() => getBuildEnv(withAnnotation(JSON.stringify({ A: value })))).to.throw( - 'A must be a string or {"secret": "", "version": ""}.', + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ NPM_TOKEN: value })))).to.throw( + 'NPM_TOKEN must be a string or {"secret": "", "version": ""}.', ); } }); }); it("lists secret names", () => { - expect( - secretNames({ A: "1", B: { secret: "s" }, C: { secret: "t", version: "2" } }), - ).to.deep.equal(["B", "C"]); + const env = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + }; + expect(secretNames(env)).to.deep.equal(["NPM_TOKEN", "SENTRY_AUTH_TOKEN"]); }); it("converts to local build env, available only to the build", () => { expect( toLocalBuildEnv({ - A: "1", - B: { secret: "s" }, - C: { secret: "t", version: "2" }, - D: { secret: "projects/q/secrets/u" }, - E: { secret: "projects/q/secrets/v", version: "4" }, + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token" }, + CMS_TOKEN: { secret: "projects/shared-secrets/secrets/cms-token", version: "4" }, }), ).to.deep.equal({ - A: { value: "1", availability: ["BUILD"] }, - B: { secret: "s@latest", availability: ["BUILD"] }, - C: { secret: "t@2", availability: ["BUILD"] }, - D: { secret: "projects/q/secrets/u/versions/latest", availability: ["BUILD"] }, - E: { secret: "projects/q/secrets/v/versions/4", availability: ["BUILD"] }, + API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, + NPM_TOKEN: { secret: "npm-token@latest", availability: ["BUILD"] }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2", availability: ["BUILD"] }, + GITHUB_TOKEN: { + secret: "projects/shared-secrets/secrets/github-token/versions/latest", + availability: ["BUILD"], + }, + CMS_TOKEN: { + secret: "projects/shared-secrets/secrets/cms-token/versions/4", + availability: ["BUILD"], + }, }); }); }); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 1ab6470e137..7b0baf8144c 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; import * as fs from "fs"; -import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -15,12 +14,15 @@ 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"; +const APP_ID = "firebase.google.com/app-id"; describe("run deploy", () => { 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"; + const scratchDir = "/tmp/run-local-build-web-abc123"; + const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; let createArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; @@ -33,13 +35,9 @@ describe("run deploy", () => { .stub(apphostingUtil, "createSourceDeployArchive") .resolves("/tmp/web.zip"); sinon.stub(fs, "createReadStream").returns("stream" as unknown as fs.ReadStream); - sinon.stub(gcs, "uploadObject").callsFake((src) => - Promise.resolve({ - bucket: "source-bucket", - object: path.basename(src.file), - generation: "1", - }), - ); + sinon + .stub(gcs, "uploadObject") + .resolves({ bucket: "source-bucket", object: "web.zip", generation: "1" }); sinon.stub(artifactregistry, "ensureDockerRepository").resolves(); submitBuildStub = sinon.stub(runv2, "submitBuild").resolves(); const deployed = { uri: "https://web.run.app" } as runv2.Service; @@ -123,27 +121,27 @@ describe("run deploy", () => { }); describe("linked Firebase Web App", () => { - const APP_ID = "firebase.google.com/app-id"; const linkedWeb = { name: "projects/my-project/locations/us-central1/services/web", - annotations: { team: "frontend", [APP_ID]: "old-app" }, + annotations: { team: "frontend", [APP_ID]: "1:123:web:abc" }, template: { containers: [{ name: "web", image: "old-image" }] }, } as unknown as runv2.Service; it("is saved on a new service", async () => { - await deployOne({ config: web, appId: "new-app" }); + await deployOne({ config: web, appId: "1:123:web:abc" }); - expect(createServiceStub.firstCall.args[3].annotations).to.deep.equal({ - [APP_ID]: "new-app", + expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { + annotations: { [APP_ID]: "1:123:web:abc" }, }); }); it("replaces the app saved on an existing service", async () => { - await deployOne({ config: web, existing: linkedWeb, appId: "new-app" }); + await deployOne({ config: web, existing: linkedWeb, appId: "1:123:web:def" }); - const [service, { updateMask }] = updateServiceStub.firstCall.args; - expect(service.annotations).to.deep.equal({ team: "frontend", [APP_ID]: "new-app" }); - expect(updateMask).to.deep.equal(["annotations", "template", "traffic"]); + expect(updateServiceStub).to.have.been.calledWithMatch( + { annotations: { team: "frontend", [APP_ID]: "1:123:web:def" } }, + { updateMask: ["annotations", "template", "traffic"] }, + ); }); it("is removed when the service is unlinked", async () => { @@ -153,7 +151,7 @@ describe("run deploy", () => { }); it("leaves the service's annotations alone if the app didn't change", async () => { - await deployOne({ config: web, existing: linkedWeb, appId: "old-app" }); + await deployOne({ config: web, existing: linkedWeb, appId: "1:123:web:abc" }); const [service, { updateMask }] = updateServiceStub.firstCall.args; expect(service).not.to.have.property("annotations"); @@ -163,28 +161,37 @@ describe("run deploy", () => { describe("local builds", () => { const localWeb = { ...web, localBuild: true }; + let rmSyncStub: sinon.SinonStub; let validateNodeStub: sinon.SinonStub; let localBuildStub: sinon.SinonStub; let tarArchiveStub: sinon.SinonStub; - let mkdtempSpy: sinon.SinonSpy; beforeEach(() => { - sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + sinon.stub(fs, "mkdtempSync").returns(scratchDir); + rmSyncStub = sinon.stub(fs, "rmSync"); validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ outputFiles: [".next"], - buildConfig: { runCommand: "node server.js" }, + buildConfig: { runCommand: "npm run start" }, }); tarArchiveStub = sinon .stub(apphostingUtil, "createLocalBuildTarArchive") .resolves("/tmp/web.tar.gz"); - mkdtempSpy = sinon.spy(fs, "mkdtempSync"); + (gcs.uploadObject as sinon.SinonStub) + .withArgs(sinon.match({ file: "/tmp/web.tar.gz" })) + .resolves(builtApp); }); - it("builds on this machine and uploads the output, without Cloud Build", async () => { + /** Deploys web with "localBuild": true and returns the container that it rolled out. */ + async function deployLocalBuild(): Promise { await deployOne({ config: localWeb, baseImage: NODEJS22 }); + return createServiceStub.firstCall.args[3].template.containers[0]; + } + + it("builds on this machine, in a scratch folder that it deletes afterwards", async () => { + await deployLocalBuild(); - const scratchDir = mkdtempSpy.firstCall.returnValue as string; expect(validateNodeStub).to.have.been.calledWith( { runtime: { value: "nodejs22" } }, "/project/apps/web", @@ -193,71 +200,69 @@ describe("run deploy", () => { "my-project", scratchDir, {}, - { - rootDir: "apps/web", - }, + { rootDir: "apps/web" }, ); - expect(tarArchiveStub).to.have.been.calledWithMatch({ backendId: "web" }, scratchDir, [ - ".next", - ]); + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + }); + + it("uploads the build's output and runs it as is, without Cloud Build", async () => { + const container = await deployLocalBuild(); + + expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]); expect(submitBuildStub).not.to.have.been.called; - expect(createServiceStub.firstCall.args[3].template.containers[0]).to.deep.equal({ - name: "web", + expect(container).to.deep.include({ image: "scratch", - sourceCode: { cloudStorageSource: { bucket: "source-bucket", object: "web.tar.gz" } }, - command: ["node", "server.js"], - baseImageUri: NODEJS22, + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], }); - expect(fs.existsSync(scratchDir)).to.be.false; }); it("keeps the env vars the app needs at runtime", async () => { localBuildStub.resolves({ outputFiles: [".next"], buildConfig: { - runCommand: "node server.js", env: [ { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, - { variable: "BUILD_ONLY", value: "x", availability: ["BUILD"] }, + { variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] }, ], }, }); - await deployOne({ config: localWeb, baseImage: NODEJS22 }); + const container = await deployLocalBuild(); - expect(createServiceStub.firstCall.args[3].template.containers[0].env).to.deep.equal([ - { name: "NODE_ENV", value: "production" }, - ]); + expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); }); it("passes the build env, secrets included, to the local build", async () => { - const buildEnv = { API_URL: "https://api.example.com", TOKEN: { secret: "t", version: "2" } }; + const buildEnv = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token", version: "2" }, + }; await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); - expect(localBuildStub.firstCall.args[2]).to.deep.equal({ - API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, - TOKEN: { secret: "t@2", availability: ["BUILD"] }, - }); - expect(localBuildStub.firstCall.args[3]).to.include({ allowLocalBuildSecrets: true }); + expect(localBuildStub).to.have.been.calledWithMatch( + "my-project", + scratchDir, + { API_URL: { value: "https://api.example.com" }, NPM_TOKEN: { secret: "npm-token@2" } }, + { allowLocalBuildSecrets: true }, + ); }); it("cleans up and deploys nothing if the local build fails", async () => { - localBuildStub.rejects(new Error("boom")); + localBuildStub.rejects(new Error("next build failed")); - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); - expect(fs.existsSync(mkdtempSpy.firstCall.returnValue as string)).to.be.false; + expect(rmSyncStub).to.have.been.calledWith(scratchDir); expect(createServiceStub).not.to.have.been.called; }); it("reports the local build error, not the cleanup error, when both fail", async () => { - localBuildStub.rejects(new Error("boom")); - sinon.stub(fs, "rmSync").throws(new Error("EBUSY")); - - await expect(deployOne({ config: localWeb, baseImage: NODEJS22 })).to.be.rejectedWith("boom"); + localBuildStub.rejects(new Error("next build failed")); + rmSyncStub.throws(new Error("EBUSY: resource busy or locked")); - fs.rmdirSync(mkdtempSpy.firstCall.returnValue as string); + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); }); }); @@ -278,7 +283,13 @@ describe("run deploy", () => { ], }, } as unknown as runv2.Service; - const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; + + /** The live web service, running a container with these fields. */ + function webRunning(container: Partial): runv2.Service { + return { + template: { containers: [{ name: "web", image: "old-image", ...container }] }, + } as runv2.Service; + } it("starts a new service with just the image", () => { expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ @@ -312,10 +323,14 @@ describe("run deploy", () => { }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate({ config: web, existing }, { image }, "Fix the login"); + const withMessage = revisionTemplate( + { config: web, existing }, + { image }, + "Fix the login page", + ); expect(withMessage.annotations).to.deep.equal({ team: "frontend", - [DEPLOY_MESSAGE]: "Fix the login", + [DEPLOY_MESSAGE]: "Fix the login page", }); const withoutMessage = revisionTemplate({ config: web, existing }, { image }); @@ -323,91 +338,78 @@ describe("run deploy", () => { }); it("runs a local build's output with the build's start command", () => { - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], runCommand: " node server.js " }; + // The command is split on whitespace, so stray spaces don't become empty arguments. + const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container).to.include({ image: "scratch" }); - expect(container?.sourceCode).to.deep.equal({ cloudStorageSource: builtApp }); - expect(container?.command).to.deep.equal(["node", "server.js"]); + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); it("adds the env vars a local build needs, unless the service already sets them", () => { const env = [ - { name: "MODE", value: "from-build" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]; - const localBuild = { scratchDir: "/tmp/x", outputFiles: [], env }; + const localBuild = { scratchDir, outputFiles: [".next"], env }; - const container = revisionTemplate({ config: web, existing, localBuild }, { builtApp }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - expect(container?.env).to.deep.equal([ + expect(template.containers?.[0].env).to.deep.equal([ { name: "MODE", value: "prod" }, { name: "NODE_ENV", value: "production" }, ]); }); it("switches a service from a local build back to an image", () => { - const fromLocalBuild = { - ...existing, - template: { - containers: [ - { - name: "web", - image: "scratch", - command: ["node", "server.js"], - sourceCode: { cloudStorageSource: builtApp }, - }, - ], - }, - } as unknown as runv2.Service; + const fromLocalBuild = webRunning({ + image: "scratch", + command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, + }); - const container = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }) - .containers?.[0]; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); - expect(container).to.deep.equal({ name: "web", image }); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); describe("FIREBASE_CONFIG", () => { - const linked = { ...existing, annotations: { "firebase.google.com/app-id": "web-app" } }; - const withConfig = (svc: runv2.Service, value: string): runv2.Service => - ({ - ...svc, - template: { - containers: [{ name: "web", image, env: [{ name: "FIREBASE_CONFIG", value }] }], - }, - }) as unknown as runv2.Service; + /** The live web service, with FIREBASE_CONFIG set to `value`. */ + function withConfig(value: string): runv2.Service { + return webRunning({ env: [{ name: "FIREBASE_CONFIG", value }] }); + } + const linked = { ...withConfig("old"), annotations: { [APP_ID]: "1:123:web:abc" } }; it("is set from the linked app's current config", () => { - const svc = { config: web, existing: withConfig(linked, "old"), firebaseConfig: "current" }; + const svc = { config: web, existing: linked, firebaseConfig: "current" }; - const container = revisionTemplate(svc, { image }).containers?.[0]; + const template = revisionTemplate(svc, { image }); - expect(container?.env).to.deep.equal([{ name: "FIREBASE_CONFIG", value: "current" }]); + expect(template.containers?.[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: "current" }, + ]); }); it("is removed when the app is unlinked", () => { - const container = revisionTemplate( - { config: web, existing: withConfig(linked, "old") }, - { - image, - }, - ).containers?.[0]; + const svc = { config: web, existing: linked }; - expect(container).not.to.have.property("env"); + const template = revisionTemplate(svc, { image }); + + expect(template.containers?.[0]).not.to.have.property("env"); }); it("is left alone if the user set it on a service that was never linked", () => { - const container = revisionTemplate( - { config: web, existing: withConfig(existing, "mine") }, - { - image, - }, - ).containers?.[0]; + const svc = { config: web, existing: withConfig("mine") }; + + const template = revisionTemplate(svc, { image }); - expect(container?.env).to.deep.equal([{ name: "FIREBASE_CONFIG", value: "mine" }]); + expect(template.containers?.[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: "mine" }, + ]); }); }); }); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index c44fae24eda..00958c8eefc 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -8,7 +8,7 @@ import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import * as utils from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; -import { BUILD_ENV_ANNOTATION } from "./buildEnv"; +import { BUILD_ENV_ANNOTATION, BuildEnv } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; import { FIREBASE_APP_ANNOTATION } from "./util"; @@ -21,6 +21,11 @@ describe("run prepare", () => { name: "projects/my-project/locations/us-central1/services/web", template: { containers: [{ name: "web", image: "old-image", baseImageUri: nodejs22 }] }, } as unknown as runv2.Service; + /** The live web service, with this build env annotation. */ + const withBuildEnv = (env: BuildEnv): runv2.Service => ({ + ...liveWeb, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }); let prereqsStub: sinon.SinonStub; let getServiceStub: sinon.SinonStub; @@ -75,12 +80,14 @@ describe("run prepare", () => { describe("local builds", () => { it("require a base image", async () => { - getServiceStub.resolves({ + const webWithoutBaseImage = { ...liveWeb, template: { containers: [{ name: "web", image: "old-image" }] }, - }); + }; + getServiceStub.resolves(webWithoutBaseImage); + await expect(prepareRun(localWeb)).to.be.rejectedWith( - /Local builds require a base image.*firebase run:services:update web:us-central1 --base-image/, + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', ); }); @@ -89,65 +96,50 @@ describe("run prepare", () => { 'Local builds require a base image. Set one for service web in us-central1 with "firebase init run".', ); }); - - it("are left to the deploy step", async () => { - getServiceStub.resolves(liveWeb); - const [svc] = await prepareRun(localWeb); - expect(svc.baseImage).to.equal(nodejs22); - expect(svc).not.to.have.property("localBuild"); - }); }); describe("build env", () => { - const withBuildEnv = (env: Record): runv2.Service => - ({ - ...liveWeb, - annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, - }) as unknown as runv2.Service; - it("reads the build env from the service's annotation", async () => { getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); + const [svc] = await prepareRun(web); + expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); }); it("rejects build secrets on Cloud Build", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t" } })); + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + await expect(prepareRun(web)).to.be.rejectedWith( - /Service web in us-central1 has build secrets \(TOKEN\).*"localBuild": true/, + /Service web in us-central1 has build secrets \(NPM_TOKEN\).*"localBuild": true/, ); }); it("keeps build secrets for local builds", async () => { - getServiceStub.resolves(withBuildEnv({ TOKEN: { secret: "t", version: "2" } })); + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + const [svc] = await prepareRun(localWeb); - expect(svc.buildEnv).to.deep.equal({ TOKEN: { secret: "t", version: "2" } }); + + expect(svc.buildEnv).to.deep.equal({ NPM_TOKEN: { secret: "npm-token" } }); }); }); describe("sdk autoinit", () => { - const appId = "1:1:web:a"; - const defaultSa = "123-compute@developer.gserviceaccount.com"; - const adminSdkRole = "roles/firebase.sdkAdminServiceAgent"; + const appId = "1:123:web:abc"; const webConfig = { projectId: "my-project", appId, - apiKey: "k", + apiKey: "api-key", storageBucket: "my-project.appspot.com", }; - // What the Admin SDK and the client SDK read to auto-initialize. - const firebaseConfig = JSON.stringify({ - storageBucket: "my-project.appspot.com", - projectId: "my-project", + const firebaseConfig = '{"storageBucket":"my-project.appspot.com","projectId":"my-project"}'; + const defaultSa = "123-compute@developer.gserviceaccount.com"; + const adminSdkRole = "roles/firebase.sdkAdminServiceAgent"; + /** The service, linked to the app. */ + const linked = (service: runv2.Service): runv2.Service => ({ + ...service, + annotations: { ...service.annotations, [FIREBASE_APP_ANNOTATION]: appId }, }); - const webappConfig = JSON.stringify(webConfig); - /** The web service, linked to the Firebase Web App. */ - const linkedWeb = (overrides: Record = {}): runv2.Service => - ({ - ...liveWeb, - annotations: { [FIREBASE_APP_ANNOTATION]: appId }, - ...overrides, - }) as unknown as runv2.Service; let getAppConfigStub: sinon.SinonStub; let hasRolesStub: sinon.SinonStub; let addRolesStub: sinon.SinonStub; @@ -157,24 +149,22 @@ describe("run prepare", () => { sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); sinon.stub(computeEngine, "getDefaultServiceAccount").resolves(defaultSa); hasRolesStub = sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); - addRolesStub = sinon - .stub(resourceManager, "addServiceAccountToRoles") - .resolves({ bindings: [], etag: "", version: 3 }); + addRolesStub = sinon.stub(resourceManager, "addServiceAccountToRoles").resolves(); }); it("reuses the service's linked app and resolves its build and runtime config", async () => { - getServiceStub.resolves(linkedWeb()); + getServiceStub.resolves(linked(liveWeb)); const [svc] = await prepareRun(web); expect(getAppConfigStub).to.have.been.calledWith(appId, managementApps.AppPlatform.WEB); - expect(svc.appId).to.equal(appId); - expect(svc.firebaseConfig).to.equal(firebaseConfig); + expect(svc).to.include({ appId, firebaseConfig }); expect(svc.buildEnv).to.deep.equal({ - FIREBASE_WEBAPP_CONFIG: webappConfig, + FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), FIREBASE_CONFIG: firebaseConfig, }); - expect(hasRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole], true); + // The default service account already has the role. + expect(hasRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); expect(addRolesStub).not.to.have.been.called; }); @@ -183,30 +173,20 @@ describe("run prepare", () => { await prepareRun(web, { context: { appId } }); - expect(addRolesStub).to.have.been.calledOnceWithExactly( - "my-project", - defaultSa, - [adminSdkRole], - true, - ); + expect(addRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); }); it("checks and grants the Admin SDK role on a custom service account", async () => { - const customSa = "custom@my-project.iam.gserviceaccount.com"; - hasRolesStub.resolves(false); + const customSa = "web@my-project.iam.gserviceaccount.com"; getServiceStub.resolves( - linkedWeb({ template: { ...liveWeb.template, serviceAccount: customSa } }), + linked({ ...liveWeb, template: { ...liveWeb.template, serviceAccount: customSa } }), ); + hasRolesStub.resolves(false); await prepareRun(web); - expect(hasRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole], true); - expect(addRolesStub).to.have.been.calledOnceWithExactly( - "my-project", - customSa, - [adminSdkRole], - true, - ); + expect(hasRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole]); + expect(addRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole]); }); it("warns and continues if it isn't allowed to grant the Admin SDK role", async () => { @@ -221,66 +201,44 @@ describe("run prepare", () => { }); it("lets the context set or clear the linked app", async () => { - getServiceStub.resolves(linkedWeb({ annotations: { [FIREBASE_APP_ANNOTATION]: "old-app" } })); + getServiceStub.resolves(linked(liveWeb)); - const [linked] = await prepareRun(web, { context: { appId } }); - expect(linked.appId).to.equal(appId); + const [withNewApp] = await prepareRun(web, { context: { appId: "1:123:web:def" } }); + expect(withNewApp.appId).to.equal("1:123:web:def"); const [cleared] = await prepareRun(web, { context: { appId: null } }); - expect(cleared.appId).to.be.undefined; - expect(cleared.firebaseConfig).to.be.undefined; - expect(cleared.buildEnv).to.be.undefined; - }); - - it("always uses the linked app's current config, not the one on the container", async () => { - const stale = [{ name: "FIREBASE_CONFIG", value: '{"projectId":"my-project"}' }]; - getServiceStub.resolves( - linkedWeb({ template: { containers: [{ name: "web", image: "old-image", env: stale }] } }), - ); - - const [svc] = await prepareRun(web); - - expect(svc.firebaseConfig).to.equal(firebaseConfig); - expect(svc.buildEnv).to.deep.equal({ - FIREBASE_WEBAPP_CONFIG: webappConfig, - FIREBASE_CONFIG: firebaseConfig, - }); + expect(cleared).not.to.have.any.keys("appId", "firebaseConfig", "buildEnv"); }); it("lets the build env annotation override the app's config at build time only", async () => { - const userBuildEnv = { FIREBASE_WEBAPP_CONFIG: "custom-webapp", FIREBASE_CONFIG: "custom" }; - getServiceStub.resolves( - linkedWeb({ - annotations: { - [FIREBASE_APP_ANNOTATION]: appId, - [BUILD_ENV_ANNOTATION]: JSON.stringify(userBuildEnv), - }, - }), - ); + const override = '{"projectId":"my-other-project"}'; + getServiceStub.resolves(linked(withBuildEnv({ FIREBASE_CONFIG: override }))); const [svc] = await prepareRun(web); - expect(svc.buildEnv).to.deep.equal(userBuildEnv); + expect(svc.buildEnv).to.include({ FIREBASE_CONFIG: override }); expect(svc.firebaseConfig).to.equal(firebaseConfig); }); it("fails if the linked app can't be looked up", async () => { - getServiceStub.resolves(linkedWeb()); + getServiceStub.resolves(linked(liveWeb)); getAppConfigStub.rejects(new Error("boom")); await expect(prepareRun(web)).to.be.rejectedWith( - /Unable to look up Firebase Web App 1:1:web:a for service web in us-central1: boom\n.*firebase run:services:update web:us-central1 --app .*firebase run:services:update web:us-central1 --clear-app/, + "Unable to look up Firebase Web App 1:123:web:abc for service web in us-central1: boom\n" + + 'To use a different app, run "firebase run:services:update web:us-central1 --app ". ' + + 'To deploy without Firebase SDK auto-initialization, run "firebase run:services:update web:us-central1 --clear-app".', ); expect(hasRolesStub).not.to.have.been.called; }); - it("fails if a newly linked app can't be looked up", async () => { - getAppConfigStub.rejects(new Error("boom")); + it("rejects a service it can't build before looking up the app or touching IAM", async () => { + getServiceStub.resolves(linked(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } }))); - await expect(prepareRun(web, { context: { appId: "bad-app" } })).to.be.rejectedWith( - "Unable to look up Firebase Web App bad-app for service web in us-central1: boom", - ); + await expect(prepareRun(web)).to.be.rejectedWith("has build secrets (NPM_TOKEN)"); + expect(getAppConfigStub).not.to.have.been.called; expect(hasRolesStub).not.to.have.been.called; + expect(addRolesStub).not.to.have.been.called; }); }); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 3e943d330e2..af2e5b3423f 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -38,7 +38,7 @@ describe("init run", () => { sinon.stub(runv2, "getService").rejects({ status: 404 }); getOrCreateWebAppStub = sinon .stub(webApps, "getOrCreateWebApp") - .resolves({ name: "my-service", id: "1:1:web:a" }); + .resolves({ name: "my-service", id: "1:123:web:abc" }); selectStub = sinon.stub(prompt, "select"); inputStub = sinon.stub(prompt, "input"); }); @@ -46,6 +46,13 @@ describe("init run", () => { afterEach(() => sinon.restore()); describe("askQuestions", () => { + /** Answers init's prompts, select or input, in the order it asks them. */ + function answerPrompts(...answers: unknown[]): void { + const nextAnswer = (): Promise => Promise.resolve(answers.shift()); + selectStub.callsFake(nextAnswer); + inputStub.callsFake(nextAnswer); + } + it("throws if no project is selected", async () => { await expect( askQuestions({ ...setup(), projectId: undefined }, config, options), @@ -76,7 +83,7 @@ describe("init run", () => { region: "us-east1", baseImage: "", rootDir: "/", - appId: "1:1:web:a", + appId: "1:123:web:abc", }); }); @@ -94,10 +101,10 @@ describe("init run", () => { ); }); - it("updates an existing service and reuses its linked Web App", async () => { + it("updates an existing service, keeping the Firebase Web App it's linked to", async () => { const existing = { name: "projects/p/locations/europe-west1/services/web", - annotations: { "firebase.google.com/app-id": "existing-app" }, + annotations: { "firebase.google.com/app-id": "1:123:web:def" }, template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs20" }] }, }; const managed = { @@ -112,16 +119,16 @@ describe("init run", () => { await askQuestions(s, config, options); expect(runv2.listServices).to.have.been.calledWith("p", false); - expect(getOrCreateWebAppStub).not.to.have.been.called; expect(selectStub.secondCall.args[0].choices).to.deep.equal([ { name: "web (europe-west1)", value: existing }, ]); + expect(getOrCreateWebAppStub).not.to.have.been.called; expect(s.featureInfo?.run).to.deep.equal({ serviceId: "web", region: "europe-west1", baseImage: "nodejs20", rootDir: "/", - appId: "existing-app", + appId: "1:123:web:def", }); }); @@ -154,56 +161,38 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); - it("enables localBuild and requires a base image when building locally", async () => { - selectStub - .onFirstCall() - .resolves("create") - .onSecondCall() - .resolves("us-central1") - .onThirdCall() - .resolves(true); - inputStub.onFirstCall().resolves("my-service").onSecondCall().resolves("nodejs22"); - inputStub.onThirdCall().resolves("/"); - const s = setup(); + it("builds a new service remotely by default", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ false, "", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.false; - const baseImagePrompt = inputStub.secondCall.args[0]; - expect(baseImagePrompt.default).to.be.undefined; - expect(baseImagePrompt.validate("nodejs22")).to.be.true; - expect(baseImagePrompt.validate(" ")).to.include("Local builds require a base image"); - expect(s.featureInfo?.run).to.deep.equal({ - serviceId: "my-service", - region: "us-central1", - baseImage: "nodejs22", - rootDir: "/", - localBuild: true, - appId: "1:1:web:a", - }); + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.false; }); - it("defaults to building locally if the saved entry for the service does", async () => { - const existing = { - name: "projects/p/locations/europe-west1/services/web", - template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs22" }] }, - }; + it("defaults to building locally if firebase.json builds the service locally", async () => { + const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); - selectStub.onFirstCall().resolves("update").onSecondCall().resolves(existing); - selectStub.onThirdCall().callsFake((o) => Promise.resolve(o.default)); - inputStub.callsFake((o) => Promise.resolve(o.default)); - config.set("run", { - serviceId: "web", - region: "europe-west1", - rootDir: "/", - localBuild: true, - }); - const s = setup(); + config.set("run", { serviceId: "web", region: "europe-west1", localBuild: true }); + answerPrompts("update", existing, /* localBuild= */ true, "nodejs22", "/"); - await askQuestions(s, config, options); + await askQuestions(setup(), config, options); - expect(selectStub.thirdCall.args[0].default).to.be.true; - expect(s.featureInfo?.run?.localBuild).to.be.true; + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.true; + }); + + it("saves the choice to build locally, which requires a base image", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ true, "nodejs22", "/"); + const initSetup = setup(); + + await askQuestions(initSetup, config, options); + + const baseImagePrompt = inputStub.secondCall.args[0]; + expect(baseImagePrompt.default).to.be.undefined; + expect(baseImagePrompt.validate(" ")).to.equal("Local builds require a base image."); + expect(baseImagePrompt.validate("nodejs22")).to.be.true; + expect(initSetup.featureInfo?.run).to.include({ localBuild: true }); }); }); @@ -265,11 +254,10 @@ describe("init run", () => { }); it("deploys with the Firebase Web App that init linked", async () => { - await actuate(webSetup({ appId: "1:1:web:a" }), config, options); + await actuate(webSetup({ appId: "1:123:web:abc" }), config, options); - expect(deployStub.firstCall.args[2]).to.deep.equal({ - baseImage: "nodejs22", - appId: "1:1:web:a", + expect(deployStub).to.have.been.calledWithMatch(["run"], sinon.match.any, { + appId: "1:123:web:abc", }); }); @@ -304,45 +292,22 @@ describe("init run", () => { ]); }); - it("updates an existing service in place while preserving custom ignore and updating localBuild", () => { - config.set("run", { - serviceId: "web", - region: "us-central1", - rootDir: "/", - ignore: ["custom-ignore"], - }); + it("turns localBuild on for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"] }); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - localBuild: true, - ignore: ["custom-ignore"], - }); + upsertRunConfig({ ...web, localBuild: true }, config); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - }, - config, - ); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - ignore: ["custom-ignore"], - }); + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"], localBuild: true }); + }); + + it("turns localBuild off for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"], localBuild: true }); + + upsertRunConfig(web, config); + + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"] }); }); it("doesn't add an ignore list to an existing service", () => { From 716c4fdacc9a46a644ead9315164636374f16401 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 12:27:45 -0400 Subject: [PATCH 084/103] 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(); From 1e3accb3e15ec46d3c3e23f2cd8231a87985176e Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 14:00:38 -0400 Subject: [PATCH 085/103] Split run:services:update's service name into serviceId and region updateService took one name that was either or :. It now takes serviceId and region, and the command takes the region from --region. --- src/commands/run-services-update.ts | 11 ++++++--- src/deploy/run/update.spec.ts | 26 +++++++++++++------- src/deploy/run/update.ts | 38 +++++++++++++++++++---------- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts index 0d6d8dde7db..d32021e47f2 100644 --- a/src/commands/run-services-update.ts +++ b/src/commands/run-services-update.ts @@ -6,9 +6,10 @@ import { requireConfig } from "../requireConfig"; import { requirePermissions } from "../requirePermissions"; export const command = new Command("run:services:update ") - .description( - "update the settings of a Cloud Run service, then build and deploy it. " + - "If firebase.json lists the service ID in more than one region, use :", + .description("update the settings of a Cloud Run service, then build and deploy it") + .option( + "--region ", + "the service's region, if firebase.json lists the service ID in more than one region", ) .option( "--base-image ", @@ -17,4 +18,6 @@ export const command = new Command("run:services:update ") .option("--clear-base-image", "clear the base image and disable automatic base image updates") .before(requireConfig) .before(requirePermissions, TARGET_PERMISSIONS.run) - .action((name: string, options: Options) => updateService(name, options)); + .action((serviceId: string, options: Options) => + updateService(serviceId, options.region as string | undefined, options), + ); diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 86df8984c09..62a3155b968 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -20,10 +20,18 @@ describe("updateService", () => { afterEach(() => sinon.restore()); - /** Runs `firebase run:services:update ` with these flags and firebase.json "run" config. */ - function update(name: string, flags: Record, run: unknown = web): Promise { + /** Runs `firebase run:services:update ` with these flags and firebase.json "run" config. */ + function update( + serviceId: string, + flags: Record, + run: unknown = web, + ): Promise { const options = { project: "my-project", config: { src: { run } }, ...flags }; - return updateService(name, options as unknown as Options); + return updateService( + serviceId, + flags.region as string | undefined, + options as unknown as Options, + ); } /** Asserts that update deployed only `only`, with these settings. */ @@ -48,9 +56,9 @@ describe("updateService", () => { await expect(update("api", { baseImage: "nodejs22" })).to.be.rejectedWith( "Cloud Run service api not detected in firebase.json.", ); - await expect(update("web:europe-west1", { baseImage: "nodejs22" })).to.be.rejectedWith( - "Cloud Run service web:europe-west1 not detected in firebase.json.", - ); + await expect( + update("web", { region: "europe-west1", baseImage: "nodejs22" }), + ).to.be.rejectedWith("Cloud Run service web in europe-west1 not detected in firebase.json."); }); it("requires the service to exist", async () => { @@ -77,14 +85,14 @@ describe("updateService", () => { describe("when firebase.json lists the service in two regions", () => { it("asks which one to update", async () => { await expect(update("web", { baseImage: "nodejs22" }, [web, webEurope])).to.be.rejectedWith( - "web matches 2 services in firebase.json: web:us-central1, web:europe-west1. " + - "Run the command again with one of them, e.g. firebase run:services:update web:us-central1", + "firebase.json lists web in 2 regions: us-central1, europe-west1. " + + "Pick one with --region, e.g. firebase run:services:update web --region us-central1", ); expect(deployStub).not.to.have.been.called; }); it("updates only the region it's given", async () => { - await update("web:europe-west1", { baseImage: "nodejs22" }, [web, webEurope]); + await update("web", { region: "europe-west1", baseImage: "nodejs22" }, [web, webEurope]); expect(getServiceStub).to.have.been.calledOnceWith("my-project", "europe-west1", "web"); expectDeployed("run:web:europe-west1", { baseImage: "nodejs22" }); diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index 80a0f4ae65c..df81e9176a5 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -4,15 +4,19 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; -import { findServices, fullServiceName, getAllServiceConfigs, getExistingService } from "./util"; +import { fullServiceName, getAllServiceConfigs, getExistingService } from "./util"; /** * Changes a service's settings by deploying it with them, like `firebase deploy` does. It always * rebuilds, even if nothing changed: a new base image needs a new build, because images built for * a base image leave out the OS and runtime that other images bring. - * `name` is , or : if firebase.json lists the ID in several regions. + * `region` is only needed if firebase.json lists the service ID in more than one region. */ -export async function updateService(name: string, options: Options): Promise { +export async function updateService( + serviceId: string, + region: string | undefined, + options: Options, +): Promise { // Deploy reads each setting as an override: a value sets it, null clears it, and undefined keeps // the service's current value. const settings = { @@ -25,7 +29,7 @@ export async function updateService(name: string, options: Options): Promise c.serviceId === serviceId && (!region || c.region === region), + ); if (!matches.length) { - throw new FirebaseError(`Cloud Run service ${name} not detected in firebase.json.`); + const service = region ? `${serviceId} in ${region}` : serviceId; + throw new FirebaseError(`Cloud Run service ${service} not detected in firebase.json.`); } if (matches.length > 1) { - const names = matches.map(fullServiceName); + const regions = matches.map((c) => c.region); + const example = `firebase run:services:update ${serviceId} --region ${regions[0]}`; throw new FirebaseError( - `${name} matches ${matches.length} services in firebase.json: ${names.join(", ")}. ` + - `Run the command again with one of them, e.g. firebase run:services:update ${names[0]}`, + `firebase.json lists ${serviceId} in ${regions.length} regions: ${regions.join(", ")}. ` + + `Pick one with --region, e.g. ${example}`, ); } return matches[0]; From 675082b694ad5714f7e7c987048e3b7003c1043c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 14:46:42 -0400 Subject: [PATCH 086/103] Say which deploy step sets each ServiceDeploy field --- src/deploy/run/args.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index ba65fda63b1..ea01f146716 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,17 +1,21 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; -/** A service being deployed. prepare fills in the first fields, and deploy sets `deployed`. */ +/** A service being deployed. Each field says which step sets it: prepare or deploy. */ export interface ServiceDeploy { + /** Set by prepare: the service's entry in firebase.json. */ config: RunSingle; - /** The service as it is in Cloud Run before this deploy, or undefined if it doesn't exist yet. */ + /** + * Set by prepare: 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. + * Set by prepare: the base image to build and run on. It's 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. */ + /** Set by deploy: the service after the rollout. */ deployed?: runv2.Service; } From f7b5a179f76954db90ce869387dd57509aa2e655 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 14:50:49 -0400 Subject: [PATCH 087/103] Split Cloud Run deploys into remote and local build paths deploy() now calls deployRemoteBuild or deployLocalBuild. Each path has its own upload function, and local builds get their own revision template, so there are no more RevisionCode unions or repeated localBuild ternaries. The local build's output is passed between functions instead of being stored on ServiceDeploy. The local build guard's fix now uses run:services:update's --region flag. --- src/deploy/run/args.ts | 7 - src/deploy/run/deploy.spec.ts | 76 ++++++----- src/deploy/run/deploy.ts | 228 +++++++++++++++++++++++---------- src/deploy/run/prepare.spec.ts | 2 +- src/deploy/run/prepare.ts | 4 +- 5 files changed, 200 insertions(+), 117 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 435a1163330..ea01f146716 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -15,13 +15,6 @@ export interface ServiceDeploy { * this deploy sets or clears it. Undefined means none, so automatic base image updates are off. */ baseImage?: string; - /** Set by deploy, for local builds only: the local build's output. */ - localBuild?: { - scratchDir: string; - outputFiles: string[]; - runCommand?: string; - env?: NonNullable; - }; /** Set by deploy: the service after the rollout. */ deployed?: runv2.Service; } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index a6e50a83e64..c9bbc450272 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -10,7 +10,7 @@ import { Options } from "../../options"; import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { deploy, revisionTemplate } from "./deploy"; +import { deploy, localBuildTemplate, revisionTemplate } from "./deploy"; const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; @@ -229,13 +229,13 @@ describe("run deploy", () => { } it("starts a new service with just the image", () => { - expect(revisionTemplate({ config: web }, { image })).to.deep.equal({ + expect(revisionTemplate({ config: web }, image)).to.deep.equal({ containers: [{ name: "web", image }], }); }); it("keeps the live revision's settings, but not its name", () => { - const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, { image }); + const template = revisionTemplate({ config: web, existing, baseImage: NODEJS22 }, image); expect(template).to.deep.equal({ serviceAccount: "web@my-project.iam.gserviceaccount.com", @@ -251,67 +251,65 @@ describe("run deploy", () => { it("sets the base image, or clears it", () => { const withBaseImage = revisionTemplate( { config: web, existing, baseImage: "nodejs24" }, - { image }, + image, ); expect(withBaseImage.containers?.[0].baseImageUri).to.equal("nodejs24"); - const withoutBaseImage = revisionTemplate({ config: web, existing }, { image }); + const withoutBaseImage = revisionTemplate({ config: web, existing }, image); expect(withoutBaseImage.containers?.[0]).not.to.have.property("baseImageUri"); }); it("sets the deploy message, or clears the last one", () => { - const withMessage = revisionTemplate( - { config: web, existing }, - { image }, - "Fix the login page", - ); + const withMessage = revisionTemplate({ config: web, existing }, image, "Fix the login page"); expect(withMessage.annotations).to.deep.equal({ team: "frontend", [DEPLOY_MESSAGE]: "Fix the login page", }); - const withoutMessage = revisionTemplate({ config: web, existing }, { image }); + const withoutMessage = revisionTemplate({ config: web, existing }, image); expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" }); }); - it("runs a local build's output with the build's start command", () => { - // The command is split on whitespace, so stray spaces don't become empty arguments. - const localBuild = { scratchDir, outputFiles: [".next"], runCommand: " npm run start " }; - - const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - - expect(template.containers?.[0]).to.deep.include({ + it("switches a service from a local build back to an image", () => { + const fromLocalBuild = webRunning({ image: "scratch", - sourceCode: { cloudStorageSource: builtApp }, command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, }); - }); - it("adds the env vars a local build needs, unless the service already sets them", () => { - const env = [ - { name: "MODE", value: "development" }, - { name: "NODE_ENV", value: "production" }, - ]; - const localBuild = { scratchDir, outputFiles: [".next"], env }; + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, image); - const template = revisionTemplate({ config: web, existing, localBuild }, { builtApp }); - - expect(template.containers?.[0].env).to.deep.equal([ - { name: "MODE", value: "prod" }, - { name: "NODE_ENV", value: "production" }, - ]); + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); - it("switches a service from a local build back to an image", () => { - const fromLocalBuild = webRunning({ - image: "scratch", - command: ["npm", "run", "start"], - sourceCode: { cloudStorageSource: builtApp }, + describe("localBuildTemplate", () => { + it("runs a local build's output with the build's start command", () => { + // The command is split on whitespace, so stray spaces don't become empty arguments. + const build = { outputFiles: [".next"], runCommand: " npm run start ", env: [] }; + + const template = localBuildTemplate({ config: web, existing }, build, builtApp); + + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); }); - const template = revisionTemplate({ config: web, existing: fromLocalBuild }, { image }); + it("adds the env vars a local build needs, unless the service already sets them", () => { + const env = [ + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ]; + const build = { outputFiles: [".next"], env }; - expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); + const template = localBuildTemplate({ config: web, existing }, build, builtApp); + + expect(template.containers?.[0].env).to.deep.equal([ + { name: "MODE", value: "prod" }, + { name: "NODE_ENV", value: "production" }, + ]); + }); }); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 5aa1f01ea03..f6995709216 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -22,45 +22,78 @@ const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; -/** What a new revision runs: an image built on Cloud Build, or a local build's uploaded output. */ -type RevisionCode = { image: string } | { builtApp: runv2.StorageSource }; +/** + * The image of a container that runs a local build's output. Cloud Run runs the output from Cloud + * Storage on the service's base image, so the container doesn't need an image of its own. + */ +const NO_IMAGE = "scratch"; + +/** What a local build produced. */ +export interface LocalBuildOutput { + /** The built files to upload, relative to the scratch directory the build ran in. */ + outputFiles: string[]; + /** The command that starts the app, e.g. "npm run start". */ + runCommand?: string; + /** The env vars the app needs at runtime. */ + env: NonNullable; +} /** Builds each service, locally or on Cloud Build, then rolls it out. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { - const { projectId } = context; for (const svc of payload.run?.services || []) { - // A local build runs in a scratch copy of the source, which is deleted afterwards. - const scratchDir = svc.config.localBuild - ? fs.mkdtempSync(path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`)) - : undefined; - try { - if (scratchDir) { - svc.localBuild = await buildLocally(context, options, svc, scratchDir); - } - const uploaded = await uploadSource(projectId, options, svc); - // Cloud Run runs a local build's output as is. Other source is built into an image first. - const code = svc.localBuild - ? { builtApp: uploaded } - : { image: await buildImage(projectId, svc, uploaded) }; - const template = revisionTemplate(svc, code, options.message as string | undefined); - svc.deployed = svc.existing - ? await deployRevision(svc.existing, template) - : await createNewService(projectId, svc.config, template); - } finally { - if (scratchDir) { - removeTempPath(scratchDir); - } + if (svc.config.localBuild) { + await deployLocalBuild(context, options, svc); + } else { + await deployRemoteBuild(context, options, svc); } } } +/** Uploads the service's source, builds it into an image on Cloud Build, then rolls it out. */ +async function deployRemoteBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + const source = await uploadSource(projectId, options, svc.config); + const image = await buildImage(projectId, svc, source); + const template = revisionTemplate(svc, image, options.message as string | undefined); + svc.deployed = await rollOut(projectId, svc, template); +} + +/** + * Builds the service on this machine, uploads the output, then rolls it out. Cloud Run runs the + * output as is, so nothing is built on Cloud Build. + */ +async function deployLocalBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + // The build runs in a scratch copy of the source, which is deleted afterwards. + const scratchDir = fs.mkdtempSync( + path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`), + ); + try { + const build = await buildLocally(context, options, svc, scratchDir); + const builtApp = await uploadBuiltApp(projectId, options, svc.config, scratchDir, build); + const message = options.message as string | undefined; + const template = localBuildTemplate(svc, build, builtApp, message); + svc.deployed = await rollOut(projectId, svc, template); + } finally { + removeTempPath(scratchDir); + } +} + /** Builds the service on this machine, in a scratch copy of its source. */ async function buildLocally( context: Context, options: Options, svc: ServiceDeploy, scratchDir: string, -): Promise> { +): Promise { const { config } = svc; const { serviceId, region } = config; const cfg = toAppHostingConfig(config); @@ -86,19 +119,66 @@ async function buildLocally( (!e.availability || e.availability.includes("RUNTIME")), ) .map((e) => ({ name: e.variable, value: e.value! })); - return { scratchDir, outputFiles, runCommand: buildConfig.runCommand, env }; + return { outputFiles, runCommand: buildConfig.runCommand, env }; } -/** Uploads the service's source, or its local build's output, to the region's source bucket. */ +/** Zips the service's source and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, options: Options, - svc: ServiceDeploy, + config: RunSingle, ): Promise { - const { serviceId, region } = svc.config; - // Like App Hosting, each region has one source bucket, which deletes uploads after 30 days. + const { serviceId, region } = config; + const bucketName = await sourceBucket(projectId, options, region); + const cfg = toAppHostingConfig(config); + // The zip is a tmp file, so it's deleted when the CLI exits. + const archive = await createSourceDeployArchive( + cfg, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.ZIP, + ); + return { bucket, object }; +} + +/** Tars a local build's output and uploads it to the region's source bucket. */ +async function uploadBuiltApp( + projectId: string, + options: Options, + config: RunSingle, + scratchDir: string, + build: LocalBuildOutput, +): Promise { + const { serviceId, region } = config; + const bucketName = await sourceBucket(projectId, options, region); + // The tarball is a tmp file, so it's deleted when the CLI exits. + const archive = await createLocalBuildTarArchive( + toAppHostingConfig(config), + scratchDir, + build.outputFiles, + ); + logLabeledBullet("run", `Uploading built app for service ${serviceId} in ${region}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.TAR, + // Cloud Run runs the tarball as is, so it has to fit Cloud Run's size limit. + CLOUD_RUN_SIZE_LIMIT_BYTES, + ); + return { bucket, object }; +} + +/** + * Returns the region's source bucket, creating it if needed. Like App Hosting, each region has one + * source bucket, which deletes uploads after 30 days. + */ +async function sourceBucket(projectId: string, options: Options, region: string): Promise { const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; - const bucketName = await gcs.upsertBucket({ + return gcs.upsertBucket({ product: "run", createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, projectId, @@ -109,23 +189,6 @@ async function uploadSource( lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, }, }); - - const cfg = toAppHostingConfig(svc.config); - // The archive is a tmp file, so it's deleted when the CLI exits. - const archive = svc.localBuild - ? await createLocalBuildTarArchive(cfg, svc.localBuild.scratchDir, svc.localBuild.outputFiles) - : await createSourceDeployArchive(cfg, path.join(options.config.projectDir, cfg.rootDir)); - logLabeledBullet( - "run", - `Uploading ${svc.localBuild ? "built app" : "source"} for service ${serviceId} in ${region}...`, - ); - const { bucket, object } = await gcs.uploadObject( - { file: archive, stream: fs.createReadStream(archive) }, - bucketName, - svc.localBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP, - svc.localBuild ? CLOUD_RUN_SIZE_LIMIT_BYTES : undefined, - ); - return { bucket, object }; } /** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ @@ -156,17 +219,15 @@ async function buildImage( /** * Returns the revision to roll out: the live revision's settings (env vars, service account, - * scaling, etc.) with the new code, base image, and deploy message. + * scaling, etc.) with the new image, base image, and deploy message. * Exported for testing. */ export function revisionTemplate( svc: ServiceDeploy, - code: RevisionCode, + image: string, message?: string, ): runv2.RevisionTemplate { const { serviceId, region } = svc.config; - // Cloud Run runs a local build's output from source, on top of the base image. - const image = "image" in code ? code.image : "scratch"; const template: runv2.RevisionTemplate = svc.existing ? cloneDeep(svc.existing.template) : { containers: [{ name: serviceId, image }] }; @@ -177,22 +238,9 @@ export function revisionTemplate( throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); } container.image = image; - if ("builtApp" in code) { - container.sourceCode = { cloudStorageSource: code.builtApp }; - const cmd = svc.localBuild?.runCommand?.trim(); - if (cmd) { - container.command = cmd.split(/\s+/); - } else { - delete container.command; - } - // Add the env vars the build says the app needs, unless the service already sets them. - const existingNames = new Set((container.env || []).map((e) => e.name)); - const newEnv = (svc.localBuild?.env || []).filter((e) => !existingNames.has(e.name)); - if (newEnv.length) { - container.env = [...(container.env || []), ...newEnv]; - } - } else if (container.sourceCode) { - // The service was last deployed from a local build, which set these. + if (container.sourceCode) { + // The live revision ran a local build's output, with the build's start command (see + // localBuildTemplate). Drop both, so that the new revision runs the new image as built. delete container.sourceCode; delete container.command; } @@ -213,6 +261,50 @@ export function revisionTemplate( return template; } +/** + * Returns the revision to roll out for a local build: revisionTemplate's, but running the build's + * output from Cloud Storage, on the service's base image, with the build's start command. + * Exported for testing. + */ +export function localBuildTemplate( + svc: ServiceDeploy, + build: LocalBuildOutput, + builtApp: runv2.StorageSource, + message?: string, +): runv2.RevisionTemplate { + const template = revisionTemplate(svc, NO_IMAGE, message); + // revisionTemplate has already checked that there's a container. + const container = mainContainer(template)!; + container.sourceCode = { cloudStorageSource: builtApp }; + // Split on any whitespace, so that stray spaces don't become empty arguments. + const command = build.runCommand?.trim(); + if (command) { + container.command = command.split(/\s+/); + } else { + // The build didn't name a start command, so don't keep one from an earlier revision. + delete container.command; + } + // Add the env vars the build says the app needs, unless the service already sets them. + const existingNames = new Set((container.env || []).map((e) => e.name)); + const newEnv = build.env.filter((e) => !existingNames.has(e.name)); + if (newEnv.length) { + container.env = [...(container.env || []), ...newEnv]; + } + return template; +} + +/** Rolls out the new revision: creates the service if it's new, or updates it. */ +function rollOut( + projectId: string, + svc: ServiceDeploy, + template: runv2.RevisionTemplate, +): Promise { + if (svc.existing) { + return deployRevision(svc.existing, template); + } + return createNewService(projectId, svc.config, template); +} + /** Creates a new service with its first revision. */ function createNewService( projectId: string, diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 0133fb85ae1..48f597c28ce 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -75,7 +75,7 @@ describe("run prepare", () => { getServiceStub.resolves(webWithoutBaseImage); await expect(prepareRun(localWeb)).to.be.rejectedWith( - 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web:us-central1 --base-image ".', + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web --region us-central1 --base-image ".', ); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 211c7c2f5c0..a713f5ca2d4 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -3,7 +3,7 @@ import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; import { prereqs } from "./prereqs"; -import { fullServiceName, getExistingService, getServiceConfigs, mainContainer } from "./util"; +import { getExistingService, getServiceConfigs, mainContainer } from "./util"; /** * Reads each service's current state from Cloud Run and resolves its base image. @@ -32,7 +32,7 @@ async function prepareService(context: Context, config: RunSingle): Promise` + ? `firebase run:services:update ${serviceId} --region ${region} --base-image ` : "firebase init run"; throw new FirebaseError( `Local builds require a base image. Set one for service ${serviceId} in ${region} with "${fix}".`, From da44ccba9887dc4b7f6693cfa5fb23c459594934 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 16:01:10 -0400 Subject: [PATCH 088/103] Let a local build's env vars replace the service's values The env vars a build reports describe that build's output, like its start command, so they now replace any env var of the same name on the service instead of being skipped. The service's other env vars are kept. --- src/deploy/run/deploy.spec.ts | 26 ++++++++++++++++++-------- src/deploy/run/deploy.ts | 13 ++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index c9bbc450272..a56aa7fda5f 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -296,17 +296,27 @@ describe("run deploy", () => { }); }); - it("adds the env vars a local build needs, unless the service already sets them", () => { - const env = [ - { name: "MODE", value: "development" }, - { name: "NODE_ENV", value: "production" }, - ]; - const build = { outputFiles: [".next"], env }; + it("sets the env vars the build reports, replacing the service's values for the same names", () => { + const service = webRunning({ + env: [ + { name: "MODE", value: "prod" }, + { name: "API_URL", value: "https://api.example.com" }, + ], + }); + const build = { + outputFiles: [".next"], + env: [ + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ], + }; - const template = localBuildTemplate({ config: web, existing }, build, builtApp); + const template = localBuildTemplate({ config: web, existing: service }, build, builtApp); + // API_URL isn't from the build, so it's kept. expect(template.containers?.[0].env).to.deep.equal([ - { name: "MODE", value: "prod" }, + { name: "API_URL", value: "https://api.example.com" }, + { name: "MODE", value: "development" }, { name: "NODE_ENV", value: "production" }, ]); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f6995709216..812c7cceb80 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -284,11 +284,14 @@ export function localBuildTemplate( // The build didn't name a start command, so don't keep one from an earlier revision. delete container.command; } - // Add the env vars the build says the app needs, unless the service already sets them. - const existingNames = new Set((container.env || []).map((e) => e.name)); - const newEnv = build.env.filter((e) => !existingNames.has(e.name)); - if (newEnv.length) { - container.env = [...(container.env || []), ...newEnv]; + // Set the runtime env vars that the build reports. Like the start command, they describe this + // build's output, so the build wins: each one replaces any env var of the same name on the + // service, even one set in the console or with gcloud. The service's other env vars are kept. + // A var that an earlier build set stays on the service if this build stops reporting it. + if (build.env.length) { + const buildNames = new Set(build.env.map((e) => e.name)); + const otherEnv = (container.env || []).filter((e) => !buildNames.has(e.name)); + container.env = [...otherEnv, ...build.env]; } return template; } From cea325c31bdd65acdfe5c7c9c257fbba1f916ad7 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 13 Oct 2026 14:16:49 -0400 Subject: [PATCH 089/103] Use App Hosting's secret format for build secrets A build secret is now written the way apphosting.yaml writes one ({"secret": "npm-token@3"}) and passed straight to loadSecret, so there are no extra regexes or format conversions. Cloud Build gets an explicit filter for plain values instead of a cast, and the comments and errors say why only local builds can use secrets. --- src/deploy/run/args.ts | 4 +- src/deploy/run/buildEnv.spec.ts | 44 +++++++++--------- src/deploy/run/buildEnv.ts | 80 ++++++++++++++++++--------------- src/deploy/run/deploy.spec.ts | 9 ++-- src/deploy/run/deploy.ts | 7 +-- src/deploy/run/prepare.ts | 10 +++-- 6 files changed, 83 insertions(+), 71 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 2304591cded..aff79ee8333 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -17,8 +17,8 @@ export interface ServiceDeploy { */ baseImage?: string; /** - * Set by prepare: the build-time environment. Prepare only allows secrets in it for local - * builds. + * Set by prepare: the build-time env vars from the service's annotation, each a plain value or + * a secret. Only local builds can use secrets: builds on Cloud Build only take plain values. */ buildEnv?: BuildEnv; /** Set by deploy: the service after the rollout. */ diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts index e403ffde742..3f1974a4cfa 100644 --- a/src/deploy/run/buildEnv.spec.ts +++ b/src/deploy/run/buildEnv.spec.ts @@ -1,6 +1,12 @@ import { expect } from "chai"; import * as runv2 from "../../gcp/runv2"; -import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames, toLocalBuildEnv } from "./buildEnv"; +import { + BUILD_ENV_ANNOTATION, + getBuildEnv, + plainValues, + secretNames, + toLocalBuildEnv, +} from "./buildEnv"; describe("run buildEnv", () => { const web = { name: "projects/my-project/locations/us-central1/services/web" } as runv2.Service; @@ -21,12 +27,12 @@ describe("run buildEnv", () => { expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); - it("parses secret references", () => { + it("parses secrets written the way apphosting.yaml writes them", () => { const env = { API_URL: "https://api.example.com", NPM_TOKEN: { secret: "npm-token" }, - SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, - GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token", version: "latest" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token/versions/3" }, }; expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); }); @@ -47,49 +53,39 @@ describe("run buildEnv", () => { ["npm-token"], {}, { secret: 1 }, - { secret: "secrets/npm-token" }, - { secret: "npm-token@3" }, - { secret: "projects/shared-secrets/secrets/npm-token/versions/3" }, - { secret: "npm-token", version: 3 }, - { secret: "npm-token", version: "v3" }, - { secret: "npm-token", project: "shared-secrets" }, + // The version goes in the secret string, e.g. "npm-token@3". + { secret: "npm-token", version: "3" }, ]; for (const value of invalid) { expect(() => getBuildEnv(withAnnotation(JSON.stringify({ NPM_TOKEN: value })))).to.throw( - 'NPM_TOKEN must be a string or {"secret": "", "version": ""}.', + 'NPM_TOKEN must be a string or {"secret": ""}.', ); } }); }); - it("lists secret names", () => { + it("splits secrets from plain values", () => { const env = { API_URL: "https://api.example.com", NPM_TOKEN: { secret: "npm-token" }, - SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2" }, }; expect(secretNames(env)).to.deep.equal(["NPM_TOKEN", "SENTRY_AUTH_TOKEN"]); + expect(plainValues(env)).to.deep.equal({ API_URL: "https://api.example.com" }); }); - it("converts to local build env, available only to the build", () => { + it("converts to local build env, available only to the build, with secrets unchanged", () => { expect( toLocalBuildEnv({ API_URL: "https://api.example.com", NPM_TOKEN: { secret: "npm-token" }, - SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token", version: "2" }, - GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token" }, - CMS_TOKEN: { secret: "projects/shared-secrets/secrets/cms-token", version: "4" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token/versions/3" }, }), ).to.deep.equal({ API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, - NPM_TOKEN: { secret: "npm-token@latest", availability: ["BUILD"] }, - SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2", availability: ["BUILD"] }, + NPM_TOKEN: { secret: "npm-token", availability: ["BUILD"] }, GITHUB_TOKEN: { - secret: "projects/shared-secrets/secrets/github-token/versions/latest", - availability: ["BUILD"], - }, - CMS_TOKEN: { - secret: "projects/shared-secrets/secrets/cms-token/versions/4", + secret: "projects/shared-secrets/secrets/github-token/versions/3", availability: ["BUILD"], }, }); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts index 5ba9d8cb263..fe7866ab08b 100644 --- a/src/deploy/run/buildEnv.ts +++ b/src/deploy/run/buildEnv.ts @@ -3,27 +3,31 @@ import * as runv2 from "../../gcp/runv2"; import { EnvMap } from "../../apphosting/yaml"; /** - * Prototype: build-time environment variables and secret references, stored on the Cloud Run - * service. It extends Cloud Run's own buildConfig.environmentVariables (a map of plain strings): - * the same map parses unchanged, and a value can also be a secret reference in the shape of - * Cloud Run's runtime secretKeyRef. It holds references only, never secret values. - * Example: {"API_URL": "https://example.com", "NPM_TOKEN": {"secret": "npm-token", "version": "3"}} + * Prototype: build-time env vars and secrets, stored on the Cloud Run service as a JSON object. + * Each value is a plain string, or a Secret Manager secret written the way apphosting.yaml writes + * one: {"secret": ""}, {"secret": "@"}, or {"secret": ""}. + * For example: + * { + * "API_URL": "https://api.example.com", + * "NPM_TOKEN": {"secret": "npm-token"}, + * "SENTRY_AUTH_TOKEN": {"secret": "sentry-auth-token@2"}, + * "GITHUB_TOKEN": {"secret": "projects/shared-secrets/secrets/github-token/versions/3"} + * } + * The annotation only names secrets. Their values stay in Secret Manager. */ export const BUILD_ENV_ANNOTATION = "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; -/** A Secret Manager secret, as a name in the service's project or a full resource name. */ +/** + * A Secret Manager secret, in App Hosting's format. A version is optional and defaults to the + * latest. Local builds read the secret with App Hosting's loadSecret, which checks the format. + */ export interface SecretRef { secret: string; - /** A version number or "latest". Defaults to "latest". */ - version?: string; } export type BuildEnv = Record; -const SECRET_NAME = /^(?:projects\/[^/]+\/secrets\/)?[^/@]+$/; -const SECRET_VERSION = /^(?:latest|\d+)$/; - /** Reads and validates the service's build environment. Returns {} if it has none. */ export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; @@ -42,47 +46,53 @@ export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { throw invalid("it must be a JSON object."); } for (const [name, value] of Object.entries(parsed)) { - if (typeof value === "string") { - continue; - } - const ref = value as Partial | null; - const valid = - typeof ref === "object" && - ref !== null && - !Array.isArray(ref) && - Object.keys(ref).every((k) => k === "secret" || k === "version") && - typeof ref.secret === "string" && - SECRET_NAME.test(ref.secret) && - (ref.version === undefined || - (typeof ref.version === "string" && SECRET_VERSION.test(ref.version))); - if (!valid) { - throw invalid( - `${name} must be a string or {"secret": "", "version": ""}.`, - ); + if (typeof value !== "string" && !isSecretRef(value)) { + throw invalid(`${name} must be a string or {"secret": ""}.`); } } return parsed as BuildEnv; } -/** Names of the variables that are secret references. */ +/** Whether a value is {"secret": ""}, with no other fields. */ +function isSecretRef(value: unknown): value is SecretRef { + const ref = value as Partial | null; + return ( + typeof ref === "object" && + ref !== null && + Object.keys(ref).length === 1 && + typeof ref.secret === "string" + ); +} + +/** Names of the variables that are secrets. */ export function secretNames(env: BuildEnv): string[] { return Object.entries(env) .filter(([, value]) => typeof value !== "string") .map(([name]) => name); } -/** Converts the build environment to the form local builds take; they resolve the secrets. */ +/** The plain values, without the secrets. */ +export function plainValues(env: BuildEnv): Record { + const values: Record = {}; + for (const [name, value] of Object.entries(env)) { + if (typeof value === "string") { + values[name] = value; + } + } + return values; +} + +/** + * Converts the build environment to the form local builds take. Secrets pass through unchanged: + * the local build reads them with your credentials. + */ export function toLocalBuildEnv(env: BuildEnv): EnvMap { const result: EnvMap = {}; for (const [name, value] of Object.entries(env)) { if (typeof value === "string") { result[name] = { value, availability: ["BUILD"] }; } else { - const version = value.version || "latest"; - const secret = value.secret.startsWith("projects/") - ? `${value.secret}/versions/${version}` - : `${value.secret}@${version}`; - result[name] = { secret, availability: ["BUILD"] }; + result[name] = { secret: value.secret, availability: ["BUILD"] }; } } return result; diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index e5fc19e1572..fc57a27898b 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -81,11 +81,14 @@ describe("run deploy", () => { expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); - it("passes the service's build env to Cloud Build", async () => { - await deployOne({ config: web, buildEnv: { API_URL: "https://api.example.com" } }); + it("passes the service's plain build env vars to Cloud Build, but never secrets", async () => { + const buildEnv = { API_URL: "https://api.example.com", NPM_TOKEN: { secret: "npm-token" } }; + + await deployOne({ config: web, buildEnv }); const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; expect(environmentVariables).to.include({ API_URL: "https://api.example.com" }); + expect(environmentVariables).not.to.have.property("NPM_TOKEN"); }); it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => { @@ -196,7 +199,7 @@ describe("run deploy", () => { it("passes the build env, secrets included, to the local build", async () => { const buildEnv = { API_URL: "https://api.example.com", - NPM_TOKEN: { secret: "npm-token", version: "2" }, + NPM_TOKEN: { secret: "npm-token@2" }, }; await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index eeb696688f0..005a00419b9 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,7 +16,7 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; -import { secretNames, toLocalBuildEnv } from "./buildEnv"; +import { plainValues, secretNames, toLocalBuildEnv } from "./buildEnv"; import { mainContainer } from "./util"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; @@ -213,8 +213,9 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { - // Prepare rejects build secrets for builds on Cloud Build, so these are all plain values. - ...(svc.buildEnv as Record | undefined), + // Cloud Build only takes plain values, so leave out any secrets. Prepare already rejects + // secrets for builds on Cloud Build. + ...plainValues(svc.buildEnv || {}), // Use App Hosting's buildpacks, which need to know where to write the output bundle. X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 6015924b6ca..f5ed11d8a10 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -43,12 +43,14 @@ async function prepareService(context: Context, config: RunSingle): Promise Date: Tue, 13 Oct 2026 14:20:55 -0400 Subject: [PATCH 090/103] Rename plainValues to nonSecretValues The name and doc comment now say that the result never includes secrets or their values. --- src/deploy/run/buildEnv.spec.ts | 4 ++-- src/deploy/run/buildEnv.ts | 7 +++++-- src/deploy/run/deploy.ts | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts index 3f1974a4cfa..710987bdfbc 100644 --- a/src/deploy/run/buildEnv.spec.ts +++ b/src/deploy/run/buildEnv.spec.ts @@ -3,7 +3,7 @@ import * as runv2 from "../../gcp/runv2"; import { BUILD_ENV_ANNOTATION, getBuildEnv, - plainValues, + nonSecretValues, secretNames, toLocalBuildEnv, } from "./buildEnv"; @@ -71,7 +71,7 @@ describe("run buildEnv", () => { SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2" }, }; expect(secretNames(env)).to.deep.equal(["NPM_TOKEN", "SENTRY_AUTH_TOKEN"]); - expect(plainValues(env)).to.deep.equal({ API_URL: "https://api.example.com" }); + expect(nonSecretValues(env)).to.deep.equal({ API_URL: "https://api.example.com" }); }); it("converts to local build env, available only to the build, with secrets unchanged", () => { diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts index fe7866ab08b..eb177127b5b 100644 --- a/src/deploy/run/buildEnv.ts +++ b/src/deploy/run/buildEnv.ts @@ -71,8 +71,11 @@ export function secretNames(env: BuildEnv): string[] { .map(([name]) => name); } -/** The plain values, without the secrets. */ -export function plainValues(env: BuildEnv): Record { +/** + * The env vars that aren't secrets, with their values. Secrets are left out entirely, so the result + * never names a secret or holds a secret's value. + */ +export function nonSecretValues(env: BuildEnv): Record { const values: Record = {}; for (const [name, value] of Object.entries(env)) { if (typeof value === "string") { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 005a00419b9..874cd63bed3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,7 +16,7 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; -import { plainValues, secretNames, toLocalBuildEnv } from "./buildEnv"; +import { nonSecretValues, secretNames, toLocalBuildEnv } from "./buildEnv"; import { mainContainer } from "./util"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; @@ -213,9 +213,9 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { - // Cloud Build only takes plain values, so leave out any secrets. Prepare already rejects - // secrets for builds on Cloud Build. - ...plainValues(svc.buildEnv || {}), + // Cloud Build only takes plain values, so pass only the non-secret env vars. Prepare already + // rejects secrets for builds on Cloud Build. + ...nonSecretValues(svc.buildEnv || {}), // 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", From c84fca15ec6a0078171e4a220567c9cb84d3d94b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 13 Oct 2026 14:44:31 -0400 Subject: [PATCH 091/103] Accept numbers and booleans in the build env annotation, like apphosting.yaml --- src/deploy/run/buildEnv.spec.ts | 15 +++++++++++++-- src/deploy/run/buildEnv.ts | 24 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts index 710987bdfbc..8a8e580bce9 100644 --- a/src/deploy/run/buildEnv.spec.ts +++ b/src/deploy/run/buildEnv.spec.ts @@ -46,9 +46,20 @@ describe("run buildEnv", () => { } }); + it("converts numbers and booleans to strings, as apphosting.yaml does", () => { + const annotation = + '{"NEXT_TELEMETRY_DISABLED": 1, "CI": true, "DEBUG": false, "RATIO": 1.50}'; + expect(getBuildEnv(withAnnotation(annotation))).to.deep.equal({ + NEXT_TELEMETRY_DISABLED: "1", + CI: "true", + DEBUG: "false", + // JSON doesn't keep how a number was written. + RATIO: "1.5", + }); + }); + it("rejects invalid values", () => { const invalid = [ - 1, null, ["npm-token"], {}, @@ -58,7 +69,7 @@ describe("run buildEnv", () => { ]; for (const value of invalid) { expect(() => getBuildEnv(withAnnotation(JSON.stringify({ NPM_TOKEN: value })))).to.throw( - 'NPM_TOKEN must be a string or {"secret": ""}.', + 'NPM_TOKEN must be a string, number, boolean, or {"secret": ""}.', ); } }); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts index eb177127b5b..ecc7785eb19 100644 --- a/src/deploy/run/buildEnv.ts +++ b/src/deploy/run/buildEnv.ts @@ -4,11 +4,13 @@ import { EnvMap } from "../../apphosting/yaml"; /** * Prototype: build-time env vars and secrets, stored on the Cloud Run service as a JSON object. - * Each value is a plain string, or a Secret Manager secret written the way apphosting.yaml writes - * one: {"secret": ""}, {"secret": "@"}, or {"secret": ""}. + * Each value is a string, a number or boolean (which becomes a string, as in apphosting.yaml), or a + * Secret Manager secret written the way apphosting.yaml writes one: {"secret": ""}, + * {"secret": "@"}, or {"secret": ""}. * For example: * { * "API_URL": "https://api.example.com", + * "NEXT_TELEMETRY_DISABLED": 1, * "NPM_TOKEN": {"secret": "npm-token"}, * "SENTRY_AUTH_TOKEN": {"secret": "sentry-auth-token@2"}, * "GITHUB_TOKEN": {"secret": "projects/shared-secrets/secrets/github-token/versions/3"} @@ -28,7 +30,10 @@ export interface SecretRef { export type BuildEnv = Record; -/** Reads and validates the service's build environment. Returns {} if it has none. */ +/** + * Reads and validates the service's build environment. Returns {} if it has none. Numbers and + * booleans come back as strings, since env vars are strings. + */ export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; if (!service || !raw) { @@ -45,12 +50,19 @@ export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw invalid("it must be a JSON object."); } + const env: BuildEnv = {}; for (const [name, value] of Object.entries(parsed)) { - if (typeof value !== "string" && !isSecretRef(value)) { - throw invalid(`${name} must be a string or {"secret": ""}.`); + if (typeof value === "string" || isSecretRef(value)) { + env[name] = value; + } else if (typeof value === "number" || typeof value === "boolean") { + // apphosting.yaml takes unquoted numbers and booleans too. JSON doesn't keep how a number was + // written, so 1.50 becomes "1.5". Quote a value to keep it exactly as written. + env[name] = String(value); + } else { + throw invalid(`${name} must be a string, number, boolean, or {"secret": ""}.`); } } - return parsed as BuildEnv; + return env; } /** Whether a value is {"secret": ""}, with no other fields. */ From b4b4cb3340c6cb268c06d1ba9497dbf0d5f584a1 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 13 Oct 2026 14:47:20 -0400 Subject: [PATCH 092/103] Check for secrets with isSecretRef instead of typeof string --- src/deploy/run/buildEnv.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts index ecc7785eb19..6c1eab6a979 100644 --- a/src/deploy/run/buildEnv.ts +++ b/src/deploy/run/buildEnv.ts @@ -79,7 +79,7 @@ function isSecretRef(value: unknown): value is SecretRef { /** Names of the variables that are secrets. */ export function secretNames(env: BuildEnv): string[] { return Object.entries(env) - .filter(([, value]) => typeof value !== "string") + .filter(([, value]) => isSecretRef(value)) .map(([name]) => name); } @@ -90,7 +90,7 @@ export function secretNames(env: BuildEnv): string[] { export function nonSecretValues(env: BuildEnv): Record { const values: Record = {}; for (const [name, value] of Object.entries(env)) { - if (typeof value === "string") { + if (!isSecretRef(value)) { values[name] = value; } } @@ -104,10 +104,10 @@ export function nonSecretValues(env: BuildEnv): Record { export function toLocalBuildEnv(env: BuildEnv): EnvMap { const result: EnvMap = {}; for (const [name, value] of Object.entries(env)) { - if (typeof value === "string") { - result[name] = { value, availability: ["BUILD"] }; - } else { + if (isSecretRef(value)) { result[name] = { secret: value.secret, availability: ["BUILD"] }; + } else { + result[name] = { value, availability: ["BUILD"] }; } } return result; From 67a028a7f0a92b9f7ff2904e71a60dedee086d8b Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 14 Oct 2026 13:09:05 -0400 Subject: [PATCH 093/103] Make the FIREBASE_CONFIG and annotation logic in deploy easier to follow Name the case where a deploy unlinks the app. Keep an emptied env list instead of deleting it: to Cloud Run, [] and no env field mean the same thing. Always send the service's annotations, instead of only when the linked app changed, which removes the undefined return and the conditional update mask. --- src/deploy/run/deploy.spec.ts | 28 +++++++++++------ src/deploy/run/deploy.ts | 57 +++++++++++++++-------------------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 12419d7fb67..38c612d0b97 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -113,13 +113,14 @@ describe("run deploy", () => { expect(updateServiceStub).to.have.been.calledOnceWithExactly( { name: "projects/my-project/locations/us-central1/services/web", + annotations: {}, 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" }, ], }, - { updateMask: ["template", "traffic"], pollTimeoutMs: 10 * 60 * 1000 }, + { updateMask: ["template", "traffic", "annotations"], pollTimeoutMs: 10 * 60 * 1000 }, ); }); @@ -153,12 +154,13 @@ describe("run deploy", () => { expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ team: "frontend" }); }); - it("leaves the service's annotations alone if the app didn't change", async () => { + it("keeps the service's annotations as they are if the app didn't change", async () => { await deployOne({ config: web, existing: linkedWeb, appId: "1:123:web:abc" }); - const [service, { updateMask }] = updateServiceStub.firstCall.args; - expect(service).not.to.have.property("annotations"); - expect(updateMask).to.deep.equal(["template", "traffic"]); + expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ + team: "frontend", + [APP_ID]: "1:123:web:abc", + }); }); }); @@ -365,12 +367,20 @@ describe("run deploy", () => { ]); }); - it("is removed when the app is unlinked", () => { - const svc = { config: web, existing: linked }; + it("is removed when the app is unlinked, and the other env vars stay", () => { + const existing = { + ...webRunning({ + env: [ + { name: "MODE", value: "prod" }, + { name: "FIREBASE_CONFIG", value: "old" }, + ], + }), + annotations: { [APP_ID]: "1:123:web:abc" }, + }; - const template = revisionTemplate(svc, image); + const template = revisionTemplate({ config: web, existing }, image); - expect(template.containers?.[0]).not.to.have.property("env"); + expect(template.containers?.[0].env).to.deep.equal([{ name: "MODE", value: "prod" }]); }); it("is left alone if the user set it on a service that was never linked", () => { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 7f98243d5c0..a283cd76307 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -265,21 +265,20 @@ export function revisionTemplate( } else { delete container.baseImageUri; } - // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. While an app is linked, it's - // always rewritten from the app's current config, so changes like enabling Storage reach the - // service. The client SDK's FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set - // on the container. - if (svc.firebaseConfig) { - const env = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); - container.env = [...env, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; - } else if (svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] && container.env) { - // The service is being unlinked. Remove the FIREBASE_CONFIG that was set for its app, but - // leave one alone on a service that was never linked: the user set that one. - container.env = container.env.filter((e) => e.name !== "FIREBASE_CONFIG"); - if (!container.env.length) { - delete container.env; - } + // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's + // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. + const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; + const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); + if (svc.firebaseConfig !== undefined) { + // An app is linked. Always rewrite FIREBASE_CONFIG from the app's current config, so that + // changes like enabling Storage reach the service. + container.env = [...otherEnv, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; + } else if (wasLinked) { + // This deploy unlinks the app (--clear-app): the service had an app, and the deploy has none. + // Remove the FIREBASE_CONFIG that was set for the app. + container.env = otherEnv; } + // Otherwise the service was never linked, so a FIREBASE_CONFIG on it is the user's. It stays. // The message describes this deploy only, so don't carry over the last one. if (message) { template.annotations = { ...template.annotations, [DEPLOY_MESSAGE_ANNOTATION]: message }; @@ -325,15 +324,12 @@ export function localBuildTemplate( } /** - * Returns the service's annotations with the linked Firebase Web App ID, or undefined if the - * linked app didn't change. Later deploys read the ID back to reuse the app. + * Returns the service's annotations after this deploy: its current ones, with the linked Firebase + * Web App's ID set, or removed if no app is linked. Later deploys read the ID back to reuse the app. */ -function serviceAnnotations(svc: ServiceDeploy): Record | undefined { - if (svc.appId === svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION]) { - return undefined; - } +function serviceAnnotations(svc: ServiceDeploy): Record { const annotations = { ...svc.existing?.annotations }; - if (svc.appId) { + if (svc.appId !== undefined) { annotations[FIREBASE_APP_ANNOTATION] = svc.appId; } else { delete annotations[FIREBASE_APP_ANNOTATION]; @@ -359,20 +355,18 @@ function createNewService( projectId: string, config: RunSingle, template: runv2.RevisionTemplate, - annotations?: Record, + annotations: Record, ): Promise { const { serviceId, region } = config; const service: Omit = { name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + annotations, template, client: "cli-firebase", // Like an App Hosting backend, the service is public. invokerIamDisabled: true, ingress: "INGRESS_TRAFFIC_ALL", }; - if (annotations) { - service.annotations = annotations; - } return runv2.createService(projectId, region, serviceId, service, { pollTimeoutMs: ROLLOUT_TIMEOUT_MS, }); @@ -382,7 +376,7 @@ function createNewService( function deployRevision( service: runv2.Service, template: runv2.RevisionTemplate, - annotations?: Record, + annotations: Record, ): Promise { // Tagged revisions keep their tags (Hosting's pinned rewrites use them), but no traffic. const tags = (service.traffic || []) @@ -390,15 +384,14 @@ function deployRevision( .map((t) => ({ type: t.type, revision: t.revision, tag: t.tag })); const update: Omit = { name: service.name, + annotations, template, traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], }; - const updateMask = ["template", "traffic"]; - if (annotations) { - update.annotations = annotations; - updateMask.push("annotations"); - } - return runv2.updateService(update, { updateMask, pollTimeoutMs: ROLLOUT_TIMEOUT_MS }); + return runv2.updateService(update, { + updateMask: ["template", "traffic", "annotations"], + pollTimeoutMs: ROLLOUT_TIMEOUT_MS, + }); } /** Adapts a Cloud Run config so that it can reuse App Hosting's local build and archive helpers. */ From 8443369ac72068ce1115de7a8b3f48f93b424953 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 14 Oct 2026 13:15:54 -0400 Subject: [PATCH 094/103] Rename serviceAnnotations to serviceAnnotationsToDeploy --- src/deploy/run/deploy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index a283cd76307..fdc1ce55ec3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -327,7 +327,7 @@ export function localBuildTemplate( * Returns the service's annotations after this deploy: its current ones, with the linked Firebase * Web App's ID set, or removed if no app is linked. Later deploys read the ID back to reuse the app. */ -function serviceAnnotations(svc: ServiceDeploy): Record { +function serviceAnnotationsToDeploy(svc: ServiceDeploy): Record { const annotations = { ...svc.existing?.annotations }; if (svc.appId !== undefined) { annotations[FIREBASE_APP_ANNOTATION] = svc.appId; @@ -343,7 +343,7 @@ function rollOut( svc: ServiceDeploy, template: runv2.RevisionTemplate, ): Promise { - const annotations = serviceAnnotations(svc); + const annotations = serviceAnnotationsToDeploy(svc); if (svc.existing) { return deployRevision(svc.existing, template, annotations); } From d1257fa22269e10264034af10eae07de42833179 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 14 Oct 2026 14:22:16 -0400 Subject: [PATCH 095/103] Fail the deploy if the linked Firebase Web App doesn't exist For an app ID with no app, the web app config lookup returns the project's config without an appId, so a typo in --app deployed and was saved on the service. Check that the app exists first, with the same check App Hosting's --app uses, so the deploy fails before building or saving anything. --- src/deploy/run/prepare.spec.ts | 21 +++++++++++++++++++++ src/deploy/run/prepare.ts | 15 ++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 842b02bc8ff..6a3cf1121f7 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -1,5 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import { webApps } from "../../apphosting/app"; +import { FirebaseError } from "../../error"; import * as computeEngine from "../../gcp/computeEngine"; import * as resourceManager from "../../gcp/resourceManager"; import * as runv2 from "../../gcp/runv2"; @@ -141,11 +143,13 @@ describe("run prepare", () => { annotations: { ...service.annotations, [FIREBASE_APP_ANNOTATION]: appId }, }); let getAppConfigStub: sinon.SinonStub; + let getWebAppStub: sinon.SinonStub; let hasRolesStub: sinon.SinonStub; let addRolesStub: sinon.SinonStub; beforeEach(() => { getAppConfigStub = sinon.stub(managementApps, "getAppConfig").resolves(webConfig); + getWebAppStub = sinon.stub(webApps, "getOrCreateWebApp").resolves({ name: "web", id: appId }); sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); sinon.stub(computeEngine, "getDefaultServiceAccount").resolves(defaultSa); hasRolesStub = sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); @@ -157,6 +161,7 @@ describe("run prepare", () => { const [svc] = await prepareRun(web); + expect(getWebAppStub).to.have.been.calledWith("my-project", appId, "web"); expect(getAppConfigStub).to.have.been.calledWith(appId, managementApps.AppPlatform.WEB); expect(svc).to.include({ appId, firebaseConfig }); expect(svc.buildEnv).to.deep.equal({ @@ -232,6 +237,22 @@ describe("run prepare", () => { expect(hasRolesStub).not.to.have.been.called; }); + it("fails, before building or saving anything, if the project has no app with that ID", async () => { + getServiceStub.resolves(linked(liveWeb)); + getWebAppStub.rejects( + new FirebaseError("The web app '1:123:web:typo' does not exist in project my-project"), + ); + + await expect(prepareRun(web, { context: { appId: "1:123:web:typo" } })).to.be.rejectedWith( + "Unable to look up Firebase Web App 1:123:web:typo for service web in us-central1: " + + "The web app '1:123:web:typo' does not exist in project my-project\n" + + 'To use a different app, run "firebase run:services:update web --region us-central1 --app ". ' + + 'To deploy without Firebase SDK auto-initialization, run "firebase run:services:update web --region us-central1 --clear-app".', + ); + expect(getAppConfigStub).not.to.have.been.called; + expect(hasRolesStub).not.to.have.been.called; + }); + it("rejects a service it can't build before looking up the app or touching IAM", async () => { getServiceStub.resolves(linked(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } }))); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 5d923bd0497..824bef8beec 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,3 +1,4 @@ +import { webApps } from "../../apphosting/app"; import { getAutoinitEnvVars } from "../../apphosting/utils"; import { FirebaseError, getErrMsg, getError, getErrStatus } from "../../error"; import { WebConfig } from "../../fetchWebSetup"; @@ -86,7 +87,7 @@ async function prepareService(context: Context, config: RunSingle): Promise { +async function getWebAppConfig( + context: Context, + config: RunSingle, + appId: string, +): Promise { try { + // Check that the project has this app, the same way App Hosting's --app does (given an app ID, + // getOrCreateWebApp only looks it up). The config lookup below doesn't catch a bad ID: for an + // ID with no app, it returns the project's config, without an appId. + await webApps.getOrCreateWebApp(context.projectId, appId, config.serviceId); return (await managementApps.getAppConfig(appId, managementApps.AppPlatform.WEB)) as WebConfig; } catch (err: unknown) { const update = `firebase run:services:update ${config.serviceId} --region ${config.region}`; From 29234ba0a96e2473f676e75dc5b06bc8ef860b0f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 15 Oct 2026 11:00:40 -0400 Subject: [PATCH 096/103] Group the linked app's ID and config into ServiceDeploy.autoInit --- src/deploy/run/args.ts | 12 ++++-------- src/deploy/run/deploy.spec.ts | 20 ++++++++++++++++---- src/deploy/run/deploy.ts | 8 ++++---- src/deploy/run/prepare.spec.ts | 10 +++++----- src/deploy/run/prepare.ts | 3 +-- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 3655e6d9fa1..1fea2a2b376 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -17,15 +17,11 @@ export interface ServiceDeploy { */ baseImage?: string; /** - * Set by prepare: the linked Firebase Web App. Deploy saves it on the service so later deploys - * reuse it. + * Set by prepare: the linked Firebase Web App, if any. Deploy saves `appId` on the service so + * later deploys reuse it, and sets `firebaseConfig` as the FIREBASE_CONFIG env var, which the + * Admin SDK reads at runtime. */ - appId?: string; - /** - * Set by prepare: the linked app's config for the FIREBASE_CONFIG env var, which the Admin SDK - * reads at runtime. - */ - firebaseConfig?: string; + autoInit?: { appId: string; firebaseConfig: string }; /** * Set by prepare: the build-time env vars from the service's annotation, each a plain value or * a secret. Only local builds can use secrets: builds on Cloud Build only take plain values. diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 38c612d0b97..db259f7a424 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -132,7 +132,7 @@ describe("run deploy", () => { } as unknown as runv2.Service; it("is saved on a new service", async () => { - await deployOne({ config: web, appId: "1:123:web:abc" }); + await deployOne({ config: web, autoInit: { appId: "1:123:web:abc", firebaseConfig: "{}" } }); expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { annotations: { [APP_ID]: "1:123:web:abc" }, @@ -140,7 +140,11 @@ describe("run deploy", () => { }); it("replaces the app saved on an existing service", async () => { - await deployOne({ config: web, existing: linkedWeb, appId: "1:123:web:def" }); + await deployOne({ + config: web, + existing: linkedWeb, + autoInit: { appId: "1:123:web:def", firebaseConfig: "{}" }, + }); expect(updateServiceStub).to.have.been.calledWithMatch( { annotations: { team: "frontend", [APP_ID]: "1:123:web:def" } }, @@ -155,7 +159,11 @@ describe("run deploy", () => { }); it("keeps the service's annotations as they are if the app didn't change", async () => { - await deployOne({ config: web, existing: linkedWeb, appId: "1:123:web:abc" }); + await deployOne({ + config: web, + existing: linkedWeb, + autoInit: { appId: "1:123:web:abc", firebaseConfig: "{}" }, + }); expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ team: "frontend", @@ -358,7 +366,11 @@ describe("run deploy", () => { const linked = { ...withConfig("old"), annotations: { [APP_ID]: "1:123:web:abc" } }; it("is set from the linked app's current config", () => { - const svc = { config: web, existing: linked, firebaseConfig: "current" }; + const svc = { + config: web, + existing: linked, + autoInit: { appId: "1:123:web:abc", firebaseConfig: "current" }, + }; const template = revisionTemplate(svc, image); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index fdc1ce55ec3..904113202b3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -269,10 +269,10 @@ export function revisionTemplate( // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); - if (svc.firebaseConfig !== undefined) { + if (svc.autoInit) { // An app is linked. Always rewrite FIREBASE_CONFIG from the app's current config, so that // changes like enabling Storage reach the service. - container.env = [...otherEnv, { name: "FIREBASE_CONFIG", value: svc.firebaseConfig }]; + container.env = [...otherEnv, { name: "FIREBASE_CONFIG", value: svc.autoInit.firebaseConfig }]; } else if (wasLinked) { // This deploy unlinks the app (--clear-app): the service had an app, and the deploy has none. // Remove the FIREBASE_CONFIG that was set for the app. @@ -329,8 +329,8 @@ export function localBuildTemplate( */ function serviceAnnotationsToDeploy(svc: ServiceDeploy): Record { const annotations = { ...svc.existing?.annotations }; - if (svc.appId !== undefined) { - annotations[FIREBASE_APP_ANNOTATION] = svc.appId; + if (svc.autoInit) { + annotations[FIREBASE_APP_ANNOTATION] = svc.autoInit.appId; } else { delete annotations[FIREBASE_APP_ANNOTATION]; } diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 6a3cf1121f7..6785d7c2634 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -163,7 +163,7 @@ describe("run prepare", () => { expect(getWebAppStub).to.have.been.calledWith("my-project", appId, "web"); expect(getAppConfigStub).to.have.been.calledWith(appId, managementApps.AppPlatform.WEB); - expect(svc).to.include({ appId, firebaseConfig }); + expect(svc.autoInit).to.deep.equal({ appId, firebaseConfig }); expect(svc.buildEnv).to.deep.equal({ FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), FIREBASE_CONFIG: firebaseConfig, @@ -201,7 +201,7 @@ describe("run prepare", () => { const [svc] = await prepareRun(web, { context: { appId } }); - expect(svc.appId).to.equal(appId); + expect(svc.autoInit?.appId).to.equal(appId); expect(warnStub).to.have.been.calledWithMatch("run", /or ask an admin to grant this role/); }); @@ -209,10 +209,10 @@ describe("run prepare", () => { getServiceStub.resolves(linked(liveWeb)); const [withNewApp] = await prepareRun(web, { context: { appId: "1:123:web:def" } }); - expect(withNewApp.appId).to.equal("1:123:web:def"); + expect(withNewApp.autoInit?.appId).to.equal("1:123:web:def"); const [cleared] = await prepareRun(web, { context: { appId: null } }); - expect(cleared).not.to.have.any.keys("appId", "firebaseConfig", "buildEnv"); + expect(cleared).not.to.have.any.keys("autoInit", "buildEnv"); }); it("lets the build env annotation override the app's config at build time only", async () => { @@ -222,7 +222,7 @@ describe("run prepare", () => { const [svc] = await prepareRun(web); expect(svc.buildEnv).to.include({ FIREBASE_CONFIG: override }); - expect(svc.firebaseConfig).to.equal(firebaseConfig); + expect(svc.autoInit?.firebaseConfig).to.equal(firebaseConfig); }); it("fails if the linked app can't be looked up", async () => { diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 824bef8beec..b7978e52b57 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -89,8 +89,7 @@ async function prepareService(context: Context, config: RunSingle): Promise Date: Thu, 15 Oct 2026 11:49:32 -0400 Subject: [PATCH 097/103] init run: ask which web app to link, and create a new one only in actuate --- src/init/features/run.spec.ts | 52 +++++++++++++++++++++++++++++++++-- src/init/features/run.ts | 49 ++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index af2e5b3423f..306de3d4bb0 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -7,6 +7,7 @@ import * as deploy from "../../deploy"; import * as prereqs from "../../deploy/run/prereqs"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import * as prompt from "../../prompt"; import * as requirePermissions from "../../requirePermissions"; @@ -20,6 +21,7 @@ describe("init run", () => { let selectStub: sinon.SinonStub; let inputStub: sinon.SinonStub; let getOrCreateWebAppStub: sinon.SinonStub; + let listAppsStub: sinon.SinonStub; function setup(): Setup { return { @@ -39,6 +41,7 @@ describe("init run", () => { getOrCreateWebAppStub = sinon .stub(webApps, "getOrCreateWebApp") .resolves({ name: "my-service", id: "1:123:web:abc" }); + listAppsStub = sinon.stub(managementApps, "listFirebaseApps").resolves([]); selectStub = sinon.stub(prompt, "select"); inputStub = sinon.stub(prompt, "input"); }); @@ -71,7 +74,9 @@ describe("init run", () => { choices: ["us-central1", "us-east1"], default: "us-central1", }); - expect(getOrCreateWebAppStub).to.have.been.calledWith("p", null, "my-service"); + // The project has no web apps, so init doesn't ask which one to link. Actuate creates one. + expect(selectStub).to.have.been.calledThrice; + expect(getOrCreateWebAppStub).not.to.have.been.called; expect(inputStub.secondCall.args[0].default).to.be.undefined; expect(inputStub.thirdCall.args[0].default).to.equal("/"); const validateDir = inputStub.thirdCall.args[0].validate; @@ -83,7 +88,7 @@ describe("init run", () => { region: "us-east1", baseImage: "", rootDir: "/", - appId: "1:123:web:abc", + createWebApp: true, }); }); @@ -123,6 +128,7 @@ describe("init run", () => { { name: "web (europe-west1)", value: existing }, ]); expect(getOrCreateWebAppStub).not.to.have.been.called; + expect(listAppsStub).not.to.have.been.called; expect(s.featureInfo?.run).to.deep.equal({ serviceId: "web", region: "europe-west1", @@ -132,6 +138,38 @@ describe("init run", () => { }); }); + it("asks which web app to link, with creating a new one as the default", async () => { + listAppsStub.resolves([ + { appId: "1:123:web:abc", displayName: "web", platform: managementApps.AppPlatform.WEB }, + { appId: "1:123:web:def", platform: managementApps.AppPlatform.WEB }, + ]); + answerPrompts("create", "us-central1", "my-service", "1:123:web:def", false, "", "/"); + const s = setup(); + + await askQuestions(s, config, options); + + const appPrompt = selectStub.thirdCall.args[0]; + expect(appPrompt.default).to.equal(webApps.CREATE_NEW_FIREBASE_WEB_APP); + expect(appPrompt.choices).to.deep.equal([ + { name: "Create a new web app", value: webApps.CREATE_NEW_FIREBASE_WEB_APP }, + { name: "web (1:123:web:abc)", value: "1:123:web:abc" }, + { name: "1:123:web:def", value: "1:123:web:def" }, + ]); + expect(s.featureInfo?.run).to.include({ appId: "1:123:web:def" }); + expect(s.featureInfo?.run).not.to.have.property("createWebApp"); + }); + + it("doesn't create a web app while asking, so quitting init leaves none behind", async () => { + listAppsStub.resolves([{ appId: "1:123:web:abc", platform: managementApps.AppPlatform.WEB }]); + const createNew = webApps.CREATE_NEW_FIREBASE_WEB_APP; + answerPrompts("create", "us-central1", "my-service", createNew, false, ""); + inputStub.onThirdCall().rejects(new Error("User force closed the prompt")); + + await expect(askQuestions(setup(), config, options)).to.be.rejectedWith("force closed"); + + expect(getOrCreateWebAppStub).not.to.have.been.called; + }); + 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[]); @@ -256,6 +294,16 @@ describe("init run", () => { it("deploys with the Firebase Web App that init linked", async () => { await actuate(webSetup({ appId: "1:123:web:abc" }), config, options); + expect(getOrCreateWebAppStub).not.to.have.been.called; + expect(deployStub).to.have.been.calledWithMatch(["run"], sinon.match.any, { + appId: "1:123:web:abc", + }); + }); + + it("creates the new Firebase Web App, then deploys with it", async () => { + await actuate(webSetup({ createWebApp: true }), config, options); + + expect(getOrCreateWebAppStub).to.have.been.calledOnceWith("p", null, "web"); expect(deployStub).to.have.been.calledWithMatch(["run"], sinon.match.any, { appId: "1:123:web:abc", }); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 95d4735c825..372eaf83ff8 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -16,6 +16,7 @@ import { RunSingle } from "../../firebaseConfig"; import { dirExistsSync } from "../../fsutils"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; +import { AppPlatform, listFirebaseApps } from "../../management/apps"; import { Options } from "../../options"; import { input, select } from "../../prompt"; import { requirePermissions } from "../../requirePermissions"; @@ -30,7 +31,10 @@ export interface RunInfo { baseImage: string; rootDir: string; localBuild?: boolean; + /** The Firebase Web App to link. */ appId?: string; + /** Link a new Firebase Web App. Actuate creates it, so quitting init partway leaves none behind. */ + createWebApp?: boolean; } /** @@ -70,13 +74,9 @@ export async function askQuestions(setup: Setup, config: Config, options: Option serviceId = await promptNewServiceId(projectId, region); } - // Firebase SDKs auto-initialize from a linked Firebase Web App. Keep the service's app, or link a - // new one named after the service. - let appId = existing?.annotations?.[FIREBASE_APP_ANNOTATION]; - if (!appId) { - const webApp = await webApps.getOrCreateWebApp(projectId, null, serviceId); - appId = webApp?.id; - } + // Firebase SDKs auto-initialize from a linked Firebase Web App. Keep the service's app, or ask + // which app to link. A new app is created in actuate, so quitting init partway leaves none behind. + const appId = existing?.annotations?.[FIREBASE_APP_ANNOTATION] ?? (await promptWebApp(projectId)); // If firebase.json already has this service, its saved settings are the defaults. const savedEntry = findRunEntry(getRunEntries(config), serviceId, region); @@ -110,10 +110,35 @@ export async function askQuestions(setup: Setup, config: Config, options: Option } if (appId) { runInfo.appId = appId; + } else { + runInfo.createWebApp = true; } setup.featureInfo = { ...setup.featureInfo, run: runInfo }; } +/** + * Asks which of the project's Firebase Web Apps to link. Returns undefined to create a new app, + * which is the default, and what happens without asking if the project has no web apps. + */ +async function promptWebApp(projectId: string): Promise { + const apps = await listFirebaseApps(projectId, AppPlatform.WEB); + if (!apps.length) { + return undefined; + } + const choice = await select({ + message: "Which Firebase web app should this service use for Firebase SDK auto-initialization?", + choices: [ + { name: "Create a new web app", value: webApps.CREATE_NEW_FIREBASE_WEB_APP }, + ...apps.map((app) => ({ + name: app.displayName ? `${app.displayName} (${app.appId})` : app.appId, + value: app.appId, + })), + ], + default: webApps.CREATE_NEW_FIREBASE_WEB_APP, + }); + return choice === webApps.CREATE_NEW_FIREBASE_WEB_APP ? undefined : choice; +} + 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. @@ -187,10 +212,14 @@ export async function actuate(setup: Setup, config: Config, options: Options): P } else { upsertRunConfig(service, config); } - // Deploy with the base image that init asked for, and the Firebase Web App that it linked. + // Deploy with the base image that init asked for, and the Firebase Web App to link. A new app is + // created only now, after every question has been answered. const settings: Partial = { baseImage: info.baseImage || null }; - if (info.appId) { - settings.appId = info.appId; + const appId = info.createWebApp + ? (await webApps.getOrCreateWebApp(setup.projectId as string, null, info.serviceId))?.id + : info.appId; + if (appId) { + settings.appId = appId; } // Name the region too, so that only this service deploys, not ones with the same ID elsewhere. await deploy( From cfd8c7c763e7866ff0a8982c8c7a3aa800acefac Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 15 Oct 2026 12:17:51 -0400 Subject: [PATCH 098/103] Stop granting roles/firebase.sdkAdminServiceAgent; print the grant command when linking an app --- src/deploy/run/prepare.spec.ts | 49 ++++++++++++++++++++---------- src/deploy/run/prepare.ts | 54 ++++++++++++++++------------------ 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 6785d7c2634..50a8a4e4c5d 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -168,41 +168,60 @@ describe("run prepare", () => { FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), FIREBASE_CONFIG: firebaseConfig, }); - // The default service account already has the role. - expect(hasRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); - expect(addRolesStub).not.to.have.been.called; + // The app was already linked, so this deploy doesn't check IAM again. + expect(hasRolesStub).not.to.have.been.called; }); - it("grants the Admin SDK role to the default service account if it's missing", async () => { + it("says how to grant the Admin SDK role when it links an app, without granting it", async () => { + const warnStub = sinon.stub(utils, "logLabeledWarning"); hasRolesStub.resolves(false); await prepareRun(web, { context: { appId } }); - expect(addRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); + expect(hasRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `${defaultSa} doesn't have ${adminSdkRole}. Service web runs as this account`, + ); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `gcloud projects add-iam-policy-binding my-project --member=serviceAccount:${defaultSa} ` + + `--role=${adminSdkRole} --condition=None`, + ); + expect(addRolesStub).not.to.have.been.called; }); - it("checks and grants the Admin SDK role on a custom service account", async () => { + it("checks the service's own service account, and never changes its roles", async () => { const customSa = "web@my-project.iam.gserviceaccount.com"; - getServiceStub.resolves( - linked({ ...liveWeb, template: { ...liveWeb.template, serviceAccount: customSa } }), - ); + getServiceStub.resolves({ + ...liveWeb, + template: { ...liveWeb.template, serviceAccount: customSa }, + }); hasRolesStub.resolves(false); - await prepareRun(web); + await prepareRun(web, { context: { appId } }); expect(hasRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole]); - expect(addRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole]); + expect(addRolesStub).not.to.have.been.called; }); - it("warns and continues if it isn't allowed to grant the Admin SDK role", async () => { + it("says how to grant the role, and keeps deploying, if it can't check the role", async () => { const warnStub = sinon.stub(utils, "logLabeledWarning"); - hasRolesStub.resolves(false); - addRolesStub.rejects({ status: 403 }); + hasRolesStub.rejects( + new FirebaseError("The caller does not have permission", { status: 403 }), + ); const [svc] = await prepareRun(web, { context: { appId } }); expect(svc.autoInit?.appId).to.equal(appId); - expect(warnStub).to.have.been.calledWithMatch("run", /or ask an admin to grant this role/); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `Couldn't check whether ${defaultSa} has ${adminSdkRole} (The caller does not have permission)`, + ); + expect(warnStub).to.have.been.calledWithMatch( + "run", + "gcloud projects add-iam-policy-binding", + ); }); it("lets the context set or clear the linked app", async () => { diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index b7978e52b57..354926c69b6 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,6 +1,6 @@ import { webApps } from "../../apphosting/app"; import { getAutoinitEnvVars } from "../../apphosting/utils"; -import { FirebaseError, getErrMsg, getError, getErrStatus } from "../../error"; +import { FirebaseError, getErrMsg, getError } from "../../error"; import { WebConfig } from "../../fetchWebSetup"; import { RunSingle } from "../../firebaseConfig"; import { getDefaultServiceAccount } from "../../gcp/computeEngine"; @@ -88,7 +88,10 @@ async function prepareService(context: Context, config: RunSingle): Promise { const serviceAccount = existing?.template?.serviceAccount || (await getDefaultServiceAccount(await getProjectNumber(context))); + let problem = ""; try { - if ( - await resourceManager.serviceAccountHasRoles( - context.projectId, - serviceAccount, - [ADMIN_SDK_ROLE], - /* skipAccountLookup= */ true, - ) - ) { - return; - } - logLabeledWarning( - "run", - `Service account ${serviceAccount} is missing role ${ADMIN_SDK_ROLE} required for Firebase Admin SDK auto-initialization. Granting ${ADMIN_SDK_ROLE} to ${serviceAccount}...`, - ); - await resourceManager.addServiceAccountToRoles( + const hasRole = await resourceManager.serviceAccountHasRoles( context.projectId, serviceAccount, [ADMIN_SDK_ROLE], /* skipAccountLookup= */ true, ); - } catch (err: unknown) { - if (getErrStatus(err) === 403) { - logLabeledWarning( - "run", - `Failed to grant ${ADMIN_SDK_ROLE} to ${serviceAccount}. Make sure you have the resourcemanager.projects.setIamPolicy permission, or ask an admin to grant this role.`, - ); - } else { - throw err; + if (!hasRole) { + problem = `${serviceAccount} doesn't have ${ADMIN_SDK_ROLE}`; } + } catch (err: unknown) { + problem = `Couldn't check whether ${serviceAccount} has ${ADMIN_SDK_ROLE} (${getErrMsg(err)})`; + } + if (problem) { + logLabeledWarning( + "run", + `${problem}. Service ${config.serviceId} runs as this account, so if its Firebase Admin SDK ` + + `calls fail with permission errors, grant the role with:\n` + + ` gcloud projects add-iam-policy-binding ${context.projectId} ` + + `--member=serviceAccount:${serviceAccount} --role=${ADMIN_SDK_ROLE} --condition=None`, + ); } } From a2910d8e4664b97cbfacca73d04c1fb99b8ebfb7 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 16 Oct 2026 13:02:37 -0400 Subject: [PATCH 099/103] init run: fail instead of deploying unlinked when the web app can't be created getOrCreateWebApp only warns, and returns nothing, when the project is out of web apps (App Hosting backends work without one). Init then deployed the service without a linked app, silently skipping Firebase SDK auto-initialization. --- src/init/features/run.spec.ts | 11 +++++++++++ src/init/features/run.ts | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 306de3d4bb0..d9c7a00a581 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -309,6 +309,17 @@ describe("init run", () => { }); }); + it("fails without deploying if it can't create the web app", async () => { + // getOrCreateWebApp returns nothing, after a warning, when the project is out of web apps. + getOrCreateWebAppStub.resolves(undefined); + + await expect(actuate(webSetup({ createWebApp: true }), config, options)).to.be.rejectedWith( + "Couldn't create a Firebase web app for service web", + ); + + expect(deployStub).to.not.have.been.called; + }); + it("doesn't write firebase.json if the deploy fails", async () => { deployStub.rejects(new Error("build failed")); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 372eaf83ff8..7935ee3227e 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -215,9 +215,18 @@ export async function actuate(setup: Setup, config: Config, options: Options): P // Deploy with the base image that init asked for, and the Firebase Web App to link. A new app is // created only now, after every question has been answered. const settings: Partial = { baseImage: info.baseImage || null }; - const appId = info.createWebApp - ? (await webApps.getOrCreateWebApp(setup.projectId as string, null, info.serviceId))?.id - : info.appId; + let appId = info.appId; + if (info.createWebApp) { + // App Hosting's helper only warns, and returns nothing, when the project is out of web apps: + // App Hosting backends work without one. Fail instead of deploying an unlinked service. + appId = (await webApps.getOrCreateWebApp(setup.projectId as string, null, info.serviceId))?.id; + if (!appId) { + throw new FirebaseError( + `Couldn't create a Firebase web app for service ${info.serviceId}. Delete an unused web ` + + `app in the Firebase console, or run "firebase init run" again and pick an existing app.`, + ); + } + } if (appId) { settings.appId = appId; } From 32b86e7b82782413f1a41ce5fca289c6d5e20069 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 16 Oct 2026 13:02:37 -0400 Subject: [PATCH 100/103] Add a TODO for handing runtime FIREBASE_CONFIG to Cloud Run --- src/deploy/run/deploy.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 904113202b3..cae534981f1 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -267,6 +267,13 @@ export function revisionTemplate( } // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. + // TODO: Once Cloud Run sets FIREBASE_CONFIG itself from the firebase.google.com/app-id + // annotation, the CLI should only set the annotation, and remove the FIREBASE_CONFIG values it + // wrote: Cloud Run won't replace a value that's already on the container (user values win), so + // ours would hide its fresh one. To tell the CLI's values from the user's, record what the CLI + // writes (e.g. a hash in a firebase.google.com/ annotation), and only overwrite or remove + // FIREBASE_CONFIG while the live value still matches. That also stops this code from overwriting + // a value the user set on a linked service. const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); if (svc.autoInit) { From f357f9d2a31886e04497c094b7c814d3dc5ac0e6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 16 Oct 2026 13:09:22 -0400 Subject: [PATCH 101/103] Shorten the runtime FIREBASE_CONFIG TODO --- src/deploy/run/deploy.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index cae534981f1..d305de4a193 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -267,13 +267,8 @@ export function revisionTemplate( } // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. - // TODO: Once Cloud Run sets FIREBASE_CONFIG itself from the firebase.google.com/app-id - // annotation, the CLI should only set the annotation, and remove the FIREBASE_CONFIG values it - // wrote: Cloud Run won't replace a value that's already on the container (user values win), so - // ours would hide its fresh one. To tell the CLI's values from the user's, record what the CLI - // writes (e.g. a hash in a firebase.google.com/ annotation), and only overwrite or remove - // FIREBASE_CONFIG while the live value still matches. That also stops this code from overwriting - // a value the user set on a linked service. + // TODO: Once Cloud Run sets FIREBASE_CONFIG from the app-id annotation, stop setting it here and + // remove the values the CLI wrote (track them with a hash annotation, so user-set values stay). const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); if (svc.autoInit) { From 59332d498ec619746c40192dfb27393a4eb8a253 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 16 Oct 2026 13:11:39 -0400 Subject: [PATCH 102/103] Reword the FIREBASE_CONFIG TODO to match the agreed behavior The CLI always sets FIREBASE_CONFIG on linked services; users override auto-init in code, not with the env var. Once Cloud Run injects it, only the annotation is needed. --- src/deploy/run/deploy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index d305de4a193..33706c828e0 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -267,8 +267,7 @@ export function revisionTemplate( } // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. - // TODO: Once Cloud Run sets FIREBASE_CONFIG from the app-id annotation, stop setting it here and - // remove the values the CLI wrote (track them with a hash annotation, so user-set values stay). + // TODO: Stop setting FIREBASE_CONFIG once Cloud Run injects it from the app-id annotation. const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); if (svc.autoInit) { From 880c458ea47f8eeee14d405848c23376f147a33d Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 16 Oct 2026 13:23:08 -0400 Subject: [PATCH 103/103] Always use the linked app's config at build time too The build env annotation could override FIREBASE_CONFIG and FIREBASE_WEBAPP_CONFIG for the build, while the container always got the app's FIREBASE_CONFIG. Now both come from the app: users override auto-init in code, not with these env vars. --- src/deploy/run/prepare.spec.ts | 13 ++++++++----- src/deploy/run/prepare.ts | 7 ++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 50a8a4e4c5d..6d2368ccd22 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -234,14 +234,17 @@ describe("run prepare", () => { expect(cleared).not.to.have.any.keys("autoInit", "buildEnv"); }); - it("lets the build env annotation override the app's config at build time only", async () => { - const override = '{"projectId":"my-other-project"}'; - getServiceStub.resolves(linked(withBuildEnv({ FIREBASE_CONFIG: override }))); + it("uses the app's config at build time even if the build env annotation sets it", async () => { + getServiceStub.resolves( + linked(withBuildEnv({ FIREBASE_CONFIG: "mine", FIREBASE_WEBAPP_CONFIG: "mine" })), + ); const [svc] = await prepareRun(web); - expect(svc.buildEnv).to.include({ FIREBASE_CONFIG: override }); - expect(svc.autoInit?.firebaseConfig).to.equal(firebaseConfig); + expect(svc.buildEnv).to.deep.equal({ + FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), + FIREBASE_CONFIG: firebaseConfig, + }); }); it("fails if the linked app can't be looked up", async () => { diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 354926c69b6..f2900413c2f 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -85,15 +85,16 @@ async function prepareService(context: Context, config: RunSingle): Promise