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..1be9797754e 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("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/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..ea01f146716 --- /dev/null +++ b/src/deploy/run/args.ts @@ -0,0 +1,30 @@ +import { RunSingle } from "../../firebaseConfig"; +import * as runv2 from "../../gcp/runv2"; + +/** 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; + /** + * 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; + /** + * 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; + /** Set by deploy: the service after the rollout. */ + 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..97bdb5fa530 --- /dev/null +++ b/src/deploy/run/deploy.spec.ts @@ -0,0 +1,171 @@ +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, 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: "/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; + + beforeEach(() => { + sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); + 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") + .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; + createServiceStub = sinon.stub(runv2, "createService").resolves(deployed); + updateServiceStub = sinon.stub(runv2, "updateService").resolves(deployed); + sinon.stub(Date, "now").returns(42); + }); + + afterEach(() => sinon.restore()); + + 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.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: "https://web.run.app" }); + }); + + it("builds without a base image if the service doesn't have one", async () => { + await deployOne({ config: web }); + + // 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("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 deployOne({ config: web, existing }); + + 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" }, + ], + }, + { updateMask: ["template", "traffic"], pollTimeoutMs: 10 * 60 * 1000 }, + ); + }); + + describe("revisionTemplate", () => { + const existing = { + name: "projects/my-project/locations/us-central1/services/web", + template: { + revision: "web-001", + serviceAccount: "web@my-project.iam.gserviceaccount.com", + annotations: { team: "frontend", [DEPLOY_MESSAGE]: "Last deploy" }, + containers: [ + { + name: "web", + image: "old-image", + baseImageUri: NODEJS22, + env: [{ name: "MODE", value: "prod" }], + }, + ], + }, + } as unknown as runv2.Service; + + it("starts a new service with just the image", () => { + 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); + + 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"); + }); + + 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"); + + 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"); + 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 new file mode 100644 index 00000000000..02161c66ff8 --- /dev/null +++ b/src/deploy/run/deploy.ts @@ -0,0 +1,182 @@ +import * as fs from "fs"; +import * as path from "path"; +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 { 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. */ +const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; + +/** Builds each service from source 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 || []) { + 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 createNewService(projectId, svc.config, template); + } +} + +/** Zips the service's source and uploads it to the region's source bucket. */ +async function uploadSource( + projectId: string, + options: Options, + config: RunSingle, +): Promise { + 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", + 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(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 }; +} + +/** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ +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} in ${region}...`); + 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; +} + +/** + * 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. + */ +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 = 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 { + 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 a new service with its first revision. */ +function createNewService( + 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 new file mode 100644 index 00000000000..745b4322c94 --- /dev/null +++ b/src/deploy/run/index.ts @@ -0,0 +1,22 @@ +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. 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.\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" + + "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 new file mode 100644 index 00000000000..d974c8f6548 --- /dev/null +++ b/src/deploy/run/prepare.spec.ts @@ -0,0 +1,74 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +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 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; + + /** 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: "my-project", ...context }, options, payload); + return payload.run?.services ?? []; + } + + beforeEach(() => { + prereqsStub = sinon.stub(prereqs, "prereqs").resolves(); + getServiceStub = sinon.stub(runv2, "getService").rejects({ status: 404 }); + }); + + afterEach(() => sinon.restore()); + + 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; + }); + + 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 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(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 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 new file mode 100644 index 00000000000..7e5e8062855 --- /dev/null +++ b/src/deploy/run/prepare.ts @@ -0,0 +1,31 @@ +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 existing = await getExistingService(context.projectId, config.region, config.serviceId); + // 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/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 new file mode 100644 index 00000000000..022d58fe97b --- /dev/null +++ b/src/deploy/run/release.ts @@ -0,0 +1,19 @@ +import { logLabeledSuccess } from "../../utils"; +import { Payload } from "./args"; + +/** + * 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, + _options: unknown, + payload: Payload, +): Promise { + for (const { config, deployed } of payload.run?.services || []) { + logLabeledSuccess( + "run", + `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 c7c9b85eba5..e9778ec1cff 100644 --- a/src/deploy/run/util.spec.ts +++ b/src/deploy/run/util.spec.ts @@ -1,11 +1,94 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; -import { getExistingService } from "./util"; +import { Options } from "../../options"; +import * as utils from "../../utils"; +import { getExistingService, getServiceConfigs } from "./util"; describe("run util", () => { afterEach(() => sinon.restore()); + describe("getServiceConfigs", () => { + 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 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("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("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: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("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("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 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(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", "web:", "web:us-central1:extra"]) { + expect(() => getServiceConfigs(options([web], `run:${name}`))).to.throw( + `Invalid Cloud Run service "${name}". Use or :.`, + ); + } + }); + + 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.", + ); + }); + + 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."); + }); + }); + describe("getExistingService", () => { it("returns the service when it exists", async () => { const svc = { name: "projects/p/locations/r/services/s" } as runv2.Service; diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 5976083da0b..024ad820f0a 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -1,5 +1,87 @@ -import { getErrStatus } from "../../error"; +import { FirebaseError, getErrStatus } from "../../error"; +import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; + +/** + * Returns every Cloud Run service in firebase.json. Throws if one has no region or is listed twice. + */ +export function getAllServiceConfigs(options: Options): RunSingle[] { + // "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) { + if (!config.region) { + throw new FirebaseError( + `Cloud Run service ${config.serviceId} is missing a region in firebase.json.`, + ); + } + 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 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 filters = options.only ? options.only.split(",") : ["run"]; + if (filters.includes("run")) { + return configs; + } + 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)); +} + +/** + * 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) => 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}`; +} /** * Gets a Cloud Run service, or undefined if it doesn't exist yet. @@ -18,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.spec.ts b/src/init/features/run.spec.ts index 3634cef62ed..8ff53959e1c 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"; @@ -10,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; @@ -143,33 +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 () => { - await actuate(setup(), config); + 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 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: "/" } }; + it("adds the service to firebase.json, but not its base image", async () => { + await actuate(webSetup(), config, options); - await actuate(s, config); + 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; + }); + + 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:web:us-central1" }, + { baseImage: "nodejs22" }, + ); + }); + + it("doesn't write firebase.json if the deploy fails", async () => { + deployStub.rejects(new Error("build failed")); + + await expect(actuate(webSetup(), config, options)).to.be.rejectedWith("build failed"); - 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 the file once all features are set up, so a failed init leaves it untouched. 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 2fee81f383a..33f285fa564 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,8 +1,9 @@ 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 } from "../../deploy/run/util"; +import { fullServiceName, getExistingService, mainContainer } from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { dirExistsSync } from "../../fsutils"; @@ -67,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(); @@ -134,10 +135,11 @@ async function promptRootDir(projectDir: string, defaultDir: string): Promise { +export async function actuate(setup: Setup, config: Config, options: Options): Promise { const info = setup.featureInfo?.run; if (!info) { return; @@ -150,12 +152,21 @@ export async function actuate(setup: Setup, config: Config): Promise { }, 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)}` }, + { baseImage: info.baseImage || null }, + ); } /** - * 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(