diff --git a/src/commands/index.ts b/src/commands/index.ts index 8583fd39da9..3e4b13d9bf5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -305,6 +305,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("directcloudrun")) { + 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..d32021e47f2 --- /dev/null +++ b/src/commands/run-services-update.ts @@ -0,0 +1,23 @@ +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( + "--region ", + "the service's region, if firebase.json lists the service ID in more than one region", + ) + .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.region as string | undefined, options), + ); diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts index 745b4322c94..a2db3dbbdc5 100644 --- a/src/deploy/run/index.ts +++ b/src/deploy/run/index.ts @@ -19,4 +19,4 @@ export const detailedHelp = ' "ignore": ["node_modules", ".git"]\n' + " }\n" + "}\n\n" + - "Each deploy reuses the service's current base image."; + "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/update.spec.ts b/src/deploy/run/update.spec.ts new file mode 100644 index 00000000000..62a3155b968 --- /dev/null +++ b/src/deploy/run/update.spec.ts @@ -0,0 +1,101 @@ +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 web = { serviceId: "web", region: "us-central1" }; + const webEurope = { serviceId: "web", region: "europe-west1" }; + let getServiceStub: sinon.SinonStub; + let deployStub: sinon.SinonStub; + + beforeEach(() => { + 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()); + + /** 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( + serviceId, + flags.region as string | undefined, + 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", + ); + 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", async () => { + await expect(update("api", { baseImage: "nodejs22" })).to.be.rejectedWith( + "Cloud Run service api 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 () => { + getServiceStub.rejects({ status: 404 }); + + 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("deploys the service with the new base image", async () => { + await update("web", { baseImage: "nodejs22" }); + + expectDeployed("run:web:us-central1", { baseImage: "nodejs22" }); + }); + + 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 in two regions", () => { + it("asks which one to update", async () => { + await expect(update("web", { baseImage: "nodejs22" }, [web, webEurope])).to.be.rejectedWith( + "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", { 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 new file mode 100644 index 00000000000..df81e9176a5 --- /dev/null +++ b/src/deploy/run/update.ts @@ -0,0 +1,80 @@ +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 { 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. + * `region` is only needed if firebase.json lists the service ID in more than one region. + */ +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 = { + 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.", + ); + } + + const projectId = needProjectId(options); + const config = findServiceToUpdate(serviceId, region, options); + if (!(await getExistingService(projectId, config.region, config.serviceId))) { + const howToCreate = + `${clc.bold("firebase init run")} or ` + + clc.bold(`firebase deploy --only run:${fullServiceName(config)}`); + throw new FirebaseError( + `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ` + + `Create it with ${howToCreate}, then update it.`, + ); + } + + await deploy(["run"], { ...options, only: `run:${fullServiceName(config)}` }, settings); +} + +/** Reads a `-- ` / `--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); +} + +/** + * Returns the service in firebase.json with this ID, in `region` if one is given. Updates change + * one service at a time, so if firebase.json lists the ID in more than one region, `region` is + * required. + */ +function findServiceToUpdate( + serviceId: string, + region: string | undefined, + options: Options, +): RunSingle { + const matches = getAllServiceConfigs(options).filter( + (c) => c.serviceId === serviceId && (!region || c.region === region), + ); + if (!matches.length) { + const service = region ? `${serviceId} in ${region}` : serviceId; + throw new FirebaseError(`Cloud Run service ${service} not detected in firebase.json.`); + } + if (matches.length > 1) { + const regions = matches.map((c) => c.region); + const example = `firebase run:services:update ${serviceId} --region ${regions[0]}`; + throw new FirebaseError( + `firebase.json lists ${serviceId} in ${regions.length} regions: ${regions.join(", ")}. ` + + `Pick one with --region, e.g. ${example}`, + ); + } + return matches[0]; +}