From bc390c74eb9bc144cb67e2e424f739d37f2b86a5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Tue, 29 Sep 2026 23:23:49 -0400 Subject: [PATCH 1/6] 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 2/6] 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 f5ba7ec2a4938a9ad5c6db2822e680557a0d5be4 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Sun, 4 Oct 2026 13:56:20 -0700 Subject: [PATCH 3/6] 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 4037b81aaea08f2fbc159b4073de35171d76e9bc Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 8 Oct 2026 13:10:57 -0700 Subject: [PATCH 4/6] 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 84494af9eee8b737101c995dcd67f4b75f1f4907 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 9 Oct 2026 13:29:39 -0700 Subject: [PATCH 5/6] 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 1e3accb3e15ec46d3c3e23f2cd8231a87985176e Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 12 Oct 2026 14:00:38 -0400 Subject: [PATCH 6/6] 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];