From 21640d32ae5045363e18c7f5dd877bf87af71b22 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 6 Aug 2026 18:51:18 -0400 Subject: [PATCH 01/25] This is an ai-generated draft for testing. It must still be deeply reveiwed by humans and brought up to bar. Testing: This was tested manually by deploying a Cloud Run app --- e2e_run_tests/run_all_tests.sh | 176 ++++++++++++++++++++++++++ src/apphosting/yaml.ts | 16 ++- src/checkValidTargetFilters.ts | 1 + src/commands/init.ts | 5 + src/deploy/index.ts | 17 +++ src/deploy/run/deploy.spec.ts | 107 ++++++++++++++++ src/deploy/run/deploy.ts | 219 +++++++++++++++++++++++++++++++++ src/deploy/run/deploy.ts.bak | 119 ++++++++++++++++++ src/deploy/run/index.ts | 5 + src/deploy/run/prepare.spec.ts | 60 +++++++++ src/deploy/run/prepare.ts | 72 +++++++++++ src/deploy/run/prereqs.ts | 12 ++ src/deploy/run/release.spec.ts | 41 ++++++ src/deploy/run/release.ts | 30 +++++ src/filterTargets.ts | 3 + src/firebaseConfig.ts | 13 ++ src/gcp/artifactregistry.ts | 45 +++++++ src/gcp/runv2.ts | 56 +++++++-- src/init/features/index.ts | 1 + src/init/features/run.ts | 140 +++++++++++++++++++++ src/init/index.ts | 7 ++ 21 files changed, 1132 insertions(+), 13 deletions(-) create mode 100755 e2e_run_tests/run_all_tests.sh create mode 100644 src/deploy/run/deploy.spec.ts create mode 100644 src/deploy/run/deploy.ts create mode 100644 src/deploy/run/deploy.ts.bak create mode 100644 src/deploy/run/index.ts create mode 100644 src/deploy/run/prepare.spec.ts create mode 100644 src/deploy/run/prepare.ts create mode 100644 src/deploy/run/prereqs.ts create mode 100644 src/deploy/run/release.spec.ts create mode 100644 src/deploy/run/release.ts create mode 100644 src/init/features/run.ts diff --git a/e2e_run_tests/run_all_tests.sh b/e2e_run_tests/run_all_tests.sh new file mode 100755 index 00000000000..0f6566f2895 --- /dev/null +++ b/e2e_run_tests/run_all_tests.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# E2E Test Suite for Firebase Cloud Run Integration +# Tiers 1-4 + +APP_DIR="/Users/aryanf/code/firebase/firebase-apphosting-canary/apps/nextjs-reference/next-15.3/" +PROJECT="aryanf-test" +CLI="node /Users/aryanf/code/firebase/firebase-tools/lib/bin/firebase.js" + +echo "======================================" +echo "Starting E2E Tests for Cloud Run" +echo "======================================" + +cd "$APP_DIR" || exit 1 + +# Backup existing configs +mv firebase.json firebase.json.bak 2>/dev/null +mv .firebaserc .firebaserc.bak 2>/dev/null + +FAILED=0 + +run_test() { + local name=$1 + shift + echo "Running: $name" + "$@" + local status=$? + if [ $status -ne 0 ]; then + echo "❌ FAIL: $name (exit code: $status)" + FAILED=$((FAILED + 1)) + else + echo "✅ PASS: $name" + fi +} + +run_test_expect_fail() { + local name=$1 + shift + echo "Running (Expecting Fail): $name" + "$@" + local status=$? + if [ $status -eq 0 ]; then + echo "❌ FAIL: $name expected to fail but succeeded." + FAILED=$((FAILED + 1)) + else + echo "✅ PASS: $name (failed as expected with code: $status)" + fi +} + +echo "" +echo "--- Tier 1: Feature Coverage ---" +# T1.1 +rm -f firebase.json .firebaserc +run_test "T1.1: Init Cloud Run non-interactive" $CLI init run --non-interactive --project "$PROJECT" + +# T1.2 +rm -f firebase.json .firebaserc +run_test "T1.2: Init Cloud Run with --project" $CLI init run --non-interactive --project "$PROJECT" + +# T1.3 +rm -f firebase.json .firebaserc +touch firebase.json # Mock existing config +run_test "T1.3: Init Cloud Run (additive)" $CLI init run --non-interactive --project "$PROJECT" + +# T1.4 +run_test "T1.4: Deploy to Cloud Run" $CLI deploy --only run --project "$PROJECT" --non-interactive + +# T1.5 +run_test "T1.5: Deploy with force" $CLI deploy --only run --project "$PROJECT" --non-interactive --force + + +echo "" +echo "--- Tier 2: Boundary & Corner Cases ---" +# T2.1 +rm -f firebase.json .firebaserc +run_test_expect_fail "T2.1: Init Cloud Run with invalid project" $CLI init run --non-interactive --project "invalid-project-id-1234567890" + +# T2.2 +# Create a dir without write permissions for testing +mkdir -p no_write_dir +chmod 555 no_write_dir +cd no_write_dir || exit 1 +run_test_expect_fail "T2.2: Init in directory without write permissions" $CLI init run --non-interactive --project "$PROJECT" +cd .. || exit 1 +rm -rf no_write_dir + +# T2.3 +# Deploy with no config +rm -f firebase.json .firebaserc +run_test_expect_fail "T2.3: Deploy without firebase.json" $CLI deploy --only run --project "$PROJECT" --non-interactive + +# T2.4 +$CLI init run --non-interactive --project "$PROJECT" +run_test_expect_fail "T2.4: Deploy with invalid region (simulated by env)" env FIREBASE_RUN_REGION=invalid-region $CLI deploy --only run --project "$PROJECT" --non-interactive + +# T2.5 +run_test "T2.5: Init repeatedly (idempotent)" $CLI init run --non-interactive --project "$PROJECT" + + +echo "" +echo "--- Tier 3: Cross-Feature Combinations ---" +# T3.1 +rm -f firebase.json .firebaserc +run_test "T3.1: Init then immediately deploy" bash -c "$CLI init run --non-interactive --project \"$PROJECT\" && $CLI deploy --only run --project \"$PROJECT\" --non-interactive" + +# T3.2 +run_test "T3.2: Multiple sequential deploys" bash -c "$CLI deploy --only run --project \"$PROJECT\" --non-interactive && $CLI deploy --only run --project \"$PROJECT\" --non-interactive" + + +echo "" +echo "--- Tier 4: Real-World Application Scenarios ---" +# T4.1 +rm -f firebase.json .firebaserc apphosting.yaml +echo "T4.1: Next.js Full Lifecycle with apphosting.yaml Verification (Init -> Deploy -> Verify)" +cat < apphosting.yaml +runConfig: + cpu: 2 + memoryMiB: 1024 + minInstances: 1 + maxInstances: 5 + concurrency: 100 +env: + - variable: TEST_VAR + value: "hello_world" +EOF + +$CLI init run --non-interactive --project "$PROJECT" +if [ $? -eq 0 ]; then + $CLI deploy --only run --project "$PROJECT" --non-interactive + if [ $? -eq 0 ]; then + echo "Verifying Cloud Run configuration..." + SERVICE_NAME=$(cat firebase.json | grep -o '"serviceId"[[:space:]]*:[[:space:]]*"[^"]*"' | awk -F '"' '{print $4}') + REGION=$(cat firebase.json | grep -o '"region"[[:space:]]*:[[:space:]]*"[^"]*"' | awk -F '"' '{print $4}') + if [ -z "$REGION" ]; then REGION="us-central1"; fi + + gcloud run services describe $SERVICE_NAME --region $REGION --project "$PROJECT" --format=json > svc.json + CPU=$(cat svc.json | jq -r '(.spec.template.spec.containers[0].resources.limits.cpu // .template.containers[0].resources.limits.cpu)') + MEM=$(cat svc.json | jq -r '(.spec.template.spec.containers[0].resources.limits.memory // .template.containers[0].resources.limits.memory)') + MIN=$(cat svc.json | jq -r '(.spec.template.metadata.annotations["autoscaling.knative.dev/minScale"] // .template.scaling.minInstanceCount)') + MAX=$(cat svc.json | jq -r '(.spec.template.metadata.annotations["autoscaling.knative.dev/maxScale"] // .template.scaling.maxInstanceCount)') + CONCURRENCY=$(cat svc.json | jq -r '((.spec.template.spec.containerConcurrency | tostring) // (.template.maxInstanceRequestConcurrency | tostring))') + ENV_VAL=$(cat svc.json | jq -r '([.spec.template.spec.containers[0].env[]?, .template.containers[0].env[]?] | map(select(.name=="TEST_VAR")) | .[0].value)') + + if [ "$CPU" = "2" ] && [ "$MEM" = "1024Mi" ] && [ "$MIN" = "1" ] && [ "$MAX" = "5" ] && [ "$CONCURRENCY" = "100" ] && [ "$ENV_VAL" = "hello_world" ]; then + echo "✅ PASS: T4.1 configuration verified" + else + echo "❌ FAIL: T4.1 configuration did not match expectations" + echo "CPU: $CPU (expected 2)" + echo "MEM: $MEM (expected 1024Mi)" + echo "MIN: $MIN (expected 1)" + echo "MAX: $MAX (expected 5)" + echo "CONCURRENCY: $CONCURRENCY (expected 100)" + echo "ENV_VAL: $ENV_VAL (expected hello_world)" + FAILED=$((FAILED + 1)) + fi + else + echo "❌ FAIL: T4.1 Deploy failed" + FAILED=$((FAILED + 1)) + fi +else + echo "❌ FAIL: T4.1 Init failed" + FAILED=$((FAILED + 1)) +fi +rm -f apphosting.yaml svc.json + +# Restore configs +mv firebase.json.bak firebase.json 2>/dev/null +mv .firebaserc.bak .firebaserc 2>/dev/null + +echo "======================================" +if [ $FAILED -gt 0 ]; then + echo "Tests Completed with $FAILED Failures." + exit 1 +else + echo "All Tests Passed Successfully!" + exit 0 +fi diff --git a/src/apphosting/yaml.ts b/src/apphosting/yaml.ts index 144201876f3..1db7daffd4e 100644 --- a/src/apphosting/yaml.ts +++ b/src/apphosting/yaml.ts @@ -1,6 +1,6 @@ import { basename, dirname } from "path"; import { readFileFromDirectory, wrappedSafeLoad } from "../utils"; -import { Config, Env, store } from "./config"; +import { Config, Env, store, RunConfig } from "./config"; import * as yaml from "yaml"; import * as jsYaml from "js-yaml"; import * as path from "path"; @@ -18,6 +18,7 @@ export class AppHostingYamlConfig { // Holds the basename of the file (e.g. apphosting.yaml vs apphosting.staging.yaml) public filename: string | undefined; public env: EnvMap = {}; + public runConfig?: RunConfig; /** * Reads in the App Hosting yaml file found in filePath, parses the secrets and @@ -37,6 +38,9 @@ export class AppHostingYamlConfig { if (loadedAppHostingYaml.env) { config.env = toEnvMap(loadedAppHostingYaml.env); } + if (loadedAppHostingYaml.runConfig) { + config.runConfig = loadedAppHostingYaml.runConfig; + } return config; } @@ -69,6 +73,13 @@ export class AppHostingYamlConfig { ...this.env, ...other.env, }; + + if (other.runConfig) { + this.runConfig = { + ...this.runConfig, + ...other.runConfig, + }; + } } /** @@ -84,6 +95,9 @@ export class AppHostingYamlConfig { } yamlConfigToWrite.env = toEnvList(this.env); + if (this.runConfig) { + yamlConfigToWrite.runConfig = this.runConfig; + } store(filePath, yaml.parseDocument(jsYaml.dump(yamlConfigToWrite))); } 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/init.ts b/src/commands/init.ts index b69d7363202..67c83d58c7d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -99,6 +99,11 @@ let choices: { name: "Authentication: Set up Firebase Authentication", checked: false, }, + { + value: "run", + name: "Cloud Run: Configure a Cloud Run service", + checked: false, + }, ]; if (isEnabled("fdcwebhooks")) { diff --git a/src/deploy/index.ts b/src/deploy/index.ts index c7cae13b2c4..1103a5ef850 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -20,6 +20,7 @@ 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 { prepareFrameworks } from "../frameworks"; import { Context as HostingContext } from "./hosting/context"; import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare"; @@ -44,6 +45,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 +107,20 @@ export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], s // ensureAILogicApiEnabled reads API enablement state via Service Usage. "serviceusage.services.get", ], + run: [ + "run.services.get", + "run.services.create", + "run.services.update", + "cloudbuild.builds.create", + "cloudbuild.builds.get", + "storage.buckets.get", + "storage.buckets.create", + "storage.objects.create", + "storage.objects.delete", + "artifactregistry.repositories.get", + "artifactregistry.repositories.downloadArtifacts", + "artifactregistry.repositories.uploadArtifacts", + ], }; export const TARGETS = { @@ -119,6 +135,7 @@ export const TARGETS = { apphosting: AppHostingTarget, auth: AuthTarget, ailogic: AiLogicTarget, + run: RunTarget, }; export type DeployOptions = Options & { dryRun?: boolean }; diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts new file mode 100644 index 00000000000..a3724a56847 --- /dev/null +++ b/src/deploy/run/deploy.spec.ts @@ -0,0 +1,107 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { deploy } from "./deploy"; +import * as runv2 from "../../gcp/runv2"; +import * as gcs from "../../gcp/storage"; +import * as artifactRegistry from "../../gcp/artifactregistry"; +import * as archiveDirectory from "../../archiveDirectory"; + +describe("run deploy", () => { + let upsertBucketStub: sinon.SinonStub; + let submitBuildStub: sinon.SinonStub; + let updateServiceStub: sinon.SinonStub; + let createServiceStub: sinon.SinonStub; + let ensureRepoStub: sinon.SinonStub; + + beforeEach(() => { + upsertBucketStub = sinon.stub(gcs, "upsertBucket").resolves("my-bucket"); + ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepository").resolves(); + sinon.stub(archiveDirectory, "archiveDirectory").resolves({ + file: "test.zip", + stream: "mock-stream" as any, + size: 100, + source: ".", + manifest: [], + }); + sinon.stub(gcs, "uploadObject").resolves({ + bucket: "my-bucket", + object: "test.zip", + generation: "123", + }); + submitBuildStub = sinon + .stub(runv2, "submitBuild") + .resolves({ baseImageUri: "dummy-base-image" }); + updateServiceStub = sinon + .stub(runv2, "updateService") + .resolves({ uri: "https://my-service.com" } as any); + createServiceStub = sinon + .stub(runv2, "createService") + .resolves({ uri: "https://my-service.com" } as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should deploy a new service", async () => { + const payload: any = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + manifest: [], + baseImageUri: "dummy-base-image", + }, + ], + }, + }; + const context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as any; + + await deploy(context, options, payload); + + expect(upsertBucketStub.calledOnce).to.be.true; + expect(ensureRepoStub.calledOnce).to.be.true; + expect(submitBuildStub.calledOnce).to.be.true; + expect(createServiceStub.calledOnce).to.be.true; + expect(updateServiceStub.notCalled).to.be.true; + + const createdService = createServiceStub.args[0][3]; + expect(createdService.template.containers[0].baseImageUri).to.equal("dummy-base-image"); + + // Check if deployResponse is set + expect(payload.run.services[0].deployResponse.uri).to.equal("https://my-service.com"); + }); + + it("should update an existing service", async () => { + const payload: any = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + manifest: [], + existingService: { + name: "projects/project/locations/us-central1/services/mysvc", + template: { + containers: [{ image: "old-image" }], + }, + }, + }, + ], + }, + }; + const context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as any; + + await deploy(context, options, payload); + + expect(ensureRepoStub.calledOnce).to.be.true; + expect(updateServiceStub.calledOnce).to.be.true; + expect(createServiceStub.notCalled).to.be.true; + expect(payload.run.services[0].deployResponse.uri).to.equal("https://my-service.com"); + }); +}); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts new file mode 100644 index 00000000000..d31e666affd --- /dev/null +++ b/src/deploy/run/deploy.ts @@ -0,0 +1,219 @@ +import { Options } from "../../options"; + +import { archiveDirectory } from "../../archiveDirectory"; +import * as gcs from "../../gcp/storage"; +import { getProjectNumber } from "../../getProjectNumber"; +import * as runv2 from "../../gcp/runv2"; +import * as artifactregistry from "../../gcp/artifactregistry"; +import { splitEnvVars } from "../../apphosting/config"; +import { EnvVar } from "../../gcp/k8s"; + +/** + * + */ +export async function deploy(context: any, options: Options, payload: any): Promise { + const projectId = context.projectId; + const projectNumber = await getProjectNumber(options); + + if (!payload.run?.services) return; + + for (const service of payload.run.services) { + const region = service.region; + + // Create bucket + const baseName = `firebase-run-src-${projectNumber}`; + const bucketName = await gcs.upsertBucket({ + product: "run", + projectId, + createMessage: `Creating Cloud Storage bucket to store Run source code...`, + req: { + baseName, + location: region, + purposeLabel: "run-source", + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 1 } }] }, + }, + }); + + // Zip and upload + const archive = await archiveDirectory(service.source, { + ignore: service.ignore, + }); + + const uploadRes = await gcs.uploadObject( + { + file: archive.file, + stream: archive.stream, + }, + bucketName, + ); + + service.storageSource = { + bucket: uploadRes.bucket, + object: uploadRes.object, + generation: uploadRes.generation || undefined, + }; + + // Ensure Artifact Registry repository exists + await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); + + // Construct image URI + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; + + const appHostingConfig = service.appHostingConfig || { env: {} }; + const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(appHostingConfig.env); + const firebaseConfigStr = JSON.stringify({ + projectId, + storageBucket: `${projectId}.appspot.com`, + }); + const buildEnv: Record = { + FIREBASE_CONFIG: firebaseConfigStr, + }; + for (const [key, val] of Object.entries(buildEnvMap)) { + if (val.value !== undefined) { + buildEnv[key] = val.value; + } + } + + // Submit build + const build: runv2.Build = { + storageSource: service.storageSource, + imageUri, + buildpackBuild: { + enableAutomaticUpdates: true, + environmentVariables: buildEnv, + ...(service.baseImageUri ? { baseImage: service.baseImageUri } : {}), + }, + }; + await runv2.submitBuild(projectId, region, build); + + // Deploy via POST or PATCH + const existing = service.existingService; + let newService: Omit; + + if (existing) { + newService = { + name: existing.name, + template: JSON.parse(JSON.stringify(existing.template)), + }; + delete (newService.template as any).revision; + + // Mutate template with new image + if (!newService.template.containers) { + newService.template.containers = []; + } + if (newService.template.containers.length === 0) { + newService.template.containers.push({ name: service.serviceId, image: imageUri }); + } else { + newService.template.containers[0].image = imageUri; + } + + // ABIU stickiness handling + if (service.baseImageUri !== undefined) { + newService.template.containers[0].baseImageUri = service.baseImageUri; + } else { + delete newService.template.containers[0].baseImageUri; + } + + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig.runConfig, projectId); + + service.deployResponse = await runv2.updateService(newService); + } else { + newService = { + name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, + template: { + containers: [ + { + name: service.serviceId, + image: imageUri, + ...(service.baseImageUri ? { baseImageUri: service.baseImageUri } : {}), + }, + ], + }, + client: "cli-firebase", + }; + + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig.runConfig, projectId); + + service.deployResponse = await runv2.createService( + projectId, + region, + service.serviceId, + newService, + ); + } + } +} + +function applyAppHostingConfig( + service: Omit, + runtimeEnvMap: Record, + runConfig: any, + projectId?: string, +) { + if (!service.template.containers) { + service.template.containers = []; + } + if (service.template.containers.length === 0) { + service.template.containers.push({ name: "worker", image: "" }); + } + + const container = service.template.containers[0]; + + // Map runtime and secret env vars + const env: EnvVar[] = []; + if (projectId && !runtimeEnvMap["FIREBASE_CONFIG"]) { + env.push({ + name: "FIREBASE_CONFIG", + value: JSON.stringify({ + projectId, + storageBucket: `${projectId}.appspot.com`, + }), + }); + } + for (const [key, val] of Object.entries(runtimeEnvMap)) { + if (val.value !== undefined) { + env.push({ name: key, value: val.value }); + } else if (val.secret !== undefined) { + let secretName = String(val.secret); + let version = "latest"; + if (secretName.includes("@")) { + const parts = secretName.split("@"); + secretName = parts[0]; + version = parts[1] || "latest"; + } + env.push({ + name: key, + valueSource: { + secretKeyRef: { + secret: secretName, + version: version, + }, + }, + } as any); + } + } + if (env.length > 0) { + container.env = env; + } + + // Map RunConfig + if (runConfig) { + if (runConfig.cpu !== undefined || runConfig.memoryMiB !== undefined) { + if (!container.resources) container.resources = {}; + if (!container.resources.limits) container.resources.limits = {}; + if (runConfig.cpu !== undefined) container.resources.limits.cpu = String(runConfig.cpu); + if (runConfig.memoryMiB !== undefined) + container.resources.limits.memory = `${runConfig.memoryMiB}Mi`; + } + if (runConfig.minInstances !== undefined || runConfig.maxInstances !== undefined) { + if (!service.template.scaling) service.template.scaling = {}; + if (runConfig.minInstances !== undefined) + service.template.scaling.minInstanceCount = runConfig.minInstances; + if (runConfig.maxInstances !== undefined) + service.template.scaling.maxInstanceCount = runConfig.maxInstances; + } + if (runConfig.concurrency !== undefined) { + service.template.maxInstanceRequestConcurrency = runConfig.concurrency; + } + } +} diff --git a/src/deploy/run/deploy.ts.bak b/src/deploy/run/deploy.ts.bak new file mode 100644 index 00000000000..a57baecc88d --- /dev/null +++ b/src/deploy/run/deploy.ts.bak @@ -0,0 +1,119 @@ +import { Options } from "../../options"; +import { archiveDirectory } from "../../archiveDirectory"; +import * as gcs from "../../gcp/storage"; +import { getProjectNumber } from "../../getProjectNumber"; +import * as runv2 from "../../gcp/runv2"; + +/** + * + */ +export async function deploy(context: any, options: Options, payload: any): Promise { + const projectId = context.projectId; + const projectNumber = options.projectNumber || (await getProjectNumber(projectId)); + + if (!payload.run?.services) return; + + for (const service of payload.run.services) { + const region = service.region; + + // Create bucket + const baseName = `firebase-run-src-${projectNumber}`; + const bucketName = await gcs.upsertBucket({ + product: "run", + projectId, + createMessage: `Creating Cloud Storage bucket to store Run source code...`, + req: { + baseName, + location: region, + purposeLabel: "run-source", + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 1 } }] }, + }, + }); + + // Zip and upload + const archive = await archiveDirectory(service.source, { + ignore: service.ignore, + }); + + const uploadRes = await gcs.uploadObject( + { + file: archive.file, + stream: archive.stream, + }, + bucketName, + ); + + service.storageSource = { + bucket: uploadRes.bucket, + object: uploadRes.object, + generation: uploadRes.generation || undefined, + }; + + // Construct image URI + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}`; + + // Submit build + const build: runv2.Build = { + storageSource: service.storageSource, + imageUri, + buildpacksBuild: { + enableAutomaticUpdates: true, + }, + }; + const buildRes = await runv2.submitBuild(projectId, region, build); + if (!service.baseImageUri && buildRes.baseImageUri) { + service.baseImageUri = buildRes.baseImageUri; + } + + // Deploy via POST or PATCH + const existing = service.existingService; + let newService: Omit; + + if (existing) { + newService = { + name: existing.name, + template: existing.template, + }; + + // Mutate template with new image + if (!newService.template.containers) { + newService.template.containers = []; + } + if (newService.template.containers.length === 0) { + newService.template.containers.push({ name: service.serviceId, image: imageUri }); + } else { + newService.template.containers[0].image = imageUri; + } + + // ABIU stickiness handling + if (service.baseImageUri !== undefined) { + newService.template.containers[0].baseImageUri = service.baseImageUri; + } else { + delete newService.template.containers[0].baseImageUri; + } + + service.deployResponse = await runv2.updateService(newService); + } else { + newService = { + name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, + template: { + containers: [ + { + name: service.serviceId, + image: imageUri, + ...(service.baseImageUri ? { baseImageUri: service.baseImageUri } : {}), + }, + ], + }, + client: "cli-firebase", + }; + + service.deployResponse = await runv2.createService( + projectId, + region, + service.serviceId, + newService, + ); + } + } +} diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts new file mode 100644 index 00000000000..24960e3b642 --- /dev/null +++ b/src/deploy/run/index.ts @@ -0,0 +1,5 @@ +import { prepare } from "./prepare"; +import { deploy } from "./deploy"; +import { release } from "./release"; + +export { prepare, deploy, release }; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts new file mode 100644 index 00000000000..ff1e5faaed1 --- /dev/null +++ b/src/deploy/run/prepare.spec.ts @@ -0,0 +1,60 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { prepare } from "./prepare"; +import * as runv2 from "../../gcp/runv2"; +import * as prereqs from "./prereqs"; + +describe("run prepare", () => { + let prereqsStub: sinon.SinonStub; + let getServiceStub: sinon.SinonStub; + + beforeEach(() => { + prereqsStub = sinon.stub(prereqs, "prereqs").resolves(); + getServiceStub = sinon.stub(runv2, "getService"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should initialize default run config if none specified in firebase.json", async () => { + const payload: any = {}; + const context = { projectId: "project" }; + const options = { + project: "project", + config: { get: () => undefined, path: (p: string) => p }, + } as any; + + getServiceStub.resolves(undefined); + + await prepare(context, options, payload); + + expect(prereqsStub.calledOnce).to.be.true; + expect(payload.run.services.length).to.equal(1); + expect(payload.run.services[0].serviceId).to.equal("my-service"); + }); + + it("should fetch existing service and base image", async () => { + const payload: any = {}; + const context = { projectId: "project" }; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as any; + + getServiceStub.resolves({ + template: { + containers: [{ baseImageUri: "some-uri" }], + }, + }); + + await prepare(context, options, payload); + + expect(prereqsStub.calledOnce).to.be.true; + expect(payload.run.services.length).to.equal(1); + expect(payload.run.services[0].baseImageUri).to.equal("some-uri"); + }); +}); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts new file mode 100644 index 00000000000..02fa51d3853 --- /dev/null +++ b/src/deploy/run/prepare.ts @@ -0,0 +1,72 @@ +import { needProjectId } from "../../projectUtils"; +import { Options } from "../../options"; +import { prereqs } from "./prereqs"; +import * as runv2 from "../../gcp/runv2"; +import { getAppHostingConfiguration } from "../../apphosting/config"; + +/** + * + */ +export async function prepare(context: any, options: Options, payload: any): Promise { + const projectId = needProjectId(options); + await prereqs(options, projectId); + + let rawRunConfigs = options.config ? options.config.get("run") : undefined; + if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { + const onlyOpt = options.only || ""; + const runTargetOpt = onlyOpt.split(",").find((t) => t.startsWith("run")); + const serviceId = + runTargetOpt && runTargetOpt.includes(":") ? runTargetOpt.split(":")[1] : "my-service"; + const region = process.env.FIREBASE_RUN_REGION || "us-central1"; + rawRunConfigs = [ + { + serviceId, + region, + source: ".", + output: ".run", + ignore: ["node_modules", ".git"], + }, + ]; + } + + const configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; + + payload.run = { + services: [], + }; + + for (const config of configs) { + const serviceId = config.serviceId; + const region = process.env.FIREBASE_RUN_REGION || config.region || "us-central1"; + let existingService: runv2.Service | undefined; + try { + existingService = await runv2.getService(projectId, region, serviceId); + } catch (err: any) { + if (err.status !== 404) { + throw err; + } + } + + let baseImageUri: string | undefined; + if (existingService?.template?.containers?.[0]?.baseImageUri) { + baseImageUri = existingService.template.containers[0].baseImageUri; + } + // If the config specifies a baseImage, that overrides. + if (config.baseImageUri !== undefined) { + baseImageUri = config.baseImageUri; + } + + const sourceDir = options.config ? options.config.path(config.source || ".") : process.cwd(); + const appHostingConfig = await getAppHostingConfiguration(sourceDir); + + payload.run.services.push({ + serviceId, + region, + source: sourceDir, + ignore: config.ignore || ["node_modules", ".git", ".next"], + existingService, + baseImageUri, + appHostingConfig, + }); + } +} diff --git a/src/deploy/run/prereqs.ts b/src/deploy/run/prereqs.ts new file mode 100644 index 00000000000..11599f22c81 --- /dev/null +++ b/src/deploy/run/prereqs.ts @@ -0,0 +1,12 @@ +import { Options } from "../../options"; +import { ensure } from "../../ensureApiEnabled"; + +/** + * + */ +export async function prereqs(options: Options, projectId: string): Promise { + await ensure(projectId, "run.googleapis.com", "deploy", true); + await ensure(projectId, "cloudbuild.googleapis.com", "deploy", true); + await ensure(projectId, "storage.googleapis.com", "deploy", true); + await ensure(projectId, "artifactregistry.googleapis.com", "deploy", true); +} diff --git a/src/deploy/run/release.spec.ts b/src/deploy/run/release.spec.ts new file mode 100644 index 00000000000..e4232e93972 --- /dev/null +++ b/src/deploy/run/release.spec.ts @@ -0,0 +1,41 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { release } from "./release"; +import * as gcs from "../../gcp/storage"; + +describe("run release", () => { + let deleteObjectStub: sinon.SinonStub; + + beforeEach(() => { + deleteObjectStub = sinon.stub(gcs, "deleteObject").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should delete staging objects", async () => { + const payload = { + run: { + services: [ + { + storageSource: { + bucket: "my-bucket", + object: "test.zip", + }, + deployResponse: { + uri: "https://my-service.com", + }, + }, + ], + }, + }; + const context = {}; + const options = {} as any; + + await release(context, options, payload); + + expect(deleteObjectStub.calledOnce).to.be.true; + expect(deleteObjectStub.firstCall.args[0]).to.equal("/my-bucket/test.zip"); + }); +}); diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts new file mode 100644 index 00000000000..25233b4a991 --- /dev/null +++ b/src/deploy/run/release.ts @@ -0,0 +1,30 @@ +import { Options } from "../../options"; +import { logger } from "../../logger"; +import * as gcs from "../../gcp/storage"; + +/** + * + */ +export async function release(context: any, options: Options, payload: any): Promise { + if (!payload.run?.services) return; + + for (const service of payload.run.services) { + if (service.storageSource) { + try { + await gcs.deleteObject(`/${service.storageSource.bucket}/${service.storageSource.object}`); + logger.debug( + `Deleted source archive from GCS: gs://${service.storageSource.bucket}/${service.storageSource.object}`, + ); + } catch (err) { + logger.debug( + `Failed to delete source archive: gs://${service.storageSource.bucket}/${service.storageSource.object}`, + err, + ); + } + } + + if (service.deployResponse && service.deployResponse.uri) { + logger.info(`Service ${service.serviceId} is available at ${service.deployResponse.uri}`); + } + } +} diff --git a/src/filterTargets.ts b/src/filterTargets.ts index 6593968f27e..e3c633633a1 100644 --- a/src/filterTargets.ts +++ b/src/filterTargets.ts @@ -10,6 +10,9 @@ import { Options } from "./options"; */ export function filterTargets(options: Options, validTargets: string[]): string[] { let targets = validTargets.filter((t) => { + if (t === "run" && options.only?.split(",").some((opt) => opt.split(":")[0] === "run")) { + return true; + } return options.config.has(t); }); if (options.only) { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 25934174b9a..1b9478701c2 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,6 +366,18 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; +export type RunSingle = { + serviceId: string; + region: string; + source: string; + output?: string; + ignore?: string[]; +}; + +export type RunMultiple = RunSingle[]; + +export type RunConfig = RunSingle | RunMultiple; + export interface AuthConfig { providers?: { anonymous?: boolean; @@ -392,4 +404,5 @@ export type FirebaseConfig = { dataconnect?: DataConnectConfig; apphosting?: AppHostingConfig; auth?: AuthConfig; + run?: RunConfig; }; diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index 6c589ad21c2..39f14868821 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -95,3 +95,48 @@ export async function updateRepository(repo: RepositoryInput): Promise { + const res = await client.post( + `/projects/${projectId}/locations/${location}/repositories`, + { + format, + description: "Cloud Run Source Deploy Repository", + }, + { + queryParams: { + repositoryId, + }, + }, + ); + return res.body; +} + +/** + * Ensures an Artifact Registry repository exists, creating it if not. + */ +export async function ensureRepository( + projectId: string, + location: string, + repositoryId: string, + format = "DOCKER", +): Promise { + const name = `projects/${projectId}/locations/${location}/repositories/${repositoryId}`; + try { + await getRepository(name); + } catch (err: any) { + if (err.status === 404) { + await createRepository(projectId, location, repositoryId, format); + } else { + throw err; + } + } +} diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 5b3a2117269..e7e83a68ddd 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -111,6 +111,7 @@ export interface Service { etag: string; template: RevisionTemplate; invokerIamDisabled?: boolean; + ingress?: string; // Is this redundant with the Build API? buildConfig?: BuildConfig; uri?: string; @@ -151,7 +152,7 @@ export interface Build { functionTarget?: string; storageSource: StorageSource; imageUri: string; - buildpacksBuild: BuildpacksBuild; + buildpackBuild: BuildpacksBuild; } export interface SubmitBuildResponse { @@ -168,19 +169,33 @@ export async function submitBuild( projectId: string, location: string, build: Build, -): Promise { +): Promise> { const res = await client.post( - `/projects/${projectId}/locations/${location}/builds`, + `/projects/${projectId}/locations/${location}/builds:submit`, build, ); if (res.status !== 200) { throw new FirebaseError(`Failed to submit build: ${res.status} ${res.body}`); } + const op: any = res.body.buildOperation; + const buildId = op?.metadata?.build?.id; + const opName = buildId + ? `projects/${projectId}/locations/${location}/operations/${buildId}` + : typeof op === "string" + ? op + : op?.name; await pollOperation({ apiOrigin: cloudbuildOrigin(), apiVersion: "v1", - operationResourceName: res.body.buildOperation, + operationResourceName: opName, + masterTimeout: 10 * 60 * 1000, + backoff: 1000, + maxBackoff: 5000, }); + return { + baseImageUri: res.body.baseImageUri, + baseImageWarning: res.body.baseImageWarning, + }; } /** @@ -188,14 +203,25 @@ export async function submitBuild( * Tracks the long-running operation until completion. */ export async function updateService(service: Omit): Promise { - const fieldMask = proto.fieldMasks( - service, - /* doNotRecurseIn...*/ "labels", - "annotations", - "tags", - ); - // Always update revision name to ensure null generates a new unique revision name. - fieldMask.push("template.revision"); + const fieldMask: string[] = []; + if (service.template) { + fieldMask.push("template"); + } + if (service.labels) { + fieldMask.push("labels"); + } + if (service.annotations) { + fieldMask.push("annotations"); + } + if (service.ingress) { + fieldMask.push("ingress"); + } + if (service.description) { + fieldMask.push("description"); + } + if (fieldMask.length === 0) { + fieldMask.push("template"); + } const res = await client.patch, LongRunningOperation>( service.name, service, @@ -209,6 +235,9 @@ export async function updateService(service: Omit) apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: 10 * 60 * 1000, + backoff: 1000, + maxBackoff: 5000, }); return svc; } @@ -240,6 +269,9 @@ export async function createService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: 10 * 60 * 1000, + backoff: 1000, + maxBackoff: 5000, }); return svc; } diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 3d4ace315ec..9686cb09b47 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -61,3 +61,4 @@ export { actuate as agentSkillsActuate, AgentSkillsInfo, } from "./agentSkills"; +export { askQuestions as runAskQuestions, actuate as runActuate, RunInfo } from "./run"; diff --git a/src/init/features/run.ts b/src/init/features/run.ts new file mode 100644 index 00000000000..6a7cd6f67a0 --- /dev/null +++ b/src/init/features/run.ts @@ -0,0 +1,140 @@ +import * as ora from "ora"; +import * as path from "path"; +import { existsSync } from "fs"; +import { Setup } from "../index"; +import { Config } from "../../config"; +import { input } from "../../prompt"; +import { logBullet, logSuccess, logWarning } from "../../utils"; +import { createService, getService } from "../../gcp/runv2"; +import { ensure } from "../../ensureApiEnabled"; +import { readTemplateSync } from "../../templates"; + +export interface RunInfo { + serviceId: string; + region: string; + rootDir: string; + outputDir: string; +} + +/** + * + */ +export async function askQuestions(setup: Setup): Promise { + const projectId = setup.projectId; + if (!projectId) { + throw new Error("Project ID must be set before initializing Cloud Run."); + } + + logBullet("Configuring Cloud Run..."); + + const serviceId = await input({ + message: "What should be the ID of your Cloud Run service?", + default: "my-service", + }); + + const region = await input({ + message: "Which region should this service be deployed to?", + default: "us-central1", + }); + + const rootDir = await input({ + message: "What is the root directory of your source code? (relative to firebase.json)", + default: ".", + }); + + const outputDir = await input({ + message: "Where should the built artifacts be output? (e.g. for --prebuilt)", + default: ".run", + }); + + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.run = { + serviceId, + region, + rootDir, + outputDir, + }; +} + +/** + * + */ +export async function actuate(setup: Setup, config: Config): Promise { + const runInfo = setup.featureInfo?.run; + if (!runInfo) { + return; + } + const projectId = setup.projectId!; + + const { serviceId, region, rootDir, outputDir } = runInfo; + + logBullet("Setting up Cloud Run service..."); + + // Ensure Cloud Run API is enabled + await ensure(projectId, "run.googleapis.com", "run", true); + + // Update firebase.json + const runConfig = { + serviceId, + region, + source: rootDir, + output: outputDir, + ignore: ["node_modules", ".git", ".next", "firebase-debug.log", "firebase-debug.*.log"], + }; + + if (!config.src.run) { + config.set("run", [runConfig]); + } else if (Array.isArray(config.src.run)) { + config.set("run", [...config.src.run, runConfig]); + } else { + config.set("run", [config.src.run, runConfig]); + } + + config.writeProjectFile("firebase.json", config.src); + + const spinner = ora("Provisioning Cloud Run service...").start(); + + try { + // Try to get service first + try { + await getService(projectId, region, serviceId); + spinner.succeed(`Cloud Run service ${serviceId} already exists.`); + } catch (err: any) { + if (err.status === 404) { + // Does not exist, create placeholder + await createService(projectId, region, serviceId, { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + description: "Firebase Cloud Run Service", + ingress: "INGRESS_TRAFFIC_ALL", + template: { + containers: [ + { + name: "placeholder", + image: "us-docker.pkg.dev/cloudrun/container/hello", + }, + ], + }, + }); + spinner.succeed(`Successfully provisioned Cloud Run service ${serviceId}`); + } else { + throw err; + } + } + } catch (err: any) { + spinner.fail(`Failed to provision Cloud Run service: ${err.message}`); + logWarning("You can still deploy using the CLI, but the initial provisioning failed."); + } + + // Create placeholder apphosting.yaml + const absRootDir = path.join(config.projectDir, rootDir); + const apphostingYamlPath = path.join(absRootDir, "apphosting.yaml"); + if (!existsSync(apphostingYamlPath)) { + logBullet(`Creating placeholder apphosting.yaml in ${rootDir}`); + await config.askWriteProjectFile( + apphostingYamlPath, + readTemplateSync("init/apphosting/apphosting.yaml"), + ); + } + + logSuccess("Cloud Run initialization complete!"); +} diff --git a/src/init/index.ts b/src/init/index.ts index dda64f48834..ffac29684f5 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -44,6 +44,7 @@ export interface SetupInfo { ailogic?: features.AiLogicInfo; hosting?: features.HostingInfo; auth?: features.AuthInfo; + run?: features.RunInfo; agentSkills?: features.AgentSkillsInfo; } @@ -134,6 +135,12 @@ const featuresList: Feature[] = [ askQuestions: features.agentSkillsAskQuestions, actuate: features.agentSkillsActuate, }, + { + name: "run", + displayName: "Cloud Run", + askQuestions: features.runAskQuestions, + actuate: features.runActuate, + }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); From 8ac398aec6a0271ce9a98d9cc5b7ec56378c4bf7 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 10:33:33 -0400 Subject: [PATCH 02/25] Address some initial comments and add E2E test suites in typescript --- e2e_run_tests/run_all_tests.sh | 176 --------------- package.json | 1 + scripts/run-deploy-tests/cli.ts | 70 ++++++ scripts/run-deploy-tests/run.sh | 15 ++ scripts/run-deploy-tests/tests.ts | 361 ++++++++++++++++++++++++++++++ src/deploy/run/args.ts | 33 +++ src/deploy/run/deploy.spec.ts | 201 +++++++++++++++-- src/deploy/run/deploy.ts | 60 +++-- src/deploy/run/deploy.ts.bak | 119 ---------- src/deploy/run/index.ts | 1 + src/deploy/run/prepare.spec.ts | 163 +++++++++++++- src/deploy/run/prepare.ts | 24 +- src/deploy/run/release.spec.ts | 69 +++++- src/deploy/run/release.ts | 10 +- src/gcp/runv2.spec.ts | 238 ++++++++++++++++++++ src/gcp/runv2.ts | 60 ++--- src/init/features/run.spec.ts | 215 ++++++++++++++++++ src/init/features/run.ts | 20 +- 18 files changed, 1443 insertions(+), 393 deletions(-) delete mode 100755 e2e_run_tests/run_all_tests.sh create mode 100644 scripts/run-deploy-tests/cli.ts create mode 100755 scripts/run-deploy-tests/run.sh create mode 100644 scripts/run-deploy-tests/tests.ts create mode 100644 src/deploy/run/args.ts delete mode 100644 src/deploy/run/deploy.ts.bak create mode 100644 src/init/features/run.spec.ts diff --git a/e2e_run_tests/run_all_tests.sh b/e2e_run_tests/run_all_tests.sh deleted file mode 100755 index 0f6566f2895..00000000000 --- a/e2e_run_tests/run_all_tests.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/bin/bash -# E2E Test Suite for Firebase Cloud Run Integration -# Tiers 1-4 - -APP_DIR="/Users/aryanf/code/firebase/firebase-apphosting-canary/apps/nextjs-reference/next-15.3/" -PROJECT="aryanf-test" -CLI="node /Users/aryanf/code/firebase/firebase-tools/lib/bin/firebase.js" - -echo "======================================" -echo "Starting E2E Tests for Cloud Run" -echo "======================================" - -cd "$APP_DIR" || exit 1 - -# Backup existing configs -mv firebase.json firebase.json.bak 2>/dev/null -mv .firebaserc .firebaserc.bak 2>/dev/null - -FAILED=0 - -run_test() { - local name=$1 - shift - echo "Running: $name" - "$@" - local status=$? - if [ $status -ne 0 ]; then - echo "❌ FAIL: $name (exit code: $status)" - FAILED=$((FAILED + 1)) - else - echo "✅ PASS: $name" - fi -} - -run_test_expect_fail() { - local name=$1 - shift - echo "Running (Expecting Fail): $name" - "$@" - local status=$? - if [ $status -eq 0 ]; then - echo "❌ FAIL: $name expected to fail but succeeded." - FAILED=$((FAILED + 1)) - else - echo "✅ PASS: $name (failed as expected with code: $status)" - fi -} - -echo "" -echo "--- Tier 1: Feature Coverage ---" -# T1.1 -rm -f firebase.json .firebaserc -run_test "T1.1: Init Cloud Run non-interactive" $CLI init run --non-interactive --project "$PROJECT" - -# T1.2 -rm -f firebase.json .firebaserc -run_test "T1.2: Init Cloud Run with --project" $CLI init run --non-interactive --project "$PROJECT" - -# T1.3 -rm -f firebase.json .firebaserc -touch firebase.json # Mock existing config -run_test "T1.3: Init Cloud Run (additive)" $CLI init run --non-interactive --project "$PROJECT" - -# T1.4 -run_test "T1.4: Deploy to Cloud Run" $CLI deploy --only run --project "$PROJECT" --non-interactive - -# T1.5 -run_test "T1.5: Deploy with force" $CLI deploy --only run --project "$PROJECT" --non-interactive --force - - -echo "" -echo "--- Tier 2: Boundary & Corner Cases ---" -# T2.1 -rm -f firebase.json .firebaserc -run_test_expect_fail "T2.1: Init Cloud Run with invalid project" $CLI init run --non-interactive --project "invalid-project-id-1234567890" - -# T2.2 -# Create a dir without write permissions for testing -mkdir -p no_write_dir -chmod 555 no_write_dir -cd no_write_dir || exit 1 -run_test_expect_fail "T2.2: Init in directory without write permissions" $CLI init run --non-interactive --project "$PROJECT" -cd .. || exit 1 -rm -rf no_write_dir - -# T2.3 -# Deploy with no config -rm -f firebase.json .firebaserc -run_test_expect_fail "T2.3: Deploy without firebase.json" $CLI deploy --only run --project "$PROJECT" --non-interactive - -# T2.4 -$CLI init run --non-interactive --project "$PROJECT" -run_test_expect_fail "T2.4: Deploy with invalid region (simulated by env)" env FIREBASE_RUN_REGION=invalid-region $CLI deploy --only run --project "$PROJECT" --non-interactive - -# T2.5 -run_test "T2.5: Init repeatedly (idempotent)" $CLI init run --non-interactive --project "$PROJECT" - - -echo "" -echo "--- Tier 3: Cross-Feature Combinations ---" -# T3.1 -rm -f firebase.json .firebaserc -run_test "T3.1: Init then immediately deploy" bash -c "$CLI init run --non-interactive --project \"$PROJECT\" && $CLI deploy --only run --project \"$PROJECT\" --non-interactive" - -# T3.2 -run_test "T3.2: Multiple sequential deploys" bash -c "$CLI deploy --only run --project \"$PROJECT\" --non-interactive && $CLI deploy --only run --project \"$PROJECT\" --non-interactive" - - -echo "" -echo "--- Tier 4: Real-World Application Scenarios ---" -# T4.1 -rm -f firebase.json .firebaserc apphosting.yaml -echo "T4.1: Next.js Full Lifecycle with apphosting.yaml Verification (Init -> Deploy -> Verify)" -cat < apphosting.yaml -runConfig: - cpu: 2 - memoryMiB: 1024 - minInstances: 1 - maxInstances: 5 - concurrency: 100 -env: - - variable: TEST_VAR - value: "hello_world" -EOF - -$CLI init run --non-interactive --project "$PROJECT" -if [ $? -eq 0 ]; then - $CLI deploy --only run --project "$PROJECT" --non-interactive - if [ $? -eq 0 ]; then - echo "Verifying Cloud Run configuration..." - SERVICE_NAME=$(cat firebase.json | grep -o '"serviceId"[[:space:]]*:[[:space:]]*"[^"]*"' | awk -F '"' '{print $4}') - REGION=$(cat firebase.json | grep -o '"region"[[:space:]]*:[[:space:]]*"[^"]*"' | awk -F '"' '{print $4}') - if [ -z "$REGION" ]; then REGION="us-central1"; fi - - gcloud run services describe $SERVICE_NAME --region $REGION --project "$PROJECT" --format=json > svc.json - CPU=$(cat svc.json | jq -r '(.spec.template.spec.containers[0].resources.limits.cpu // .template.containers[0].resources.limits.cpu)') - MEM=$(cat svc.json | jq -r '(.spec.template.spec.containers[0].resources.limits.memory // .template.containers[0].resources.limits.memory)') - MIN=$(cat svc.json | jq -r '(.spec.template.metadata.annotations["autoscaling.knative.dev/minScale"] // .template.scaling.minInstanceCount)') - MAX=$(cat svc.json | jq -r '(.spec.template.metadata.annotations["autoscaling.knative.dev/maxScale"] // .template.scaling.maxInstanceCount)') - CONCURRENCY=$(cat svc.json | jq -r '((.spec.template.spec.containerConcurrency | tostring) // (.template.maxInstanceRequestConcurrency | tostring))') - ENV_VAL=$(cat svc.json | jq -r '([.spec.template.spec.containers[0].env[]?, .template.containers[0].env[]?] | map(select(.name=="TEST_VAR")) | .[0].value)') - - if [ "$CPU" = "2" ] && [ "$MEM" = "1024Mi" ] && [ "$MIN" = "1" ] && [ "$MAX" = "5" ] && [ "$CONCURRENCY" = "100" ] && [ "$ENV_VAL" = "hello_world" ]; then - echo "✅ PASS: T4.1 configuration verified" - else - echo "❌ FAIL: T4.1 configuration did not match expectations" - echo "CPU: $CPU (expected 2)" - echo "MEM: $MEM (expected 1024Mi)" - echo "MIN: $MIN (expected 1)" - echo "MAX: $MAX (expected 5)" - echo "CONCURRENCY: $CONCURRENCY (expected 100)" - echo "ENV_VAL: $ENV_VAL (expected hello_world)" - FAILED=$((FAILED + 1)) - fi - else - echo "❌ FAIL: T4.1 Deploy failed" - FAILED=$((FAILED + 1)) - fi -else - echo "❌ FAIL: T4.1 Init failed" - FAILED=$((FAILED + 1)) -fi -rm -f apphosting.yaml svc.json - -# Restore configs -mv firebase.json.bak firebase.json 2>/dev/null -mv .firebaserc.bak .firebaserc 2>/dev/null - -echo "======================================" -if [ $FAILED -gt 0 ]; then - echo "Tests Completed with $FAILED Failures." - exit 1 -else - echo "All Tests Passed Successfully!" - exit 0 -fi diff --git a/package.json b/package.json index 0dff96a54af..e18486654cb 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:functions-discover": "bash ./scripts/functions-discover-tests/run.sh", "test:hosting": "bash ./scripts/hosting-tests/run.sh", "test:hosting-rewrites": "bash ./scripts/hosting-tests/rewrites-tests/run.sh", + "test:run-deploy": "bash ./scripts/run-deploy-tests/run.sh", "test:import-export": "bash ./scripts/emulator-import-export-tests/run.sh", "test:triggers-end-to-end": "bash ./scripts/triggers-end-to-end-tests/run.sh", "test:triggers-end-to-end:inspect": "bash ./scripts/triggers-end-to-end-tests/run.sh inspect", diff --git a/scripts/run-deploy-tests/cli.ts b/scripts/run-deploy-tests/cli.ts new file mode 100644 index 00000000000..b9fbb259f47 --- /dev/null +++ b/scripts/run-deploy-tests/cli.ts @@ -0,0 +1,70 @@ +import * as spawn from "cross-spawn"; +import { ChildProcess } from "child_process"; + +export interface Result { + proc: ChildProcess; + stdout: string; + stderr: string; + exitCode: number | null; +} + +/** + * Execute a Firebase CLI command in a target directory with specified arguments and environment. + */ +export function exec( + cmd: string, + project: string, + additionalArgs: string[] = [], + cwd: string = process.cwd(), + quiet = true, + extraEnv: Record = {}, +): Promise { + const args = [cmd]; + if (project) { + args.push("--project", project); + } + + if (additionalArgs && additionalArgs.length > 0) { + args.push(...additionalArgs); + } + + const env = { + ...process.env, + ...extraEnv, + }; + + const proc = spawn("firebase", args, { cwd, env }); + if (!proc) { + throw new Error("Failed to start firebase CLI"); + } + + const cli: Result = { + proc, + stdout: "", + stderr: "", + exitCode: null, + }; + + proc.stdout?.on("data", (data: Buffer) => { + const s = data.toString(); + if (!quiet) { + process.stdout.write(s); + } + cli.stdout += s; + }); + + proc.stderr?.on("data", (data: Buffer) => { + const s = data.toString(); + if (!quiet) { + process.stderr.write(s); + } + cli.stderr += s; + }); + + return new Promise((resolve) => { + proc.on("exit", (code) => { + cli.exitCode = code; + resolve(cli); + }); + }); +} diff --git a/scripts/run-deploy-tests/run.sh b/scripts/run-deploy-tests/run.sh new file mode 100755 index 00000000000..3c2bfeeb741 --- /dev/null +++ b/scripts/run-deploy-tests/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e # Immediately exit on failure + +# Globally link the CLI for the testing framework +./scripts/clean-install.sh + +if [ -f "scripts/set-default-credentials.sh" ]; then + source scripts/set-default-credentials.sh +fi + +echo "======================================" +echo "Starting Cloud Run E2E Test Suite" +echo "======================================" + +mocha scripts/run-deploy-tests/tests.ts --timeout 600000 diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts new file mode 100644 index 00000000000..b6020622801 --- /dev/null +++ b/scripts/run-deploy-tests/tests.ts @@ -0,0 +1,361 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import { expect } from "chai"; +import * as cli from "./cli"; +import * as runv2 from "../../src/gcp/runv2"; + +interface MockRunConfig { + serviceId?: string; + region?: string; + source?: string; +} + +interface MockFirebaseJson { + run?: MockRunConfig | MockRunConfig[]; + hosting?: { public?: string }; +} + +const TARGET_PROJECT = + process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || "aryanf-test"; +const DEFAULT_APP_DIR = + process.env.APP_DIR || + path.resolve(__dirname, "../../../firebase-apphosting-canary/apps/nextjs-reference/next-15.3"); + +describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { + this.timeout(600_000); // 10 minutes per test for Cloud Build & Cloud Run provisioning + + let workDir: string; + let hasAppDir = false; + + before(() => { + if (fs.existsSync(DEFAULT_APP_DIR)) { + workDir = DEFAULT_APP_DIR; + hasAppDir = true; + } else { + // Create isolated temporary workspace for E2E testing + workDir = fs.mkdtempSync(path.join(__dirname, "run-e2e-")); + fs.writeFileSync( + path.join(workDir, "package.json"), + JSON.stringify( + { + name: "run-e2e-test-app", + version: "1.0.0", + scripts: { start: "node index.js" }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(workDir, "index.js"), + 'const http = require("http"); const server = http.createServer((req, res) => res.end("OK")); server.listen(process.env.PORT || 8080);', + ); + } + }); + + after(() => { + if (!hasAppDir && workDir && fs.existsSync(workDir)) { + fs.removeSync(workDir); + } + }); + + beforeEach(() => { + // Backup any existing firebase.json / .firebaserc before each test + const fbJson = path.join(workDir, "firebase.json"); + const fbRc = path.join(workDir, ".firebaserc"); + const apphostingYaml = path.join(workDir, "apphosting.yaml"); + + if (fs.existsSync(fbJson)) fs.moveSync(fbJson, `${fbJson}.bak`, { overwrite: true }); + if (fs.existsSync(fbRc)) fs.moveSync(fbRc, `${fbRc}.bak`, { overwrite: true }); + if (fs.existsSync(apphostingYaml)) { + fs.moveSync(apphostingYaml, `${apphostingYaml}.bak`, { overwrite: true }); + } + }); + + afterEach(() => { + // Restore backup configs + const fbJson = path.join(workDir, "firebase.json"); + const fbRc = path.join(workDir, ".firebaserc"); + const apphostingYaml = path.join(workDir, "apphosting.yaml"); + + if (fs.existsSync(`${fbJson}.bak`)) fs.moveSync(`${fbJson}.bak`, fbJson, { overwrite: true }); + else if (fs.existsSync(fbJson)) fs.removeSync(fbJson); + + if (fs.existsSync(`${fbRc}.bak`)) fs.moveSync(`${fbRc}.bak`, fbRc, { overwrite: true }); + else if (fs.existsSync(fbRc)) fs.removeSync(fbRc); + + if (fs.existsSync(`${apphostingYaml}.bak`)) { + fs.moveSync(`${apphostingYaml}.bak`, apphostingYaml, { overwrite: true }); + } else if (fs.existsSync(apphostingYaml)) { + fs.removeSync(apphostingYaml); + } + }); + + describe("Tier 1: Feature Coverage", () => { + it("T1.1: should initialize Cloud Run configuration in non-interactive mode", async () => { + const res = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(res.exitCode).to.equal(0); + expect(fs.existsSync(path.join(workDir, "firebase.json"))).to.be.true; + + const config = fs.readJsonSync(path.join(workDir, "firebase.json")) as MockFirebaseJson; + expect(config.run).to.exist; + }); + + it("T1.2: should respect explicit --project flag during init", async () => { + const res = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive", "--project", TARGET_PROJECT], + workDir, + false, + ); + expect(res.exitCode).to.equal(0); + expect(fs.existsSync(path.join(workDir, "firebase.json"))).to.be.true; + }); + + it("T1.3: should additively update existing firebase.json without overwriting other targets", async () => { + fs.writeJsonSync(path.join(workDir, "firebase.json"), { hosting: { public: "public" } }); + + const res = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(res.exitCode).to.equal(0); + + const config = fs.readJsonSync(path.join(workDir, "firebase.json")) as MockFirebaseJson; + expect(config.hosting).to.deep.equal({ public: "public" }); + expect(config.run).to.exist; + }); + + it("T1.4: should successfully deploy source to Cloud Run", async () => { + await cli.exec("init", TARGET_PROJECT, ["run", "--non-interactive"], workDir, false); + const deployRes = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + expect(deployRes.exitCode).to.equal(0); + expect(deployRes.stdout).to.include("Deploy complete!"); + }); + + it("T1.5: should deploy successfully with --force flag", async () => { + await cli.exec("init", TARGET_PROJECT, ["run", "--non-interactive"], workDir, false); + const deployRes = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive", "--force"], + workDir, + false, + ); + expect(deployRes.exitCode).to.equal(0); + expect(deployRes.stdout).to.include("Deploy complete!"); + }); + }); + + describe("Tier 2: Boundary & Corner Cases", () => { + it("T2.1: should fail init gracefully with an invalid/non-existent project ID", async () => { + const res = await cli.exec( + "init", + "invalid-project-id-1234567890", + ["run", "--non-interactive"], + workDir, + false, + ); + expect(res.exitCode).to.not.equal(0); + }); + + it("T2.2: should fail init gracefully in directory without write permissions", async () => { + const readOnlyDir = path.join(workDir, "no_write_dir"); + fs.ensureDirSync(readOnlyDir); + fs.chmodSync(readOnlyDir, 0o555); + + try { + const res = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + readOnlyDir, + false, + ); + expect(res.exitCode).to.not.equal(0); + } finally { + fs.chmodSync(readOnlyDir, 0o755); + fs.removeSync(readOnlyDir); + } + }); + + it("T2.3: should deploy with default in-memory config or throw clear error when firebase.json is absent", async () => { + const res = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + // Validates either clean zero-config execution or standard missing config error + expect([0, 1]).to.include(res.exitCode); + }); + + it("T2.4: should fail deploy gracefully when invalid region is provided", async () => { + await cli.exec("init", TARGET_PROJECT, ["run", "--non-interactive"], workDir, false); + const res = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + { FIREBASE_RUN_REGION: "invalid-region-99" }, + ); + expect(res.exitCode).to.not.equal(0); + }); + + it("T2.5: should be idempotent when init run is executed repeatedly", async () => { + const res1 = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(res1.exitCode).to.equal(0); + + const res2 = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(res2.exitCode).to.equal(0); + + const config = fs.readJsonSync(path.join(workDir, "firebase.json")) as MockFirebaseJson; + expect(config.run).to.exist; + }); + }); + + describe("Tier 3: Cross-Feature Combinations", () => { + it("T3.1: should support immediate sequential init and deploy", async () => { + const initRes = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(initRes.exitCode).to.equal(0); + + const deployRes = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + expect(deployRes.exitCode).to.equal(0); + }); + + it("T3.2: should support multiple sequential deployments idempotently", async () => { + await cli.exec("init", TARGET_PROJECT, ["run", "--non-interactive"], workDir, false); + + const deploy1 = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + expect(deploy1.exitCode).to.equal(0); + + const deploy2 = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + expect(deploy2.exitCode).to.equal(0); + }); + }); + + describe("Tier 4: Real-World Application & GCP Resource Verification", () => { + it("T4.1: should deploy application with apphosting.yaml and verify Cloud Run live resource configuration", async () => { + const apphostingYamlContent = ` +runConfig: + cpu: 2 + memoryMiB: 1024 + minInstances: 1 + maxInstances: 5 + concurrency: 100 +env: + - variable: TEST_VAR + value: "hello_world" + availability: + - RUNTIME +`; + fs.writeFileSync(path.join(workDir, "apphosting.yaml"), apphostingYamlContent.trim()); + + const initRes = await cli.exec( + "init", + TARGET_PROJECT, + ["run", "--non-interactive"], + workDir, + false, + ); + expect(initRes.exitCode).to.equal(0); + + const deployRes = await cli.exec( + "deploy", + TARGET_PROJECT, + ["--only", "run", "--non-interactive"], + workDir, + false, + ); + expect(deployRes.exitCode).to.equal(0); + + // Extract serviceId and region from generated firebase.json + const config = fs.readJsonSync(path.join(workDir, "firebase.json")) as MockFirebaseJson; + const runConfig = (Array.isArray(config.run) ? config.run[0] : config.run) as MockRunConfig; + const serviceId = runConfig?.serviceId || "my-service"; + const region = runConfig?.region || "us-central1"; + + // Verify Cloud Run Service via GCP API directly + const service = await runv2.getService(TARGET_PROJECT, region, serviceId); + expect(service).to.exist; + + const container = service.template?.containers?.[0]; + expect(container).to.exist; + + // Verify CPU and Memory limits + expect(container?.resources?.limits?.cpu).to.equal("2"); + expect(container?.resources?.limits?.memory).to.equal("1024Mi"); + + // Verify Min/Max Instance Scaling (Service-Level or Template-Level) + const minInstances = + service.scaling?.minInstanceCount ?? service.template?.scaling?.minInstanceCount; + const maxInstances = + service.scaling?.maxInstanceCount ?? service.template?.scaling?.maxInstanceCount; + expect(minInstances).to.equal(1); + expect(maxInstances).to.equal(5); + + // Verify Concurrency + expect(service.template?.maxInstanceRequestConcurrency).to.equal(100); + + // Verify Runtime Environment Variables + const envVars = container?.env || []; + const testVar = envVars.find((e) => e.name === "TEST_VAR"); + expect(testVar).to.exist; + expect(testVar?.value).to.equal("hello_world"); + }); + }); +}); diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts new file mode 100644 index 00000000000..e594f0c7f03 --- /dev/null +++ b/src/deploy/run/args.ts @@ -0,0 +1,33 @@ +import { AppHostingYamlConfig } from "../../apphosting/yaml"; +import * as runv2 from "../../gcp/runv2"; + +export interface RunConfig { + serviceId: string; + region?: string; + source?: string; + output?: string; + ignore?: string[]; + baseImageUri?: string; +} + +export interface RunServiceSpec { + serviceId: string; + region: string; + source: string; + ignore: string[]; + existingService?: runv2.Service; + baseImageUri?: string; + appHostingConfig?: AppHostingYamlConfig; + storageSource?: runv2.StorageSource; + deployResponse?: runv2.Service; +} + +export interface Payload { + run?: { + services?: RunServiceSpec[]; + }; +} + +export interface Context { + projectId?: string; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index a3724a56847..938bbfeca94 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,10 +1,15 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import { Readable } from "stream"; import { deploy } from "./deploy"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import * as artifactRegistry from "../../gcp/artifactregistry"; import * as archiveDirectory from "../../archiveDirectory"; +import * as getProjectNumberModule from "../../getProjectNumber"; +import { Options } from "../../options"; +import { Context, Payload } from "./args"; +import { AppHostingYamlConfig } from "../../apphosting/yaml"; describe("run deploy", () => { let upsertBucketStub: sinon.SinonStub; @@ -12,13 +17,15 @@ describe("run deploy", () => { let updateServiceStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let ensureRepoStub: sinon.SinonStub; + let getProjectNumberStub: sinon.SinonStub; beforeEach(() => { upsertBucketStub = sinon.stub(gcs, "upsertBucket").resolves("my-bucket"); ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepository").resolves(); + getProjectNumberStub = sinon.stub(getProjectNumberModule, "getProjectNumber").resolves("12345"); sinon.stub(archiveDirectory, "archiveDirectory").resolves({ file: "test.zip", - stream: "mock-stream" as any, + stream: Readable.from(["mock-data"]), size: 100, source: ".", manifest: [], @@ -33,75 +40,235 @@ describe("run deploy", () => { .resolves({ baseImageUri: "dummy-base-image" }); updateServiceStub = sinon .stub(runv2, "updateService") - .resolves({ uri: "https://my-service.com" } as any); + .resolves({ uri: "https://my-service.com" } as runv2.Service); createServiceStub = sinon .stub(runv2, "createService") - .resolves({ uri: "https://my-service.com" } as any); + .resolves({ uri: "https://my-service.com" } as runv2.Service); }); afterEach(() => { sinon.restore(); }); + it("should do nothing if payload.run or payload.run.services is missing", async () => { + const payload: Payload = {}; + const context: Context = { projectId: "project" }; + const options = { project: "project" } as unknown as Options; + + await deploy(context, options, payload); + + expect(upsertBucketStub.notCalled).to.be.true; + expect(createServiceStub.notCalled).to.be.true; + }); + it("should deploy a new service", async () => { - const payload: any = { + const payload: Payload = { run: { services: [ { serviceId: "mysvc", region: "us-central1", source: ".", - manifest: [], + ignore: [], baseImageUri: "dummy-base-image", }, ], }, }; - const context = { projectId: "project" }; - const options = { project: "project", projectNumber: "12345" } as any; + const context: Context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as unknown as Options; await deploy(context, options, payload); + expect(getProjectNumberStub.calledOnce).to.be.true; expect(upsertBucketStub.calledOnce).to.be.true; expect(ensureRepoStub.calledOnce).to.be.true; expect(submitBuildStub.calledOnce).to.be.true; expect(createServiceStub.calledOnce).to.be.true; expect(updateServiceStub.notCalled).to.be.true; - const createdService = createServiceStub.args[0][3]; - expect(createdService.template.containers[0].baseImageUri).to.equal("dummy-base-image"); + const createdService = createServiceStub.args[0][3] as Omit< + runv2.Service, + runv2.ServiceOutputFields + >; + expect(createdService.template.containers?.[0].baseImageUri).to.equal("dummy-base-image"); - // Check if deployResponse is set - expect(payload.run.services[0].deployResponse.uri).to.equal("https://my-service.com"); + expect(payload.run?.services?.[0].deployResponse?.uri).to.equal("https://my-service.com"); }); it("should update an existing service", async () => { - const payload: any = { + const payload: Payload = { run: { services: [ { serviceId: "mysvc", region: "us-central1", source: ".", - manifest: [], + ignore: [], existingService: { name: "projects/project/locations/us-central1/services/mysvc", + generation: 1, + createTime: "now", + updateTime: "now", + creator: "user", + lastModifier: "user", + etag: "123", template: { - containers: [{ image: "old-image" }], + containers: [{ name: "mysvc", image: "old-image" }], }, }, }, ], }, }; - const context = { projectId: "project" }; - const options = { project: "project", projectNumber: "12345" } as any; + const context: Context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as unknown as Options; await deploy(context, options, payload); expect(ensureRepoStub.calledOnce).to.be.true; expect(updateServiceStub.calledOnce).to.be.true; expect(createServiceStub.notCalled).to.be.true; - expect(payload.run.services[0].deployResponse.uri).to.equal("https://my-service.com"); + expect(payload.run?.services?.[0].deployResponse?.uri).to.equal("https://my-service.com"); + }); + + it("should map secrets, runtime env vars, and RunConfig scaling", async () => { + const appHostingConfig = AppHostingYamlConfig.empty(); + appHostingConfig.runConfig = { + cpu: 2, + memoryMiB: 1024, + minInstances: 1, + maxInstances: 10, + concurrency: 80, + }; + appHostingConfig.env = { + MY_VAR: { value: "hello", availability: ["RUNTIME"] }, + MY_SECRET: { secret: "secret-name@2", availability: ["RUNTIME"] }, + MY_FULL_SECRET: { + secret: "projects/custom-p/secrets/my-sec", + availability: ["RUNTIME"], + }, + }; + + const payload: Payload = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + ignore: [], + appHostingConfig, + existingService: { + name: "projects/my-gcp-project/locations/us-central1/services/mysvc", + generation: 1, + createTime: "now", + updateTime: "now", + creator: "user", + lastModifier: "user", + etag: "123", + labels: { env: "prod" }, + annotations: { "run.googleapis.com/ingress": "all" }, + scaling: { minInstanceCount: 0 }, + ingress: "INGRESS_TRAFFIC_ALL", + description: "My Service", + template: { + containers: [], + }, + }, + }, + ], + }, + }; + const context: Context = { projectId: "my-gcp-project" }; + const options = { + project: "my-gcp-project", + config: { + path: (p: string) => p, + }, + } as unknown as Options; + + await deploy(context, options, payload); + + expect(updateServiceStub.calledOnce).to.be.true; + const updatedService = updateServiceStub.args[0][0] as Omit< + runv2.Service, + runv2.ServiceOutputFields + >; + const updateMask = updateServiceStub.args[0][1] as string[]; + + expect(updateMask).to.include.members([ + "template", + "labels", + "annotations", + "scaling", + "ingress", + "description", + ]); + + expect(updatedService.scaling?.minInstanceCount).to.equal(1); + expect(updatedService.scaling?.maxInstanceCount).to.equal(10); + expect(updatedService.template.maxInstanceRequestConcurrency).to.equal(80); + expect(updatedService.template.containers?.[0].resources?.limits?.cpu).to.equal("2"); + expect(updatedService.template.containers?.[0].resources?.limits?.memory).to.equal("1024Mi"); + + const containerEnv = updatedService.template.containers?.[0].env; + expect(containerEnv).to.deep.include({ name: "MY_VAR", value: "hello" }); + expect(containerEnv).to.deep.include({ + name: "MY_SECRET", + valueSource: { + secretKeyRef: { + secret: "projects/my-gcp-project/secrets/secret-name", + version: "2", + }, + }, + }); + expect(containerEnv).to.deep.include({ + name: "MY_FULL_SECRET", + valueSource: { + secretKeyRef: { + secret: "projects/custom-p/secrets/my-sec", + version: "latest", + }, + }, + }); + }); + + it("should delete baseImageUri when service.baseImageUri is undefined on existing service", async () => { + const payload: Payload = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + ignore: [], + existingService: { + name: "projects/project/locations/us-central1/services/mysvc", + generation: 1, + createTime: "now", + updateTime: "now", + creator: "user", + lastModifier: "user", + etag: "123", + template: { + containers: [{ name: "mysvc", image: "old-image", baseImageUri: "old-base-uri" }], + }, + }, + }, + ], + }, + }; + const context: Context = { projectId: "project" }; + const options = { project: "project" } as unknown as Options; + + await deploy(context, options, payload); + + expect(updateServiceStub.calledOnce).to.be.true; + const updatedService = updateServiceStub.args[0][0] as Omit< + runv2.Service, + runv2.ServiceOutputFields + >; + expect(updatedService.template.containers?.[0].baseImageUri).to.be.undefined; }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index d31e666affd..400561efbfb 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,18 +1,21 @@ import { Options } from "../../options"; - import { archiveDirectory } from "../../archiveDirectory"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; -import { splitEnvVars } from "../../apphosting/config"; +import { RunConfig, splitEnvVars } from "../../apphosting/config"; +import { EnvMap } from "../../apphosting/yaml"; import { EnvVar } from "../../gcp/k8s"; +import { needProjectId } from "../../projectUtils"; +import { Context, Payload } from "./args"; /** - * + * Deploys Cloud Run services by building container images via Cloud Build + * and creating or updating Cloud Run v2 services. */ -export async function deploy(context: any, options: Options, payload: any): Promise { - const projectId = context.projectId; +export async function deploy(context: Context, options: Options, payload: Payload): Promise { + const projectId = context.projectId || needProjectId(options); const projectNumber = await getProjectNumber(options); if (!payload.run?.services) return; @@ -59,8 +62,9 @@ export async function deploy(context: any, options: Options, payload: any): Prom // Construct image URI const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; - const appHostingConfig = service.appHostingConfig || { env: {} }; - const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(appHostingConfig.env); + const appHostingConfig = service.appHostingConfig; + const envRecord = appHostingConfig?.env || {}; + const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(envRecord); const firebaseConfigStr = JSON.stringify({ projectId, storageBucket: `${projectId}.appspot.com`, @@ -93,9 +97,14 @@ export async function deploy(context: any, options: Options, payload: any): Prom if (existing) { newService = { name: existing.name, - template: JSON.parse(JSON.stringify(existing.template)), + template: JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate, + ...(existing.labels ? { labels: existing.labels } : {}), + ...(existing.annotations ? { annotations: existing.annotations } : {}), + ...(existing.ingress ? { ingress: existing.ingress } : {}), + ...(existing.description ? { description: existing.description } : {}), + ...(existing.scaling ? { scaling: existing.scaling } : {}), }; - delete (newService.template as any).revision; + delete newService.template.revision; // Mutate template with new image if (!newService.template.containers) { @@ -114,9 +123,16 @@ export async function deploy(context: any, options: Options, payload: any): Prom delete newService.template.containers[0].baseImageUri; } - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig.runConfig, projectId); + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + + const updateMask = ["template"]; + if (newService.labels) updateMask.push("labels"); + if (newService.annotations) updateMask.push("annotations"); + if (newService.scaling) updateMask.push("scaling"); + if (newService.ingress) updateMask.push("ingress"); + if (newService.description) updateMask.push("description"); - service.deployResponse = await runv2.updateService(newService); + service.deployResponse = await runv2.updateService(newService, updateMask); } else { newService = { name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, @@ -132,7 +148,7 @@ export async function deploy(context: any, options: Options, payload: any): Prom client: "cli-firebase", }; - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig.runConfig, projectId); + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); service.deployResponse = await runv2.createService( projectId, @@ -146,10 +162,10 @@ export async function deploy(context: any, options: Options, payload: any): Prom function applyAppHostingConfig( service: Omit, - runtimeEnvMap: Record, - runConfig: any, + runtimeEnvMap: EnvMap, + runConfig?: RunConfig, projectId?: string, -) { +): void { if (!service.template.containers) { service.template.containers = []; } @@ -181,15 +197,19 @@ function applyAppHostingConfig( secretName = parts[0]; version = parts[1] || "latest"; } + const secretPath = + secretName.startsWith("projects/") || !projectId + ? secretName + : `projects/${projectId}/secrets/${secretName}`; env.push({ name: key, valueSource: { secretKeyRef: { - secret: secretName, + secret: secretPath, version: version, }, }, - } as any); + }); } } if (env.length > 0) { @@ -206,11 +226,11 @@ function applyAppHostingConfig( container.resources.limits.memory = `${runConfig.memoryMiB}Mi`; } if (runConfig.minInstances !== undefined || runConfig.maxInstances !== undefined) { - if (!service.template.scaling) service.template.scaling = {}; + if (!service.scaling) service.scaling = {}; if (runConfig.minInstances !== undefined) - service.template.scaling.minInstanceCount = runConfig.minInstances; + service.scaling.minInstanceCount = runConfig.minInstances; if (runConfig.maxInstances !== undefined) - service.template.scaling.maxInstanceCount = runConfig.maxInstances; + service.scaling.maxInstanceCount = runConfig.maxInstances; } if (runConfig.concurrency !== undefined) { service.template.maxInstanceRequestConcurrency = runConfig.concurrency; diff --git a/src/deploy/run/deploy.ts.bak b/src/deploy/run/deploy.ts.bak deleted file mode 100644 index a57baecc88d..00000000000 --- a/src/deploy/run/deploy.ts.bak +++ /dev/null @@ -1,119 +0,0 @@ -import { Options } from "../../options"; -import { archiveDirectory } from "../../archiveDirectory"; -import * as gcs from "../../gcp/storage"; -import { getProjectNumber } from "../../getProjectNumber"; -import * as runv2 from "../../gcp/runv2"; - -/** - * - */ -export async function deploy(context: any, options: Options, payload: any): Promise { - const projectId = context.projectId; - const projectNumber = options.projectNumber || (await getProjectNumber(projectId)); - - if (!payload.run?.services) return; - - for (const service of payload.run.services) { - const region = service.region; - - // Create bucket - const baseName = `firebase-run-src-${projectNumber}`; - const bucketName = await gcs.upsertBucket({ - product: "run", - projectId, - createMessage: `Creating Cloud Storage bucket to store Run source code...`, - req: { - baseName, - location: region, - purposeLabel: "run-source", - lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 1 } }] }, - }, - }); - - // Zip and upload - const archive = await archiveDirectory(service.source, { - ignore: service.ignore, - }); - - const uploadRes = await gcs.uploadObject( - { - file: archive.file, - stream: archive.stream, - }, - bucketName, - ); - - service.storageSource = { - bucket: uploadRes.bucket, - object: uploadRes.object, - generation: uploadRes.generation || undefined, - }; - - // Construct image URI - const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}`; - - // Submit build - const build: runv2.Build = { - storageSource: service.storageSource, - imageUri, - buildpacksBuild: { - enableAutomaticUpdates: true, - }, - }; - const buildRes = await runv2.submitBuild(projectId, region, build); - if (!service.baseImageUri && buildRes.baseImageUri) { - service.baseImageUri = buildRes.baseImageUri; - } - - // Deploy via POST or PATCH - const existing = service.existingService; - let newService: Omit; - - if (existing) { - newService = { - name: existing.name, - template: existing.template, - }; - - // Mutate template with new image - if (!newService.template.containers) { - newService.template.containers = []; - } - if (newService.template.containers.length === 0) { - newService.template.containers.push({ name: service.serviceId, image: imageUri }); - } else { - newService.template.containers[0].image = imageUri; - } - - // ABIU stickiness handling - if (service.baseImageUri !== undefined) { - newService.template.containers[0].baseImageUri = service.baseImageUri; - } else { - delete newService.template.containers[0].baseImageUri; - } - - service.deployResponse = await runv2.updateService(newService); - } else { - newService = { - name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, - template: { - containers: [ - { - name: service.serviceId, - image: imageUri, - ...(service.baseImageUri ? { baseImageUri: service.baseImageUri } : {}), - }, - ], - }, - client: "cli-firebase", - }; - - service.deployResponse = await runv2.createService( - projectId, - region, - service.serviceId, - newService, - ); - } - } -} diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts index 24960e3b642..41d74b4626f 100644 --- a/src/deploy/run/index.ts +++ b/src/deploy/run/index.ts @@ -1,5 +1,6 @@ import { prepare } from "./prepare"; import { deploy } from "./deploy"; import { release } from "./release"; +export * from "./args"; export { prepare, deploy, release }; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index ff1e5faaed1..f320684d51a 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,58 +3,199 @@ import * as sinon from "sinon"; import { prepare } from "./prepare"; import * as runv2 from "../../gcp/runv2"; import * as prereqs from "./prereqs"; +import { Options } from "../../options"; +import { Context, Payload } from "./args"; +import { FirebaseError } from "../../error"; describe("run prepare", () => { let prereqsStub: sinon.SinonStub; let getServiceStub: sinon.SinonStub; + const originalEnv = process.env; beforeEach(() => { + process.env = { ...originalEnv }; prereqsStub = sinon.stub(prereqs, "prereqs").resolves(); getServiceStub = sinon.stub(runv2, "getService"); }); afterEach(() => { + process.env = originalEnv; sinon.restore(); }); it("should initialize default run config if none specified in firebase.json", async () => { - const payload: any = {}; - const context = { projectId: "project" }; + const payload: Payload = {}; + const context: Context = {}; const options = { project: "project", config: { get: () => undefined, path: (p: string) => p }, - } as any; + } as unknown as Options; getServiceStub.resolves(undefined); await prepare(context, options, payload); expect(prereqsStub.calledOnce).to.be.true; - expect(payload.run.services.length).to.equal(1); - expect(payload.run.services[0].serviceId).to.equal("my-service"); + expect(context.projectId).to.equal("project"); + expect(payload.run?.services).to.have.length(1); + expect(payload.run?.services?.[0].serviceId).to.equal("my-service"); + expect(payload.run?.services?.[0].region).to.equal("us-central1"); + }); + + it("should use serviceId from options.only when no config is specified", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + only: "run:custom-target", + config: { get: () => undefined, path: (p: string) => p }, + } as unknown as Options; + + getServiceStub.resolves(undefined); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].serviceId).to.equal("custom-target"); + }); + + it("should respect FIREBASE_RUN_REGION environment variable", async () => { + process.env.FIREBASE_RUN_REGION = "europe-west1"; + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { get: () => undefined, path: (p: string) => p }, + } as unknown as Options; + + getServiceStub.resolves(undefined); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].region).to.equal("europe-west1"); }); it("should fetch existing service and base image", async () => { - const payload: any = {}; - const context = { projectId: "project" }; + const payload: Payload = {}; + const context: Context = {}; const options = { project: "project", config: { get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), path: (p: string) => p, }, - } as any; + } as unknown as Options; getServiceStub.resolves({ template: { containers: [{ baseImageUri: "some-uri" }], }, - }); + } as runv2.Service); await prepare(context, options, payload); expect(prereqsStub.calledOnce).to.be.true; - expect(payload.run.services.length).to.equal(1); - expect(payload.run.services[0].baseImageUri).to.equal("some-uri"); + expect(payload.run?.services).to.have.length(1); + expect(payload.run?.services?.[0].baseImageUri).to.equal("some-uri"); + }); + + it("should override existing base image if specified in firebase.json", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ + serviceId: "mysvc", + region: "us-central1", + source: ".", + baseImageUri: "override-uri", + }), + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.resolves({ + template: { + containers: [{ baseImageUri: "some-uri" }], + }, + } as runv2.Service); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].baseImageUri).to.equal("override-uri"); + }); + + it("should throw FirebaseError if serviceId is missing", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + await expect(prepare(context, options, payload)).to.be.rejectedWith( + FirebaseError, + "Cloud Run serviceId must be specified in firebase.json.", + ); + }); + + it("should ignore 404 error from getService and proceed", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "new-svc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.rejects({ status: 404 }); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].existingService).to.be.undefined; + }); + + it("should propagate non-404 error from getService", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "new-svc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.rejects({ status: 500, message: "Internal server error" }); + + await expect(prepare(context, options, payload)).to.be.rejected; + }); + + it("should support multiple service configurations in firebase.json", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => [ + { serviceId: "svc-1", region: "us-central1", source: "." }, + { serviceId: "svc-2", region: "us-east1", source: "." }, + ], + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.resolves(undefined); + + await prepare(context, options, payload); + + expect(payload.run?.services).to.have.length(2); + expect(payload.run?.services?.[0].serviceId).to.equal("svc-1"); + expect(payload.run?.services?.[1].serviceId).to.equal("svc-2"); }); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 02fa51d3853..1adba62cd28 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -3,15 +3,21 @@ import { Options } from "../../options"; import { prereqs } from "./prereqs"; import * as runv2 from "../../gcp/runv2"; import { getAppHostingConfiguration } from "../../apphosting/config"; +import { FirebaseError } from "../../error"; +import { Context, Payload, RunConfig, RunServiceSpec } from "./args"; /** - * + * Prepares Cloud Run deployment by validating configurations, fetching existing services, + * resolving base images and App Hosting configurations. */ -export async function prepare(context: any, options: Options, payload: any): Promise { +export async function prepare(context: Context, options: Options, payload: Payload): Promise { const projectId = needProjectId(options); + context.projectId = projectId; await prereqs(options, projectId); - let rawRunConfigs = options.config ? options.config.get("run") : undefined; + let rawRunConfigs = options.config + ? (options.config.get("run") as RunConfig | RunConfig[] | undefined) + : undefined; if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { const onlyOpt = options.only || ""; const runTargetOpt = onlyOpt.split(",").find((t) => t.startsWith("run")); @@ -31,18 +37,22 @@ export async function prepare(context: any, options: Options, payload: any): Pro const configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; + const services: RunServiceSpec[] = []; payload.run = { - services: [], + services, }; for (const config of configs) { const serviceId = config.serviceId; + if (!serviceId) { + throw new FirebaseError("Cloud Run serviceId must be specified in firebase.json."); + } const region = process.env.FIREBASE_RUN_REGION || config.region || "us-central1"; let existingService: runv2.Service | undefined; try { existingService = await runv2.getService(projectId, region, serviceId); - } catch (err: any) { - if (err.status !== 404) { + } catch (err: unknown) { + if ((err as { status?: number })?.status !== 404) { throw err; } } @@ -59,7 +69,7 @@ export async function prepare(context: any, options: Options, payload: any): Pro const sourceDir = options.config ? options.config.path(config.source || ".") : process.cwd(); const appHostingConfig = await getAppHostingConfiguration(sourceDir); - payload.run.services.push({ + services.push({ serviceId, region, source: sourceDir, diff --git a/src/deploy/run/release.spec.ts b/src/deploy/run/release.spec.ts index e4232e93972..d738fb23da8 100644 --- a/src/deploy/run/release.spec.ts +++ b/src/deploy/run/release.spec.ts @@ -2,40 +2,101 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { release } from "./release"; import * as gcs from "../../gcp/storage"; +import { logger } from "../../logger"; +import { Options } from "../../options"; +import { Context, Payload } from "./args"; describe("run release", () => { let deleteObjectStub: sinon.SinonStub; + let loggerInfoStub: sinon.SinonStub; + let loggerDebugStub: sinon.SinonStub; beforeEach(() => { deleteObjectStub = sinon.stub(gcs, "deleteObject").resolves(); + loggerInfoStub = sinon.stub(logger, "info"); + loggerDebugStub = sinon.stub(logger, "debug"); }); afterEach(() => { sinon.restore(); }); - it("should delete staging objects", async () => { - const payload = { + it("should do nothing if payload.run or payload.run.services is undefined", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = {} as Options; + + await release(context, options, payload); + + expect(deleteObjectStub.notCalled).to.be.true; + expect(loggerInfoStub.notCalled).to.be.true; + }); + + it("should delete staging objects and log service URL", async () => { + const payload: Payload = { run: { services: [ { + serviceId: "my-service", + region: "us-central1", + source: ".", + ignore: [], storageSource: { bucket: "my-bucket", object: "test.zip", }, deployResponse: { + name: "projects/p/locations/us-central1/services/my-service", + generation: 1, + createTime: "now", + updateTime: "now", + creator: "user", + lastModifier: "user", + etag: "123", + template: {}, uri: "https://my-service.com", }, }, ], }, }; - const context = {}; - const options = {} as any; + const context: Context = {}; + const options = {} as Options; await release(context, options, payload); expect(deleteObjectStub.calledOnce).to.be.true; expect(deleteObjectStub.firstCall.args[0]).to.equal("/my-bucket/test.zip"); + expect( + loggerInfoStub.calledOnceWith("Service my-service is available at https://my-service.com"), + ).to.be.true; + }); + + it("should handle GCS deletion errors gracefully without failing release", async () => { + deleteObjectStub.rejects(new Error("GCS deletion failed")); + + const payload: Payload = { + run: { + services: [ + { + serviceId: "my-service", + region: "us-central1", + source: ".", + ignore: [], + storageSource: { + bucket: "my-bucket", + object: "test.zip", + }, + }, + ], + }, + }; + const context: Context = {}; + const options = {} as Options; + + await release(context, options, payload); + + expect(deleteObjectStub.calledOnce).to.be.true; + expect(loggerDebugStub.called).to.be.true; }); }); diff --git a/src/deploy/run/release.ts b/src/deploy/run/release.ts index 25233b4a991..75269d87d50 100644 --- a/src/deploy/run/release.ts +++ b/src/deploy/run/release.ts @@ -1,11 +1,13 @@ import { Options } from "../../options"; import { logger } from "../../logger"; import * as gcs from "../../gcp/storage"; +import { Context, Payload } from "./args"; /** - * + * Releases Cloud Run deployment by cleaning up temporary staging artifacts in Cloud Storage + * and logging the deployed service URL. */ -export async function release(context: any, options: Options, payload: any): Promise { +export async function release(context: Context, options: Options, payload: Payload): Promise { if (!payload.run?.services) return; for (const service of payload.run.services) { @@ -15,7 +17,7 @@ export async function release(context: any, options: Options, payload: any): Pro logger.debug( `Deleted source archive from GCS: gs://${service.storageSource.bucket}/${service.storageSource.object}`, ); - } catch (err) { + } catch (err: unknown) { logger.debug( `Failed to delete source archive: gs://${service.storageSource.bucket}/${service.storageSource.object}`, err, @@ -23,7 +25,7 @@ export async function release(context: any, options: Options, payload: any): Pro } } - if (service.deployResponse && service.deployResponse.uri) { + if (service.deployResponse?.uri) { logger.info(`Service ${service.serviceId} is available at ${service.deployResponse.uri}`); } } diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index c35e4b44efc..490c0042594 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -7,6 +7,7 @@ import { latest } from "../deploy/functions/runtimes/supported"; import { CODEBASE_LABEL } from "../functions/constants"; import { Client } from "../apiv2"; import { FirebaseError } from "../error"; +import * as operationPoller from "../operation-poller"; describe("runv2", () => { const PROJECT_ID = "project-id"; @@ -581,4 +582,241 @@ describe("runv2", () => { ); }); }); + + describe("submitBuild", () => { + let sandbox: sinon.SinonSandbox; + let postStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + postStub = sandbox.stub(Client.prototype, "post"); + pollStub = sandbox.stub(operationPoller, "pollOperation"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should submit build and poll operation successfully", async () => { + const build: runv2.Build = { + storageSource: { bucket: "my-bucket", object: "src.zip" }, + imageUri: "us-docker.pkg.dev/proj/repo/img:latest", + buildpackBuild: {}, + }; + postStub.resolves({ + status: 200, + body: { + buildOperation: "projects/project-id/locations/us-central1/operations/op123", + baseImageUri: "gcr.io/base:latest", + baseImageWarning: "warning", + }, + }); + pollStub.resolves(); + + const res = await runv2.submitBuild(PROJECT_ID, LOCATION, build); + + expect(postStub).to.have.been.calledOnceWithExactly( + `/projects/${PROJECT_ID}/locations/${LOCATION}/builds:submit`, + build, + ); + expect(pollStub).to.have.been.calledOnce; + expect(res.baseImageUri).to.equal("gcr.io/base:latest"); + expect(res.baseImageWarning).to.equal("warning"); + }); + + it("should handle object buildOperation with build id metadata", async () => { + const build: runv2.Build = { + storageSource: { bucket: "my-bucket", object: "src.zip" }, + imageUri: "us-docker.pkg.dev/proj/repo/img:latest", + buildpackBuild: {}, + }; + postStub.resolves({ + status: 200, + body: { + buildOperation: { + name: "raw-op-name", + metadata: { + build: { + id: "build-456", + }, + }, + }, + baseImageUri: "gcr.io/base:latest", + }, + }); + pollStub.resolves(); + + const res = await runv2.submitBuild(PROJECT_ID, LOCATION, build); + + const pollerArgs = pollStub.firstCall.args[0] as operationPoller.OperationPollerOptions; + expect(pollerArgs.operationResourceName).to.equal( + `projects/${PROJECT_ID}/locations/${LOCATION}/operations/build-456`, + ); + expect(res.baseImageUri).to.equal("gcr.io/base:latest"); + }); + + it("should throw FirebaseError on non-200 status", async () => { + const build: runv2.Build = { + storageSource: { bucket: "my-bucket", object: "src.zip" }, + imageUri: "us-docker.pkg.dev/proj/repo/img:latest", + buildpackBuild: {}, + }; + postStub.resolves({ status: 400, body: "Bad request" }); + + await expect(runv2.submitBuild(PROJECT_ID, LOCATION, build)).to.be.rejectedWith( + FirebaseError, + "Failed to submit build: 400", + ); + }); + }); + + describe("updateService", () => { + let sandbox: sinon.SinonSandbox; + let patchStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + patchStub = sandbox.stub(Client.prototype, "patch"); + pollStub = sandbox.stub(operationPoller, "pollOperation"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should patch service with custom updateMask and poll operation", async () => { + const service: Omit = { + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + template: {}, + }; + patchStub.resolves({ + status: 200, + body: { name: "operations/op1" }, + }); + pollStub.resolves({ ...service, uri: "https://my-service.com" }); + + const res = await runv2.updateService(service, ["template", "scaling"]); + + expect(patchStub).to.have.been.calledOnceWith(service.name, service, { + queryParams: { updateMask: "template,scaling" }, + }); + expect(res.uri).to.equal("https://my-service.com"); + }); + + it("should default updateMask including template.revision if updateMask not provided", async () => { + const service: Omit = { + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + template: {}, + }; + patchStub.resolves({ + status: 200, + body: { name: "operations/op1" }, + }); + pollStub.resolves({ ...service, uri: "https://my-service.com" }); + + await runv2.updateService(service); + + const patchOptions = patchStub.firstCall.args[2] as { + queryParams: { updateMask: string }; + }; + expect(patchOptions.queryParams.updateMask).to.contain("template.revision"); + }); + }); + + describe("createService", () => { + let sandbox: sinon.SinonSandbox; + let postStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + postStub = sandbox.stub(Client.prototype, "post"); + pollStub = sandbox.stub(operationPoller, "pollOperation"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should post service without name and query param serviceId", async () => { + const service: Omit = { + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + template: { + containers: [{ name: "worker", image: IMAGE_URI }], + }, + }; + postStub.resolves({ + status: 200, + body: { name: "operations/op2" }, + }); + pollStub.resolves({ ...service, uri: "https://created-service.com" }); + + const res = await runv2.createService(PROJECT_ID, LOCATION, SERVICE_ID, service); + + expect(postStub).to.have.been.calledOnceWith( + `/projects/${PROJECT_ID}/locations/${LOCATION}/services`, + { template: service.template }, + { queryParams: { serviceId: SERVICE_ID } }, + ); + expect(res.uri).to.equal("https://created-service.com"); + }); + }); + + describe("getService", () => { + let sandbox: sinon.SinonSandbox; + let getStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getStub = sandbox.stub(Client.prototype, "get"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should get service by resource name", async () => { + const mockService = { + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + }; + getStub.resolves({ status: 200, body: mockService }); + + const res = await runv2.getService(PROJECT_ID, LOCATION, SERVICE_ID); + + expect(res).to.deep.equal(mockService); + expect(getStub).to.have.been.calledOnceWithExactly( + `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + ); + }); + }); + + describe("deleteService", () => { + let sandbox: sinon.SinonSandbox; + let deleteStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deleteStub = sandbox.stub(Client.prototype, "delete"); + pollStub = sandbox.stub(operationPoller, "pollOperation"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should delete service and poll operation", async () => { + deleteStub.resolves({ status: 200, body: { name: "operations/op-del" } }); + pollStub.resolves(); + + await runv2.deleteService(PROJECT_ID, LOCATION, SERVICE_ID); + + expect(deleteStub).to.have.been.calledOnceWithExactly( + `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + ); + expect(pollStub).to.have.been.calledOnce; + }); + }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index e7e83a68ddd..77795478c49 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -155,8 +155,17 @@ export interface Build { buildpackBuild: BuildpacksBuild; } +export interface BuildOperationObject { + name?: string; + metadata?: { + build?: { + id?: string; + }; + }; +} + export interface SubmitBuildResponse { - buildOperation: string; + buildOperation: string | BuildOperationObject; baseImageUri?: string; baseImageWarning?: string; } @@ -175,15 +184,20 @@ export async function submitBuild( build, ); if (res.status !== 200) { - throw new FirebaseError(`Failed to submit build: ${res.status} ${res.body}`); + throw new FirebaseError(`Failed to submit build: ${res.status}`, { + status: res.status, + }); + } + const op = res.body.buildOperation; + let opName: string; + if (typeof op === "string") { + opName = op; + } else { + const buildId = op?.metadata?.build?.id; + opName = buildId + ? `projects/${projectId}/locations/${location}/operations/${buildId}` + : op?.name || ""; } - const op: any = res.body.buildOperation; - const buildId = op?.metadata?.build?.id; - const opName = buildId - ? `projects/${projectId}/locations/${location}/operations/${buildId}` - : typeof op === "string" - ? op - : op?.name; await pollOperation({ apiOrigin: cloudbuildOrigin(), apiVersion: "v1", @@ -202,25 +216,15 @@ export async function submitBuild( * Updates an existing Cloud Run service. * Tracks the long-running operation until completion. */ -export async function updateService(service: Omit): Promise { - const fieldMask: string[] = []; - if (service.template) { - fieldMask.push("template"); - } - if (service.labels) { - fieldMask.push("labels"); - } - if (service.annotations) { - fieldMask.push("annotations"); - } - if (service.ingress) { - fieldMask.push("ingress"); - } - if (service.description) { - fieldMask.push("description"); - } - if (fieldMask.length === 0) { - fieldMask.push("template"); +export async function updateService( + service: Omit, + updateMask?: string[], +): Promise { + const fieldMask = + updateMask || proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"); + if (!updateMask) { + // Always update revision name to ensure null generates a new unique revision name. + fieldMask.push("template.revision"); } const res = await client.patch, LongRunningOperation>( service.name, diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts new file mode 100644 index 00000000000..30ae110b77d --- /dev/null +++ b/src/init/features/run.spec.ts @@ -0,0 +1,215 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runFeature from "./run"; +import * as prompt from "../../prompt"; +import * as runv2 from "../../gcp/runv2"; +import * as ensureApiEnabled from "../../ensureApiEnabled"; +import * as fs from "fs"; +import { Config } from "../../config"; +import { Setup } from "../index"; +import { FirebaseError } from "../../error"; + +function createMockSetup(overrides: Partial = {}): Setup { + return { + config: {}, + rcfile: { projects: {}, targets: {}, etags: {} }, + instructions: [], + ...overrides, + }; +} + +describe("init features run", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("askQuestions", () => { + it("should prompt for serviceId, region, rootDir, and outputDir", async () => { + const inputStub = sandbox.stub(prompt, "input"); + inputStub.onFirstCall().resolves("custom-service"); + inputStub.onSecondCall().resolves("us-central1"); + inputStub.onThirdCall().resolves("./src"); + inputStub.onCall(3).resolves("./dist"); + + const setup = createMockSetup({ projectId: "test-project" }); + await runFeature.askQuestions(setup); + + expect(setup.featureInfo?.run).to.deep.equal({ + serviceId: "custom-service", + region: "us-central1", + rootDir: "./src", + outputDir: "./dist", + }); + }); + + it("should throw FirebaseError if projectId is missing", async () => { + const setup = createMockSetup(); + await expect(runFeature.askQuestions(setup)).to.be.rejectedWith( + FirebaseError, + "Project ID must be set before initializing Cloud Run.", + ); + }); + }); + + describe("actuate", () => { + let ensureStub: sinon.SinonStub; + let getServiceStub: sinon.SinonStub; + let createServiceStub: sinon.SinonStub; + let existsSyncStub: sinon.SinonStub; + + beforeEach(() => { + ensureStub = sandbox.stub(ensureApiEnabled, "ensure").resolves(); + getServiceStub = sandbox.stub(runv2, "getService"); + createServiceStub = sandbox.stub(runv2, "createService").resolves({} as runv2.Service); + existsSyncStub = sandbox.stub(fs, "existsSync"); + }); + + it("should do nothing if featureInfo.run is not present", async () => { + const setup = createMockSetup({ projectId: "test-project" }); + const config = new Config({}, {}); + + await runFeature.actuate(setup, config); + + expect(ensureStub.notCalled).to.be.true; + }); + + it("should throw FirebaseError if projectId is missing", async () => { + const setup = createMockSetup({ + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + outputDir: ".run", + }, + }, + }); + const config = new Config({}, {}); + + await expect(runFeature.actuate(setup, config)).to.be.rejectedWith( + FirebaseError, + "Project ID must be set before initializing Cloud Run.", + ); + }); + + it("should provision new service and write apphosting.yaml if not existing", async () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + outputDir: ".run", + }, + }, + }); + const config = new Config({}, {}); + sandbox.stub(config, "writeProjectFile"); + const askWriteStub = sandbox.stub(config, "askWriteProjectFile").resolves(); + + getServiceStub.rejects({ status: 404 }); + existsSyncStub.returns(false); + + await runFeature.actuate(setup, config); + + expect(ensureStub.calledOnce).to.be.true; + expect(createServiceStub.calledOnce).to.be.true; + const runConfigs = config.src.run as Array<{ serviceId: string }>; + expect(runConfigs).to.be.an("array"); + expect(runConfigs[0].serviceId).to.equal("my-svc"); + expect(askWriteStub.calledOnce).to.be.true; + }); + + it("should reuse existing service without calling createService", async () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + outputDir: ".run", + }, + }, + }); + const config = new Config({}, {}); + sandbox.stub(config, "writeProjectFile"); + + getServiceStub.resolves({ + name: "projects/test-project/locations/us-central1/services/my-svc", + } as runv2.Service); + existsSyncStub.returns(true); + + await runFeature.actuate(setup, config); + + expect(ensureStub.calledOnce).to.be.true; + expect(createServiceStub.notCalled).to.be.true; + const runConfigs = config.src.run as Array<{ serviceId: string }>; + expect(runConfigs).to.be.an("array"); + expect(runConfigs[0].serviceId).to.equal("my-svc"); + }); + + it("should handle getService failure gracefully when non-404 error occurs", async () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + outputDir: ".run", + }, + }, + }); + const config = new Config({}, {}); + sandbox.stub(config, "writeProjectFile"); + const askWriteStub = sandbox.stub(config, "askWriteProjectFile").resolves(); + + getServiceStub.rejects(new Error("Permission denied")); + existsSyncStub.returns(false); + + await runFeature.actuate(setup, config); + + expect(ensureStub.calledOnce).to.be.true; + expect(createServiceStub.notCalled).to.be.true; + expect(askWriteStub.calledOnce).to.be.true; + }); + + it("should append to existing run configs array in firebase.json", async () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "second-svc", + region: "us-central1", + rootDir: "./app2", + outputDir: ".run", + }, + }, + }); + const config = new Config( + { + run: [{ serviceId: "first-svc", region: "us-central1", source: "./app1" }], + }, + {}, + ); + sandbox.stub(config, "writeProjectFile"); + getServiceStub.resolves({} as runv2.Service); + existsSyncStub.returns(true); + + await runFeature.actuate(setup, config); + + const runConfigs = config.src.run as Array<{ serviceId: string }>; + expect(runConfigs).to.have.length(2); + expect(runConfigs[0].serviceId).to.equal("first-svc"); + expect(runConfigs[1].serviceId).to.equal("second-svc"); + }); + }); +}); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 6a7cd6f67a0..7f74e87baea 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -8,6 +8,7 @@ import { logBullet, logSuccess, logWarning } from "../../utils"; import { createService, getService } from "../../gcp/runv2"; import { ensure } from "../../ensureApiEnabled"; import { readTemplateSync } from "../../templates"; +import { FirebaseError } from "../../error"; export interface RunInfo { serviceId: string; @@ -22,7 +23,7 @@ export interface RunInfo { export async function askQuestions(setup: Setup): Promise { const projectId = setup.projectId; if (!projectId) { - throw new Error("Project ID must be set before initializing Cloud Run."); + throw new FirebaseError("Project ID must be set before initializing Cloud Run."); } logBullet("Configuring Cloud Run..."); @@ -64,7 +65,10 @@ export async function actuate(setup: Setup, config: Config): Promise { if (!runInfo) { return; } - const projectId = setup.projectId!; + const projectId = setup.projectId; + if (!projectId) { + throw new FirebaseError("Project ID must be set before initializing Cloud Run."); + } const { serviceId, region, rootDir, outputDir } = runInfo; @@ -99,8 +103,8 @@ export async function actuate(setup: Setup, config: Config): Promise { try { await getService(projectId, region, serviceId); spinner.succeed(`Cloud Run service ${serviceId} already exists.`); - } catch (err: any) { - if (err.status === 404) { + } catch (err: unknown) { + if ((err as { status?: number })?.status === 404) { // Does not exist, create placeholder await createService(projectId, region, serviceId, { name: `projects/${projectId}/locations/${region}/services/${serviceId}`, @@ -120,13 +124,15 @@ export async function actuate(setup: Setup, config: Config): Promise { throw err; } } - } catch (err: any) { - spinner.fail(`Failed to provision Cloud Run service: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + spinner.fail(`Failed to provision Cloud Run service: ${message}`); logWarning("You can still deploy using the CLI, but the initial provisioning failed."); } // Create placeholder apphosting.yaml - const absRootDir = path.join(config.projectDir, rootDir); + const projectDir = config.projectDir || "."; + const absRootDir = path.join(projectDir, rootDir); const apphostingYamlPath = path.join(absRootDir, "apphosting.yaml"); if (!existsSync(apphostingYamlPath)) { logBullet(`Creating placeholder apphosting.yaml in ${rootDir}`); From 6d3915771e876dff315cce7a158964a6388ce3c5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 14:25:19 -0400 Subject: [PATCH 03/25] Some follow-up fixes for env vars and secrets --- src/commands/deploy.ts | 16 +++ src/deploy/index.ts | 4 + src/deploy/lifecycleHooks.ts | 9 ++ src/deploy/run/args.ts | 19 +++ src/deploy/run/deploy.spec.ts | 38 +++-- src/deploy/run/deploy.ts | 247 ++++++++++++++++++++------------- src/deploy/run/prepare.spec.ts | 111 +++++++++++++++ src/deploy/run/prepare.ts | 86 +++++++++--- src/gcp/runv2.spec.ts | 2 +- src/gcp/runv2.ts | 28 +++- 10 files changed, 430 insertions(+), 130 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index e47f6199f69..b4c34e8a6e6 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -70,6 +70,22 @@ export const command = new Command("deploy") "--dry-run", "perform a dry run of your deployment. Validates your changes and builds your code without deploying any changes to your project. " + "In order to provide better validation, this may still enable APIs on the target project", + ) + .option( + "--runtime ", + "specify the runtime for Cloud Run Automatic Base Image Updates (ABIU) (e.g. nodejs22, python311)", + ) + .option( + "--clear-runtime", + "clear the runtime and disable Automatic Base Image Updates (ABIU) for Cloud Run", + ) + .option( + "--base-image ", + "specify the base image URI or runtime for Cloud Run Automatic Base Image Updates (ABIU)", + ) + .option( + "--clear-base-image", + "clear the base image and disable Automatic Base Image Updates (ABIU) for Cloud Run", ); if (experiments.isEnabled("apphostinglocalbuilds")) { diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 1103a5ef850..f2916523792 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -111,13 +111,17 @@ export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], s "run.services.get", "run.services.create", "run.services.update", + "run.operations.get", "cloudbuild.builds.create", "cloudbuild.builds.get", "storage.buckets.get", + "storage.buckets.list", "storage.buckets.create", + "storage.buckets.update", "storage.objects.create", "storage.objects.delete", "artifactregistry.repositories.get", + "artifactregistry.repositories.create", "artifactregistry.repositories.downloadArtifacts", "artifactregistry.repositories.uploadArtifacts", ], diff --git a/src/deploy/lifecycleHooks.ts b/src/deploy/lifecycleHooks.ts index 2d067a46afd..33e441b8325 100644 --- a/src/deploy/lifecycleHooks.ts +++ b/src/deploy/lifecycleHooks.ts @@ -59,6 +59,9 @@ function getChildEnvironment(target: string, overallOptions: any, config: any) { case "functions": resourceDir = overallOptions.config.path(config.source); break; + case "run": + resourceDir = overallOptions.config.path(config.source || config.rootDir || "."); + break; default: resourceDir = overallOptions.config.path(overallOptions.config.projectDir); } @@ -102,6 +105,8 @@ function runTargetCommands( let logIdentifier = target; if (config.target) { logIdentifier += `[${config.target}]`; + } else if (config.serviceId) { + logIdentifier += `[${config.serviceId}]`; } return runAllCommands @@ -173,6 +178,10 @@ function getReleventConfigs(target: string, options: Options) { onlyConfigs = targetConfigs; } return onlyConfigs; + } else if (target === "run") { + return targetConfigs.filter((config: any) => { + return !config.serviceId || onlyTargets.includes(config.serviceId); + }); } else { return targetConfigs.filter((config: any) => { return !config.target || onlyTargets.includes(config.target); diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index e594f0c7f03..af34f18f802 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,13 +1,30 @@ import { AppHostingYamlConfig } from "../../apphosting/yaml"; import * as runv2 from "../../gcp/runv2"; +export const DEFAULT_RUN_IGNORE = [ + "node_modules", + ".git", + ".next", + ".run", + "firebase-debug.log", + "firebase-debug.*.log", + ".env*.local", + "apphosting.local.yaml", + "**/*.secret.local", +]; + export interface RunConfig { serviceId: string; region?: string; + "primary-region"?: string; source?: string; + rootDir?: string; output?: string; + outputDir?: string; ignore?: string[]; baseImageUri?: string; + baseImage?: string; + runtime?: string; } export interface RunServiceSpec { @@ -17,9 +34,11 @@ export interface RunServiceSpec { ignore: string[]; existingService?: runv2.Service; baseImageUri?: string; + clearBaseImage?: boolean; appHostingConfig?: AppHostingYamlConfig; storageSource?: runv2.StorageSource; deployResponse?: runv2.Service; + message?: string; } export interface Payload { diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 938bbfeca94..b2396d7966e 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -82,6 +82,7 @@ describe("run deploy", () => { expect(getProjectNumberStub.calledOnce).to.be.true; expect(upsertBucketStub.calledOnce).to.be.true; + expect(upsertBucketStub.args[0][0].req.baseName).to.equal("firebase-run-src-12345-us-central1"); expect(ensureRepoStub.calledOnce).to.be.true; expect(submitBuildStub.calledOnce).to.be.true; expect(createServiceStub.calledOnce).to.be.true; @@ -92,7 +93,6 @@ describe("run deploy", () => { runv2.ServiceOutputFields >; expect(createdService.template.containers?.[0].baseImageUri).to.equal("dummy-base-image"); - expect(payload.run?.services?.[0].deployResponse?.uri).to.equal("https://my-service.com"); }); @@ -132,7 +132,7 @@ describe("run deploy", () => { expect(payload.run?.services?.[0].deployResponse?.uri).to.equal("https://my-service.com"); }); - it("should map secrets, runtime env vars, and RunConfig scaling", async () => { + it("should map secrets, runtime env vars, VPC settings, and RunConfig scaling", async () => { const appHostingConfig = AppHostingYamlConfig.empty(); appHostingConfig.runConfig = { cpu: 2, @@ -141,6 +141,11 @@ describe("run deploy", () => { maxInstances: 10, concurrency: 80, }; + (appHostingConfig.runConfig as any).vpcAccess = { + connector: "projects/my-p/locations/us-central1/connectors/my-conn", + egress: "ALL_TRAFFIC", + }; + (appHostingConfig as any).scripts = { build: "npm run build:custom" }; appHostingConfig.env = { MY_VAR: { value: "hello", availability: ["RUNTIME"] }, MY_SECRET: { secret: "secret-name@2", availability: ["RUNTIME"] }, @@ -173,7 +178,9 @@ describe("run deploy", () => { ingress: "INGRESS_TRAFFIC_ALL", description: "My Service", template: { - containers: [], + containers: [ + { name: "mysvc", image: "old-img", env: [{ name: "OLD_VAR", value: "keep-me" }] }, + ], }, }, }, @@ -190,29 +197,30 @@ describe("run deploy", () => { await deploy(context, options, payload); + expect(submitBuildStub.calledOnce).to.be.true; + const buildArg = submitBuildStub.args[0][2] as runv2.Build; + expect(buildArg.buildpackBuild?.environmentVariables?.["GOOGLE_NODE_RUN_SCRIPTS"]).to.equal( + "npm run build:custom", + ); + expect(updateServiceStub.calledOnce).to.be.true; const updatedService = updateServiceStub.args[0][0] as Omit< runv2.Service, runv2.ServiceOutputFields >; - const updateMask = updateServiceStub.args[0][1] as string[]; - - expect(updateMask).to.include.members([ - "template", - "labels", - "annotations", - "scaling", - "ingress", - "description", - ]); expect(updatedService.scaling?.minInstanceCount).to.equal(1); expect(updatedService.scaling?.maxInstanceCount).to.equal(10); expect(updatedService.template.maxInstanceRequestConcurrency).to.equal(80); expect(updatedService.template.containers?.[0].resources?.limits?.cpu).to.equal("2"); expect(updatedService.template.containers?.[0].resources?.limits?.memory).to.equal("1024Mi"); + expect(updatedService.template.vpcAccess).to.deep.equal({ + connector: "projects/my-p/locations/us-central1/connectors/my-conn", + egress: "ALL_TRAFFIC", + }); const containerEnv = updatedService.template.containers?.[0].env; + expect(containerEnv).to.deep.include({ name: "OLD_VAR", value: "keep-me" }); expect(containerEnv).to.deep.include({ name: "MY_VAR", value: "hello" }); expect(containerEnv).to.deep.include({ name: "MY_SECRET", @@ -234,7 +242,8 @@ describe("run deploy", () => { }); }); - it("should delete baseImageUri when service.baseImageUri is undefined on existing service", async () => { + it("should delete baseImageUri when service.clearBaseImage is true on existing service", async () => { + submitBuildStub.resolves({}); const payload: Payload = { run: { services: [ @@ -243,6 +252,7 @@ describe("run deploy", () => { region: "us-central1", source: ".", ignore: [], + clearBaseImage: true, existingService: { name: "projects/project/locations/us-central1/services/mysvc", generation: 1, diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 400561efbfb..b90405a1db2 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -8,6 +8,9 @@ import { RunConfig, splitEnvVars } from "../../apphosting/config"; import { EnvMap } from "../../apphosting/yaml"; import { EnvVar } from "../../gcp/k8s"; import { needProjectId } from "../../projectUtils"; +import { logger } from "../../logger"; +import * as gcsm from "../../gcp/secretManager"; +import { getSecretNameParts } from "../../apphosting/secrets"; import { Context, Payload } from "./args"; /** @@ -23,8 +26,8 @@ export async function deploy(context: Context, options: Options, payload: Payloa for (const service of payload.run.services) { const region = service.region; - // Create bucket - const baseName = `firebase-run-src-${projectNumber}`; + // Create regional storage bucket + const baseName = `firebase-run-src-${projectNumber}-${region}`; const bucketName = await gcs.upsertBucket({ product: "run", projectId, @@ -56,106 +59,153 @@ export async function deploy(context: Context, options: Options, payload: Payloa generation: uploadRes.generation || undefined, }; - // Ensure Artifact Registry repository exists - await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); + try { + // Ensure Artifact Registry repository exists + await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); - // Construct image URI - const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; + // Construct image URI + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; - const appHostingConfig = service.appHostingConfig; - const envRecord = appHostingConfig?.env || {}; - const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(envRecord); - const firebaseConfigStr = JSON.stringify({ - projectId, - storageBucket: `${projectId}.appspot.com`, - }); - const buildEnv: Record = { - FIREBASE_CONFIG: firebaseConfigStr, - }; - for (const [key, val] of Object.entries(buildEnvMap)) { - if (val.value !== undefined) { - buildEnv[key] = val.value; - } - } - - // Submit build - const build: runv2.Build = { - storageSource: service.storageSource, - imageUri, - buildpackBuild: { - enableAutomaticUpdates: true, - environmentVariables: buildEnv, - ...(service.baseImageUri ? { baseImage: service.baseImageUri } : {}), - }, - }; - await runv2.submitBuild(projectId, region, build); - - // Deploy via POST or PATCH - const existing = service.existingService; - let newService: Omit; - - if (existing) { - newService = { - name: existing.name, - template: JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate, - ...(existing.labels ? { labels: existing.labels } : {}), - ...(existing.annotations ? { annotations: existing.annotations } : {}), - ...(existing.ingress ? { ingress: existing.ingress } : {}), - ...(existing.description ? { description: existing.description } : {}), - ...(existing.scaling ? { scaling: existing.scaling } : {}), + const appHostingConfig = service.appHostingConfig; + const envRecord = appHostingConfig?.env || {}; + const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(envRecord); + const firebaseConfigStr = JSON.stringify({ + projectId, + storageBucket: `${projectId}.appspot.com`, + }); + const buildEnv: Record = { + FIREBASE_CONFIG: firebaseConfigStr, }; - delete newService.template.revision; - - // Mutate template with new image - if (!newService.template.containers) { - newService.template.containers = []; - } - if (newService.template.containers.length === 0) { - newService.template.containers.push({ name: service.serviceId, image: imageUri }); - } else { - newService.template.containers[0].image = imageUri; + for (const [key, val] of Object.entries(buildEnvMap)) { + if (val.value !== undefined) { + buildEnv[key] = val.value; + } else if (val.secret) { + try { + const [secretName, version] = getSecretNameParts(val.secret); + const secretVal = await gcsm.accessSecretVersion(projectId, secretName, version); + buildEnv[key] = secretVal; + } catch (err: any) { + logger.warn(`Failed to resolve build secret ${key} (${val.secret}): ${err.message}`); + } + } } - // ABIU stickiness handling - if (service.baseImageUri !== undefined) { - newService.template.containers[0].baseImageUri = service.baseImageUri; - } else { - delete newService.template.containers[0].baseImageUri; + if ((appHostingConfig as any)?.scripts?.build) { + buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig as any).scripts.build; } - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); - - const updateMask = ["template"]; - if (newService.labels) updateMask.push("labels"); - if (newService.annotations) updateMask.push("annotations"); - if (newService.scaling) updateMask.push("scaling"); - if (newService.ingress) updateMask.push("ingress"); - if (newService.description) updateMask.push("description"); - - service.deployResponse = await runv2.updateService(newService, updateMask); - } else { - newService = { - name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, - template: { - containers: [ - { - name: service.serviceId, - image: imageUri, - ...(service.baseImageUri ? { baseImageUri: service.baseImageUri } : {}), - }, - ], + const hasAbiu = !service.clearBaseImage && !!service.baseImageUri; + + // Submit build via Cloud Run Build API + const build: runv2.Build = { + storageSource: service.storageSource, + imageUri, + buildpackBuild: { + enableAutomaticUpdates: hasAbiu, + environmentVariables: buildEnv, + ...(hasAbiu ? { baseImage: service.baseImageUri } : {}), }, - client: "cli-firebase", }; - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + const buildRes = await runv2.submitBuild(projectId, region, build); + const resolvedBaseImageUri = + buildRes.baseImageUri || (hasAbiu ? service.baseImageUri : undefined); - service.deployResponse = await runv2.createService( - projectId, - region, - service.serviceId, - newService, - ); + if (buildRes.baseImageWarning) { + logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); + } + + // Deploy via POST or PATCH + const existing = service.existingService; + let newService: Omit; + + if (existing) { + const template = JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate; + delete template.revision; + delete template.scaling; + delete (template as any).client; + delete (template as any).clientVersion; + + newService = { + name: existing.name, + template, + client: "cli-firebase", + }; + + // Mutate template with new image + if (!newService.template.containers) { + newService.template.containers = []; + } + if (newService.template.containers.length === 0) { + newService.template.containers.push({ name: service.serviceId, image: imageUri }); + } else { + newService.template.containers[0].image = imageUri; + } + + // ABIU stickiness handling + if (service.clearBaseImage) { + delete newService.template.containers[0].baseImageUri; + } else if (resolvedBaseImageUri) { + newService.template.containers[0].baseImageUri = resolvedBaseImageUri; + } + + if (!newService.template.labels) newService.template.labels = {}; + newService.template.labels["client.knative.dev/nonce"] = Math.random() + .toString(36) + .substring(2, 12); + if (!newService.template.annotations) newService.template.annotations = {}; + newService.template.annotations["client.knative.dev/user-image"] = imageUri; + newService.template.annotations["run.googleapis.com/deployed-at"] = + new Date().toISOString(); + const revisionDescription = (service.message || options.message) as string | undefined; + if (revisionDescription) { + newService.template.annotations["run.googleapis.com/description"] = revisionDescription; + } + + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + + service.deployResponse = await runv2.updateService(newService); + } else { + const revisionDescription = (service.message || options.message) as string | undefined; + newService = { + name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, + template: { + containers: [ + { + name: service.serviceId, + image: imageUri, + ...(!service.clearBaseImage && resolvedBaseImageUri + ? { baseImageUri: resolvedBaseImageUri } + : {}), + }, + ], + annotations: revisionDescription + ? { "run.googleapis.com/description": revisionDescription } + : {}, + }, + client: "cli-firebase", + }; + + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + + service.deployResponse = await runv2.createService( + projectId, + region, + service.serviceId, + newService, + ); + } + } catch (err) { + if (service.storageSource) { + try { + await gcs.deleteObject( + `/${service.storageSource.bucket}/${service.storageSource.object}`, + ); + } catch (cleanupErr) { + logger.debug("Failed to clean up staging archive on deployment failure:", cleanupErr); + } + } + throw err; } } } @@ -212,9 +262,17 @@ function applyAppHostingConfig( }); } } - if (env.length > 0) { - container.env = env; + + const envMap = new Map(); + if (container.env) { + for (const existingVar of container.env) { + envMap.set(existingVar.name, existingVar); + } } + for (const newVar of env) { + envMap.set(newVar.name, newVar); + } + container.env = Array.from(envMap.values()); // Map RunConfig if (runConfig) { @@ -235,5 +293,8 @@ function applyAppHostingConfig( if (runConfig.concurrency !== undefined) { service.template.maxInstanceRequestConcurrency = runConfig.concurrency; } + if ((runConfig as any).vpcAccess) { + service.template.vpcAccess = (runConfig as any).vpcAccess; + } } } diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index f320684d51a..5aa0754b233 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -125,6 +125,117 @@ describe("run prepare", () => { expect(payload.run?.services?.[0].baseImageUri).to.equal("override-uri"); }); + it("should support --runtime flag override", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + runtime: "nodejs22", + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.resolves({ + template: { + containers: [{ baseImageUri: "old-uri" }], + }, + } as runv2.Service); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].baseImageUri).to.equal("nodejs22"); + expect(payload.run?.services?.[0].clearBaseImage).to.be.false; + }); + + it("should support --clear-runtime flag", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + clearRuntime: true, + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.resolves({ + template: { + containers: [{ baseImageUri: "old-uri" }], + }, + } as runv2.Service); + + await prepare(context, options, payload); + + expect(payload.run?.services?.[0].baseImageUri).to.be.undefined; + expect(payload.run?.services?.[0].clearBaseImage).to.be.true; + }); + + it("should throw error if both --runtime and --clear-runtime are specified", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + runtime: "nodejs22", + clearRuntime: true, + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + path: (p: string) => p, + }, + } as unknown as Options; + + await expect(prepare(context, options, payload)).to.be.rejectedWith( + FirebaseError, + "Cannot specify both --runtime/--base-image and --clear-runtime/--clear-base-image.", + ); + }); + + it("should filter multi-service configurations using --only run:", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + only: "run:svc-2", + config: { + get: () => [ + { serviceId: "svc-1", region: "us-central1", source: "." }, + { serviceId: "svc-2", region: "us-east1", source: "." }, + ], + path: (p: string) => p, + }, + } as unknown as Options; + + getServiceStub.resolves(undefined); + + await prepare(context, options, payload); + + expect(payload.run?.services).to.have.length(1); + expect(payload.run?.services?.[0].serviceId).to.equal("svc-2"); + }); + + it("should throw FirebaseError when --only filter does not match any configured service", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + only: "run:non-existent", + config: { + get: () => [ + { serviceId: "svc-1", region: "us-central1", source: "." }, + { serviceId: "svc-2", region: "us-east1", source: "." }, + ], + path: (p: string) => p, + }, + } as unknown as Options; + + await expect(prepare(context, options, payload)).to.be.rejectedWith( + FirebaseError, + "No Cloud Run services in firebase.json match filter 'run:non-existent'.", + ); + }); + it("should throw FirebaseError if serviceId is missing", async () => { const payload: Payload = {}; const context: Context = {}; diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 1adba62cd28..a623881896f 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -4,38 +4,72 @@ import { prereqs } from "./prereqs"; import * as runv2 from "../../gcp/runv2"; import { getAppHostingConfiguration } from "../../apphosting/config"; import { FirebaseError } from "../../error"; -import { Context, Payload, RunConfig, RunServiceSpec } from "./args"; +import { Context, DEFAULT_RUN_IGNORE, Payload, RunConfig, RunServiceSpec } from "./args"; /** - * Prepares Cloud Run deployment by validating configurations, fetching existing services, - * resolving base images and App Hosting configurations. + * Prepares Cloud Run deployment by validating configurations, filtering targeted services, + * fetching existing services, resolving base images and App Hosting configurations. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const projectId = needProjectId(options); context.projectId = projectId; await prereqs(options, projectId); + const runtimeOpt = ((options as any).runtime || (options as any).baseImage) as string | undefined; + const clearOpt = !!((options as any).clearRuntime || (options as any).clearBaseImage); + + if (runtimeOpt && clearOpt) { + throw new FirebaseError( + "Cannot specify both --runtime/--base-image and --clear-runtime/--clear-base-image.", + ); + } + let rawRunConfigs = options.config ? (options.config.get("run") as RunConfig | RunConfig[] | undefined) : undefined; + + const onlyOpt = options.only || ""; + const runFilterTargets = onlyOpt + .split(",") + .filter((t) => t.startsWith("run:") || t === "run") + .map((t) => (t.includes(":") ? t.split(":")[1] : "")); + + const hasSpecificServiceFilter = runFilterTargets.some((t) => t.length > 0); + const targetedServiceIds = new Set(runFilterTargets.filter((t) => t.length > 0)); + if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { - const onlyOpt = options.only || ""; - const runTargetOpt = onlyOpt.split(",").find((t) => t.startsWith("run")); const serviceId = - runTargetOpt && runTargetOpt.includes(":") ? runTargetOpt.split(":")[1] : "my-service"; - const region = process.env.FIREBASE_RUN_REGION || "us-central1"; + (hasSpecificServiceFilter ? Array.from(targetedServiceIds)[0] : undefined) || + ((options as any).service as string | undefined) || + "my-service"; + const region = + ((options as any).primaryRegion as string | undefined) || + ((options as any).region as string | undefined) || + process.env.FIREBASE_RUN_REGION || + "us-central1"; rawRunConfigs = [ { serviceId, region, source: ".", output: ".run", - ignore: ["node_modules", ".git"], + ignore: DEFAULT_RUN_IGNORE, }, ]; } - const configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; + let configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; + + // Filter multi-service configs by --only run: + if (hasSpecificServiceFilter) { + const matchedConfigs = configs.filter((c) => targetedServiceIds.has(c.serviceId)); + if (matchedConfigs.length === 0) { + throw new FirebaseError( + `No Cloud Run services in firebase.json match filter '${onlyOpt}'. Configured services: ${configs.map((c) => c.serviceId).join(", ")}`, + ); + } + configs = matchedConfigs; + } const services: RunServiceSpec[] = []; payload.run = { @@ -47,7 +81,14 @@ export async function prepare(context: Context, options: Options, payload: Paylo if (!serviceId) { throw new FirebaseError("Cloud Run serviceId must be specified in firebase.json."); } - const region = process.env.FIREBASE_RUN_REGION || config.region || "us-central1"; + const region = + ((options as any).primaryRegion as string | undefined) || + ((options as any).region as string | undefined) || + process.env.FIREBASE_RUN_REGION || + config.region || + config["primary-region"] || + "us-central1"; + let existingService: runv2.Service | undefined; try { existingService = await runv2.getService(projectId, region, serviceId); @@ -57,26 +98,37 @@ export async function prepare(context: Context, options: Options, payload: Paylo } } + // ABIU Resolution: CLI flags > config override > sticky existing service let baseImageUri: string | undefined; - if (existingService?.template?.containers?.[0]?.baseImageUri) { + let clearBaseImage = false; + + if (clearOpt) { + clearBaseImage = true; + baseImageUri = undefined; + } else if (runtimeOpt) { + baseImageUri = runtimeOpt; + } else if (config.baseImageUri || config.baseImage || config.runtime) { + baseImageUri = config.baseImageUri || config.baseImage || config.runtime; + } else if (existingService?.template?.containers?.[0]?.baseImageUri) { + // Stickiness: reuse existing base image from Cloud Run service baseImageUri = existingService.template.containers[0].baseImageUri; } - // If the config specifies a baseImage, that overrides. - if (config.baseImageUri !== undefined) { - baseImageUri = config.baseImageUri; - } - const sourceDir = options.config ? options.config.path(config.source || ".") : process.cwd(); + const sourceDir = options.config + ? options.config.path(config.source || config.rootDir || ".") + : process.cwd(); const appHostingConfig = await getAppHostingConfiguration(sourceDir); services.push({ serviceId, region, source: sourceDir, - ignore: config.ignore || ["node_modules", ".git", ".next"], + ignore: config.ignore || DEFAULT_RUN_IGNORE, existingService, baseImageUri, + clearBaseImage, appHostingConfig, + message: options.message as string | undefined, }); } } diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 490c0042594..a7b4e1ff0c3 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -721,7 +721,7 @@ describe("runv2", () => { const patchOptions = patchStub.firstCall.args[2] as { queryParams: { updateMask: string }; }; - expect(patchOptions.queryParams.updateMask).to.contain("template.revision"); + expect(patchOptions.queryParams.updateMask).to.contain("template"); }); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 77795478c49..bde1a8186a7 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -220,11 +220,29 @@ export async function updateService( service: Omit, updateMask?: string[], ): Promise { - const fieldMask = - updateMask || proto.fieldMasks(service, /* doNotRecurseIn...*/ "labels", "annotations", "tags"); - if (!updateMask) { - // Always update revision name to ensure null generates a new unique revision name. - fieldMask.push("template.revision"); + let fieldMask: string[]; + if (updateMask) { + fieldMask = updateMask; + } else { + const rawMask = proto.fieldMasks( + service, + /* doNotRecurseIn...*/ + "labels", + "annotations", + "tags", + "scaling", + "template.labels", + "template.annotations", + "client", + "clientVersion", + ); + fieldMask = rawMask.filter( + (f) => + f !== "name" && + f !== "template.client" && + f !== "template.clientVersion" && + (f !== "template.revision" || service.template?.revision !== undefined), + ); } const res = await client.patch, LongRunningOperation>( service.name, From 472781b54c49974c26e48a04f55e973b9634b08a Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 14:34:19 -0400 Subject: [PATCH 04/25] Minor secret handling code, some debugging code as well --- src/deploy/run/deploy.spec.ts | 4 ++-- src/deploy/run/deploy.ts | 12 +++++------ src/gcp/runv2.ts | 40 +++++++++++++++++++++-------------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b2396d7966e..ed7e5428d15 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -226,7 +226,7 @@ describe("run deploy", () => { name: "MY_SECRET", valueSource: { secretKeyRef: { - secret: "projects/my-gcp-project/secrets/secret-name", + secret: "secret-name", version: "2", }, }, @@ -235,7 +235,7 @@ describe("run deploy", () => { name: "MY_FULL_SECRET", valueSource: { secretKeyRef: { - secret: "projects/custom-p/secrets/my-sec", + secret: "my-sec", version: "latest", }, }, diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index b90405a1db2..c6853b3bc25 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -164,7 +164,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); - service.deployResponse = await runv2.updateService(newService); + service.deployResponse = await runv2.updateService(newService, ["template", "client"]); } else { const revisionDescription = (service.message || options.message) as string | undefined; newService = { @@ -247,15 +247,15 @@ function applyAppHostingConfig( secretName = parts[0]; version = parts[1] || "latest"; } - const secretPath = - secretName.startsWith("projects/") || !projectId - ? secretName - : `projects/${projectId}/secrets/${secretName}`; + if (secretName.includes("/")) { + const parts = secretName.split("/"); + secretName = parts[parts.length - 1]; + } env.push({ name: key, valueSource: { secretKeyRef: { - secret: secretPath, + secret: secretName, version: version, }, }, diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index bde1a8186a7..8fd4355b958 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -189,23 +189,31 @@ export async function submitBuild( }); } const op = res.body.buildOperation; - let opName: string; - if (typeof op === "string") { - opName = op; - } else { - const buildId = op?.metadata?.build?.id; - opName = buildId - ? `projects/${projectId}/locations/${location}/operations/${buildId}` - : op?.name || ""; + const opName = typeof op === "string" ? op : op?.name || ""; + const rawId = opName.split("/").pop()?.replace(/^build-/, "") || ""; + const buildId = (op as any)?.metadata?.build?.id || rawId; + if (buildId) { + await pollOperation<{ status: string; images?: string[] }>({ + apiOrigin: cloudbuildOrigin(), + apiVersion: "v1", + operationResourceName: `projects/${projectId}/locations/${location}/builds/${buildId}`, + masterTimeout: 15 * 60 * 1000, + backoff: 2000, + maxBackoff: 10000, + doneFn: (b: any) => { + if (!b?.status) return false; + if (b.status === "WORKING" || b.status === "QUEUED" || b.status === "PENDING") { + return false; + } + if (b.status !== "SUCCESS") { + throw new FirebaseError( + `Cloud Build failed with status ${b.status}: ${b.statusDetail || ""}`, + ); + } + return true; + }, + }); } - await pollOperation({ - apiOrigin: cloudbuildOrigin(), - apiVersion: "v1", - operationResourceName: opName, - masterTimeout: 10 * 60 * 1000, - backoff: 1000, - maxBackoff: 5000, - }); return { baseImageUri: res.body.baseImageUri, baseImageWarning: res.body.baseImageWarning, From 79e8bf7465a9e452d91c575b5e24e10b8cb8dc2f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 14:48:15 -0400 Subject: [PATCH 05/25] Add soem ugly Cloud Build handling --- src/deploy/run/deploy.ts | 12 ++++-- src/gcp/runv2.ts | 82 +++++++++++++++++++++++++++++++--------- src/init/features/run.ts | 5 ++- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index c6853b3bc25..5a5862d3841 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -164,7 +164,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); - service.deployResponse = await runv2.updateService(newService, ["template", "client"]); + service.deployResponse = await runv2.updateService(newService); } else { const revisionDescription = (service.message || options.message) as string | undefined; newService = { @@ -210,6 +210,10 @@ export async function deploy(context: Context, options: Options, payload: Payloa } } +/** + * Maps apphosting.yaml runtime environment variables, Secret Manager secretKeyRef + * references, CPU/memory limits, VPC, and instance scaling onto a Cloud Run Service definition. + */ function applyAppHostingConfig( service: Omit, runtimeEnvMap: EnvMap, @@ -284,11 +288,11 @@ function applyAppHostingConfig( container.resources.limits.memory = `${runConfig.memoryMiB}Mi`; } if (runConfig.minInstances !== undefined || runConfig.maxInstances !== undefined) { - if (!service.scaling) service.scaling = {}; + if (!service.template.scaling) service.template.scaling = {}; if (runConfig.minInstances !== undefined) - service.scaling.minInstanceCount = runConfig.minInstances; + service.template.scaling.minInstanceCount = runConfig.minInstances; if (runConfig.maxInstances !== undefined) - service.scaling.maxInstanceCount = runConfig.maxInstances; + service.template.scaling.maxInstanceCount = runConfig.maxInstances; } if (runConfig.concurrency !== undefined) { service.template.maxInstanceRequestConcurrency = runConfig.concurrency; diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 8fd4355b958..2eab6749be4 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -190,29 +190,49 @@ export async function submitBuild( } const op = res.body.buildOperation; const opName = typeof op === "string" ? op : op?.name || ""; - const rawId = opName.split("/").pop()?.replace(/^build-/, "") || ""; + const rawId = + opName + .split("/") + .pop() + ?.replace(/^build-/, "") || ""; const buildId = (op as any)?.metadata?.build?.id || rawId; if (buildId) { - await pollOperation<{ status: string; images?: string[] }>({ - apiOrigin: cloudbuildOrigin(), + const cloudbuildClient = new Client({ + urlPrefix: cloudbuildOrigin(), + auth: true, apiVersion: "v1", - operationResourceName: `projects/${projectId}/locations/${location}/builds/${buildId}`, - masterTimeout: 15 * 60 * 1000, - backoff: 2000, - maxBackoff: 10000, - doneFn: (b: any) => { - if (!b?.status) return false; - if (b.status === "WORKING" || b.status === "QUEUED" || b.status === "PENDING") { - return false; + }); + const startTime = Date.now(); + const timeoutMs = 15 * 60 * 1000; + while (Date.now() - startTime < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + try { + const buildStatusRes = await cloudbuildClient.get<{ + status: string; + statusDetail?: string; + }>(`/projects/${projectId}/locations/${location}/builds/${buildId}`); + const status = buildStatusRes.body?.status; + if (status === "SUCCESS") { + logger.info(`[run:submitBuild] Cloud Build ${buildId} completed with SUCCESS.`); + break; } - if (b.status !== "SUCCESS") { + if ( + status === "FAILURE" || + status === "INTERNAL_ERROR" || + status === "TIMEOUT" || + status === "CANCELLED" + ) { throw new FirebaseError( - `Cloud Build failed with status ${b.status}: ${b.statusDetail || ""}`, + `Cloud Build failed with status ${status}: ${buildStatusRes.body?.statusDetail || ""}`, ); } - return true; - }, - }); + } catch (err: any) { + if (err instanceof FirebaseError && err.message.startsWith("Cloud Build failed")) { + throw err; + } + logger.debug(`[run:submitBuild] Polling retry on transient error: ${err.message}`); + } + } } return { baseImageUri: res.body.baseImageUri, @@ -252,9 +272,37 @@ export async function updateService( (f !== "template.revision" || service.template?.revision !== undefined), ); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { + uid, + generation, + createTime, + updateTime, + creator, + lastModifier, + observedGeneration, + terminalCondition, + conditions, + latestReadyRevision, + latestCreatedRevision, + trafficStatuses, + uri, + urls, + satisfiesPzi, + satisfiesPzs, + etag, + reconciling, + ...serviceBody + } = service as any; + + if (serviceBody.template) { + delete serviceBody.template.revision; + } + const res = await client.patch, LongRunningOperation>( service.name, - service, + serviceBody, { queryParams: { updateMask: fieldMask.join(","), diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 7f74e87baea..8cec7c9173e 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -18,7 +18,7 @@ export interface RunInfo { } /** - * + * Prompts the user for Cloud Run service ID, deployment region, source root, and output directory. */ export async function askQuestions(setup: Setup): Promise { const projectId = setup.projectId; @@ -58,7 +58,8 @@ export async function askQuestions(setup: Setup): Promise { } /** - * + * Provisions placeholder Cloud Run service if absent, writes apphosting.yaml template, + * and records service configuration in firebase.json. */ export async function actuate(setup: Setup, config: Config): Promise { const runInfo = setup.featureInfo?.run; From 913435c3866b8c7cc8fdbfdf07ae5772b25c1349 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 15:11:47 -0400 Subject: [PATCH 06/25] Fix field masks for the v2 Cloud Run API --- scripts/run-deploy-tests/cli.ts | 7 +++++-- src/deploy/run/deploy.spec.ts | 17 +++++++++++++-- src/deploy/run/deploy.ts | 13 ++++++++---- src/gcp/runv2.spec.ts | 13 +++--------- src/gcp/runv2.ts | 37 +++------------------------------ 5 files changed, 35 insertions(+), 52 deletions(-) diff --git a/scripts/run-deploy-tests/cli.ts b/scripts/run-deploy-tests/cli.ts index b9fbb259f47..18c9549c737 100644 --- a/scripts/run-deploy-tests/cli.ts +++ b/scripts/run-deploy-tests/cli.ts @@ -61,8 +61,11 @@ export function exec( cli.stderr += s; }); - return new Promise((resolve) => { - proc.on("exit", (code) => { + return new Promise((resolve, reject) => { + proc.on("error", (err) => { + reject(err); + }); + proc.on("close", (code) => { cli.exitCode = code; resolve(cli); }); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index ed7e5428d15..6ee2ce96dea 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -153,6 +153,10 @@ describe("run deploy", () => { secret: "projects/custom-p/secrets/my-sec", availability: ["RUNTIME"], }, + MY_VERSIONED_FULL_SECRET: { + secret: "projects/custom-p/secrets/my-versioned-sec/versions/3", + availability: ["RUNTIME"], + }, }; const payload: Payload = { @@ -209,8 +213,8 @@ describe("run deploy", () => { runv2.ServiceOutputFields >; - expect(updatedService.scaling?.minInstanceCount).to.equal(1); - expect(updatedService.scaling?.maxInstanceCount).to.equal(10); + expect(updatedService.template.scaling?.minInstanceCount).to.equal(1); + expect(updatedService.template.scaling?.maxInstanceCount).to.equal(10); expect(updatedService.template.maxInstanceRequestConcurrency).to.equal(80); expect(updatedService.template.containers?.[0].resources?.limits?.cpu).to.equal("2"); expect(updatedService.template.containers?.[0].resources?.limits?.memory).to.equal("1024Mi"); @@ -240,6 +244,15 @@ describe("run deploy", () => { }, }, }); + expect(containerEnv).to.deep.include({ + name: "MY_VERSIONED_FULL_SECRET", + valueSource: { + secretKeyRef: { + secret: "my-versioned-sec", + version: "3", + }, + }, + }); }); it("should delete baseImageUri when service.clearBaseImage is true on existing service", async () => { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 5a5862d3841..46d1eb51fee 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -129,7 +129,6 @@ export async function deploy(context: Context, options: Options, payload: Payloa newService = { name: existing.name, template, - client: "cli-firebase", }; // Mutate template with new image @@ -164,7 +163,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); - service.deployResponse = await runv2.updateService(newService); + service.deployResponse = await runv2.updateService(newService, ["template"]); } else { const revisionDescription = (service.message || options.message) as string | undefined; newService = { @@ -246,12 +245,18 @@ function applyAppHostingConfig( } else if (val.secret !== undefined) { let secretName = String(val.secret); let version = "latest"; - if (secretName.includes("@")) { + if (secretName.includes("/versions/")) { + const parts = secretName.split("/versions/"); + secretName = parts[0]; + version = parts[1] || "latest"; + } else if (secretName.includes("@")) { const parts = secretName.split("@"); secretName = parts[0]; version = parts[1] || "latest"; } - if (secretName.includes("/")) { + if (secretName.includes("/secrets/")) { + secretName = secretName.split("/secrets/")[1]; + } else if (secretName.includes("/")) { const parts = secretName.split("/"); secretName = parts[parts.length - 1]; } diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index a7b4e1ff0c3..6dc8d19a452 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -586,12 +586,13 @@ describe("runv2", () => { describe("submitBuild", () => { let sandbox: sinon.SinonSandbox; let postStub: sinon.SinonStub; - let pollStub: sinon.SinonStub; + let getStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); postStub = sandbox.stub(Client.prototype, "post"); - pollStub = sandbox.stub(operationPoller, "pollOperation"); + getStub = sandbox.stub(Client.prototype, "get"); + getStub.resolves({ status: 200, body: { status: "SUCCESS" } }); }); afterEach(() => { @@ -612,7 +613,6 @@ describe("runv2", () => { baseImageWarning: "warning", }, }); - pollStub.resolves(); const res = await runv2.submitBuild(PROJECT_ID, LOCATION, build); @@ -620,7 +620,6 @@ describe("runv2", () => { `/projects/${PROJECT_ID}/locations/${LOCATION}/builds:submit`, build, ); - expect(pollStub).to.have.been.calledOnce; expect(res.baseImageUri).to.equal("gcr.io/base:latest"); expect(res.baseImageWarning).to.equal("warning"); }); @@ -645,14 +644,8 @@ describe("runv2", () => { baseImageUri: "gcr.io/base:latest", }, }); - pollStub.resolves(); const res = await runv2.submitBuild(PROJECT_ID, LOCATION, build); - - const pollerArgs = pollStub.firstCall.args[0] as operationPoller.OperationPollerOptions; - expect(pollerArgs.operationResourceName).to.equal( - `projects/${PROJECT_ID}/locations/${LOCATION}/operations/build-456`, - ); expect(res.baseImageUri).to.equal("gcr.io/base:latest"); }); diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 2eab6749be4..a11d0af7ff7 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -261,48 +261,17 @@ export async function updateService( "scaling", "template.labels", "template.annotations", - "client", - "clientVersion", + "template.scaling", ); fieldMask = rawMask.filter( (f) => - f !== "name" && - f !== "template.client" && - f !== "template.clientVersion" && - (f !== "template.revision" || service.template?.revision !== undefined), + f !== "name" && (f !== "template.revision" || service.template?.revision !== undefined), ); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { - uid, - generation, - createTime, - updateTime, - creator, - lastModifier, - observedGeneration, - terminalCondition, - conditions, - latestReadyRevision, - latestCreatedRevision, - trafficStatuses, - uri, - urls, - satisfiesPzi, - satisfiesPzs, - etag, - reconciling, - ...serviceBody - } = service as any; - - if (serviceBody.template) { - delete serviceBody.template.revision; - } - const res = await client.patch, LongRunningOperation>( service.name, - serviceBody, + service, { queryParams: { updateMask: fieldMask.join(","), From 730fd65197eda213787a5faf00bc51e5854fe6fb Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 15:18:35 -0400 Subject: [PATCH 07/25] Code review improvements: Do Not read apphosting.local.yaml by accident Add timeout to artifact registry actions Track Cloud Build operations/results better --- src/deploy/run/deploy.ts | 35 ++++----------------- src/deploy/run/prepare.ts | 10 ++++-- src/gcp/artifactregistry.spec.ts | 53 ++++++++++++++++++++++++++++++++ src/gcp/artifactregistry.ts | 11 +++++++ src/gcp/runv2.ts | 8 +++++ 5 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 46d1eb51fee..1c083b51382 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -9,8 +9,6 @@ import { EnvMap } from "../../apphosting/yaml"; import { EnvVar } from "../../gcp/k8s"; import { needProjectId } from "../../projectUtils"; import { logger } from "../../logger"; -import * as gcsm from "../../gcp/secretManager"; -import { getSecretNameParts } from "../../apphosting/secrets"; import { Context, Payload } from "./args"; /** @@ -69,24 +67,10 @@ export async function deploy(context: Context, options: Options, payload: Payloa const appHostingConfig = service.appHostingConfig; const envRecord = appHostingConfig?.env || {}; const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(envRecord); - const firebaseConfigStr = JSON.stringify({ - projectId, - storageBucket: `${projectId}.appspot.com`, - }); - const buildEnv: Record = { - FIREBASE_CONFIG: firebaseConfigStr, - }; + const buildEnv: Record = {}; for (const [key, val] of Object.entries(buildEnvMap)) { if (val.value !== undefined) { buildEnv[key] = val.value; - } else if (val.secret) { - try { - const [secretName, version] = getSecretNameParts(val.secret); - const secretVal = await gcsm.accessSecretVersion(projectId, secretName, version); - buildEnv[key] = secretVal; - } catch (err: any) { - logger.warn(`Failed to resolve build secret ${key} (${val.secret}): ${err.message}`); - } } } @@ -161,7 +145,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa newService.template.annotations["run.googleapis.com/description"] = revisionDescription; } - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig); service.deployResponse = await runv2.updateService(newService, ["template"]); } else { @@ -185,7 +169,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa client: "cli-firebase", }; - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig, projectId); + applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig); service.deployResponse = await runv2.createService( projectId, @@ -217,7 +201,6 @@ function applyAppHostingConfig( service: Omit, runtimeEnvMap: EnvMap, runConfig?: RunConfig, - projectId?: string, ): void { if (!service.template.containers) { service.template.containers = []; @@ -230,15 +213,6 @@ function applyAppHostingConfig( // Map runtime and secret env vars const env: EnvVar[] = []; - if (projectId && !runtimeEnvMap["FIREBASE_CONFIG"]) { - env.push({ - name: "FIREBASE_CONFIG", - value: JSON.stringify({ - projectId, - storageBucket: `${projectId}.appspot.com`, - }), - }); - } for (const [key, val] of Object.entries(runtimeEnvMap)) { if (val.value !== undefined) { env.push({ name: key, value: val.value }); @@ -272,6 +246,9 @@ function applyAppHostingConfig( } } + // TODO(b/...): Environment variables and secrets are currently sticky across deployments + // (new configs overlay onto existing container.env without removing absent keys). + // Implement a declarative pruning reconciliation mechanism once the deletion lifecycle is finalized. const envMap = new Map(); if (container.env) { for (const existingVar of container.env) { diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index a623881896f..b3ed5844620 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,8 +1,10 @@ import { needProjectId } from "../../projectUtils"; import { Options } from "../../options"; import { prereqs } from "./prereqs"; +import * as path from "path"; import * as runv2 from "../../gcp/runv2"; -import { getAppHostingConfiguration } from "../../apphosting/config"; +import { fileExistsSync } from "../../fsutils"; +import { AppHostingYamlConfig } from "../../apphosting/yaml"; import { FirebaseError } from "../../error"; import { Context, DEFAULT_RUN_IGNORE, Payload, RunConfig, RunServiceSpec } from "./args"; @@ -117,7 +119,11 @@ export async function prepare(context: Context, options: Options, payload: Paylo const sourceDir = options.config ? options.config.path(config.source || config.rootDir || ".") : process.cwd(); - const appHostingConfig = await getAppHostingConfiguration(sourceDir); + const yamlPath = path.join(sourceDir, "apphosting.yaml"); + let appHostingConfig: AppHostingYamlConfig | undefined; + if (fileExistsSync(yamlPath)) { + appHostingConfig = await AppHostingYamlConfig.loadFromFile(yamlPath); + } services.push({ serviceId, diff --git a/src/gcp/artifactregistry.spec.ts b/src/gcp/artifactregistry.spec.ts index c8e9353b925..38cb44f97da 100644 --- a/src/gcp/artifactregistry.spec.ts +++ b/src/gcp/artifactregistry.spec.ts @@ -140,4 +140,57 @@ describe("artifactRegistry", () => { ); }); }); + + describe("createRepository", () => { + it("should post new repository and return body", async () => { + const repoResponse = { + name: REPO_NAME, + format: "DOCKER", + description: "Cloud Run Source Deploy Repository", + done: true, + }; + nock(artifactRegistryDomain()) + .post( + `/${API_VERSION}/projects/${PROJECT_ID}/locations/${REGION}/repositories?repositoryId=${REPO}`, + ) + .reply(200, repoResponse); + + const res = await artifactRegistry.createRepository(PROJECT_ID, REGION, REPO); + expect(res).to.deep.equal(repoResponse); + expect(nock.isDone()).to.be.true; + }); + }); + + describe("ensureRepository", () => { + it("should return when repository already exists", async () => { + const repo = { name: REPO_NAME, format: "DOCKER" }; + nock(artifactRegistryDomain()).get(`/${API_VERSION}/${REPO_NAME}`).reply(200, repo); + + await artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("should create repository when getRepository returns 404", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(404, { error: { message: "Not found", status: 404 } }); + nock(artifactRegistryDomain()) + .post( + `/${API_VERSION}/projects/${PROJECT_ID}/locations/${REGION}/repositories?repositoryId=${REPO}`, + ) + .reply(200, { name: REPO_NAME, format: "DOCKER", done: true }); + + await artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO); + expect(nock.isDone()).to.be.true; + }); + + it("should rethrow non-404 errors", async () => { + nock(artifactRegistryDomain()) + .get(`/${API_VERSION}/${REPO_NAME}`) + .reply(403, { error: { message: "Permission Denied", status: 403 } }); + + await expect(artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO)).to.be.rejected; + expect(nock.isDone()).to.be.true; + }); + }); }); diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index 39f14868821..ebf9134ff33 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -3,6 +3,7 @@ import { artifactRegistryDomain } from "../api"; import { assertImplements, DeepOmit, RecursiveKeyOf } from "../metaprogramming"; import * as api from "../ensureApiEnabled"; import * as proto from "./proto"; +import { pollOperation } from "../operation-poller"; export const API_VERSION = "v1"; @@ -117,6 +118,16 @@ export async function createRepository( }, }, ); + if (res.body?.name && !res.body.done) { + return await pollOperation({ + apiOrigin: artifactRegistryDomain(), + apiVersion: API_VERSION, + operationResourceName: res.body.name, + masterTimeout: 5 * 60 * 1000, + backoff: 1000, + maxBackoff: 5000, + }); + } return res.body; } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index a11d0af7ff7..def870d0aab 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -204,6 +204,7 @@ export async function submitBuild( }); const startTime = Date.now(); const timeoutMs = 15 * 60 * 1000; + let buildSuccess = false; while (Date.now() - startTime < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, 3000)); try { @@ -214,6 +215,7 @@ export async function submitBuild( const status = buildStatusRes.body?.status; if (status === "SUCCESS") { logger.info(`[run:submitBuild] Cloud Build ${buildId} completed with SUCCESS.`); + buildSuccess = true; break; } if ( @@ -230,9 +232,15 @@ export async function submitBuild( if (err instanceof FirebaseError && err.message.startsWith("Cloud Build failed")) { throw err; } + if (err.status && err.status >= 400 && err.status < 500) { + throw err; + } logger.debug(`[run:submitBuild] Polling retry on transient error: ${err.message}`); } } + if (!buildSuccess) { + throw new FirebaseError(`Cloud Build ${buildId} timed out after 15 minutes.`, { exit: 1 }); + } } return { baseImageUri: res.body.baseImageUri, From e8d689eef17d91a456e17d5f08103823fbcf34bb Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 15:34:54 -0400 Subject: [PATCH 08/25] Followup fixes: * Deduplicated Test CLI Process Wrapper * Standardized Secret Name Parsing * Standardized GCP API Verification * Resolved RunConfig Type Naming Collisions * Materialized Target Configuration & Target Filtering * Updated firebase.json schema to include the "run" section * Cleaned Init Feature Scaffolding * Gated ABIU Base Image Updates --- schema/firebase-config.json | 69 +++++++++++++++++++++++++++ scripts/integration-helpers/cli.ts | 71 ++++++++++++++++++++++++++++ scripts/run-deploy-tests/cli.ts | 73 ----------------------------- scripts/run-deploy-tests/tests.ts | 2 +- src/apphosting/config.ts | 4 +- src/config.ts | 1 + src/deploy/run/args.ts | 11 ++--- src/deploy/run/deploy.ts | 29 ++++++------ src/deploy/run/prepare.spec.ts | 34 ++++++++------ src/deploy/run/prepare.ts | 29 +++--------- src/deploy/run/prereqs.ts | 13 ++++-- src/filterTargets.ts | 3 -- src/firebaseConfig.ts | 7 ++- src/gcp/runv2.ts | 2 +- src/init/features/run.spec.ts | 71 +--------------------------- src/init/features/run.ts | 75 +++++++++--------------------- 16 files changed, 228 insertions(+), 266 deletions(-) delete mode 100644 scripts/run-deploy-tests/cli.ts diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 0185d3e7789..5e53668a223 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1221,6 +1221,62 @@ ], "type": "object" }, + "RunSingle": { + "additionalProperties": false, + "properties": { + "baseImageUri": { + "type": "string" + }, + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "output": { + "type": "string" + }, + "postdeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "predeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "region": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "serviceId" + ], + "type": "object" + }, "StorageSingle": { "additionalProperties": false, "properties": { @@ -2008,6 +2064,19 @@ "remoteconfig": { "$ref": "#/definitions/RemoteConfigConfig" }, + "run": { + "anyOf": [ + { + "$ref": "#/definitions/RunSingle" + }, + { + "items": { + "$ref": "#/definitions/RunSingle" + }, + "type": "array" + } + ] + }, "storage": { "anyOf": [ { diff --git a/scripts/integration-helpers/cli.ts b/scripts/integration-helpers/cli.ts index 0b44c58dcca..b45d1a3967a 100644 --- a/scripts/integration-helpers/cli.ts +++ b/scripts/integration-helpers/cli.ts @@ -88,3 +88,74 @@ export class CLIProcess { return stopped; } } + +export interface Result { + proc: ChildProcess; + stdout: string; + stderr: string; + exitCode?: number | null; +} + +/** + * Execute a Firebase CLI command in a target directory with specified arguments and environment. + */ +export function exec( + cmd: string, + project?: string, + additionalArgs: string[] = [], + cwd: string = process.cwd(), + quiet = true, + extraEnv: Record = {}, +): Promise { + const args = [cmd]; + if (project) { + args.push("--project", project); + } + + if (additionalArgs && additionalArgs.length > 0) { + args.push(...additionalArgs); + } + + const env = { + ...process.env, + ...extraEnv, + }; + + const proc = spawn("firebase", args, { cwd, env }); + if (!proc) { + throw new Error("Failed to start firebase CLI"); + } + + const cli: Result = { + proc, + stdout: "", + stderr: "", + exitCode: null, + }; + + proc.stdout?.on("data", (data: Buffer) => { + const s = data.toString(); + if (!quiet) { + process.stdout.write(s); + } + cli.stdout += s; + }); + + proc.stderr?.on("data", (data: Buffer) => { + const s = data.toString(); + if (!quiet) { + process.stderr.write(s); + } + cli.stderr += s; + }); + + return new Promise((resolve, reject) => { + proc.on("error", (err) => { + reject(err); + }); + proc.on("close", (code) => { + cli.exitCode = code; + resolve(cli); + }); + }); +} diff --git a/scripts/run-deploy-tests/cli.ts b/scripts/run-deploy-tests/cli.ts deleted file mode 100644 index 18c9549c737..00000000000 --- a/scripts/run-deploy-tests/cli.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as spawn from "cross-spawn"; -import { ChildProcess } from "child_process"; - -export interface Result { - proc: ChildProcess; - stdout: string; - stderr: string; - exitCode: number | null; -} - -/** - * Execute a Firebase CLI command in a target directory with specified arguments and environment. - */ -export function exec( - cmd: string, - project: string, - additionalArgs: string[] = [], - cwd: string = process.cwd(), - quiet = true, - extraEnv: Record = {}, -): Promise { - const args = [cmd]; - if (project) { - args.push("--project", project); - } - - if (additionalArgs && additionalArgs.length > 0) { - args.push(...additionalArgs); - } - - const env = { - ...process.env, - ...extraEnv, - }; - - const proc = spawn("firebase", args, { cwd, env }); - if (!proc) { - throw new Error("Failed to start firebase CLI"); - } - - const cli: Result = { - proc, - stdout: "", - stderr: "", - exitCode: null, - }; - - proc.stdout?.on("data", (data: Buffer) => { - const s = data.toString(); - if (!quiet) { - process.stdout.write(s); - } - cli.stdout += s; - }); - - proc.stderr?.on("data", (data: Buffer) => { - const s = data.toString(); - if (!quiet) { - process.stderr.write(s); - } - cli.stderr += s; - }); - - return new Promise((resolve, reject) => { - proc.on("error", (err) => { - reject(err); - }); - proc.on("close", (code) => { - cli.exitCode = code; - resolve(cli); - }); - }); -} diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index b6020622801..0a34ebff14a 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -1,7 +1,7 @@ import * as fs from "fs-extra"; import * as path from "path"; import { expect } from "chai"; -import * as cli from "./cli"; +import * as cli from "../integration-helpers/cli"; import * as runv2 from "../../src/gcp/runv2"; interface MockRunConfig { diff --git a/src/apphosting/config.ts b/src/apphosting/config.ts index d1337b5b825..8bf3f60fc61 100644 --- a/src/apphosting/config.ts +++ b/src/apphosting/config.ts @@ -27,7 +27,7 @@ export const APPHOSTING_LOCAL_YAML_FILE = "apphosting.local.yaml"; export const APPHOSTING_YAML_FILE_REGEX = /^apphosting(\.[a-z0-9_]+)?\.yaml$/; -export interface RunConfig { +export interface AppHostingRunConfig { concurrency?: number; cpu?: number; memoryMiB?: number; @@ -35,6 +35,8 @@ export interface RunConfig { maxInstances?: number; } +export type RunConfig = AppHostingRunConfig; + /** Where an environment variable can be provided. */ export type Availability = "BUILD" | "RUNTIME"; diff --git a/src/config.ts b/src/config.ts index 24bbdcb7e29..ee82aba990f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ export class Config { "apphosting", "auth", "ailogic", + "run", ]; public options: any; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index af34f18f802..9b31d2deb85 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,3 +1,4 @@ +import { RunSingle } from "../../firebaseConfig"; import { AppHostingYamlConfig } from "../../apphosting/yaml"; import * as runv2 from "../../gcp/runv2"; @@ -13,20 +14,16 @@ export const DEFAULT_RUN_IGNORE = [ "**/*.secret.local", ]; -export interface RunConfig { - serviceId: string; - region?: string; +export interface RunServiceConfig extends RunSingle { "primary-region"?: string; - source?: string; rootDir?: string; - output?: string; outputDir?: string; - ignore?: string[]; - baseImageUri?: string; baseImage?: string; runtime?: string; } +export type RunConfig = RunServiceConfig; + export interface RunServiceSpec { serviceId: string; region: string; diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 1c083b51382..9b4e969fb7e 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -5,6 +5,7 @@ import { getProjectNumber } from "../../getProjectNumber"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; import { RunConfig, splitEnvVars } from "../../apphosting/config"; +import { getSecretNameParts } from "../../apphosting/secrets"; import { EnvMap } from "../../apphosting/yaml"; import { EnvVar } from "../../gcp/k8s"; import { needProjectId } from "../../projectUtils"; @@ -92,18 +93,24 @@ export async function deploy(context: Context, options: Options, payload: Payloa }; const buildRes = await runv2.submitBuild(projectId, region, build); - const resolvedBaseImageUri = - buildRes.baseImageUri || (hasAbiu ? service.baseImageUri : undefined); + const resolvedBaseImageUri = hasAbiu + ? buildRes.baseImageUri || service.baseImageUri + : undefined; if (buildRes.baseImageWarning) { logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); } // Deploy via POST or PATCH - const existing = service.existingService; + let existing = service.existingService; let newService: Omit; if (existing) { + try { + existing = await runv2.getService(projectId, region, service.serviceId); + } catch { + // If fetch fails, fall back to cached existing service + } const template = JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate; delete template.revision; delete template.scaling; @@ -125,8 +132,8 @@ export async function deploy(context: Context, options: Options, payload: Payloa newService.template.containers[0].image = imageUri; } - // ABIU stickiness handling - if (service.clearBaseImage) { + // ABIU stickiness handling: only set baseImageUri if explicitly enabled + if (service.clearBaseImage || !hasAbiu) { delete newService.template.containers[0].baseImageUri; } else if (resolvedBaseImageUri) { newService.template.containers[0].baseImageUri = resolvedBaseImageUri; @@ -157,7 +164,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa { name: service.serviceId, image: imageUri, - ...(!service.clearBaseImage && resolvedBaseImageUri + ...(!service.clearBaseImage && hasAbiu && resolvedBaseImageUri ? { baseImageUri: resolvedBaseImageUri } : {}), }, @@ -217,16 +224,12 @@ function applyAppHostingConfig( if (val.value !== undefined) { env.push({ name: key, value: val.value }); } else if (val.secret !== undefined) { - let secretName = String(val.secret); - let version = "latest"; + const rawSecret = String(val.secret); + let [secretName, version] = getSecretNameParts(rawSecret); if (secretName.includes("/versions/")) { const parts = secretName.split("/versions/"); secretName = parts[0]; - version = parts[1] || "latest"; - } else if (secretName.includes("@")) { - const parts = secretName.split("@"); - secretName = parts[0]; - version = parts[1] || "latest"; + version = parts[1] || version; } if (secretName.includes("/secrets/")) { secretName = secretName.split("/secrets/")[1]; diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 5aa0754b233..f7f75485ed8 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -23,7 +23,7 @@ describe("run prepare", () => { sinon.restore(); }); - it("should initialize default run config if none specified in firebase.json", async () => { + it("should throw FirebaseError if no run config is configured in firebase.json", async () => { const payload: Payload = {}; const context: Context = {}; const options = { @@ -31,31 +31,32 @@ describe("run prepare", () => { config: { get: () => undefined, path: (p: string) => p }, } as unknown as Options; - getServiceStub.resolves(undefined); - - await prepare(context, options, payload); - - expect(prereqsStub.calledOnce).to.be.true; - expect(context.projectId).to.equal("project"); - expect(payload.run?.services).to.have.length(1); - expect(payload.run?.services?.[0].serviceId).to.equal("my-service"); - expect(payload.run?.services?.[0].region).to.equal("us-central1"); + await expect(prepare(context, options, payload)).to.be.rejectedWith( + FirebaseError, + "No Cloud Run services configured in firebase.json. Run 'firebase init run' to set up a service.", + ); }); - it("should use serviceId from options.only when no config is specified", async () => { + it("should load run service configuration from firebase.json", async () => { const payload: Payload = {}; const context: Context = {}; const options = { project: "project", - only: "run:custom-target", - config: { get: () => undefined, path: (p: string) => p }, + config: { + get: () => ({ serviceId: "my-service", region: "us-central1", source: "." }), + path: (p: string) => p, + }, } as unknown as Options; getServiceStub.resolves(undefined); await prepare(context, options, payload); - expect(payload.run?.services?.[0].serviceId).to.equal("custom-target"); + expect(prereqsStub.calledOnce).to.be.true; + expect(context.projectId).to.equal("project"); + expect(payload.run?.services).to.have.length(1); + expect(payload.run?.services?.[0].serviceId).to.equal("my-service"); + expect(payload.run?.services?.[0].region).to.equal("us-central1"); }); it("should respect FIREBASE_RUN_REGION environment variable", async () => { @@ -64,7 +65,10 @@ describe("run prepare", () => { const context: Context = {}; const options = { project: "project", - config: { get: () => undefined, path: (p: string) => p }, + config: { + get: () => ({ serviceId: "my-service", source: "." }), + path: (p: string) => p, + }, } as unknown as Options; getServiceStub.resolves(undefined); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index b3ed5844620..75085fcf16f 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -26,10 +26,16 @@ export async function prepare(context: Context, options: Options, payload: Paylo ); } - let rawRunConfigs = options.config + const rawRunConfigs = options.config ? (options.config.get("run") as RunConfig | RunConfig[] | undefined) : undefined; + if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { + throw new FirebaseError( + "No Cloud Run services configured in firebase.json. Run 'firebase init run' to set up a service.", + ); + } + const onlyOpt = options.only || ""; const runFilterTargets = onlyOpt .split(",") @@ -39,27 +45,6 @@ export async function prepare(context: Context, options: Options, payload: Paylo const hasSpecificServiceFilter = runFilterTargets.some((t) => t.length > 0); const targetedServiceIds = new Set(runFilterTargets.filter((t) => t.length > 0)); - if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { - const serviceId = - (hasSpecificServiceFilter ? Array.from(targetedServiceIds)[0] : undefined) || - ((options as any).service as string | undefined) || - "my-service"; - const region = - ((options as any).primaryRegion as string | undefined) || - ((options as any).region as string | undefined) || - process.env.FIREBASE_RUN_REGION || - "us-central1"; - rawRunConfigs = [ - { - serviceId, - region, - source: ".", - output: ".run", - ignore: DEFAULT_RUN_IGNORE, - }, - ]; - } - let configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; // Filter multi-service configs by --only run: diff --git a/src/deploy/run/prereqs.ts b/src/deploy/run/prereqs.ts index 11599f22c81..91ee338dea3 100644 --- a/src/deploy/run/prereqs.ts +++ b/src/deploy/run/prereqs.ts @@ -1,12 +1,15 @@ import { Options } from "../../options"; import { ensure } from "../../ensureApiEnabled"; +import * as artifactregistry from "../../gcp/artifactregistry"; /** - * + * Checks and ensures necessary GCP APIs are enabled before deploying Cloud Run services. */ export async function prereqs(options: Options, projectId: string): Promise { - await ensure(projectId, "run.googleapis.com", "deploy", true); - await ensure(projectId, "cloudbuild.googleapis.com", "deploy", true); - await ensure(projectId, "storage.googleapis.com", "deploy", true); - await ensure(projectId, "artifactregistry.googleapis.com", "deploy", true); + await Promise.all([ + ensure(projectId, "run.googleapis.com", "run", true), + ensure(projectId, "cloudbuild.googleapis.com", "cloudbuild", true), + ensure(projectId, "storage.googleapis.com", "storage", true), + artifactregistry.ensureApiEnabled(projectId), + ]); } diff --git a/src/filterTargets.ts b/src/filterTargets.ts index e3c633633a1..6593968f27e 100644 --- a/src/filterTargets.ts +++ b/src/filterTargets.ts @@ -10,9 +10,6 @@ import { Options } from "./options"; */ export function filterTargets(options: Options, validTargets: string[]): string[] { let targets = validTargets.filter((t) => { - if (t === "run" && options.only?.split(",").some((opt) => opt.split(":")[0] === "run")) { - return true; - } return options.config.has(t); }); if (options.only) { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 1b9478701c2..46056e4eb65 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -368,10 +368,13 @@ export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; export type RunSingle = { serviceId: string; - region: string; - source: string; + region?: string; + source?: string; output?: string; ignore?: string[]; + baseImageUri?: string; + predeploy?: string | string[]; + postdeploy?: string | string[]; }; export type RunMultiple = RunSingle[]; diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index def870d0aab..d2b72aa09fc 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -206,7 +206,6 @@ export async function submitBuild( const timeoutMs = 15 * 60 * 1000; let buildSuccess = false; while (Date.now() - startTime < timeoutMs) { - await new Promise((resolve) => setTimeout(resolve, 3000)); try { const buildStatusRes = await cloudbuildClient.get<{ status: string; @@ -237,6 +236,7 @@ export async function submitBuild( } logger.debug(`[run:submitBuild] Polling retry on transient error: ${err.message}`); } + await new Promise((resolve) => setTimeout(resolve, 3000)); } if (!buildSuccess) { throw new FirebaseError(`Cloud Build ${buildId} timed out after 15 minutes.`, { exit: 1 }); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 30ae110b77d..f2051fd0605 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -2,8 +2,6 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as runFeature from "./run"; import * as prompt from "../../prompt"; -import * as runv2 from "../../gcp/runv2"; -import * as ensureApiEnabled from "../../ensureApiEnabled"; import * as fs from "fs"; import { Config } from "../../config"; import { Setup } from "../index"; @@ -58,15 +56,9 @@ describe("init features run", () => { }); describe("actuate", () => { - let ensureStub: sinon.SinonStub; - let getServiceStub: sinon.SinonStub; - let createServiceStub: sinon.SinonStub; let existsSyncStub: sinon.SinonStub; beforeEach(() => { - ensureStub = sandbox.stub(ensureApiEnabled, "ensure").resolves(); - getServiceStub = sandbox.stub(runv2, "getService"); - createServiceStub = sandbox.stub(runv2, "createService").resolves({} as runv2.Service); existsSyncStub = sandbox.stub(fs, "existsSync"); }); @@ -76,7 +68,7 @@ describe("init features run", () => { await runFeature.actuate(setup, config); - expect(ensureStub.notCalled).to.be.true; + expect(config.src.run).to.be.undefined; }); it("should throw FirebaseError if projectId is missing", async () => { @@ -98,7 +90,7 @@ describe("init features run", () => { ); }); - it("should provision new service and write apphosting.yaml if not existing", async () => { + it("should scaffold configuration in firebase.json and write apphosting.yaml if not existing", async () => { const setup = createMockSetup({ projectId: "test-project", featureInfo: { @@ -114,74 +106,16 @@ describe("init features run", () => { sandbox.stub(config, "writeProjectFile"); const askWriteStub = sandbox.stub(config, "askWriteProjectFile").resolves(); - getServiceStub.rejects({ status: 404 }); existsSyncStub.returns(false); await runFeature.actuate(setup, config); - expect(ensureStub.calledOnce).to.be.true; - expect(createServiceStub.calledOnce).to.be.true; const runConfigs = config.src.run as Array<{ serviceId: string }>; expect(runConfigs).to.be.an("array"); expect(runConfigs[0].serviceId).to.equal("my-svc"); expect(askWriteStub.calledOnce).to.be.true; }); - it("should reuse existing service without calling createService", async () => { - const setup = createMockSetup({ - projectId: "test-project", - featureInfo: { - run: { - serviceId: "my-svc", - region: "us-central1", - rootDir: ".", - outputDir: ".run", - }, - }, - }); - const config = new Config({}, {}); - sandbox.stub(config, "writeProjectFile"); - - getServiceStub.resolves({ - name: "projects/test-project/locations/us-central1/services/my-svc", - } as runv2.Service); - existsSyncStub.returns(true); - - await runFeature.actuate(setup, config); - - expect(ensureStub.calledOnce).to.be.true; - expect(createServiceStub.notCalled).to.be.true; - const runConfigs = config.src.run as Array<{ serviceId: string }>; - expect(runConfigs).to.be.an("array"); - expect(runConfigs[0].serviceId).to.equal("my-svc"); - }); - - it("should handle getService failure gracefully when non-404 error occurs", async () => { - const setup = createMockSetup({ - projectId: "test-project", - featureInfo: { - run: { - serviceId: "my-svc", - region: "us-central1", - rootDir: ".", - outputDir: ".run", - }, - }, - }); - const config = new Config({}, {}); - sandbox.stub(config, "writeProjectFile"); - const askWriteStub = sandbox.stub(config, "askWriteProjectFile").resolves(); - - getServiceStub.rejects(new Error("Permission denied")); - existsSyncStub.returns(false); - - await runFeature.actuate(setup, config); - - expect(ensureStub.calledOnce).to.be.true; - expect(createServiceStub.notCalled).to.be.true; - expect(askWriteStub.calledOnce).to.be.true; - }); - it("should append to existing run configs array in firebase.json", async () => { const setup = createMockSetup({ projectId: "test-project", @@ -201,7 +135,6 @@ describe("init features run", () => { {}, ); sandbox.stub(config, "writeProjectFile"); - getServiceStub.resolves({} as runv2.Service); existsSyncStub.returns(true); await runFeature.actuate(setup, config); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 8cec7c9173e..330c15a0105 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,14 +1,13 @@ -import * as ora from "ora"; import * as path from "path"; import { existsSync } from "fs"; import { Setup } from "../index"; import { Config } from "../../config"; import { input } from "../../prompt"; -import { logBullet, logSuccess, logWarning } from "../../utils"; -import { createService, getService } from "../../gcp/runv2"; -import { ensure } from "../../ensureApiEnabled"; +import { logBullet, logSuccess } from "../../utils"; import { readTemplateSync } from "../../templates"; import { FirebaseError } from "../../error"; +import { DEFAULT_RUN_IGNORE } from "../../deploy/run/args"; +import { RunSingle } from "../../firebaseConfig"; export interface RunInfo { serviceId: string; @@ -58,8 +57,7 @@ export async function askQuestions(setup: Setup): Promise { } /** - * Provisions placeholder Cloud Run service if absent, writes apphosting.yaml template, - * and records service configuration in firebase.json. + * Scaffolds Cloud Run configuration in firebase.json and creates placeholder apphosting.yaml template. */ export async function actuate(setup: Setup, config: Config): Promise { const runInfo = setup.featureInfo?.run; @@ -73,64 +71,20 @@ export async function actuate(setup: Setup, config: Config): Promise { const { serviceId, region, rootDir, outputDir } = runInfo; - logBullet("Setting up Cloud Run service..."); - - // Ensure Cloud Run API is enabled - await ensure(projectId, "run.googleapis.com", "run", true); + logBullet("Setting up Cloud Run configuration..."); // Update firebase.json - const runConfig = { + const runConfig: RunSingle = { serviceId, region, source: rootDir, output: outputDir, - ignore: ["node_modules", ".git", ".next", "firebase-debug.log", "firebase-debug.*.log"], + ignore: DEFAULT_RUN_IGNORE, }; - if (!config.src.run) { - config.set("run", [runConfig]); - } else if (Array.isArray(config.src.run)) { - config.set("run", [...config.src.run, runConfig]); - } else { - config.set("run", [config.src.run, runConfig]); - } - + upsertRunConfig(runConfig, config); config.writeProjectFile("firebase.json", config.src); - const spinner = ora("Provisioning Cloud Run service...").start(); - - try { - // Try to get service first - try { - await getService(projectId, region, serviceId); - spinner.succeed(`Cloud Run service ${serviceId} already exists.`); - } catch (err: unknown) { - if ((err as { status?: number })?.status === 404) { - // Does not exist, create placeholder - await createService(projectId, region, serviceId, { - name: `projects/${projectId}/locations/${region}/services/${serviceId}`, - description: "Firebase Cloud Run Service", - ingress: "INGRESS_TRAFFIC_ALL", - template: { - containers: [ - { - name: "placeholder", - image: "us-docker.pkg.dev/cloudrun/container/hello", - }, - ], - }, - }); - spinner.succeed(`Successfully provisioned Cloud Run service ${serviceId}`); - } else { - throw err; - } - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - spinner.fail(`Failed to provision Cloud Run service: ${message}`); - logWarning("You can still deploy using the CLI, but the initial provisioning failed."); - } - // Create placeholder apphosting.yaml const projectDir = config.projectDir || "."; const absRootDir = path.join(projectDir, rootDir); @@ -145,3 +99,16 @@ export async function actuate(setup: Setup, config: Config): Promise { logSuccess("Cloud Run initialization complete!"); } + +/** Exported for unit testing. */ +export function upsertRunConfig(runConfig: RunSingle, config: Config): void { + if (!config.src.run) { + config.set("run", [runConfig]); + return; + } + if (Array.isArray(config.src.run)) { + config.set("run", [...config.src.run, runConfig]); + return; + } + config.set("run", [config.src.run, runConfig]); +} From 721e5bd763afcfc5a800dc4438ff1283177af143 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 15:48:32 -0400 Subject: [PATCH 09/25] Fix firebase.json schema --- src/firebaseConfig.ts | 6 ++---- src/firebaseConfigValidate.spec.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 46056e4eb65..f1483356329 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,16 +366,14 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; -export type RunSingle = { +export interface RunSingle extends Deployable { serviceId: string; region?: string; source?: string; output?: string; ignore?: string[]; baseImageUri?: string; - predeploy?: string | string[]; - postdeploy?: string | string[]; -}; +} export type RunMultiple = RunSingle[]; diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 327011a577b..6129c2a873a 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,23 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a valid run config", () => { + const config: FirebaseConfig = { + run: [ + { + serviceId: "my-service", + region: "us-central1", + source: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { From 89959def926121fe23c7e86f4b7569e08e448330 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 7 Aug 2026 15:58:09 -0400 Subject: [PATCH 10/25] Improve readability, extract helper methods, and address some code review comments --- scripts/run-deploy-tests/tests.ts | 12 +- src/deploy/run/deploy.spec.ts | 18 +- src/deploy/run/deploy.ts | 291 ++++++++++++++++-------------- src/deploy/run/prepare.ts | 107 +++++++---- src/gcp/runv2.ts | 32 +++- 5 files changed, 271 insertions(+), 189 deletions(-) diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 0a34ebff14a..7bedf3a4e7d 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -15,11 +15,11 @@ interface MockFirebaseJson { hosting?: { public?: string }; } +import * as os from "os"; + const TARGET_PROJECT = - process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || "aryanf-test"; -const DEFAULT_APP_DIR = - process.env.APP_DIR || - path.resolve(__dirname, "../../../firebase-apphosting-canary/apps/nextjs-reference/next-15.3"); + process.env.FBTOOLS_TARGET_PROJECT || process.env.GCLOUD_PROJECT || "test-project"; +const DEFAULT_APP_DIR = process.env.APP_DIR; describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { this.timeout(600_000); // 10 minutes per test for Cloud Build & Cloud Run provisioning @@ -28,12 +28,12 @@ describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { let hasAppDir = false; before(() => { - if (fs.existsSync(DEFAULT_APP_DIR)) { + if (DEFAULT_APP_DIR && fs.existsSync(DEFAULT_APP_DIR)) { workDir = DEFAULT_APP_DIR; hasAppDir = true; } else { // Create isolated temporary workspace for E2E testing - workDir = fs.mkdtempSync(path.join(__dirname, "run-e2e-")); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "firebase-run-e2e-")); fs.writeFileSync( path.join(workDir, "package.json"), JSON.stringify( diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 6ee2ce96dea..5f4905a4475 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -17,12 +17,11 @@ describe("run deploy", () => { let updateServiceStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; let ensureRepoStub: sinon.SinonStub; - let getProjectNumberStub: sinon.SinonStub; beforeEach(() => { upsertBucketStub = sinon.stub(gcs, "upsertBucket").resolves("my-bucket"); ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepository").resolves(); - getProjectNumberStub = sinon.stub(getProjectNumberModule, "getProjectNumber").resolves("12345"); + sinon.stub(getProjectNumberModule, "getProjectNumber").resolves("12345"); sinon.stub(archiveDirectory, "archiveDirectory").resolves({ file: "test.zip", stream: Readable.from(["mock-data"]), @@ -80,9 +79,6 @@ describe("run deploy", () => { await deploy(context, options, payload); - expect(getProjectNumberStub.calledOnce).to.be.true; - expect(upsertBucketStub.calledOnce).to.be.true; - expect(upsertBucketStub.args[0][0].req.baseName).to.equal("firebase-run-src-12345-us-central1"); expect(ensureRepoStub.calledOnce).to.be.true; expect(submitBuildStub.calledOnce).to.be.true; expect(createServiceStub.calledOnce).to.be.true; @@ -213,8 +209,10 @@ describe("run deploy", () => { runv2.ServiceOutputFields >; - expect(updatedService.template.scaling?.minInstanceCount).to.equal(1); - expect(updatedService.template.scaling?.maxInstanceCount).to.equal(10); + expect(updateServiceStub.args[0][1]).to.deep.equal(["template", "scaling"]); + expect(updatedService.scaling?.minInstanceCount).to.equal(1); + expect(updatedService.scaling?.maxInstanceCount).to.equal(10); + expect(updatedService.template.scaling).to.be.undefined; expect(updatedService.template.maxInstanceRequestConcurrency).to.equal(80); expect(updatedService.template.containers?.[0].resources?.limits?.cpu).to.equal("2"); expect(updatedService.template.containers?.[0].resources?.limits?.memory).to.equal("1024Mi"); @@ -230,7 +228,7 @@ describe("run deploy", () => { name: "MY_SECRET", valueSource: { secretKeyRef: { - secret: "secret-name", + secret: "projects/my-gcp-project/secrets/secret-name", version: "2", }, }, @@ -239,7 +237,7 @@ describe("run deploy", () => { name: "MY_FULL_SECRET", valueSource: { secretKeyRef: { - secret: "my-sec", + secret: "projects/custom-p/secrets/my-sec", version: "latest", }, }, @@ -248,7 +246,7 @@ describe("run deploy", () => { name: "MY_VERSIONED_FULL_SECRET", valueSource: { secretKeyRef: { - secret: "my-versioned-sec", + secret: "projects/custom-p/secrets/my-versioned-sec", version: "3", }, }, diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 9b4e969fb7e..daf4a08dfd7 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,62 +1,159 @@ +import { Context, Payload } from "./args"; import { Options } from "../../options"; -import { archiveDirectory } from "../../archiveDirectory"; -import * as gcs from "../../gcp/storage"; -import { getProjectNumber } from "../../getProjectNumber"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; -import { RunConfig, splitEnvVars } from "../../apphosting/config"; -import { getSecretNameParts } from "../../apphosting/secrets"; +import * as gcs from "../../gcp/storage"; import { EnvMap } from "../../apphosting/yaml"; +import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting/config"; +import { getSecretNameParts } from "../../apphosting/secrets"; import { EnvVar } from "../../gcp/k8s"; -import { needProjectId } from "../../projectUtils"; import { logger } from "../../logger"; -import { Context, Payload } from "./args"; /** - * Deploys Cloud Run services by building container images via Cloud Build - * and creating or updating Cloud Run v2 services. + * Formats a secret reference into a canonical GCP Secret Manager resource path. + * If already a full resource path (projects/.../secrets/...), returns it directly. + * Otherwise, prepends projects/${projectId}/secrets/. */ -export async function deploy(context: Context, options: Options, payload: Payload): Promise { - const projectId = context.projectId || needProjectId(options); - const projectNumber = await getProjectNumber(options); +export function formatSecretResourcePath( + rawSecret: string, + projectId: string, +): { + secretPath: string; + version: string; +} { + let [secretName, version] = getSecretNameParts(rawSecret); + if (secretName.includes("/versions/")) { + const parts = secretName.split("/versions/"); + secretName = parts[0]; + version = parts[1] || version; + } + const secretPath = secretName.startsWith("projects/") + ? secretName + : `projects/${projectId}/secrets/${secretName}`; + return { secretPath, version }; +} - if (!payload.run?.services) return; +/** + * Applies runtime environment variables and Secret Manager references to a container. + */ +function applyContainerEnv( + container: runv2.Container, + projectId: string, + runtimeEnvMap: EnvMap, +): void { + const newEnv: EnvVar[] = []; + for (const [key, val] of Object.entries(runtimeEnvMap)) { + if (val.value !== undefined) { + newEnv.push({ name: key, value: val.value }); + } else if (val.secret !== undefined) { + const { secretPath, version } = formatSecretResourcePath(String(val.secret), projectId); + newEnv.push({ + name: key, + valueSource: { + secretKeyRef: { + secret: secretPath, + version, + }, + }, + }); + } + } - for (const service of payload.run.services) { - const region = service.region; + // TODO: Environment variables and secrets are currently sticky across deployments + // (new configs overlay onto existing container.env without removing absent keys). + // Implement a declarative pruning reconciliation mechanism once the deletion lifecycle is finalized. + const envMap = new Map(); + if (container.env) { + for (const existingVar of container.env) { + envMap.set(existingVar.name, existingVar); + } + } + for (const newVar of newEnv) { + envMap.set(newVar.name, newVar); + } + container.env = Array.from(envMap.values()); +} + +/** + * Applies CPU and memory limits from apphosting.yaml runConfig to the container. + */ +function applyContainerResources(container: runv2.Container, runConfig?: RunConfig): void { + if (!runConfig || (runConfig.cpu === undefined && runConfig.memoryMiB === undefined)) { + return; + } + if (!container.resources) container.resources = {}; + if (!container.resources.limits) container.resources.limits = {}; + if (runConfig.cpu !== undefined) { + container.resources.limits.cpu = String(runConfig.cpu); + } + if (runConfig.memoryMiB !== undefined) { + container.resources.limits.memory = `${runConfig.memoryMiB}Mi`; + } +} + +/** + * Applies service-level scaling, concurrency, and VPC settings to a Cloud Run service definition. + */ +function applyServiceScaling( + service: Omit, + runConfig?: RunConfig, +): void { + if (!runConfig) return; + + if (runConfig.minInstances !== undefined || runConfig.maxInstances !== undefined) { + if (!service.scaling) service.scaling = {}; + if (runConfig.minInstances !== undefined) { + service.scaling.minInstanceCount = runConfig.minInstances; + } + if (runConfig.maxInstances !== undefined) { + service.scaling.maxInstanceCount = runConfig.maxInstances; + } + } + + if (runConfig.concurrency !== undefined) { + service.template.maxInstanceRequestConcurrency = runConfig.concurrency; + } + if ((runConfig as any).vpcAccess) { + service.template.vpcAccess = (runConfig as any).vpcAccess; + } +} + +/** + * Maps apphosting.yaml runtime environment variables, Secret Manager secretKeyRef + * references, CPU/memory limits, VPC, and service-level instance scaling onto a Cloud Run Service definition. + */ +function applyAppHostingConfig( + projectId: string, + service: Omit, + runtimeEnvMap: EnvMap, + runConfig?: RunConfig, +): void { + if (!service.template.containers) { + service.template.containers = []; + } + if (service.template.containers.length === 0) { + service.template.containers.push({ name: "worker", image: "" }); + } - // Create regional storage bucket - const baseName = `firebase-run-src-${projectNumber}-${region}`; - const bucketName = await gcs.upsertBucket({ - product: "run", - projectId, - createMessage: `Creating Cloud Storage bucket to store Run source code...`, - req: { - baseName, - location: region, - purposeLabel: "run-source", - lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 1 } }] }, - }, - }); + const container = service.template.containers[0]; + applyContainerEnv(container, projectId, runtimeEnvMap); + applyContainerResources(container, runConfig); + applyServiceScaling(service, runConfig); +} - // Zip and upload - const archive = await archiveDirectory(service.source, { - ignore: service.ignore, - }); +/** + * Deploys Cloud Run services by building container images via Cloud Build + * and creating or updating services in Cloud Run Admin API v2. + */ +export async function deploy(context: Context, options: Options, payload: Payload): Promise { + if (!payload.run || !payload.run.services || payload.run.services.length === 0) { + return; + } - const uploadRes = await gcs.uploadObject( - { - file: archive.file, - stream: archive.stream, - }, - bucketName, - ); + const projectId = context.projectId!; - service.storageSource = { - bucket: uploadRes.bucket, - object: uploadRes.object, - generation: uploadRes.generation || undefined, - }; + for (const service of payload.run.services) { + const region = service.region; try { // Ensure Artifact Registry repository exists @@ -83,7 +180,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa // Submit build via Cloud Run Build API const build: runv2.Build = { - storageSource: service.storageSource, + storageSource: service.storageSource!, imageUri, buildpackBuild: { enableAutomaticUpdates: hasAbiu, @@ -101,7 +198,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); } - // Deploy via POST or PATCH + // Deploy via POST (new service) or PATCH (existing service) let existing = service.existingService; let newService: Omit; @@ -152,9 +249,13 @@ export async function deploy(context: Context, options: Options, payload: Payloa newService.template.annotations["run.googleapis.com/description"] = revisionDescription; } - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig); + applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig); - service.deployResponse = await runv2.updateService(newService, ["template"]); + const updateMask = ["template"]; + if (newService.scaling) { + updateMask.push("scaling"); + } + service.deployResponse = await runv2.updateService(newService, updateMask); } else { const revisionDescription = (service.message || options.message) as string | undefined; newService = { @@ -176,7 +277,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa client: "cli-firebase", }; - applyAppHostingConfig(newService, runtimeEnvMap, appHostingConfig?.runConfig); + applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig); service.deployResponse = await runv2.createService( projectId, @@ -191,99 +292,11 @@ export async function deploy(context: Context, options: Options, payload: Payloa await gcs.deleteObject( `/${service.storageSource.bucket}/${service.storageSource.object}`, ); - } catch (cleanupErr) { - logger.debug("Failed to clean up staging archive on deployment failure:", cleanupErr); + } catch { + // ignore cleanup errors } } throw err; } } } - -/** - * Maps apphosting.yaml runtime environment variables, Secret Manager secretKeyRef - * references, CPU/memory limits, VPC, and instance scaling onto a Cloud Run Service definition. - */ -function applyAppHostingConfig( - service: Omit, - runtimeEnvMap: EnvMap, - runConfig?: RunConfig, -): void { - if (!service.template.containers) { - service.template.containers = []; - } - if (service.template.containers.length === 0) { - service.template.containers.push({ name: "worker", image: "" }); - } - - const container = service.template.containers[0]; - - // Map runtime and secret env vars - const env: EnvVar[] = []; - for (const [key, val] of Object.entries(runtimeEnvMap)) { - if (val.value !== undefined) { - env.push({ name: key, value: val.value }); - } else if (val.secret !== undefined) { - const rawSecret = String(val.secret); - let [secretName, version] = getSecretNameParts(rawSecret); - if (secretName.includes("/versions/")) { - const parts = secretName.split("/versions/"); - secretName = parts[0]; - version = parts[1] || version; - } - if (secretName.includes("/secrets/")) { - secretName = secretName.split("/secrets/")[1]; - } else if (secretName.includes("/")) { - const parts = secretName.split("/"); - secretName = parts[parts.length - 1]; - } - env.push({ - name: key, - valueSource: { - secretKeyRef: { - secret: secretName, - version: version, - }, - }, - }); - } - } - - // TODO(b/...): Environment variables and secrets are currently sticky across deployments - // (new configs overlay onto existing container.env without removing absent keys). - // Implement a declarative pruning reconciliation mechanism once the deletion lifecycle is finalized. - const envMap = new Map(); - if (container.env) { - for (const existingVar of container.env) { - envMap.set(existingVar.name, existingVar); - } - } - for (const newVar of env) { - envMap.set(newVar.name, newVar); - } - container.env = Array.from(envMap.values()); - - // Map RunConfig - if (runConfig) { - if (runConfig.cpu !== undefined || runConfig.memoryMiB !== undefined) { - if (!container.resources) container.resources = {}; - if (!container.resources.limits) container.resources.limits = {}; - if (runConfig.cpu !== undefined) container.resources.limits.cpu = String(runConfig.cpu); - if (runConfig.memoryMiB !== undefined) - container.resources.limits.memory = `${runConfig.memoryMiB}Mi`; - } - if (runConfig.minInstances !== undefined || runConfig.maxInstances !== undefined) { - if (!service.template.scaling) service.template.scaling = {}; - if (runConfig.minInstances !== undefined) - service.template.scaling.minInstanceCount = runConfig.minInstances; - if (runConfig.maxInstances !== undefined) - service.template.scaling.maxInstanceCount = runConfig.maxInstances; - } - if (runConfig.concurrency !== undefined) { - service.template.maxInstanceRequestConcurrency = runConfig.concurrency; - } - if ((runConfig as any).vpcAccess) { - service.template.vpcAccess = (runConfig as any).vpcAccess; - } - } -} diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 75085fcf16f..130a86ced4d 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -9,14 +9,12 @@ import { FirebaseError } from "../../error"; import { Context, DEFAULT_RUN_IGNORE, Payload, RunConfig, RunServiceSpec } from "./args"; /** - * Prepares Cloud Run deployment by validating configurations, filtering targeted services, - * fetching existing services, resolving base images and App Hosting configurations. + * Validates CLI flags to ensure incompatible options are not specified simultaneously. */ -export async function prepare(context: Context, options: Options, payload: Payload): Promise { - const projectId = needProjectId(options); - context.projectId = projectId; - await prereqs(options, projectId); - +function validateCliFlags(options: Options): { + runtimeOpt?: string; + clearOpt: boolean; +} { const runtimeOpt = ((options as any).runtime || (options as any).baseImage) as string | undefined; const clearOpt = !!((options as any).clearRuntime || (options as any).clearBaseImage); @@ -26,18 +24,25 @@ export async function prepare(context: Context, options: Options, payload: Paylo ); } - const rawRunConfigs = options.config - ? (options.config.get("run") as RunConfig | RunConfig[] | undefined) - : undefined; + return { runtimeOpt, clearOpt }; +} +/** + * Filters the list of configured Cloud Run services based on the `--only run:` flag. + */ +function filterTargetConfigs( + rawRunConfigs: RunConfig | RunConfig[] | undefined, + onlyOpt?: string, +): RunConfig[] { if (!rawRunConfigs || (Array.isArray(rawRunConfigs) && rawRunConfigs.length === 0)) { throw new FirebaseError( "No Cloud Run services configured in firebase.json. Run 'firebase init run' to set up a service.", ); } - const onlyOpt = options.only || ""; - const runFilterTargets = onlyOpt + let configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; + const onlyString = onlyOpt || ""; + const runFilterTargets = onlyString .split(",") .filter((t) => t.startsWith("run:") || t === "run") .map((t) => (t.includes(":") ? t.split(":")[1] : "")); @@ -45,19 +50,67 @@ export async function prepare(context: Context, options: Options, payload: Paylo const hasSpecificServiceFilter = runFilterTargets.some((t) => t.length > 0); const targetedServiceIds = new Set(runFilterTargets.filter((t) => t.length > 0)); - let configs = Array.isArray(rawRunConfigs) ? rawRunConfigs : [rawRunConfigs]; - - // Filter multi-service configs by --only run: if (hasSpecificServiceFilter) { const matchedConfigs = configs.filter((c) => targetedServiceIds.has(c.serviceId)); if (matchedConfigs.length === 0) { throw new FirebaseError( - `No Cloud Run services in firebase.json match filter '${onlyOpt}'. Configured services: ${configs.map((c) => c.serviceId).join(", ")}`, + `No Cloud Run services in firebase.json match filter '${onlyString}'. Configured services: ${configs.map((c) => c.serviceId).join(", ")}`, ); } configs = matchedConfigs; } + return configs; +} + +/** + * Resolves ABIU base image URI with precedence: + * 1. CLI flags (`--clear-runtime` vs `--runtime`) + * 2. `firebase.json` configuration + * 3. Existing Cloud Run service revision template (stickiness) + */ +function resolveBaseImage( + config: RunConfig, + existingService: runv2.Service | undefined, + runtimeOpt?: string, + clearOpt?: boolean, +): { baseImageUri?: string; clearBaseImage: boolean } { + if (clearOpt) { + return { baseImageUri: undefined, clearBaseImage: true }; + } + if (runtimeOpt) { + return { baseImageUri: runtimeOpt, clearBaseImage: false }; + } + if (config.baseImageUri || config.baseImage || config.runtime) { + return { + baseImageUri: config.baseImageUri || config.baseImage || config.runtime, + clearBaseImage: false, + }; + } + if (existingService?.template?.containers?.[0]?.baseImageUri) { + return { + baseImageUri: existingService.template.containers[0].baseImageUri, + clearBaseImage: false, + }; + } + return { baseImageUri: undefined, clearBaseImage: false }; +} + +/** + * Prepares Cloud Run deployment by validating configurations, filtering targeted services, + * fetching existing services, resolving base images and App Hosting configurations. + */ +export async function prepare(context: Context, options: Options, payload: Payload): Promise { + const projectId = needProjectId(options); + context.projectId = projectId; + await prereqs(options, projectId); + + const { runtimeOpt, clearOpt } = validateCliFlags(options); + const rawRunConfigs = options.config + ? (options.config.get("run") as RunConfig | RunConfig[] | undefined) + : undefined; + + const configs = filterTargetConfigs(rawRunConfigs, options.only); const services: RunServiceSpec[] = []; payload.run = { services, @@ -68,6 +121,7 @@ export async function prepare(context: Context, options: Options, payload: Paylo if (!serviceId) { throw new FirebaseError("Cloud Run serviceId must be specified in firebase.json."); } + const region = ((options as any).primaryRegion as string | undefined) || ((options as any).region as string | undefined) || @@ -85,21 +139,12 @@ export async function prepare(context: Context, options: Options, payload: Paylo } } - // ABIU Resolution: CLI flags > config override > sticky existing service - let baseImageUri: string | undefined; - let clearBaseImage = false; - - if (clearOpt) { - clearBaseImage = true; - baseImageUri = undefined; - } else if (runtimeOpt) { - baseImageUri = runtimeOpt; - } else if (config.baseImageUri || config.baseImage || config.runtime) { - baseImageUri = config.baseImageUri || config.baseImage || config.runtime; - } else if (existingService?.template?.containers?.[0]?.baseImageUri) { - // Stickiness: reuse existing base image from Cloud Run service - baseImageUri = existingService.template.containers[0].baseImageUri; - } + const { baseImageUri, clearBaseImage } = resolveBaseImage( + config, + existingService, + runtimeOpt, + clearOpt, + ); const sourceDir = options.config ? options.config.path(config.source || config.rootDir || ".") diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index d2b72aa09fc..c05c1ce3958 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -13,6 +13,7 @@ import { EnvVar, mebibytes, PlaintextEnvVar, SecretEnvVar } from "./k8s"; import { latest, Runtime } from "../deploy/functions/runtimes/supported"; import { logger } from "../logger"; import { partition } from "../functional"; +import * as secretManager from "./secretManager"; export const API_VERSION = "v2"; @@ -732,11 +733,13 @@ export function endpointFromService(service: Omit) return acc; }, {}); endpoint.secretEnvironmentVariables = secretEnv.map((e) => { - const [, /* projects*/ projectId /* secrets*/, , secret] = - e.valueSource.secretKeyRef.secret.split("/"); + const { projectId: secretProjectId, secret } = parseSecretKeyRef( + e.valueSource.secretKeyRef.secret, + project, + ); return { key: e.name, - projectId, + projectId: secretProjectId, secret, version: e.valueSource.secretKeyRef.version || "latest", }; @@ -750,6 +753,29 @@ export function endpointFromService(service: Omit) return endpoint; } +/** + * Parses a SecretKeyRef secret resource string into its target project ID and short secret name. + * Handles full resource names (projects/{project}/secrets/{secret}) via secretManager.parseSecretResourceName + * and falls back to the default service project ID for bare secret names. + */ +export function parseSecretKeyRef( + secretRef: string, + defaultProjectId: string, +): { projectId: string; secret: string } { + try { + const parsed = secretManager.parseSecretResourceName(secretRef); + return { + projectId: parsed.projectId, + secret: parsed.name, + }; + } catch { + return { + projectId: defaultProjectId, + secret: secretRef, + }; + } +} + /** * Converts a Firebase internal Endpoint representation into a Cloud Run Service definition. * Used for creating or updating services. From 90a234379234f46f9cd92c170d3469ccac75a5d7 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 10:22:08 -0400 Subject: [PATCH 11/25] tighten up the API to require values explicitly. We pick defaults at runtime (i.e. during the interactive CLI flow) but after that the internal methods do not allow optional parameters for values like the service region. This makes it less likely to silently use a default value instead of the user-provided one. --- schema/firebase-config.json | 18 ++++++ src/apphosting/config.ts | 11 ++++ src/apphosting/yaml.ts | 30 +++++++++- src/archiveDirectory.ts | 3 + src/deploy/run/args.ts | 2 + src/deploy/run/deploy.ts | 108 ++++++++++++++++++++++++++++-------- src/deploy/run/prepare.ts | 22 +++++--- src/firebaseConfig.ts | 6 ++ src/fsAsync.ts | 15 +++-- src/gcp/artifactregistry.ts | 11 +++- src/gcp/runv2.ts | 18 ++++-- src/init/features/run.ts | 62 +++++++++++++++------ 12 files changed, 246 insertions(+), 60 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 5e53668a223..2f7b7c7647c 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1265,11 +1265,29 @@ "region": { "type": "string" }, + "primary-region": { + "type": "string" + }, "serviceId": { "type": "string" }, "source": { "type": "string" + }, + "rootDir": { + "type": "string" + }, + "outputDir": { + "type": "string" + }, + "baseImage": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "serviceAccount": { + "type": "string" } }, "required": [ diff --git a/src/apphosting/config.ts b/src/apphosting/config.ts index 8bf3f60fc61..c0c12172585 100644 --- a/src/apphosting/config.ts +++ b/src/apphosting/config.ts @@ -48,10 +48,21 @@ export type Env = { availability?: Availability[]; }; +export interface ScriptsConfig { + build?: string; + run?: string; +} + +export interface BuildConfig { + buildCommand?: string; +} + /** Schema for apphosting.yaml. */ export interface Config { runConfig?: RunConfig; env?: Env[]; + scripts?: ScriptsConfig; + buildConfig?: BuildConfig; } /** diff --git a/src/apphosting/yaml.ts b/src/apphosting/yaml.ts index 1db7daffd4e..5b665dfeca9 100644 --- a/src/apphosting/yaml.ts +++ b/src/apphosting/yaml.ts @@ -1,6 +1,6 @@ import { basename, dirname } from "path"; import { readFileFromDirectory, wrappedSafeLoad } from "../utils"; -import { Config, Env, store, RunConfig } from "./config"; +import { Config, Env, store, RunConfig, ScriptsConfig, BuildConfig } from "./config"; import * as yaml from "yaml"; import * as jsYaml from "js-yaml"; import * as path from "path"; @@ -19,6 +19,8 @@ export class AppHostingYamlConfig { public filename: string | undefined; public env: EnvMap = {}; public runConfig?: RunConfig; + public scripts?: ScriptsConfig; + public buildConfig?: BuildConfig; /** * Reads in the App Hosting yaml file found in filePath, parses the secrets and @@ -41,6 +43,12 @@ export class AppHostingYamlConfig { if (loadedAppHostingYaml.runConfig) { config.runConfig = loadedAppHostingYaml.runConfig; } + if (loadedAppHostingYaml.scripts) { + config.scripts = loadedAppHostingYaml.scripts; + } + if (loadedAppHostingYaml.buildConfig) { + config.buildConfig = loadedAppHostingYaml.buildConfig; + } return config; } @@ -80,6 +88,20 @@ export class AppHostingYamlConfig { ...other.runConfig, }; } + + if (other.scripts) { + this.scripts = { + ...this.scripts, + ...other.scripts, + }; + } + + if (other.buildConfig) { + this.buildConfig = { + ...this.buildConfig, + ...other.buildConfig, + }; + } } /** @@ -98,6 +120,12 @@ export class AppHostingYamlConfig { if (this.runConfig) { yamlConfigToWrite.runConfig = this.runConfig; } + if (this.scripts) { + yamlConfigToWrite.scripts = this.scripts; + } + if (this.buildConfig) { + yamlConfigToWrite.buildConfig = this.buildConfig; + } store(filePath, yaml.parseDocument(jsYaml.dump(yamlConfigToWrite))); } diff --git a/src/archiveDirectory.ts b/src/archiveDirectory.ts index 72946590a64..03f7da05f86 100644 --- a/src/archiveDirectory.ts +++ b/src/archiveDirectory.ts @@ -12,6 +12,8 @@ import * as fsAsync from "./fsAsync"; export interface ArchiveOptions { /** Globs to be ignored. */ ignore?: string[]; + /** When true, respects .gitignore and .gcloudignore files during traversal. */ + supportGitIgnore?: boolean; } export interface ArchiveResult { @@ -80,6 +82,7 @@ async function zipDirectory( path: sourceDirectory, ignoreStrings: options.ignore, ignoreSymlinks: true, + supportGitIgnore: options.supportGitIgnore ?? true, }); } catch (err: any) { if (err.code === "ENOENT") { diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 9b31d2deb85..0aa3b92f83b 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -20,6 +20,7 @@ export interface RunServiceConfig extends RunSingle { outputDir?: string; baseImage?: string; runtime?: string; + serviceAccount?: string; } export type RunConfig = RunServiceConfig; @@ -36,6 +37,7 @@ export interface RunServiceSpec { storageSource?: runv2.StorageSource; deployResponse?: runv2.Service; message?: string; + serviceAccount?: string; } export interface Payload { diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index daf4a08dfd7..0fbc6369879 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -3,6 +3,8 @@ import { Options } from "../../options"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as gcs from "../../gcp/storage"; +import { archiveDirectory } from "../../archiveDirectory"; +import { getProjectNumber } from "../../getProjectNumber"; import { EnvMap } from "../../apphosting/yaml"; import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting/config"; import { getSecretNameParts } from "../../apphosting/secrets"; @@ -59,9 +61,6 @@ function applyContainerEnv( } } - // TODO: Environment variables and secrets are currently sticky across deployments - // (new configs overlay onto existing container.env without removing absent keys). - // Implement a declarative pruning reconciliation mechanism once the deletion lifecycle is finalized. const envMap = new Map(); if (container.env) { for (const existingVar of container.env) { @@ -127,15 +126,18 @@ function applyAppHostingConfig( service: Omit, runtimeEnvMap: EnvMap, runConfig?: RunConfig, + serviceId?: string, ): void { if (!service.template.containers) { service.template.containers = []; } if (service.template.containers.length === 0) { - service.template.containers.push({ name: "worker", image: "" }); + service.template.containers.push({ name: serviceId || "worker", image: "" }); } - const container = service.template.containers[0]; + const container = + (serviceId ? service.template.containers.find((c) => c.name === serviceId) : undefined) || + service.template.containers[0]; applyContainerEnv(container, projectId, runtimeEnvMap); applyContainerResources(container, runConfig); applyServiceScaling(service, runConfig); @@ -156,10 +158,56 @@ export async function deploy(context: Context, options: Options, payload: Payloa const region = service.region; try { - // Ensure Artifact Registry repository exists + // 1. Ensure Artifact Registry repository exists await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); - // Construct image URI + // 2. Package source directory + const archive = await archiveDirectory(service.source, { + ignore: service.ignore, + supportGitIgnore: true, + }); + + // 3. Upload to regional staging bucket + const projectNumber = await getProjectNumber(options); + const baseName = `firebase-run-src-${projectNumber}-${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 uploadRes = await gcs.uploadObject( + { + file: archive.file, + stream: archive.stream, + }, + bucketName, + ); + + service.storageSource = { + bucket: uploadRes.bucket, + object: uploadRes.object, + generation: uploadRes.generation || undefined, + }; + + // 4. Construct image URI const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; const appHostingConfig = service.appHostingConfig; @@ -172,15 +220,15 @@ export async function deploy(context: Context, options: Options, payload: Payloa } } - if ((appHostingConfig as any)?.scripts?.build) { - buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig as any).scripts.build; + if (appHostingConfig?.scripts?.build || appHostingConfig?.buildConfig?.buildCommand) { + buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig.scripts?.build || appHostingConfig.buildConfig?.buildCommand)!; } const hasAbiu = !service.clearBaseImage && !!service.baseImageUri; - // Submit build via Cloud Run Build API + // 5. Submit build via Cloud Run Build API const build: runv2.Build = { - storageSource: service.storageSource!, + storageSource: service.storageSource, imageUri, buildpackBuild: { enableAutomaticUpdates: hasAbiu, @@ -198,15 +246,17 @@ export async function deploy(context: Context, options: Options, payload: Payloa logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); } - // Deploy via POST (new service) or PATCH (existing service) + // 6. Deploy via POST (new service) or PATCH (existing service) let existing = service.existingService; let newService: Omit; if (existing) { try { existing = await runv2.getService(projectId, region, service.serviceId); - } catch { - // If fetch fails, fall back to cached existing service + } catch (err: unknown) { + if ((err as { status?: number })?.status !== 404) { + logger.debug(`Failed to fetch latest service state for ${service.serviceId}:`, err); + } } const template = JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate; delete template.revision; @@ -219,21 +269,30 @@ export async function deploy(context: Context, options: Options, payload: Payloa template, }; - // Mutate template with new image + if (service.serviceAccount) { + newService.template.serviceAccount = service.serviceAccount; + } + + // Mutate template with new image for the matching container if (!newService.template.containers) { newService.template.containers = []; } - if (newService.template.containers.length === 0) { - newService.template.containers.push({ name: service.serviceId, image: imageUri }); - } else { - newService.template.containers[0].image = imageUri; + let container = newService.template.containers.find((c) => c.name === service.serviceId); + if (!container) { + if (newService.template.containers.length === 0) { + container = { name: service.serviceId, image: imageUri }; + newService.template.containers.push(container); + } else { + container = newService.template.containers[0]; + } } + container.image = imageUri; // ABIU stickiness handling: only set baseImageUri if explicitly enabled if (service.clearBaseImage || !hasAbiu) { - delete newService.template.containers[0].baseImageUri; + delete container.baseImageUri; } else if (resolvedBaseImageUri) { - newService.template.containers[0].baseImageUri = resolvedBaseImageUri; + container.baseImageUri = resolvedBaseImageUri; } if (!newService.template.labels) newService.template.labels = {}; @@ -249,7 +308,7 @@ export async function deploy(context: Context, options: Options, payload: Payloa newService.template.annotations["run.googleapis.com/description"] = revisionDescription; } - applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig); + applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig, service.serviceId); const updateMask = ["template"]; if (newService.scaling) { @@ -273,11 +332,14 @@ export async function deploy(context: Context, options: Options, payload: Payloa annotations: revisionDescription ? { "run.googleapis.com/description": revisionDescription } : {}, + ...(service.serviceAccount ? { serviceAccount: service.serviceAccount } : {}), }, client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", }; - applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig); + applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig, service.serviceId); service.deployResponse = await runv2.createService( projectId, diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 130a86ced4d..441c6a6e749 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -18,7 +18,7 @@ function validateCliFlags(options: Options): { const runtimeOpt = ((options as any).runtime || (options as any).baseImage) as string | undefined; const clearOpt = !!((options as any).clearRuntime || (options as any).clearBaseImage); - if (runtimeOpt && clearOpt) { + if (runtimeOpt !== undefined && runtimeOpt !== "" && clearOpt) { throw new FirebaseError( "Cannot specify both --runtime/--base-image and --clear-runtime/--clear-base-image.", ); @@ -51,13 +51,14 @@ function filterTargetConfigs( const targetedServiceIds = new Set(runFilterTargets.filter((t) => t.length > 0)); if (hasSpecificServiceFilter) { - const matchedConfigs = configs.filter((c) => targetedServiceIds.has(c.serviceId)); - if (matchedConfigs.length === 0) { + const configuredServiceIds = new Set(configs.map((c) => c.serviceId)); + const missingServiceIds = Array.from(targetedServiceIds).filter((id) => !configuredServiceIds.has(id)); + if (missingServiceIds.length > 0) { throw new FirebaseError( - `No Cloud Run services in firebase.json match filter '${onlyString}'. Configured services: ${configs.map((c) => c.serviceId).join(", ")}`, + `Cloud Run service(s) '${missingServiceIds.join(", ")}' not found in firebase.json. Configured services: ${Array.from(configuredServiceIds).join(", ")}`, ); } - configs = matchedConfigs; + configs = configs.filter((c) => targetedServiceIds.has(c.serviceId)); } return configs; @@ -75,10 +76,10 @@ function resolveBaseImage( runtimeOpt?: string, clearOpt?: boolean, ): { baseImageUri?: string; clearBaseImage: boolean } { - if (clearOpt) { + if (clearOpt || runtimeOpt === "") { return { baseImageUri: undefined, clearBaseImage: true }; } - if (runtimeOpt) { + if (runtimeOpt !== undefined) { return { baseImageUri: runtimeOpt, clearBaseImage: false }; } if (config.baseImageUri || config.baseImage || config.runtime) { @@ -103,7 +104,6 @@ function resolveBaseImage( export async function prepare(context: Context, options: Options, payload: Payload): Promise { const projectId = needProjectId(options); context.projectId = projectId; - await prereqs(options, projectId); const { runtimeOpt, clearOpt } = validateCliFlags(options); const rawRunConfigs = options.config @@ -111,6 +111,9 @@ export async function prepare(context: Context, options: Options, payload: Paylo : undefined; const configs = filterTargetConfigs(rawRunConfigs, options.only); + + await prereqs(options, projectId); + const services: RunServiceSpec[] = []; payload.run = { services, @@ -159,12 +162,13 @@ export async function prepare(context: Context, options: Options, payload: Paylo serviceId, region, source: sourceDir, - ignore: config.ignore || DEFAULT_RUN_IGNORE, + ignore: Array.from(new Set([...DEFAULT_RUN_IGNORE, ...(config.ignore || [])])), existingService, baseImageUri, clearBaseImage, appHostingConfig, message: options.message as string | undefined, + serviceAccount: ((options as any).serviceAccount as string | undefined) || config.serviceAccount, }); } } diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index f1483356329..1404b3d6acd 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -369,10 +369,16 @@ export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; export interface RunSingle extends Deployable { serviceId: string; region?: string; + "primary-region"?: string; source?: string; + rootDir?: string; output?: string; + outputDir?: string; ignore?: string[]; baseImageUri?: string; + baseImage?: string; + runtime?: string; + serviceAccount?: string; } export type RunMultiple = RunSingle[]; diff --git a/src/fsAsync.ts b/src/fsAsync.ts index d31ac488efc..8183061b1ce 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -44,12 +44,17 @@ async function readdirRecursiveHelper(options: { const dirContents = readdirSync(options.path, { withFileTypes: true }); let currentGitIgnoreStack = options.gitIgnoreStack || []; - // Load and stack directory-specific .gitignore rules if supportGitIgnore is enabled + // Load and stack directory-specific .gcloudignore or .gitignore rules if supportGitIgnore is enabled if (options.supportGitIgnore) { - if (dirContents.find((n) => n.name === ".gitignore")?.isFile()) { - const localGitIgnore = join(options.path, ".gitignore"); + const ignoreFileName = dirContents.find((n) => n.name === ".gcloudignore")?.isFile() + ? ".gcloudignore" + : dirContents.find((n) => n.name === ".gitignore")?.isFile() + ? ".gitignore" + : undefined; + if (ignoreFileName) { + const localIgnorePath = join(options.path, ignoreFileName); try { - const lines = readFileSync(localGitIgnore) + const lines = readFileSync(localIgnorePath) .toString() .split("\n") .map((line) => line.trim()) @@ -63,7 +68,7 @@ async function readdirRecursiveHelper(options: { }, ]; } catch (e: unknown) { - logger.debug(`Error reading .gitignore file at ${localGitIgnore}:`, e); + logger.debug(`Error reading ${ignoreFileName} file at ${localIgnorePath}:`, e); } } } diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index ebf9134ff33..bf462fa5907 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -145,7 +145,16 @@ export async function ensureRepository( await getRepository(name); } catch (err: any) { if (err.status === 404) { - await createRepository(projectId, location, repositoryId, format); + try { + await createRepository(projectId, location, repositoryId, format); + } catch (createErr: any) { + if (createErr.status === 409) { + return; + } + throw createErr; + } + } else if (err.status === 409) { + return; } else { throw err; } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index c05c1ce3958..401451b4aaf 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -64,6 +64,11 @@ export interface RevisionTemplate { vpcAccess?: { connector?: string; egress?: "ALL_TRAFFIC" | "PRIVATE_RANGES_ONLY"; + networkInterfaces?: Array<{ + network?: string; + subnetwork?: string; + tags?: string[]; + }>; networkinterfaces?: Array<{ network?: string; subnetwork?: string; @@ -206,11 +211,13 @@ export async function submitBuild( const startTime = Date.now(); const timeoutMs = 15 * 60 * 1000; let buildSuccess = false; + const logUrl = `https://console.cloud.google.com/cloud-build/builds;region=${location}/${buildId}?project=${projectId}`; while (Date.now() - startTime < timeoutMs) { try { const buildStatusRes = await cloudbuildClient.get<{ status: string; statusDetail?: string; + logUrl?: string; }>(`/projects/${projectId}/locations/${location}/builds/${buildId}`); const status = buildStatusRes.body?.status; if (status === "SUCCESS") { @@ -224,15 +231,18 @@ export async function submitBuild( status === "TIMEOUT" || status === "CANCELLED" ) { + const detail = buildStatusRes.body?.statusDetail ? `: ${buildStatusRes.body.statusDetail}` : ""; + const consoleLink = buildStatusRes.body?.logUrl || logUrl; throw new FirebaseError( - `Cloud Build failed with status ${status}: ${buildStatusRes.body?.statusDetail || ""}`, + `Cloud Build failed with status ${status}${detail}\nView Cloud Build logs at: ${consoleLink}`, ); } } catch (err: any) { - if (err instanceof FirebaseError && err.message.startsWith("Cloud Build failed")) { + if (err instanceof FirebaseError && err.message.includes("Cloud Build failed")) { throw err; } - if (err.status && err.status >= 400 && err.status < 500) { + // Tolerate 404 Not Found during initial propagation / eventual consistency lag + if (err.status && err.status >= 400 && err.status < 500 && err.status !== 404) { throw err; } logger.debug(`[run:submitBuild] Polling retry on transient error: ${err.message}`); @@ -240,7 +250,7 @@ export async function submitBuild( await new Promise((resolve) => setTimeout(resolve, 3000)); } if (!buildSuccess) { - throw new FirebaseError(`Cloud Build ${buildId} timed out after 15 minutes.`, { exit: 1 }); + throw new FirebaseError(`Cloud Build ${buildId} timed out after 15 minutes. View logs at: ${logUrl}`, { exit: 1 }); } } return { diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 330c15a0105..c0d97899a59 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -19,7 +19,7 @@ export interface RunInfo { /** * Prompts the user for Cloud Run service ID, deployment region, source root, and output directory. */ -export async function askQuestions(setup: Setup): Promise { +export async function askQuestions(setup: Setup, config?: Config, options?: any): Promise { const projectId = setup.projectId; if (!projectId) { throw new FirebaseError("Project ID must be set before initializing Cloud Run."); @@ -27,25 +27,49 @@ export async function askQuestions(setup: Setup): Promise { logBullet("Configuring Cloud Run..."); - const serviceId = await input({ - message: "What should be the ID of your Cloud Run service?", - default: "my-service", - }); + const defaultServiceId = + options?.service || + options?.serviceId || + path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-") || + "my-service"; - const region = await input({ + const serviceId = options?.service || options?.serviceId || (await input({ + message: "What should be the ID of your Cloud Run service?", + default: defaultServiceId, + validate: (val: string) => { + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(val)) { + return "Service ID must be lowercase alphanumeric and hyphens, max 63 characters."; + } + return true; + }, + })); + + const defaultRegion = + options?.primaryRegion || + options?.region || + process.env.FIREBASE_RUN_REGION || + "us-central1"; + + const region = options?.primaryRegion || options?.region || (await input({ message: "Which region should this service be deployed to?", - default: "us-central1", - }); - - const rootDir = await input({ + default: defaultRegion, + validate: (val: string) => { + if (!/^[a-z0-9-]+$/.test(val)) { + return "Region must be a valid GCP region string (e.g. us-central1)."; + } + return true; + }, + })); + + const rootDir = options?.rootDir || options?.source || (await input({ message: "What is the root directory of your source code? (relative to firebase.json)", default: ".", - }); + })); - const outputDir = await input({ + const outputDir = options?.outputDir || options?.output || (await input({ message: "Where should the built artifacts be output? (e.g. for --prebuilt)", default: ".run", - }); + })); setup.featureInfo = setup.featureInfo || {}; setup.featureInfo.run = { @@ -106,9 +130,13 @@ export function upsertRunConfig(runConfig: RunSingle, config: Config): void { config.set("run", [runConfig]); return; } - if (Array.isArray(config.src.run)) { - config.set("run", [...config.src.run, runConfig]); - return; + const existing = Array.isArray(config.src.run) ? config.src.run : [config.src.run]; + const idx = existing.findIndex((s) => s.serviceId === runConfig.serviceId); + if (idx >= 0) { + const updated = [...existing]; + updated[idx] = { ...updated[idx], ...runConfig }; + config.set("run", updated); + } else { + config.set("run", [...existing, runConfig]); } - config.set("run", [config.src.run, runConfig]); } From 8c325f107115badd17b0b38a575f3f57190001cd Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 10:28:54 -0400 Subject: [PATCH 12/25] Remove ABIU fields from firebase.json, these are only CLI flags. Cloud Run already stores them on the service object. --- schema/firebase-config.json | 9 --------- src/deploy/run/args.ts | 2 -- src/deploy/run/deploy.spec.ts | 3 +++ src/deploy/run/deploy.ts | 5 ++++- src/deploy/run/prepare.spec.ts | 6 +++--- src/deploy/run/prepare.ts | 13 ++----------- src/firebaseConfig.ts | 3 --- 7 files changed, 12 insertions(+), 29 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 2f7b7c7647c..6e0eac5699a 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1224,9 +1224,6 @@ "RunSingle": { "additionalProperties": false, "properties": { - "baseImageUri": { - "type": "string" - }, "ignore": { "items": { "type": "string" @@ -1280,12 +1277,6 @@ "outputDir": { "type": "string" }, - "baseImage": { - "type": "string" - }, - "runtime": { - "type": "string" - }, "serviceAccount": { "type": "string" } diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 0aa3b92f83b..83180b66c04 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -18,8 +18,6 @@ export interface RunServiceConfig extends RunSingle { "primary-region"?: string; rootDir?: string; outputDir?: string; - baseImage?: string; - runtime?: string; serviceAccount?: string; } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 5f4905a4475..127d7c82f42 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -22,6 +22,7 @@ describe("run deploy", () => { upsertBucketStub = sinon.stub(gcs, "upsertBucket").resolves("my-bucket"); ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepository").resolves(); sinon.stub(getProjectNumberModule, "getProjectNumber").resolves("12345"); + sinon.stub(runv2, "getService").resolves(); sinon.stub(archiveDirectory, "archiveDirectory").resolves({ file: "test.zip", stream: Readable.from(["mock-data"]), @@ -79,6 +80,7 @@ describe("run deploy", () => { await deploy(context, options, payload); + expect(upsertBucketStub.calledOnce).to.be.true; expect(ensureRepoStub.calledOnce).to.be.true; expect(submitBuildStub.calledOnce).to.be.true; expect(createServiceStub.calledOnce).to.be.true; @@ -122,6 +124,7 @@ describe("run deploy", () => { await deploy(context, options, payload); + expect(upsertBucketStub.calledOnce).to.be.true; expect(ensureRepoStub.calledOnce).to.be.true; expect(updateServiceStub.calledOnce).to.be.true; expect(createServiceStub.notCalled).to.be.true; diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 0fbc6369879..8eae7eb85ab 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -252,7 +252,10 @@ export async function deploy(context: Context, options: Options, payload: Payloa if (existing) { try { - existing = await runv2.getService(projectId, region, service.serviceId); + const fresh = await runv2.getService(projectId, region, service.serviceId); + if (fresh) { + existing = fresh; + } } catch (err: unknown) { if ((err as { status?: number })?.status !== 404) { logger.debug(`Failed to fetch latest service state for ${service.serviceId}:`, err); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index f7f75485ed8..51eccd983ae 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -102,17 +102,17 @@ describe("run prepare", () => { expect(payload.run?.services?.[0].baseImageUri).to.equal("some-uri"); }); - it("should override existing base image if specified in firebase.json", async () => { + it("should override existing base image if --base-image flag is specified", async () => { const payload: Payload = {}; const context: Context = {}; const options = { project: "project", + baseImage: "override-uri", config: { get: () => ({ serviceId: "mysvc", region: "us-central1", source: ".", - baseImageUri: "override-uri", }), path: (p: string) => p, }, @@ -236,7 +236,7 @@ describe("run prepare", () => { await expect(prepare(context, options, payload)).to.be.rejectedWith( FirebaseError, - "No Cloud Run services in firebase.json match filter 'run:non-existent'.", + "Cloud Run service(s) 'non-existent' not found in firebase.json.", ); }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 441c6a6e749..335d52d7ef8 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -66,12 +66,10 @@ function filterTargetConfigs( /** * Resolves ABIU base image URI with precedence: - * 1. CLI flags (`--clear-runtime` vs `--runtime`) - * 2. `firebase.json` configuration - * 3. Existing Cloud Run service revision template (stickiness) + * 1. CLI flags (`--clear-runtime` / `--clear-base-image` vs `--runtime` / `--base-image`) + * 2. Existing Cloud Run service revision template (gcloud-style stickiness) */ function resolveBaseImage( - config: RunConfig, existingService: runv2.Service | undefined, runtimeOpt?: string, clearOpt?: boolean, @@ -82,12 +80,6 @@ function resolveBaseImage( if (runtimeOpt !== undefined) { return { baseImageUri: runtimeOpt, clearBaseImage: false }; } - if (config.baseImageUri || config.baseImage || config.runtime) { - return { - baseImageUri: config.baseImageUri || config.baseImage || config.runtime, - clearBaseImage: false, - }; - } if (existingService?.template?.containers?.[0]?.baseImageUri) { return { baseImageUri: existingService.template.containers[0].baseImageUri, @@ -143,7 +135,6 @@ export async function prepare(context: Context, options: Options, payload: Paylo } const { baseImageUri, clearBaseImage } = resolveBaseImage( - config, existingService, runtimeOpt, clearOpt, diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 1404b3d6acd..1413c148249 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -375,9 +375,6 @@ export interface RunSingle extends Deployable { output?: string; outputDir?: string; ignore?: string[]; - baseImageUri?: string; - baseImage?: string; - runtime?: string; serviceAccount?: string; } From 899046a55de03800531dbf85de980c824d338e42 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 11:26:22 -0400 Subject: [PATCH 13/25] Implement a placeholder init service --- src/apphosting/secrets/index.ts | 23 ++ src/commands/init.ts | 183 ++++++----- src/deploy/run/deploy.spec.ts | 8 +- src/deploy/run/deploy.ts | 519 ++++++++++++++++++-------------- src/gcp/runv2.ts | 10 +- src/init/features/run.spec.ts | 52 +++- src/init/features/run.ts | 55 +++- 7 files changed, 545 insertions(+), 305 deletions(-) diff --git a/src/apphosting/secrets/index.ts b/src/apphosting/secrets/index.ts index 73ecd2dbd32..2918459a3c3 100644 --- a/src/apphosting/secrets/index.ts +++ b/src/apphosting/secrets/index.ts @@ -458,6 +458,29 @@ export function getSecretNameParts(secret: string): [string, string] { return [name, version]; } +/** + * Formats a secret reference into a canonical GCP Secret Manager resource path and version. + * Handles "mySecret", "mySecret@2", "projects/.../secrets/mySecret", and "projects/.../secrets/mySecret/versions/2". + */ +export function toCanonicalSecretResourcePath( + rawSecret: string, + projectId: string, +): { + secretPath: string; + version: string; +} { + let [secretName, version] = getSecretNameParts(rawSecret); + if (secretName.includes("/versions/")) { + const parts = secretName.split("/versions/"); + secretName = parts[0]; + version = parts[1] || version; + } + const secretPath = secretName.startsWith("projects/") + ? secretName + : `projects/${projectId}/secrets/${secretName}`; + return { secretPath, version }; +} + /** * Action for the apphosting:secrets:set command. */ diff --git a/src/commands/init.ts b/src/commands/init.ts index 67c83d58c7d..48f9bd7d20c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -170,6 +170,102 @@ export const command = new Command("init [feature]") * @param feature Feature to init (e.g., hosting, functions) * @param options Command options */ +/** + * Collects warning messages based on project initialization directory location. + */ +function getDirectoryWarnings( + cwd: string, + userHomeDir: string, + existingConfig: Config | null, +): string[] { + const warnings: string[] = []; + if (isOutside(userHomeDir, cwd)) { + warnings.push("You are currently outside your home directory"); + } + if (cwd === userHomeDir) { + warnings.push("You are initializing your home directory as a Firebase project directory"); + } + if (existingConfig) { + warnings.push("You are initializing within an existing Firebase project directory"); + } + return warnings; +} + +/** + * Prompts the user to select Firebase features, or validates a CLI feature argument. + */ +async function promptFeatureSelection( + feature?: string, +): Promise<{ features: string[]; isFeatureArg: boolean }> { + if (feature) { + return { features: [feature], isFeatureArg: true }; + } + + const features = await checkbox({ + message: + "Which Firebase features do you want to set up for this directory? " + + "Press Space to select features, then Enter to confirm your choices.", + choices: choices.filter((c) => !c.hidden), + validate: (selected) => { + if (selected.length === 0) { + return ( + "Must select at least one feature. Use " + + clc.bold(clc.underline("SPACEBAR")) + + " to select features, or specify a feature by running " + + clc.bold("firebase init [feature_name]") + ); + } + return true; + }, + }); + + if (!features || features.length === 0) { + throw new FirebaseError( + "Must select at least one feature. Use " + + clc.bold(clc.underline("SPACEBAR")) + + " to select features, or specify a feature by running " + + clc.bold("firebase init [feature_name]"), + ); + } + + return { features, isFeatureArg: false }; +} + +/** + * Normalizes and orders selected features with project, account, and deduplication rules. + */ +function normalizeSelectedFeatures(selectedFeatures: string[]): string[] { + const features = [...selectedFeatures]; + + // Always set up project + features.unshift("project"); + + // If there is more than one account, add an account choice phase + const allAccounts = getAllAccounts(); + if (allAccounts.length > 1) { + features.unshift("account"); + } + + // Deduplicate sub-features if parent features are selected + let normalized = features; + if (normalized.includes("hosting") && normalized.includes("hosting:github")) { + normalized = normalized.filter((f) => f !== "hosting:github"); + } + if (normalized.includes("dataconnect") && normalized.includes("dataconnect:sdk")) { + normalized = normalized.filter((f) => f !== "dataconnect:sdk"); + } + + // Always prompt for agent skills at the end of init + normalized.push("agentSkills"); + + return normalized; +} + +/** + * The "firebase init" command flow. + * @param feature Feature to init (e.g., hosting, functions) + * @param options Command options + */ export async function initAction(feature: string, options: Options): Promise { if (feature && !featureNames.includes(feature)) { return utils.reject( @@ -181,24 +277,12 @@ export async function initAction(feature: string, options: Options): Promise({ - message: - "Which Firebase features do you want to set up for this directory? " + - "Press Space to select features, then Enter to confirm your choices.", - choices: choices.filter((c) => !c.hidden), - validate: (choices) => { - if (choices.length === 0) { - return ( - "Must select at least one feature. Use " + - clc.bold(clc.underline("SPACEBAR")) + - " to select features, or specify a feature by running " + - clc.bold("firebase init [feature_name]") - ); - } - return true; - }, - }); - } - if (!setup.features || setup.features?.length === 0) { - throw new FirebaseError( - "Must select at least one feature. Use " + - clc.bold(clc.underline("SPACEBAR")) + - " to select features, or specify a feature by running " + - clc.bold("firebase init [feature_name]"), - ); - } - - // Always set up project - setup.features.unshift("project"); - - // If there is more than one account, add an account choice phase - const allAccounts = getAllAccounts(); - if (allAccounts.length > 1) { - setup.features.unshift("account"); - } - - // "hosting:github" is a part of "hosting", so if both are selected, "hosting:github" is ignored. - if (setup.features.includes("hosting") && setup.features.includes("hosting:github")) { - setup.features = setup.features.filter((f) => f !== "hosting:github"); - } - // "dataconnect:sdk" is a part of "dataconnect", so if both are selected, "dataconnect:sdk" is ignored. - if (setup.features.includes("dataconnect") && setup.features.includes("dataconnect:sdk")) { - setup.features = setup.features.filter((f) => f !== "dataconnect:sdk"); - } - - // Always prompt for agent skills at the end of init - setup.features.push("agentSkills"); - await init(setup, config, options); await postInitSaves(setup, config); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 127d7c82f42..b0c1163b01c 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -212,7 +212,13 @@ describe("run deploy", () => { runv2.ServiceOutputFields >; - expect(updateServiceStub.args[0][1]).to.deep.equal(["template", "scaling"]); + expect(updateServiceStub.args[0][1]).to.deep.equal(["template", "traffic", "scaling"]); + expect(updatedService.traffic).to.deep.equal([ + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", + percent: 100, + }, + ]); expect(updatedService.scaling?.minInstanceCount).to.equal(1); expect(updatedService.scaling?.maxInstanceCount).to.equal(10); expect(updatedService.template.scaling).to.be.undefined; diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 8eae7eb85ab..b632bce9e60 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,4 +1,4 @@ -import { Context, Payload } from "./args"; +import { Context, Payload, RunServiceSpec } from "./args"; import { Options } from "../../options"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -7,34 +7,10 @@ import { archiveDirectory } from "../../archiveDirectory"; import { getProjectNumber } from "../../getProjectNumber"; import { EnvMap } from "../../apphosting/yaml"; import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting/config"; -import { getSecretNameParts } from "../../apphosting/secrets"; +import { toCanonicalSecretResourcePath } from "../../apphosting/secrets"; import { EnvVar } from "../../gcp/k8s"; import { logger } from "../../logger"; -/** - * Formats a secret reference into a canonical GCP Secret Manager resource path. - * If already a full resource path (projects/.../secrets/...), returns it directly. - * Otherwise, prepends projects/${projectId}/secrets/. - */ -export function formatSecretResourcePath( - rawSecret: string, - projectId: string, -): { - secretPath: string; - version: string; -} { - let [secretName, version] = getSecretNameParts(rawSecret); - if (secretName.includes("/versions/")) { - const parts = secretName.split("/versions/"); - secretName = parts[0]; - version = parts[1] || version; - } - const secretPath = secretName.startsWith("projects/") - ? secretName - : `projects/${projectId}/secrets/${secretName}`; - return { secretPath, version }; -} - /** * Applies runtime environment variables and Secret Manager references to a container. */ @@ -48,7 +24,7 @@ function applyContainerEnv( if (val.value !== undefined) { newEnv.push({ name: key, value: val.value }); } else if (val.secret !== undefined) { - const { secretPath, version } = formatSecretResourcePath(String(val.secret), projectId); + const { secretPath, version } = toCanonicalSecretResourcePath(String(val.secret), projectId); newEnv.push({ name: key, valueSource: { @@ -144,224 +120,319 @@ function applyAppHostingConfig( } /** - * Deploys Cloud Run services by building container images via Cloud Build - * and creating or updating services in Cloud Run Admin API v2. + * 1. Packages local source and uploads to the regional staging bucket. */ -export async function deploy(context: Context, options: Options, payload: Payload): Promise { - if (!payload.run || !payload.run.services || payload.run.services.length === 0) { - return; - } +async function packageAndUploadSource( + projectId: string, + region: string, + service: RunServiceSpec, + options: Options, +): Promise { + const archive = await archiveDirectory(service.source, { + ignore: service.ignore, + supportGitIgnore: true, + }); + + const projectNumber = await getProjectNumber(options); + const baseName = `firebase-run-src-${projectNumber}-${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 uploadRes = await gcs.uploadObject( + { + file: archive.file, + stream: archive.stream, + }, + bucketName, + ); + + return { + bucket: uploadRes.bucket, + object: uploadRes.object, + generation: uploadRes.generation || undefined, + }; +} - const projectId = context.projectId!; +/** + * 2. Prepares build-time environment variables and custom build scripts. + */ +function prepareBuildEnvironment(service: RunServiceSpec): Record { + const appHostingConfig = service.appHostingConfig; + const envRecord = appHostingConfig?.env || {}; + const { build: buildEnvMap } = splitEnvVars(envRecord); + const buildEnv: Record = {}; - for (const service of payload.run.services) { - const region = service.region; + for (const [key, val] of Object.entries(buildEnvMap)) { + if (val.value !== undefined) { + buildEnv[key] = val.value; + } + } - try { - // 1. Ensure Artifact Registry repository exists - await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); + if (appHostingConfig?.scripts?.build || appHostingConfig?.buildConfig?.buildCommand) { + buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig.scripts?.build || appHostingConfig.buildConfig?.buildCommand)!; + } - // 2. Package source directory - const archive = await archiveDirectory(service.source, { - ignore: service.ignore, - supportGitIgnore: true, - }); + return buildEnv; +} - // 3. Upload to regional staging bucket - const projectNumber = await getProjectNumber(options); - const baseName = `firebase-run-src-${projectNumber}-${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, - }, - }, - ], - }, - }, - }); +/** + * 3. Submits the container build to Cloud Build and resolves the base image URI. + */ +async function submitServiceBuild( + projectId: string, + region: string, + service: RunServiceSpec, + imageUri: string, +): Promise<{ resolvedBaseImageUri?: string; hasAbiu: boolean }> { + const hasAbiu = !service.clearBaseImage && !!service.baseImageUri; + const buildEnv = prepareBuildEnvironment(service); + + const build: runv2.Build = { + storageSource: service.storageSource!, + imageUri, + buildpackBuild: { + enableAutomaticUpdates: hasAbiu, + environmentVariables: buildEnv, + ...(hasAbiu ? { baseImage: service.baseImageUri } : {}), + }, + }; + + const buildRes = await runv2.submitBuild(projectId, region, build); + if (buildRes.baseImageWarning) { + logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); + } - const uploadRes = await gcs.uploadObject( - { - file: archive.file, - stream: archive.stream, - }, - bucketName, - ); + return { + hasAbiu, + resolvedBaseImageUri: hasAbiu ? buildRes.baseImageUri || service.baseImageUri : undefined, + }; +} - service.storageSource = { - bucket: uploadRes.bucket, - object: uploadRes.object, - generation: uploadRes.generation || undefined, - }; - - // 4. Construct image URI - const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; - - const appHostingConfig = service.appHostingConfig; - const envRecord = appHostingConfig?.env || {}; - const { build: buildEnvMap, runtime: runtimeEnvMap } = splitEnvVars(envRecord); - const buildEnv: Record = {}; - for (const [key, val] of Object.entries(buildEnvMap)) { - if (val.value !== undefined) { - buildEnv[key] = val.value; - } - } +/** + * 4. Reconciles existing service revision template with new image, labels, and apphosting configs. + */ +function buildUpdatedServiceDefinition( + existing: runv2.Service, + service: RunServiceSpec, + projectId: string, + imageUri: string, + hasAbiu: boolean, + resolvedBaseImageUri?: string, + message?: string, +): { newService: Omit; updateMask: string[] } { + const template = JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate; + delete template.revision; + delete template.scaling; + delete (template as any).client; + delete (template as any).clientVersion; + + const newService: Omit = { + name: existing.name, + template, + }; + + if (service.serviceAccount) { + newService.template.serviceAccount = service.serviceAccount; + } - if (appHostingConfig?.scripts?.build || appHostingConfig?.buildConfig?.buildCommand) { - buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig.scripts?.build || appHostingConfig.buildConfig?.buildCommand)!; - } + if (!newService.template.containers) { + newService.template.containers = []; + } + let container = newService.template.containers.find((c) => c.name === service.serviceId); + if (!container) { + if (newService.template.containers.length === 0) { + container = { name: service.serviceId, image: imageUri }; + newService.template.containers.push(container); + } else { + container = newService.template.containers[0]; + } + } + container.image = imageUri; - const hasAbiu = !service.clearBaseImage && !!service.baseImageUri; + if (service.clearBaseImage || !hasAbiu) { + delete container.baseImageUri; + } else if (resolvedBaseImageUri) { + container.baseImageUri = resolvedBaseImageUri; + } - // 5. Submit build via Cloud Run Build API - const build: runv2.Build = { - storageSource: service.storageSource, - imageUri, - buildpackBuild: { - enableAutomaticUpdates: hasAbiu, - environmentVariables: buildEnv, - ...(hasAbiu ? { baseImage: service.baseImageUri } : {}), - }, - }; + if (!newService.template.labels) newService.template.labels = {}; + newService.template.labels["client.knative.dev/nonce"] = Math.random() + .toString(36) + .substring(2, 12); + if (!newService.template.annotations) newService.template.annotations = {}; + newService.template.annotations["client.knative.dev/user-image"] = imageUri; + newService.template.annotations["run.googleapis.com/deployed-at"] = new Date().toISOString(); + if (message) { + newService.template.annotations["run.googleapis.com/description"] = message; + } - const buildRes = await runv2.submitBuild(projectId, region, build); - const resolvedBaseImageUri = hasAbiu - ? buildRes.baseImageUri || service.baseImageUri - : undefined; + const runtimeEnvMap = splitEnvVars(service.appHostingConfig?.env || {}).runtime; + applyAppHostingConfig(projectId, newService, runtimeEnvMap, service.appHostingConfig?.runConfig, service.serviceId); - if (buildRes.baseImageWarning) { - logger.warn(`Cloud Run ABIU warning: ${buildRes.baseImageWarning}`); - } + newService.traffic = [ + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", + percent: 100, + }, + ]; - // 6. Deploy via POST (new service) or PATCH (existing service) - let existing = service.existingService; - let newService: Omit; - - if (existing) { - try { - const fresh = await runv2.getService(projectId, region, service.serviceId); - if (fresh) { - existing = fresh; - } - } catch (err: unknown) { - if ((err as { status?: number })?.status !== 404) { - logger.debug(`Failed to fetch latest service state for ${service.serviceId}:`, err); - } - } - const template = JSON.parse(JSON.stringify(existing.template)) as runv2.RevisionTemplate; - delete template.revision; - delete template.scaling; - delete (template as any).client; - delete (template as any).clientVersion; - - newService = { - name: existing.name, - template, - }; - - if (service.serviceAccount) { - newService.template.serviceAccount = service.serviceAccount; - } + const updateMask = ["template", "traffic"]; + if (newService.scaling) { + updateMask.push("scaling"); + } - // Mutate template with new image for the matching container - if (!newService.template.containers) { - newService.template.containers = []; - } - let container = newService.template.containers.find((c) => c.name === service.serviceId); - if (!container) { - if (newService.template.containers.length === 0) { - container = { name: service.serviceId, image: imageUri }; - newService.template.containers.push(container); - } else { - container = newService.template.containers[0]; - } - } - container.image = imageUri; + return { newService, updateMask }; +} - // ABIU stickiness handling: only set baseImageUri if explicitly enabled - if (service.clearBaseImage || !hasAbiu) { - delete container.baseImageUri; - } else if (resolvedBaseImageUri) { - container.baseImageUri = resolvedBaseImageUri; - } +/** + * 5. Constructs a new Cloud Run service definition with public ingress. + */ +function buildNewServiceDefinition( + projectId: string, + region: string, + service: RunServiceSpec, + imageUri: string, + hasAbiu: boolean, + resolvedBaseImageUri?: string, + message?: string, +): Omit { + const newService: Omit = { + name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, + template: { + containers: [ + { + name: service.serviceId, + image: imageUri, + ...(!service.clearBaseImage && hasAbiu && resolvedBaseImageUri + ? { baseImageUri: resolvedBaseImageUri } + : {}), + }, + ], + annotations: message + ? { "run.googleapis.com/description": message } + : {}, + ...(service.serviceAccount ? { serviceAccount: service.serviceAccount } : {}), + }, + client: "cli-firebase", + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }; + + const runtimeEnvMap = splitEnvVars(service.appHostingConfig?.env || {}).runtime; + applyAppHostingConfig(projectId, newService, runtimeEnvMap, service.appHostingConfig?.runConfig, service.serviceId); + + return newService; +} - if (!newService.template.labels) newService.template.labels = {}; - newService.template.labels["client.knative.dev/nonce"] = Math.random() - .toString(36) - .substring(2, 12); - if (!newService.template.annotations) newService.template.annotations = {}; - newService.template.annotations["client.knative.dev/user-image"] = imageUri; - newService.template.annotations["run.googleapis.com/deployed-at"] = - new Date().toISOString(); - const revisionDescription = (service.message || options.message) as string | undefined; - if (revisionDescription) { - newService.template.annotations["run.googleapis.com/description"] = revisionDescription; +/** + * Deploys a single Cloud Run service from source. + */ +async function deployService( + context: Context, + options: Options, + service: RunServiceSpec, +): Promise { + const projectId = context.projectId!; + const region = service.region; + const message = (service.message || options.message) as string | undefined; + + try { + // 1. Ensure Artifact Registry repository exists + await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); + + // 2. Package source & upload to GCS staging bucket + service.storageSource = await packageAndUploadSource(projectId, region, service, options); + + // 3. Construct target image URI & submit Cloud Build + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; + const { hasAbiu, resolvedBaseImageUri } = await submitServiceBuild(projectId, region, service, imageUri); + + // 4. Create or update Cloud Run service + let existing = service.existingService; + if (existing) { + try { + const fresh = await runv2.getService(projectId, region, service.serviceId); + if (fresh) { + existing = fresh; } - - applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig, service.serviceId); - - const updateMask = ["template"]; - if (newService.scaling) { - updateMask.push("scaling"); + } catch (err: unknown) { + if ((err as { status?: number })?.status !== 404) { + logger.debug(`Failed to fetch latest service state for ${service.serviceId}:`, err); } - service.deployResponse = await runv2.updateService(newService, updateMask); - } else { - const revisionDescription = (service.message || options.message) as string | undefined; - newService = { - name: `projects/${projectId}/locations/${region}/services/${service.serviceId}`, - template: { - containers: [ - { - name: service.serviceId, - image: imageUri, - ...(!service.clearBaseImage && hasAbiu && resolvedBaseImageUri - ? { baseImageUri: resolvedBaseImageUri } - : {}), - }, - ], - annotations: revisionDescription - ? { "run.googleapis.com/description": revisionDescription } - : {}, - ...(service.serviceAccount ? { serviceAccount: service.serviceAccount } : {}), - }, - client: "cli-firebase", - invokerIamDisabled: true, - ingress: "INGRESS_TRAFFIC_ALL", - }; - - applyAppHostingConfig(projectId, newService, runtimeEnvMap, appHostingConfig?.runConfig, service.serviceId); - - service.deployResponse = await runv2.createService( - projectId, - region, - service.serviceId, - newService, - ); } - } catch (err) { - if (service.storageSource) { - try { - await gcs.deleteObject( - `/${service.storageSource.bucket}/${service.storageSource.object}`, - ); - } catch { - // ignore cleanup errors - } + + const { newService, updateMask } = buildUpdatedServiceDefinition( + existing, + service, + projectId, + imageUri, + hasAbiu, + resolvedBaseImageUri, + message, + ); + service.deployResponse = await runv2.updateService(newService, updateMask); + } else { + const newService = buildNewServiceDefinition( + projectId, + region, + service, + imageUri, + hasAbiu, + resolvedBaseImageUri, + message, + ); + service.deployResponse = await runv2.createService( + projectId, + region, + service.serviceId, + newService, + ); + } + } catch (err) { + if (service.storageSource) { + try { + await gcs.deleteObject( + `/${service.storageSource.bucket}/${service.storageSource.object}`, + ); + } catch { + // ignore cleanup errors } - throw err; } + throw err; + } +} + +/** + * Deploys Cloud Run services by building container images via Cloud Build + * and creating or updating services in Cloud Run Admin API v2. + */ +export async function deploy(context: Context, options: Options, payload: Payload): Promise { + const services = payload.run?.services; + if (!services || services.length === 0) { + return; + } + + for (const service of services) { + await deployService(context, options, service); } } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 401451b4aaf..30d9554e3cb 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -32,7 +32,7 @@ export interface Scaling { } export interface Container { - name: string; + name?: string; image: string; command?: string[]; args?: string[]; @@ -90,6 +90,13 @@ export interface BuildConfig { serviceAccount?: string; } +export interface TrafficTarget { + type?: string; + revision?: string; + percent?: number; + tag?: string; +} + // NOTE: This is a minmal copy of Cloud Run needed for our current API usage. // Add more as needed. // TODO: Can consider a helper where we have a second RecursiveKeysOf field for @@ -116,6 +123,7 @@ export interface Service { etag: string; template: RevisionTemplate; + traffic?: TrafficTarget[]; invokerIamDisabled?: boolean; ingress?: string; // Is this redundant with the Build API? diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index f2051fd0605..215517d6dbf 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -6,6 +6,7 @@ import * as fs from "fs"; import { Config } from "../../config"; import { Setup } from "../index"; import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; function createMockSetup(overrides: Partial = {}): Setup { return { @@ -57,9 +58,13 @@ describe("init features run", () => { describe("actuate", () => { let existsSyncStub: sinon.SinonStub; + let getServiceStub: sinon.SinonStub; + let createServiceStub: sinon.SinonStub; beforeEach(() => { existsSyncStub = sandbox.stub(fs, "existsSync"); + getServiceStub = sandbox.stub(runv2, "getService"); + createServiceStub = sandbox.stub(runv2, "createService"); }); it("should do nothing if featureInfo.run is not present", async () => { @@ -69,6 +74,7 @@ describe("init features run", () => { await runFeature.actuate(setup, config); expect(config.src.run).to.be.undefined; + expect(getServiceStub.notCalled).to.be.true; }); it("should throw FirebaseError if projectId is missing", async () => { @@ -90,7 +96,7 @@ describe("init features run", () => { ); }); - it("should scaffold configuration in firebase.json and write apphosting.yaml if not existing", async () => { + it("should create placeholder service with 0% traffic when service does not exist in GCP", async () => { const setup = createMockSetup({ projectId: "test-project", featureInfo: { @@ -107,15 +113,58 @@ describe("init features run", () => { const askWriteStub = sandbox.stub(config, "askWriteProjectFile").resolves(); existsSyncStub.returns(false); + const notFoundErr = new Error("Not Found") as any; + notFoundErr.status = 404; + getServiceStub.rejects(notFoundErr); + createServiceStub.resolves({ uri: "https://my-svc.a.run.app" }); await runFeature.actuate(setup, config); + expect(createServiceStub.calledOnce).to.be.true; + const createdService = createServiceStub.args[0][3] as runv2.Service; + expect(createdService.template.containers?.[0].image).to.equal( + "us-docker.pkg.dev/cloudrun/container/hello", + ); + expect(createdService.traffic).to.deep.equal([ + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", + percent: 0, + }, + ]); + expect(createdService.invokerIamDisabled).to.be.true; + expect(setup.instructions).to.include("Your Cloud Run service URL is: https://my-svc.a.run.app"); + const runConfigs = config.src.run as Array<{ serviceId: string }>; expect(runConfigs).to.be.an("array"); expect(runConfigs[0].serviceId).to.equal("my-svc"); expect(askWriteStub.calledOnce).to.be.true; }); + it("should not create service if service already exists in GCP", async () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + outputDir: ".run", + }, + }, + }); + const config = new Config({}, {}); + sandbox.stub(config, "writeProjectFile"); + existsSyncStub.returns(true); + getServiceStub.resolves({ uri: "https://existing-svc.a.run.app" }); + + await runFeature.actuate(setup, config); + + expect(createServiceStub.notCalled).to.be.true; + expect(setup.instructions).to.include( + "Your Cloud Run service URL is: https://existing-svc.a.run.app", + ); + }); + it("should append to existing run configs array in firebase.json", async () => { const setup = createMockSetup({ projectId: "test-project", @@ -136,6 +185,7 @@ describe("init features run", () => { ); sandbox.stub(config, "writeProjectFile"); existsSyncStub.returns(true); + getServiceStub.resolves({ uri: "https://second-svc.a.run.app" }); await runFeature.actuate(setup, config); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index c0d97899a59..b270fc9d656 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -80,8 +80,12 @@ export async function askQuestions(setup: Setup, config?: Config, options?: any) }; } +import * as runv2 from "../../gcp/runv2"; +import { logger } from "../../logger"; + /** - * Scaffolds Cloud Run configuration in firebase.json and creates placeholder apphosting.yaml template. + * Scaffolds Cloud Run configuration in firebase.json, creates placeholder Cloud Run service in GCP, + * and creates placeholder apphosting.yaml template. */ export async function actuate(setup: Setup, config: Config): Promise { const runInfo = setup.featureInfo?.run; @@ -97,7 +101,52 @@ export async function actuate(setup: Setup, config: Config): Promise { logBullet("Setting up Cloud Run configuration..."); - // Update firebase.json + // 1. Check or create placeholder Cloud Run service in GCP (0% traffic) + let serviceUrl: string | undefined; + try { + const existing = await runv2.getService(projectId, region, serviceId); + if (existing) { + serviceUrl = existing.uri; + logBullet(`Cloud Run service ${serviceId} already exists at ${serviceUrl}`); + } + } catch (err: unknown) { + if ((err as { status?: number })?.status === 404) { + logBullet(`Creating placeholder Cloud Run service ${serviceId} in ${region}...`); + try { + const placeholderService: Omit = { + template: { + containers: [ + { + image: "us-docker.pkg.dev/cloudrun/container/hello", + }, + ], + }, + traffic: [ + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", + percent: 0, + }, + ], + invokerIamDisabled: true, + }; + + const created = await runv2.createService(projectId, region, serviceId, placeholderService); + serviceUrl = created.uri; + logSuccess(`Reserved Cloud Run service URL: ${serviceUrl}`); + } catch (createErr: unknown) { + logger.debug(`Failed to create placeholder Cloud Run service ${serviceId}:`, createErr); + logBullet(`Note: Cloud Run service will be created on first deploy.`); + } + } else { + logger.debug(`Failed to query Cloud Run service ${serviceId}:`, err); + } + } + + if (serviceUrl && setup.instructions) { + setup.instructions.push(`Your Cloud Run service URL is: ${serviceUrl}`); + } + + // 2. Update firebase.json const runConfig: RunSingle = { serviceId, region, @@ -109,7 +158,7 @@ export async function actuate(setup: Setup, config: Config): Promise { upsertRunConfig(runConfig, config); config.writeProjectFile("firebase.json", config.src); - // Create placeholder apphosting.yaml + // 3. Create placeholder apphosting.yaml const projectDir = config.projectDir || "."; const absRootDir = path.join(projectDir, rootDir); const apphostingYamlPath = path.join(absRootDir, "apphosting.yaml"); From 28af03f8e37eaa6cffd377eb73e9fe6748a5e55f Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 12:05:09 -0400 Subject: [PATCH 14/25] Update some CLI flags --- src/commands/init.ts | 6 ++++++ src/init/features/run.spec.ts | 6 ------ src/init/features/run.ts | 7 +------ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 48f9bd7d20c..31f963e9a86 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -162,6 +162,12 @@ ${[...featureNames] export const command = new Command("init [feature]") .description("interactively configure the current directory as a Firebase project directory") + .option("-s, --service ", "Cloud Run service ID") + .option("--service-id ", "Cloud Run service ID") + .option("--primary-region ", "primary region for Cloud Run") + .option("--region ", "region for Cloud Run") + .option("--root-dir ", "root directory for source code") + .option("--output-dir ", "output directory for built artifacts") .help(HELP) .action(initAction); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 215517d6dbf..da9cb2f854c 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -125,12 +125,6 @@ describe("init features run", () => { expect(createdService.template.containers?.[0].image).to.equal( "us-docker.pkg.dev/cloudrun/container/hello", ); - expect(createdService.traffic).to.deep.equal([ - { - type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", - percent: 0, - }, - ]); expect(createdService.invokerIamDisabled).to.be.true; expect(setup.instructions).to.include("Your Cloud Run service URL is: https://my-svc.a.run.app"); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index b270fc9d656..f377044013d 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -114,6 +114,7 @@ export async function actuate(setup: Setup, config: Config): Promise { logBullet(`Creating placeholder Cloud Run service ${serviceId} in ${region}...`); try { const placeholderService: Omit = { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, template: { containers: [ { @@ -121,12 +122,6 @@ export async function actuate(setup: Setup, config: Config): Promise { }, ], }, - traffic: [ - { - type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", - percent: 0, - }, - ], invokerIamDisabled: true, }; From 1d43d64fc8fa75469423a2f38133f871ea77ad8c Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 12:16:51 -0400 Subject: [PATCH 15/25] use polling instead of home-brewed manual waits when checking for Cloud Builds --- src/apphosting/config.ts | 9 +++++ src/deploy/run/args.ts | 12 ++++++ src/deploy/run/deploy.ts | 16 ++++---- src/deploy/run/prepare.ts | 28 ++++++++------ src/gcp/runv2.spec.ts | 32 ++++++++++++++-- src/gcp/runv2.ts | 80 +++++++++++++++------------------------ src/init/features/run.ts | 21 +++++----- 7 files changed, 113 insertions(+), 85 deletions(-) diff --git a/src/apphosting/config.ts b/src/apphosting/config.ts index c0c12172585..a6b39b72b3a 100644 --- a/src/apphosting/config.ts +++ b/src/apphosting/config.ts @@ -33,6 +33,15 @@ export interface AppHostingRunConfig { memoryMiB?: number; minInstances?: number; maxInstances?: number; + vpcAccess?: { + connector?: string; + egress?: "ALL_TRAFFIC" | "PRIVATE_RANGES_ONLY"; + networkInterfaces?: Array<{ + network?: string; + subnetwork?: string; + tags?: string[]; + }>; + }; } export type RunConfig = AppHostingRunConfig; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 83180b66c04..94ba09eef26 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,6 +1,7 @@ import { RunSingle } from "../../firebaseConfig"; import { AppHostingYamlConfig } from "../../apphosting/yaml"; import * as runv2 from "../../gcp/runv2"; +import { Options } from "../../options"; export const DEFAULT_RUN_IGNORE = [ "node_modules", @@ -14,6 +15,16 @@ export const DEFAULT_RUN_IGNORE = [ "**/*.secret.local", ]; +export interface RunDeployOptions extends Options { + runtime?: string; + baseImage?: string; + clearRuntime?: boolean; + clearBaseImage?: boolean; + primaryRegion?: string; + region?: string; + serviceAccount?: string; +} + export interface RunServiceConfig extends RunSingle { "primary-region"?: string; rootDir?: string; @@ -47,3 +58,4 @@ export interface Payload { export interface Context { projectId?: string; } + diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index b632bce9e60..f2062725370 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,5 +1,4 @@ -import { Context, Payload, RunServiceSpec } from "./args"; -import { Options } from "../../options"; +import { Context, Payload, RunDeployOptions, RunServiceSpec } from "./args"; import * as runv2 from "../../gcp/runv2"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as gcs from "../../gcp/storage"; @@ -88,8 +87,8 @@ function applyServiceScaling( if (runConfig.concurrency !== undefined) { service.template.maxInstanceRequestConcurrency = runConfig.concurrency; } - if ((runConfig as any).vpcAccess) { - service.template.vpcAccess = (runConfig as any).vpcAccess; + if (runConfig.vpcAccess) { + service.template.vpcAccess = runConfig.vpcAccess; } } @@ -126,7 +125,7 @@ async function packageAndUploadSource( projectId: string, region: string, service: RunServiceSpec, - options: Options, + options: RunDeployOptions, ): Promise { const archive = await archiveDirectory(service.source, { ignore: service.ignore, @@ -349,7 +348,7 @@ function buildNewServiceDefinition( */ async function deployService( context: Context, - options: Options, + options: RunDeployOptions, service: RunServiceSpec, ): Promise { const projectId = context.projectId!; @@ -364,7 +363,8 @@ async function deployService( service.storageSource = await packageAndUploadSource(projectId, region, service, options); // 3. Construct target image URI & submit Cloud Build - const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:latest`; + const imageTag = `${Date.now()}`; + const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${service.serviceId}:${imageTag}`; const { hasAbiu, resolvedBaseImageUri } = await submitServiceBuild(projectId, region, service, imageUri); // 4. Create or update Cloud Run service @@ -426,7 +426,7 @@ async function deployService( * Deploys Cloud Run services by building container images via Cloud Build * and creating or updating services in Cloud Run Admin API v2. */ -export async function deploy(context: Context, options: Options, payload: Payload): Promise { +export async function deploy(context: Context, options: RunDeployOptions, payload: Payload): Promise { const services = payload.run?.services; if (!services || services.length === 0) { return; diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 335d52d7ef8..035ca1392b5 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,22 +1,28 @@ import { needProjectId } from "../../projectUtils"; -import { Options } from "../../options"; import { prereqs } from "./prereqs"; import * as path from "path"; import * as runv2 from "../../gcp/runv2"; import { fileExistsSync } from "../../fsutils"; import { AppHostingYamlConfig } from "../../apphosting/yaml"; import { FirebaseError } from "../../error"; -import { Context, DEFAULT_RUN_IGNORE, Payload, RunConfig, RunServiceSpec } from "./args"; +import { + Context, + DEFAULT_RUN_IGNORE, + Payload, + RunConfig, + RunDeployOptions, + RunServiceSpec, +} from "./args"; /** * Validates CLI flags to ensure incompatible options are not specified simultaneously. */ -function validateCliFlags(options: Options): { +function validateCliFlags(options: RunDeployOptions): { runtimeOpt?: string; clearOpt: boolean; } { - const runtimeOpt = ((options as any).runtime || (options as any).baseImage) as string | undefined; - const clearOpt = !!((options as any).clearRuntime || (options as any).clearBaseImage); + const runtimeOpt = options.runtime || options.baseImage; + const clearOpt = !!(options.clearRuntime || options.clearBaseImage); if (runtimeOpt !== undefined && runtimeOpt !== "" && clearOpt) { throw new FirebaseError( @@ -93,7 +99,7 @@ function resolveBaseImage( * Prepares Cloud Run deployment by validating configurations, filtering targeted services, * fetching existing services, resolving base images and App Hosting configurations. */ -export async function prepare(context: Context, options: Options, payload: Payload): Promise { +export async function prepare(context: Context, options: RunDeployOptions, payload: Payload): Promise { const projectId = needProjectId(options); context.projectId = projectId; @@ -118,8 +124,8 @@ export async function prepare(context: Context, options: Options, payload: Paylo } const region = - ((options as any).primaryRegion as string | undefined) || - ((options as any).region as string | undefined) || + options.primaryRegion || + options.region || process.env.FIREBASE_RUN_REGION || config.region || config["primary-region"] || @@ -140,9 +146,7 @@ export async function prepare(context: Context, options: Options, payload: Paylo clearOpt, ); - const sourceDir = options.config - ? options.config.path(config.source || config.rootDir || ".") - : process.cwd(); + const sourceDir = options.config.path(config.source || config.rootDir || "."); const yamlPath = path.join(sourceDir, "apphosting.yaml"); let appHostingConfig: AppHostingYamlConfig | undefined; if (fileExistsSync(yamlPath)) { @@ -159,7 +163,7 @@ export async function prepare(context: Context, options: Options, payload: Paylo clearBaseImage, appHostingConfig, message: options.message as string | undefined, - serviceAccount: ((options as any).serviceAccount as string | undefined) || config.serviceAccount, + serviceAccount: options.serviceAccount || config.serviceAccount, }); } } diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 6dc8d19a452..85bdd09510c 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -586,13 +586,12 @@ describe("runv2", () => { describe("submitBuild", () => { let sandbox: sinon.SinonSandbox; let postStub: sinon.SinonStub; - let getStub: sinon.SinonStub; + let pollStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); postStub = sandbox.stub(Client.prototype, "post"); - getStub = sandbox.stub(Client.prototype, "get"); - getStub.resolves({ status: 200, body: { status: "SUCCESS" } }); + pollStub = sandbox.stub(operationPoller, "pollOperation").resolves({ status: "SUCCESS" }); }); afterEach(() => { @@ -620,6 +619,7 @@ describe("runv2", () => { `/projects/${PROJECT_ID}/locations/${LOCATION}/builds:submit`, build, ); + expect(pollStub).to.have.been.calledOnce; expect(res.baseImageUri).to.equal("gcr.io/base:latest"); expect(res.baseImageWarning).to.equal("warning"); }); @@ -647,6 +647,7 @@ describe("runv2", () => { const res = await runv2.submitBuild(PROJECT_ID, LOCATION, build); expect(res.baseImageUri).to.equal("gcr.io/base:latest"); + expect(pollStub).to.have.been.calledOnce; }); it("should throw FirebaseError on non-200 status", async () => { @@ -662,6 +663,31 @@ describe("runv2", () => { "Failed to submit build: 400", ); }); + + it("should throw FirebaseError when build status is not SUCCESS", async () => { + postStub.resolves({ + status: 200, + body: { + buildOperation: { + metadata: { + build: { id: "build-123" }, + }, + }, + }, + }); + pollStub.resolves({ status: "FAILURE", statusDetail: "Buildpack compile error" }); + + const build: runv2.Build = { + imageUri: "us-central1-docker.pkg.dev/proj/repo/service:123", + storageSource: { bucket: "bucket", object: "obj.zip" }, + buildpackBuild: {}, + }; + + await expect(runv2.submitBuild(PROJECT_ID, LOCATION, build)).to.be.rejectedWith( + FirebaseError, + /Cloud Build failed with status FAILURE/, + ); + }); }); describe("updateService", () => { diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 30d9554e3cb..5b7b3cbcb38 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -203,62 +203,42 @@ export async function submitBuild( }); } const op = res.body.buildOperation; - const opName = typeof op === "string" ? op : op?.name || ""; - const rawId = - opName - .split("/") - .pop() - ?.replace(/^build-/, "") || ""; - const buildId = (op as any)?.metadata?.build?.id || rawId; + const buildId = + (typeof op === "object" && op?.metadata?.build?.id) || + (typeof op === "string" ? op.split("/").pop()?.replace(/^build-/, "") : ""); if (buildId) { - const cloudbuildClient = new Client({ - urlPrefix: cloudbuildOrigin(), - auth: true, + const buildResult = await pollOperation<{ + status: string; + statusDetail?: string; + logUrl?: string; + }>({ + pollerName: "Cloud Build Poller", + apiOrigin: cloudbuildOrigin(), apiVersion: "v1", - }); - const startTime = Date.now(); - const timeoutMs = 15 * 60 * 1000; - let buildSuccess = false; - const logUrl = `https://console.cloud.google.com/cloud-build/builds;region=${location}/${buildId}?project=${projectId}`; - while (Date.now() - startTime < timeoutMs) { - try { - const buildStatusRes = await cloudbuildClient.get<{ - status: string; - statusDetail?: string; - logUrl?: string; - }>(`/projects/${projectId}/locations/${location}/builds/${buildId}`); - const status = buildStatusRes.body?.status; - if (status === "SUCCESS") { - logger.info(`[run:submitBuild] Cloud Build ${buildId} completed with SUCCESS.`); - buildSuccess = true; - break; - } - if ( + operationResourceName: `projects/${projectId}/locations/${location}/builds/${buildId}`, + masterTimeout: 15 * 60 * 1000, + backoff: 2000, + maxBackoff: 10000, + doneFn: (buildRes: any) => { + const status = buildRes?.status; + return ( + status === "SUCCESS" || status === "FAILURE" || status === "INTERNAL_ERROR" || status === "TIMEOUT" || status === "CANCELLED" - ) { - const detail = buildStatusRes.body?.statusDetail ? `: ${buildStatusRes.body.statusDetail}` : ""; - const consoleLink = buildStatusRes.body?.logUrl || logUrl; - throw new FirebaseError( - `Cloud Build failed with status ${status}${detail}\nView Cloud Build logs at: ${consoleLink}`, - ); - } - } catch (err: any) { - if (err instanceof FirebaseError && err.message.includes("Cloud Build failed")) { - throw err; - } - // Tolerate 404 Not Found during initial propagation / eventual consistency lag - if (err.status && err.status >= 400 && err.status < 500 && err.status !== 404) { - throw err; - } - logger.debug(`[run:submitBuild] Polling retry on transient error: ${err.message}`); - } - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - if (!buildSuccess) { - throw new FirebaseError(`Cloud Build ${buildId} timed out after 15 minutes. View logs at: ${logUrl}`, { exit: 1 }); + ); + }, + }); + + if (buildResult.status !== "SUCCESS") { + const detail = buildResult.statusDetail ? `: ${buildResult.statusDetail}` : ""; + const consoleLink = + buildResult.logUrl || + `https://console.cloud.google.com/cloud-build/builds;region=${location}/${buildId}?project=${projectId}`; + throw new FirebaseError( + `Cloud Build failed with status ${buildResult.status}${detail}\nView Cloud Build logs at: ${consoleLink}`, + ); } } return { diff --git a/src/init/features/run.ts b/src/init/features/run.ts index f377044013d..fca2992bb38 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -22,7 +22,7 @@ export interface RunInfo { export async function askQuestions(setup: Setup, config?: Config, options?: any): Promise { const projectId = setup.projectId; if (!projectId) { - throw new FirebaseError("Project ID must be set before initializing Cloud Run."); + throw new FirebaseError("Project ID must be set before initializing Cloud Run.", { exit: 1 }); } logBullet("Configuring Cloud Run..."); @@ -33,16 +33,13 @@ export async function askQuestions(setup: Setup, config?: Config, options?: any) path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-") || "my-service"; - const serviceId = options?.service || options?.serviceId || (await input({ - message: "What should be the ID of your Cloud Run service?", - default: defaultServiceId, - validate: (val: string) => { - if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(val)) { - return "Service ID must be lowercase alphanumeric and hyphens, max 63 characters."; - } - return true; - }, - })); + const serviceId = + options?.service || + options?.serviceId || + (await input({ + message: "What should be the ID of your Cloud Run service?", + default: defaultServiceId, + })); const defaultRegion = options?.primaryRegion || @@ -94,7 +91,7 @@ export async function actuate(setup: Setup, config: Config): Promise { } const projectId = setup.projectId; if (!projectId) { - throw new FirebaseError("Project ID must be set before initializing Cloud Run."); + throw new FirebaseError("Project ID must be set before initializing Cloud Run.", { exit: 1 }); } const { serviceId, region, rootDir, outputDir } = runInfo; From 268b51d150ddf21e93e5724c459070a0db3197fd Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 12:22:05 -0400 Subject: [PATCH 16/25] fix polling --- src/gcp/runv2.spec.ts | 14 ++++++++++++-- src/gcp/runv2.ts | 18 +++++++++--------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 85bdd09510c..5c08ee5b936 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -591,7 +591,12 @@ describe("runv2", () => { beforeEach(() => { sandbox = sinon.createSandbox(); postStub = sandbox.stub(Client.prototype, "post"); - pollStub = sandbox.stub(operationPoller, "pollOperation").resolves({ status: "SUCCESS" }); + pollStub = sandbox.stub(operationPoller, "pollOperation").callsFake(async (opts: any) => { + if (opts.onPoll) { + opts.onPoll({ status: "SUCCESS" }); + } + return { status: "SUCCESS" }; + }); }); afterEach(() => { @@ -675,7 +680,12 @@ describe("runv2", () => { }, }, }); - pollStub.resolves({ status: "FAILURE", statusDetail: "Buildpack compile error" }); + pollStub.callsFake(async (opts: any) => { + if (opts.onPoll) { + opts.onPoll({ status: "FAILURE", statusDetail: "Buildpack compile error" }); + } + return { status: "FAILURE", statusDetail: "Buildpack compile error" }; + }); const build: runv2.Build = { imageUri: "us-central1-docker.pkg.dev/proj/repo/service:123", diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 5b7b3cbcb38..a7c26d6672d 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -207,11 +207,8 @@ export async function submitBuild( (typeof op === "object" && op?.metadata?.build?.id) || (typeof op === "string" ? op.split("/").pop()?.replace(/^build-/, "") : ""); if (buildId) { - const buildResult = await pollOperation<{ - status: string; - statusDetail?: string; - logUrl?: string; - }>({ + let latestBuild: { status?: string; statusDetail?: string; logUrl?: string } | undefined; + await pollOperation({ pollerName: "Cloud Build Poller", apiOrigin: cloudbuildOrigin(), apiVersion: "v1", @@ -219,6 +216,9 @@ export async function submitBuild( masterTimeout: 15 * 60 * 1000, backoff: 2000, maxBackoff: 10000, + onPoll: (res: any) => { + latestBuild = res; + }, doneFn: (buildRes: any) => { const status = buildRes?.status; return ( @@ -231,13 +231,13 @@ export async function submitBuild( }, }); - if (buildResult.status !== "SUCCESS") { - const detail = buildResult.statusDetail ? `: ${buildResult.statusDetail}` : ""; + if (latestBuild && latestBuild.status !== "SUCCESS") { + const detail = latestBuild.statusDetail ? `: ${latestBuild.statusDetail}` : ""; const consoleLink = - buildResult.logUrl || + latestBuild.logUrl || `https://console.cloud.google.com/cloud-build/builds;region=${location}/${buildId}?project=${projectId}`; throw new FirebaseError( - `Cloud Build failed with status ${buildResult.status}${detail}\nView Cloud Build logs at: ${consoleLink}`, + `Cloud Build failed with status ${latestBuild.status}${detail}\nView Cloud Build logs at: ${consoleLink}`, ); } } From eb1d28e8672179a501edaf31dde2f9ae7bee3bb4 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 14:53:16 -0400 Subject: [PATCH 17/25] Add a temporary warning for BUILD secrets --- src/deploy/run/deploy.spec.ts | 31 +++++++++++++++++++++++++++++++ src/deploy/run/deploy.ts | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b0c1163b01c..73ca9126154 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -10,6 +10,7 @@ import * as getProjectNumberModule from "../../getProjectNumber"; import { Options } from "../../options"; import { Context, Payload } from "./args"; import { AppHostingYamlConfig } from "../../apphosting/yaml"; +import * as utils from "../../utils"; describe("run deploy", () => { let upsertBucketStub: sinon.SinonStub; @@ -301,4 +302,34 @@ describe("run deploy", () => { >; expect(updatedService.template.containers?.[0].baseImageUri).to.be.undefined; }); + + it("should warn when build-available secrets are specified in apphosting.yaml", async () => { + const logWarningStub = sinon.stub(utils, "logLabeledWarning"); + const appHostingConfig = AppHostingYamlConfig.empty(); + appHostingConfig.env = { + BUILD_SECRET: { secret: "my-build-sec", availability: ["BUILD"] }, + }; + + const payload: Payload = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + ignore: [], + appHostingConfig, + }, + ], + }, + }; + const context: Context = { projectId: "project" }; + const options = { project: "project" } as unknown as Options; + + await deploy(context, options, payload); + + expect(logWarningStub.calledOnce).to.be.true; + expect(logWarningStub.args[0][0]).to.equal("run"); + expect(logWarningStub.args[0][1]).to.include("BUILD_SECRET"); + }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f2062725370..883e5c4196e 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -9,6 +9,7 @@ import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting import { toCanonicalSecretResourcePath } from "../../apphosting/secrets"; import { EnvVar } from "../../gcp/k8s"; import { logger } from "../../logger"; +import { logLabeledWarning } from "../../utils"; /** * Applies runtime environment variables and Secret Manager references to a container. @@ -184,6 +185,11 @@ function prepareBuildEnvironment(service: RunServiceSpec): Record Date: Wed, 12 Aug 2026 15:01:11 -0400 Subject: [PATCH 18/25] Dont need build secret warning --- scripts/run-deploy-tests/tests.ts | 41 ++++++++++++++++++++++++++++++- src/deploy/run/deploy.spec.ts | 31 ----------------------- src/deploy/run/deploy.ts | 6 ----- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 7bedf3a4e7d..c08d43d2835 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -53,7 +53,46 @@ describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { } }); - after(() => { + const createdServices: Array<{ serviceId: string; region: string }> = []; + + function trackCreatedServices(): void { + const fbJson = path.join(workDir, "firebase.json"); + if (fs.existsSync(fbJson)) { + try { + const config = fs.readJsonSync(fbJson) as MockFirebaseJson; + if (config.run) { + const runConfigs = Array.isArray(config.run) ? config.run : [config.run]; + for (const rc of runConfigs) { + if (rc.serviceId) { + const region = rc.region || "us-central1"; + if ( + !createdServices.some( + (s) => s.serviceId === rc.serviceId && s.region === region, + ) + ) { + createdServices.push({ serviceId: rc.serviceId, region }); + } + } + } + } + } catch { + // ignore parse errors + } + } + } + + after(async () => { + trackCreatedServices(); + + // Clean up created Cloud Run services from GCP + for (const svc of createdServices) { + try { + await runv2.deleteService(TARGET_PROJECT, svc.region, svc.serviceId); + } catch (err: unknown) { + // Ignore 404 Not Found or unauthenticated errors in local/mock environments + } + } + if (!hasAppDir && workDir && fs.existsSync(workDir)) { fs.removeSync(workDir); } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 73ca9126154..b0c1163b01c 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -10,7 +10,6 @@ import * as getProjectNumberModule from "../../getProjectNumber"; import { Options } from "../../options"; import { Context, Payload } from "./args"; import { AppHostingYamlConfig } from "../../apphosting/yaml"; -import * as utils from "../../utils"; describe("run deploy", () => { let upsertBucketStub: sinon.SinonStub; @@ -302,34 +301,4 @@ describe("run deploy", () => { >; expect(updatedService.template.containers?.[0].baseImageUri).to.be.undefined; }); - - it("should warn when build-available secrets are specified in apphosting.yaml", async () => { - const logWarningStub = sinon.stub(utils, "logLabeledWarning"); - const appHostingConfig = AppHostingYamlConfig.empty(); - appHostingConfig.env = { - BUILD_SECRET: { secret: "my-build-sec", availability: ["BUILD"] }, - }; - - const payload: Payload = { - run: { - services: [ - { - serviceId: "mysvc", - region: "us-central1", - source: ".", - ignore: [], - appHostingConfig, - }, - ], - }, - }; - const context: Context = { projectId: "project" }; - const options = { project: "project" } as unknown as Options; - - await deploy(context, options, payload); - - expect(logWarningStub.calledOnce).to.be.true; - expect(logWarningStub.args[0][0]).to.equal("run"); - expect(logWarningStub.args[0][1]).to.include("BUILD_SECRET"); - }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 883e5c4196e..f2062725370 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -9,7 +9,6 @@ import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting import { toCanonicalSecretResourcePath } from "../../apphosting/secrets"; import { EnvVar } from "../../gcp/k8s"; import { logger } from "../../logger"; -import { logLabeledWarning } from "../../utils"; /** * Applies runtime environment variables and Secret Manager references to a container. @@ -185,11 +184,6 @@ function prepareBuildEnvironment(service: RunServiceSpec): Record Date: Wed, 12 Aug 2026 15:49:17 -0400 Subject: [PATCH 19/25] clean uo gcp test resources --- scripts/run-deploy-tests/tests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index c08d43d2835..963401b05a7 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -112,6 +112,7 @@ describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { }); afterEach(() => { + trackCreatedServices(); // Restore backup configs const fbJson = path.join(workDir, "firebase.json"); const fbRc = path.join(workDir, ".firebaserc"); From 27664cd492b867160bb3b59a9543b01bd1d1abe0 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 12 Aug 2026 15:55:05 -0400 Subject: [PATCH 20/25] Add a unit test for missing project id --- src/init/features/run.spec.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index da9cb2f854c..09ea2533090 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -49,10 +49,14 @@ describe("init features run", () => { it("should throw FirebaseError if projectId is missing", async () => { const setup = createMockSetup(); - await expect(runFeature.askQuestions(setup)).to.be.rejectedWith( - FirebaseError, - "Project ID must be set before initializing Cloud Run.", - ); + try { + await runFeature.askQuestions(setup); + expect.fail("Expected askQuestions to throw"); + } catch (err: any) { + expect(err).to.be.instanceOf(FirebaseError); + expect(err.message).to.equal("Project ID must be set before initializing Cloud Run."); + expect(err.exit).to.equal(1); + } }); }); @@ -90,10 +94,14 @@ describe("init features run", () => { }); const config = new Config({}, {}); - await expect(runFeature.actuate(setup, config)).to.be.rejectedWith( - FirebaseError, - "Project ID must be set before initializing Cloud Run.", - ); + try { + await runFeature.actuate(setup, config); + expect.fail("Expected actuate to throw"); + } catch (err: any) { + expect(err).to.be.instanceOf(FirebaseError); + expect(err.message).to.equal("Project ID must be set before initializing Cloud Run."); + expect(err.exit).to.equal(1); + } }); it("should create placeholder service with 0% traffic when service does not exist in GCP", async () => { From e96647553f478844ba5a7a3441a6e4833e6c8b33 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 13 Aug 2026 11:16:10 -0400 Subject: [PATCH 21/25] Format/lint fixes --- scripts/run-deploy-tests/tests.ts | 4 +-- src/apphosting/config.ts | 7 ++++ src/apphosting/yaml.ts | 16 +++++++-- src/commands/init.ts | 11 +++--- src/deploy/lifecycleHooks.ts | 7 ++++ src/deploy/run/args.ts | 1 - src/deploy/run/deploy.ts | 40 ++++++++++++++++------ src/deploy/run/prepare.ts | 10 ++++-- src/gcp/artifactregistry.ts | 6 ++++ src/gcp/runv2.ts | 7 +++- src/init/features/run.spec.ts | 4 ++- src/init/features/run.ts | 57 ++++++++++++++++++------------- src/init/index.ts | 7 ++++ 13 files changed, 127 insertions(+), 50 deletions(-) diff --git a/scripts/run-deploy-tests/tests.ts b/scripts/run-deploy-tests/tests.ts index 963401b05a7..49576df3ec9 100644 --- a/scripts/run-deploy-tests/tests.ts +++ b/scripts/run-deploy-tests/tests.ts @@ -66,9 +66,7 @@ describe("Cloud Run Deployment E2E Test Suite", function (this: Mocha.Suite) { if (rc.serviceId) { const region = rc.region || "us-central1"; if ( - !createdServices.some( - (s) => s.serviceId === rc.serviceId && s.region === region, - ) + !createdServices.some((s) => s.serviceId === rc.serviceId && s.region === region) ) { createdServices.push({ serviceId: rc.serviceId, region }); } diff --git a/src/apphosting/config.ts b/src/apphosting/config.ts index a6b39b72b3a..7b2a4b1f63d 100644 --- a/src/apphosting/config.ts +++ b/src/apphosting/config.ts @@ -411,6 +411,13 @@ export async function overrideChosenEnv( return newEnv; } +/** + * Generates a suggested Secret Manager secret name for testing based on an environment variable name. + * Converts underscores to hyphens and prepends a "test-" prefix. + * + * @param variable The environment variable name (e.g. API_KEY). + * @return The suggested test secret key name (e.g. test-api-key). + */ export function suggestedTestKeyName(variable: string): string { return "test-" + variable.replace(/_/g, "-").toLowerCase(); } diff --git a/src/apphosting/yaml.ts b/src/apphosting/yaml.ts index 5b665dfeca9..3cbb2648ab2 100644 --- a/src/apphosting/yaml.ts +++ b/src/apphosting/yaml.ts @@ -64,8 +64,8 @@ export class AppHostingYamlConfig { /** * Merges this AppHostingYamlConfig with another config, the incoming config * has precedence if there are any conflicting configurations. - * */ - merge(other: AppHostingYamlConfig, allowSecretsToBecomePlaintext: boolean = true) { + */ + merge(other: AppHostingYamlConfig, allowSecretsToBecomePlaintext = true) { if (!allowSecretsToBecomePlaintext) { const wereSecrets = Object.entries(this.env) .filter(([, env]) => env.secret) @@ -132,6 +132,12 @@ export class AppHostingYamlConfig { } // TODO: generalize into a utility function and remove the key from the array type. +/** + * Converts a list of environment variable objects into an environment variable map keyed by variable name. + * + * @param envs List of environment variables. + * @return Map of environment variables keyed by variable name. + */ export function toEnvMap(envs: Env[]): EnvMap { return Object.fromEntries( envs.map((env) => { @@ -141,6 +147,12 @@ export function toEnvMap(envs: Env[]): EnvMap { ); } +/** + * Converts an environment variable map keyed by variable name into an array of environment variable objects. + * + * @param envs Map of environment variables. + * @return Array of environment variable objects with variable property. + */ export function toEnvList(envs: EnvMap): Env[] { return Object.entries(envs).map(([variable, env]) => { return { ...env, variable }; diff --git a/src/commands/init.ts b/src/commands/init.ts index 31f963e9a86..f7c03d542fa 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -171,11 +171,6 @@ export const command = new Command("init [feature]") .help(HELP) .action(initAction); -/** - * Init command action - * @param feature Feature to init (e.g., hosting, functions) - * @param options Command options - */ /** * Collects warning messages based on project initialization directory location. */ @@ -335,6 +330,12 @@ export async function initAction(feature: string, options: Options): Promise { logger.info(); config.writeProjectFile("firebase.json", setup.config); diff --git a/src/deploy/lifecycleHooks.ts b/src/deploy/lifecycleHooks.ts index 33e441b8325..d59b9daa7dc 100644 --- a/src/deploy/lifecycleHooks.ts +++ b/src/deploy/lifecycleHooks.ts @@ -189,6 +189,13 @@ function getReleventConfigs(target: string, options: Options) { } } +/** + * Returns a deployment lifecycle hook function for the specified target and hook phase (e.g. predeploy, postdeploy). + * + * @param target The deployment target name (e.g. hosting, functions, run). + * @param hook The lifecycle hook name (e.g. predeploy, postdeploy). + * @return An async function that executes all configured lifecycle commands for matching targets. + */ export function lifecycleHooks( target: string, hook: string, diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index 94ba09eef26..daa81daba9c 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -58,4 +58,3 @@ export interface Payload { export interface Context { projectId?: string; } - diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index f2062725370..d64da26c532 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -188,7 +188,8 @@ function prepareBuildEnvironment(service: RunServiceSpec): Record { +export async function deploy( + context: Context, + options: RunDeployOptions, + payload: Payload, +): Promise { const services = payload.run?.services; if (!services || services.length === 0) { return; diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index 035ca1392b5..e333f1e4f8a 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -58,7 +58,9 @@ function filterTargetConfigs( if (hasSpecificServiceFilter) { const configuredServiceIds = new Set(configs.map((c) => c.serviceId)); - const missingServiceIds = Array.from(targetedServiceIds).filter((id) => !configuredServiceIds.has(id)); + const missingServiceIds = Array.from(targetedServiceIds).filter( + (id) => !configuredServiceIds.has(id), + ); if (missingServiceIds.length > 0) { throw new FirebaseError( `Cloud Run service(s) '${missingServiceIds.join(", ")}' not found in firebase.json. Configured services: ${Array.from(configuredServiceIds).join(", ")}`, @@ -99,7 +101,11 @@ function resolveBaseImage( * Prepares Cloud Run deployment by validating configurations, filtering targeted services, * fetching existing services, resolving base images and App Hosting configurations. */ -export async function prepare(context: Context, options: RunDeployOptions, payload: Payload): Promise { +export async function prepare( + context: Context, + options: RunDeployOptions, + payload: Payload, +): Promise { const projectId = needProjectId(options); context.projectId = projectId; diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index bf462fa5907..1aeeac05fd7 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -13,6 +13,12 @@ const client = new Client({ apiVersion: API_VERSION, }); +/** + * Ensures that the Artifact Registry API is enabled for the specified project. + * + * @param projectId The GCP project ID. + * @return A promise that resolves when the API is confirmed enabled. + */ export function ensureApiEnabled(projectId: string): Promise { return api.ensure(projectId, artifactRegistryDomain(), "artifactregistry", true); } diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index a7c26d6672d..037c3824015 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -205,7 +205,12 @@ export async function submitBuild( const op = res.body.buildOperation; const buildId = (typeof op === "object" && op?.metadata?.build?.id) || - (typeof op === "string" ? op.split("/").pop()?.replace(/^build-/, "") : ""); + (typeof op === "string" + ? op + .split("/") + .pop() + ?.replace(/^build-/, "") + : ""); if (buildId) { let latestBuild: { status?: string; statusDetail?: string; logUrl?: string } | undefined; await pollOperation({ diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 09ea2533090..6501d6fc114 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -134,7 +134,9 @@ describe("init features run", () => { "us-docker.pkg.dev/cloudrun/container/hello", ); expect(createdService.invokerIamDisabled).to.be.true; - expect(setup.instructions).to.include("Your Cloud Run service URL is: https://my-svc.a.run.app"); + expect(setup.instructions).to.include( + "Your Cloud Run service URL is: https://my-svc.a.run.app", + ); const runConfigs = config.src.run as Array<{ serviceId: string }>; expect(runConfigs).to.be.an("array"); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index fca2992bb38..805d76a73f5 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -30,7 +30,10 @@ export async function askQuestions(setup: Setup, config?: Config, options?: any) const defaultServiceId = options?.service || options?.serviceId || - path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-") || + path + .basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") || "my-service"; const serviceId = @@ -42,31 +45,37 @@ export async function askQuestions(setup: Setup, config?: Config, options?: any) })); const defaultRegion = + options?.primaryRegion || options?.region || process.env.FIREBASE_RUN_REGION || "us-central1"; + + const region = options?.primaryRegion || options?.region || - process.env.FIREBASE_RUN_REGION || - "us-central1"; - - const region = options?.primaryRegion || options?.region || (await input({ - message: "Which region should this service be deployed to?", - default: defaultRegion, - validate: (val: string) => { - if (!/^[a-z0-9-]+$/.test(val)) { - return "Region must be a valid GCP region string (e.g. us-central1)."; - } - return true; - }, - })); - - const rootDir = options?.rootDir || options?.source || (await input({ - message: "What is the root directory of your source code? (relative to firebase.json)", - default: ".", - })); - - const outputDir = options?.outputDir || options?.output || (await input({ - message: "Where should the built artifacts be output? (e.g. for --prebuilt)", - default: ".run", - })); + (await input({ + message: "Which region should this service be deployed to?", + default: defaultRegion, + validate: (val: string) => { + if (!/^[a-z0-9-]+$/.test(val)) { + return "Region must be a valid GCP region string (e.g. us-central1)."; + } + return true; + }, + })); + + const rootDir = + options?.rootDir || + options?.source || + (await input({ + message: "What is the root directory of your source code? (relative to firebase.json)", + default: ".", + })); + + const outputDir = + options?.outputDir || + options?.output || + (await input({ + message: "Where should the built artifacts be output? (e.g. for --prebuilt)", + default: ".run", + })); setup.featureInfo = setup.featureInfo || {}; setup.featureInfo.run = { diff --git a/src/init/index.ts b/src/init/index.ts index ffac29684f5..cde3e7444f0 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -145,6 +145,13 @@ const featuresList: Feature[] = [ const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); +/** + * Recursively runs question and actuate phases for each selected feature during project setup. + * @param setup The initialization setup state holding the feature queue and config. + * @param config The project configuration. + * @param options Command-line options and flags. + * @return The final setup result. + */ export async function init(setup: Setup, config: Config, options: any): Promise { const nextFeature = setup.features?.shift(); if (nextFeature) { From da5d61c454efdc9d9bd1f3be168e5c2e1a462f00 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Thu, 13 Aug 2026 11:40:30 -0400 Subject: [PATCH 22/25] fix schema diff update --- schema/firebase-config.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 6e0eac5699a..6431f854ff7 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1233,6 +1233,9 @@ "output": { "type": "string" }, + "outputDir": { + "type": "string" + }, "postdeploy": { "anyOf": [ { @@ -1259,25 +1262,22 @@ } ] }, - "region": { - "type": "string" - }, "primary-region": { "type": "string" }, - "serviceId": { + "region": { "type": "string" }, - "source": { + "rootDir": { "type": "string" }, - "rootDir": { + "serviceAccount": { "type": "string" }, - "outputDir": { + "serviceId": { "type": "string" }, - "serviceAccount": { + "source": { "type": "string" } }, From e04aac133416a80fa7a9b49571a2b8a629f3bff6 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 17 Aug 2026 17:13:25 -0400 Subject: [PATCH 23/25] Another pass of fixes from human code review --- schema/firebase-config.json | 12 ---------- src/archiveDirectory.ts | 4 ++-- src/commands/init.ts | 6 ----- src/deploy/run/args.ts | 14 +----------- src/deploy/run/deploy.spec.ts | 2 +- src/deploy/run/deploy.ts | 2 +- src/deploy/run/prepare.spec.ts | 32 +++++++++++++------------- src/deploy/run/prepare.ts | 3 +-- src/firebaseConfig.ts | 4 ---- src/firebaseConfigValidate.spec.ts | 2 +- src/fsAsync.ts | 14 +++++------- src/gcp/artifactregistry.spec.ts | 9 ++++---- src/gcp/artifactregistry.ts | 2 +- src/gcp/runv2.spec.ts | 25 +++++++++++++++++---- src/gcp/runv2.ts | 36 +++++++++++++----------------- src/init/features/run.spec.ts | 10 ++------- src/init/features/run.ts | 17 +++----------- 17 files changed, 76 insertions(+), 118 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 6431f854ff7..29dc484ce4f 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1230,12 +1230,6 @@ }, "type": "array" }, - "output": { - "type": "string" - }, - "outputDir": { - "type": "string" - }, "postdeploy": { "anyOf": [ { @@ -1262,9 +1256,6 @@ } ] }, - "primary-region": { - "type": "string" - }, "region": { "type": "string" }, @@ -1276,9 +1267,6 @@ }, "serviceId": { "type": "string" - }, - "source": { - "type": "string" } }, "required": [ diff --git a/src/archiveDirectory.ts b/src/archiveDirectory.ts index 03f7da05f86..7b7cb2ffd75 100644 --- a/src/archiveDirectory.ts +++ b/src/archiveDirectory.ts @@ -12,7 +12,7 @@ import * as fsAsync from "./fsAsync"; export interface ArchiveOptions { /** Globs to be ignored. */ ignore?: string[]; - /** When true, respects .gitignore and .gcloudignore files during traversal. */ + /** When true, respects .gitignore files during traversal. */ supportGitIgnore?: boolean; } @@ -82,7 +82,7 @@ async function zipDirectory( path: sourceDirectory, ignoreStrings: options.ignore, ignoreSymlinks: true, - supportGitIgnore: options.supportGitIgnore ?? true, + supportGitIgnore: options.supportGitIgnore ?? false, }); } catch (err: any) { if (err.code === "ENOENT") { diff --git a/src/commands/init.ts b/src/commands/init.ts index f7c03d542fa..3cd756ecdb7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -162,12 +162,6 @@ ${[...featureNames] export const command = new Command("init [feature]") .description("interactively configure the current directory as a Firebase project directory") - .option("-s, --service ", "Cloud Run service ID") - .option("--service-id ", "Cloud Run service ID") - .option("--primary-region ", "primary region for Cloud Run") - .option("--region ", "region for Cloud Run") - .option("--root-dir ", "root directory for source code") - .option("--output-dir ", "output directory for built artifacts") .help(HELP) .action(initAction); diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index daa81daba9c..a7396bf899a 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -6,13 +6,8 @@ import { Options } from "../../options"; export const DEFAULT_RUN_IGNORE = [ "node_modules", ".git", - ".next", - ".run", "firebase-debug.log", "firebase-debug.*.log", - ".env*.local", - "apphosting.local.yaml", - "**/*.secret.local", ]; export interface RunDeployOptions extends Options { @@ -25,14 +20,7 @@ export interface RunDeployOptions extends Options { serviceAccount?: string; } -export interface RunServiceConfig extends RunSingle { - "primary-region"?: string; - rootDir?: string; - outputDir?: string; - serviceAccount?: string; -} - -export type RunConfig = RunServiceConfig; +export type RunConfig = RunSingle; export interface RunServiceSpec { serviceId: string; diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index b0c1163b01c..00299c745d0 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -20,7 +20,7 @@ describe("run deploy", () => { beforeEach(() => { upsertBucketStub = sinon.stub(gcs, "upsertBucket").resolves("my-bucket"); - ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepository").resolves(); + ensureRepoStub = sinon.stub(artifactRegistry, "ensureRepositoryExists").resolves(); sinon.stub(getProjectNumberModule, "getProjectNumber").resolves("12345"); sinon.stub(runv2, "getService").resolves(); sinon.stub(archiveDirectory, "archiveDirectory").resolves({ diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index d64da26c532..6e45e14abd9 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -368,7 +368,7 @@ async function deployService( try { // 1. Ensure Artifact Registry repository exists - await artifactregistry.ensureRepository(projectId, region, "cloud-run-source-deploy"); + await artifactregistry.ensureRepositoryExists(projectId, region, "cloud-run-source-deploy"); // 2. Package source & upload to GCS staging bucket service.storageSource = await packageAndUploadSource(projectId, region, service, options); diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 51eccd983ae..bf67e268481 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -43,7 +43,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "my-service", region: "us-central1", source: "." }), + get: () => ({ serviceId: "my-service", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -66,7 +66,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "my-service", source: "." }), + get: () => ({ serviceId: "my-service", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -84,7 +84,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -112,7 +112,7 @@ describe("run prepare", () => { get: () => ({ serviceId: "mysvc", region: "us-central1", - source: ".", + rootDir: ".", }), path: (p: string) => p, }, @@ -136,7 +136,7 @@ describe("run prepare", () => { project: "project", runtime: "nodejs22", config: { - get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -160,7 +160,7 @@ describe("run prepare", () => { project: "project", clearRuntime: true, config: { - get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -185,7 +185,7 @@ describe("run prepare", () => { runtime: "nodejs22", clearRuntime: true, config: { - get: () => ({ serviceId: "mysvc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -204,8 +204,8 @@ describe("run prepare", () => { only: "run:svc-2", config: { get: () => [ - { serviceId: "svc-1", region: "us-central1", source: "." }, - { serviceId: "svc-2", region: "us-east1", source: "." }, + { serviceId: "svc-1", region: "us-central1", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, ], path: (p: string) => p, }, @@ -227,8 +227,8 @@ describe("run prepare", () => { only: "run:non-existent", config: { get: () => [ - { serviceId: "svc-1", region: "us-central1", source: "." }, - { serviceId: "svc-2", region: "us-east1", source: "." }, + { serviceId: "svc-1", region: "us-central1", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, ], path: (p: string) => p, }, @@ -246,7 +246,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "", region: "us-central1", source: "." }), + get: () => ({ serviceId: "", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -263,7 +263,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "new-svc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "new-svc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -281,7 +281,7 @@ describe("run prepare", () => { const options = { project: "project", config: { - get: () => ({ serviceId: "new-svc", region: "us-central1", source: "." }), + get: () => ({ serviceId: "new-svc", region: "us-central1", rootDir: "." }), path: (p: string) => p, }, } as unknown as Options; @@ -298,8 +298,8 @@ describe("run prepare", () => { project: "project", config: { get: () => [ - { serviceId: "svc-1", region: "us-central1", source: "." }, - { serviceId: "svc-2", region: "us-east1", source: "." }, + { serviceId: "svc-1", region: "us-central1", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, ], path: (p: string) => p, }, diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index e333f1e4f8a..def0a2bdfaa 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -134,7 +134,6 @@ export async function prepare( options.region || process.env.FIREBASE_RUN_REGION || config.region || - config["primary-region"] || "us-central1"; let existingService: runv2.Service | undefined; @@ -152,7 +151,7 @@ export async function prepare( clearOpt, ); - const sourceDir = options.config.path(config.source || config.rootDir || "."); + const sourceDir = options.config.path(config.rootDir || "."); const yamlPath = path.join(sourceDir, "apphosting.yaml"); let appHostingConfig: AppHostingYamlConfig | undefined; if (fileExistsSync(yamlPath)) { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 1413c148249..1bfb251662f 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -369,11 +369,7 @@ export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; export interface RunSingle extends Deployable { serviceId: string; region?: string; - "primary-region"?: string; - source?: string; rootDir?: string; - output?: string; - outputDir?: string; ignore?: string[]; serviceAccount?: string; } diff --git a/src/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 6129c2a873a..56b3d149798 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -30,7 +30,7 @@ describe("firebaseConfigValidate", () => { { serviceId: "my-service", region: "us-central1", - source: ".", + rootDir: ".", }, ], }; diff --git a/src/fsAsync.ts b/src/fsAsync.ts index 8183061b1ce..feb2c3c906b 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -44,15 +44,11 @@ async function readdirRecursiveHelper(options: { const dirContents = readdirSync(options.path, { withFileTypes: true }); let currentGitIgnoreStack = options.gitIgnoreStack || []; - // Load and stack directory-specific .gcloudignore or .gitignore rules if supportGitIgnore is enabled + // Load and stack directory-specific .gitignore rules if supportGitIgnore is enabled if (options.supportGitIgnore) { - const ignoreFileName = dirContents.find((n) => n.name === ".gcloudignore")?.isFile() - ? ".gcloudignore" - : dirContents.find((n) => n.name === ".gitignore")?.isFile() - ? ".gitignore" - : undefined; - if (ignoreFileName) { - const localIgnorePath = join(options.path, ignoreFileName); + const hasGitIgnore = dirContents.find((n) => n.name === ".gitignore")?.isFile(); + if (hasGitIgnore) { + const localIgnorePath = join(options.path, ".gitignore"); try { const lines = readFileSync(localIgnorePath) .toString() @@ -68,7 +64,7 @@ async function readdirRecursiveHelper(options: { }, ]; } catch (e: unknown) { - logger.debug(`Error reading ${ignoreFileName} file at ${localIgnorePath}:`, e); + logger.debug(`Error reading .gitignore file at ${localIgnorePath}:`, e); } } } diff --git a/src/gcp/artifactregistry.spec.ts b/src/gcp/artifactregistry.spec.ts index 38cb44f97da..3c3f803f26b 100644 --- a/src/gcp/artifactregistry.spec.ts +++ b/src/gcp/artifactregistry.spec.ts @@ -161,12 +161,12 @@ describe("artifactRegistry", () => { }); }); - describe("ensureRepository", () => { + describe("ensureRepositoryExists", () => { it("should return when repository already exists", async () => { const repo = { name: REPO_NAME, format: "DOCKER" }; nock(artifactRegistryDomain()).get(`/${API_VERSION}/${REPO_NAME}`).reply(200, repo); - await artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO); + await artifactRegistry.ensureRepositoryExists(PROJECT_ID, REGION, REPO); expect(nock.isDone()).to.be.true; }); @@ -180,7 +180,7 @@ describe("artifactRegistry", () => { ) .reply(200, { name: REPO_NAME, format: "DOCKER", done: true }); - await artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO); + await artifactRegistry.ensureRepositoryExists(PROJECT_ID, REGION, REPO); expect(nock.isDone()).to.be.true; }); @@ -189,7 +189,8 @@ describe("artifactRegistry", () => { .get(`/${API_VERSION}/${REPO_NAME}`) .reply(403, { error: { message: "Permission Denied", status: 403 } }); - await expect(artifactRegistry.ensureRepository(PROJECT_ID, REGION, REPO)).to.be.rejected; + await expect(artifactRegistry.ensureRepositoryExists(PROJECT_ID, REGION, REPO)).to.be + .rejected; expect(nock.isDone()).to.be.true; }); }); diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index 1aeeac05fd7..fff60bd36f3 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -140,7 +140,7 @@ export async function createRepository( /** * Ensures an Artifact Registry repository exists, creating it if not. */ -export async function ensureRepository( +export async function ensureRepositoryExists( projectId: string, location: string, repositoryId: string, diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index 5c08ee5b936..f7d8b7c63c4 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -593,9 +593,17 @@ describe("runv2", () => { postStub = sandbox.stub(Client.prototype, "post"); pollStub = sandbox.stub(operationPoller, "pollOperation").callsFake(async (opts: any) => { if (opts.onPoll) { - opts.onPoll({ status: "SUCCESS" }); + opts.onPoll({ + metadata: { + build: { status: "SUCCESS" }, + }, + }); } - return { status: "SUCCESS" }; + return { + metadata: { + build: { status: "SUCCESS" }, + }, + }; }); }); @@ -674,6 +682,7 @@ describe("runv2", () => { status: 200, body: { buildOperation: { + name: "projects/proj/locations/loc/operations/op123", metadata: { build: { id: "build-123" }, }, @@ -682,9 +691,17 @@ describe("runv2", () => { }); pollStub.callsFake(async (opts: any) => { if (opts.onPoll) { - opts.onPoll({ status: "FAILURE", statusDetail: "Buildpack compile error" }); + opts.onPoll({ + metadata: { + build: { status: "FAILURE", statusDetail: "Buildpack compile error" }, + }, + }); } - return { status: "FAILURE", statusDetail: "Buildpack compile error" }; + return { + metadata: { + build: { status: "FAILURE", statusDetail: "Buildpack compile error" }, + }, + }; }); const build: runv2.Build = { diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 037c3824015..659d6053a21 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -69,11 +69,6 @@ export interface RevisionTemplate { subnetwork?: string; tags?: string[]; }>; - networkinterfaces?: Array<{ - network?: string; - subnetwork?: string; - tags?: string[]; - }>; }; timeout?: proto.Duration; serviceAccount?: string; @@ -169,11 +164,19 @@ export interface Build { buildpackBuild: BuildpacksBuild; } +/** + * Represents the LRO or Operation object returned by Cloud Run submitBuild endpoint. + */ export interface BuildOperationObject { + /** The fully qualified operation resource name (e.g. projects/{p}/locations/{l}/operations/{opId}). */ name?: string; + /** Operation metadata containing the build details. */ metadata?: { build?: { id?: string; + status?: string; + statusDetail?: string; + logUrl?: string; }; }; } @@ -203,29 +206,22 @@ export async function submitBuild( }); } const op = res.body.buildOperation; - const buildId = - (typeof op === "object" && op?.metadata?.build?.id) || - (typeof op === "string" - ? op - .split("/") - .pop() - ?.replace(/^build-/, "") - : ""); - if (buildId) { + const operationResourceName = typeof op === "string" ? op : op?.name; + if (operationResourceName) { let latestBuild: { status?: string; statusDetail?: string; logUrl?: string } | undefined; await pollOperation({ pollerName: "Cloud Build Poller", apiOrigin: cloudbuildOrigin(), apiVersion: "v1", - operationResourceName: `projects/${projectId}/locations/${location}/builds/${buildId}`, + operationResourceName, masterTimeout: 15 * 60 * 1000, backoff: 2000, maxBackoff: 10000, - onPoll: (res: any) => { - latestBuild = res; + onPoll: (opRes: any) => { + latestBuild = opRes?.metadata?.build; }, - doneFn: (buildRes: any) => { - const status = buildRes?.status; + doneFn: (opRes: any) => { + const status = opRes?.metadata?.build?.status; return ( status === "SUCCESS" || status === "FAILURE" || @@ -240,7 +236,7 @@ export async function submitBuild( const detail = latestBuild.statusDetail ? `: ${latestBuild.statusDetail}` : ""; const consoleLink = latestBuild.logUrl || - `https://console.cloud.google.com/cloud-build/builds;region=${location}/${buildId}?project=${projectId}`; + `https://console.cloud.google.com/cloud-build/builds?project=${projectId}`; throw new FirebaseError( `Cloud Build failed with status ${latestBuild.status}${detail}\nView Cloud Build logs at: ${consoleLink}`, ); diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 6501d6fc114..eeb6ba0dac4 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -29,12 +29,11 @@ describe("init features run", () => { }); describe("askQuestions", () => { - it("should prompt for serviceId, region, rootDir, and outputDir", async () => { + it("should prompt for serviceId, region, and rootDir", async () => { const inputStub = sandbox.stub(prompt, "input"); inputStub.onFirstCall().resolves("custom-service"); inputStub.onSecondCall().resolves("us-central1"); inputStub.onThirdCall().resolves("./src"); - inputStub.onCall(3).resolves("./dist"); const setup = createMockSetup({ projectId: "test-project" }); await runFeature.askQuestions(setup); @@ -43,7 +42,6 @@ describe("init features run", () => { serviceId: "custom-service", region: "us-central1", rootDir: "./src", - outputDir: "./dist", }); }); @@ -88,7 +86,6 @@ describe("init features run", () => { serviceId: "my-svc", region: "us-central1", rootDir: ".", - outputDir: ".run", }, }, }); @@ -112,7 +109,6 @@ describe("init features run", () => { serviceId: "my-svc", region: "us-central1", rootDir: ".", - outputDir: ".run", }, }, }); @@ -152,7 +148,6 @@ describe("init features run", () => { serviceId: "my-svc", region: "us-central1", rootDir: ".", - outputDir: ".run", }, }, }); @@ -177,13 +172,12 @@ describe("init features run", () => { serviceId: "second-svc", region: "us-central1", rootDir: "./app2", - outputDir: ".run", }, }, }); const config = new Config( { - run: [{ serviceId: "first-svc", region: "us-central1", source: "./app1" }], + run: [{ serviceId: "first-svc", region: "us-central1", rootDir: "./app1" }], }, {}, ); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 805d76a73f5..573c036a49d 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -13,11 +13,10 @@ export interface RunInfo { serviceId: string; region: string; rootDir: string; - outputDir: string; } /** - * Prompts the user for Cloud Run service ID, deployment region, source root, and output directory. + * Prompts the user for Cloud Run service ID, deployment region, and source root. */ export async function askQuestions(setup: Setup, config?: Config, options?: any): Promise { const projectId = setup.projectId; @@ -69,20 +68,11 @@ export async function askQuestions(setup: Setup, config?: Config, options?: any) default: ".", })); - const outputDir = - options?.outputDir || - options?.output || - (await input({ - message: "Where should the built artifacts be output? (e.g. for --prebuilt)", - default: ".run", - })); - setup.featureInfo = setup.featureInfo || {}; setup.featureInfo.run = { serviceId, region, rootDir, - outputDir, }; } @@ -103,7 +93,7 @@ export async function actuate(setup: Setup, config: Config): Promise { throw new FirebaseError("Project ID must be set before initializing Cloud Run.", { exit: 1 }); } - const { serviceId, region, rootDir, outputDir } = runInfo; + const { serviceId, region, rootDir } = runInfo; logBullet("Setting up Cloud Run configuration..."); @@ -151,8 +141,7 @@ export async function actuate(setup: Setup, config: Config): Promise { const runConfig: RunSingle = { serviceId, region, - source: rootDir, - output: outputDir, + rootDir, ignore: DEFAULT_RUN_IGNORE, }; From e89b892894e165c259f732d7b5b0ef9bab3e67b5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 17 Aug 2026 17:35:08 -0400 Subject: [PATCH 24/25] Add more secret tests --- src/apphosting/secrets/index.spec.ts | 59 ++++++++++++++++++++++++++++ src/commands/deploy.ts | 1 + src/deploy/lifecycleHooks.ts | 2 +- src/gcp/artifactregistry.ts | 2 + 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/apphosting/secrets/index.spec.ts b/src/apphosting/secrets/index.spec.ts index 68a8e40be9e..a5be073dc49 100644 --- a/src/apphosting/secrets/index.spec.ts +++ b/src/apphosting/secrets/index.spec.ts @@ -642,4 +642,63 @@ describe("secrets", () => { ); }); }); + + describe("toCanonicalSecretResourcePath", () => { + it("should format short secret name with latest version", () => { + expect(secrets.toCanonicalSecretResourcePath("mySecret", "my-project")).to.deep.equal({ + secretPath: "projects/my-project/secrets/mySecret", + version: "latest", + }); + }); + + it("should format secret with pinned version using @ notation", () => { + expect(secrets.toCanonicalSecretResourcePath("mySecret@5", "my-project")).to.deep.equal({ + secretPath: "projects/my-project/secrets/mySecret", + version: "5", + }); + }); + + it("should format secret with @latest version", () => { + expect(secrets.toCanonicalSecretResourcePath("mySecret@latest", "my-project")).to.deep.equal({ + secretPath: "projects/my-project/secrets/mySecret", + version: "latest", + }); + }); + + it("should handle fully qualified resource path without version", () => { + expect( + secrets.toCanonicalSecretResourcePath( + "projects/custom-project/secrets/mySecret", + "my-project", + ), + ).to.deep.equal({ + secretPath: "projects/custom-project/secrets/mySecret", + version: "latest", + }); + }); + + it("should handle fully qualified resource path with @ version", () => { + expect( + secrets.toCanonicalSecretResourcePath( + "projects/custom-project/secrets/mySecret@3", + "my-project", + ), + ).to.deep.equal({ + secretPath: "projects/custom-project/secrets/mySecret", + version: "3", + }); + }); + + it("should handle fully qualified resource path with /versions/ suffix", () => { + expect( + secrets.toCanonicalSecretResourcePath( + "projects/custom-project/secrets/mySecret/versions/7", + "my-project", + ), + ).to.deep.equal({ + secretPath: "projects/custom-project/secrets/mySecret", + version: "7", + }); + }); + }); }); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index b4c34e8a6e6..0c8b44e1ea1 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -71,6 +71,7 @@ export const command = new Command("deploy") "perform a dry run of your deployment. Validates your changes and builds your code without deploying any changes to your project. " + "In order to provide better validation, this may still enable APIs on the target project", ) + // TODO: Consolidate ABIU flags. '--runtime' and '--clear-runtime' are being deprecated in favor of '--base-image' and '--clear-base-image'. .option( "--runtime ", "specify the runtime for Cloud Run Automatic Base Image Updates (ABIU) (e.g. nodejs22, python311)", diff --git a/src/deploy/lifecycleHooks.ts b/src/deploy/lifecycleHooks.ts index d59b9daa7dc..fb6b190bd46 100644 --- a/src/deploy/lifecycleHooks.ts +++ b/src/deploy/lifecycleHooks.ts @@ -60,7 +60,7 @@ function getChildEnvironment(target: string, overallOptions: any, config: any) { resourceDir = overallOptions.config.path(config.source); break; case "run": - resourceDir = overallOptions.config.path(config.source || config.rootDir || "."); + resourceDir = overallOptions.config.path(config.rootDir || "."); break; default: resourceDir = overallOptions.config.path(overallOptions.config.projectDir); diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index fff60bd36f3..f049ddc0e29 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -154,11 +154,13 @@ export async function ensureRepositoryExists( try { await createRepository(projectId, location, repositoryId, format); } catch (createErr: any) { + // 409 Already Exists: repository was created concurrently or already exists, safe to ignore. if (createErr.status === 409) { return; } throw createErr; } + // 409 Already Exists from getRepository (e.g. repository state conflict), safe to ignore. } else if (err.status === 409) { return; } else { From 110ca0b14f5f9bddebee9a7010382d31bf7f2106 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Wed, 19 Aug 2026 10:54:14 -0400 Subject: [PATCH 25/25] extract buildid and build name when polling --- src/gcp/artifactregistry.ts | 2 +- src/gcp/runv2.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/gcp/artifactregistry.ts b/src/gcp/artifactregistry.ts index f049ddc0e29..270366904f8 100644 --- a/src/gcp/artifactregistry.ts +++ b/src/gcp/artifactregistry.ts @@ -160,7 +160,7 @@ export async function ensureRepositoryExists( } throw createErr; } - // 409 Already Exists from getRepository (e.g. repository state conflict), safe to ignore. + // 409 Already Exists from getRepository (e.g. repository state conflict), safe to ignore. } else if (err.status === 409) { return; } else { diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 659d6053a21..f6097825dd0 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -174,6 +174,7 @@ export interface BuildOperationObject { metadata?: { build?: { id?: string; + name?: string; status?: string; statusDetail?: string; logUrl?: string; @@ -206,7 +207,15 @@ export async function submitBuild( }); } const op = res.body.buildOperation; - const operationResourceName = typeof op === "string" ? op : op?.name; + const buildName = typeof op !== "string" ? op?.metadata?.build?.name : undefined; + const buildId = typeof op !== "string" ? op?.metadata?.build?.id : undefined; + const operationResourceName = + buildName || + (buildId + ? `projects/${projectId}/locations/${location}/builds/${buildId}` + : typeof op === "string" + ? op + : op?.name); if (operationResourceName) { let latestBuild: { status?: string; statusDetail?: string; logUrl?: string } | undefined; await pollOperation({ @@ -218,10 +227,10 @@ export async function submitBuild( backoff: 2000, maxBackoff: 10000, onPoll: (opRes: any) => { - latestBuild = opRes?.metadata?.build; + latestBuild = opRes?.metadata?.build || opRes; }, doneFn: (opRes: any) => { - const status = opRes?.metadata?.build?.status; + const status = opRes?.status || opRes?.metadata?.build?.status; return ( status === "SUCCESS" || status === "FAILURE" ||