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/schema/firebase-config.json b/schema/firebase-config.json index 0185d3e7789..29dc484ce4f 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -1221,6 +1221,59 @@ ], "type": "object" }, + "RunSingle": { + "additionalProperties": false, + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postdeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "predeploy": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ] + }, + "region": { + "type": "string" + }, + "rootDir": { + "type": "string" + }, + "serviceAccount": { + "type": "string" + }, + "serviceId": { + "type": "string" + } + }, + "required": [ + "serviceId" + ], + "type": "object" + }, "StorageSingle": { "additionalProperties": false, "properties": { @@ -2008,6 +2061,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/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..49576df3ec9 --- /dev/null +++ b/scripts/run-deploy-tests/tests.ts @@ -0,0 +1,399 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import { expect } from "chai"; +import * as cli from "../integration-helpers/cli"; +import * as runv2 from "../../src/gcp/runv2"; + +interface MockRunConfig { + serviceId?: string; + region?: string; + source?: string; +} + +interface MockFirebaseJson { + run?: MockRunConfig | MockRunConfig[]; + hosting?: { public?: string }; +} + +import * as os from "os"; + +const TARGET_PROJECT = + 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 + + let workDir: string; + let hasAppDir = false; + + before(() => { + 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(os.tmpdir(), "firebase-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);', + ); + } + }); + + 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); + } + }); + + 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(() => { + trackCreatedServices(); + // 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/apphosting/config.ts b/src/apphosting/config.ts index d1337b5b825..7b2a4b1f63d 100644 --- a/src/apphosting/config.ts +++ b/src/apphosting/config.ts @@ -27,14 +27,25 @@ 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; 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; + /** Where an environment variable can be provided. */ export type Availability = "BUILD" | "RUNTIME"; @@ -46,10 +57,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; } /** @@ -389,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/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/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/apphosting/yaml.ts b/src/apphosting/yaml.ts index 144201876f3..3cbb2648ab2 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, ScriptsConfig, BuildConfig } from "./config"; import * as yaml from "yaml"; import * as jsYaml from "js-yaml"; import * as path from "path"; @@ -18,6 +18,9 @@ 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; + public scripts?: ScriptsConfig; + public buildConfig?: BuildConfig; /** * Reads in the App Hosting yaml file found in filePath, parses the secrets and @@ -37,6 +40,15 @@ export class AppHostingYamlConfig { if (loadedAppHostingYaml.env) { config.env = toEnvMap(loadedAppHostingYaml.env); } + if (loadedAppHostingYaml.runConfig) { + config.runConfig = loadedAppHostingYaml.runConfig; + } + if (loadedAppHostingYaml.scripts) { + config.scripts = loadedAppHostingYaml.scripts; + } + if (loadedAppHostingYaml.buildConfig) { + config.buildConfig = loadedAppHostingYaml.buildConfig; + } return config; } @@ -52,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) @@ -69,6 +81,27 @@ export class AppHostingYamlConfig { ...this.env, ...other.env, }; + + if (other.runConfig) { + this.runConfig = { + ...this.runConfig, + ...other.runConfig, + }; + } + + if (other.scripts) { + this.scripts = { + ...this.scripts, + ...other.scripts, + }; + } + + if (other.buildConfig) { + this.buildConfig = { + ...this.buildConfig, + ...other.buildConfig, + }; + } } /** @@ -84,12 +117,27 @@ export class AppHostingYamlConfig { } yamlConfigToWrite.env = toEnvList(this.env); + 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))); } } // 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) => { @@ -99,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/archiveDirectory.ts b/src/archiveDirectory.ts index 72946590a64..7b7cb2ffd75 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 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 ?? false, }); } catch (err: any) { if (err.code === "ENOENT") { diff --git a/src/checkValidTargetFilters.ts b/src/checkValidTargetFilters.ts index 427e8deb3e4..c78014fd81f 100644 --- a/src/checkValidTargetFilters.ts +++ b/src/checkValidTargetFilters.ts @@ -32,6 +32,7 @@ const FILTERABLE_TARGETS = new Set([ "dataconnect", "apphosting", "ailogic", + "run", ]); /** diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index e47f6199f69..0c8b44e1ea1 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -70,6 +70,23 @@ 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", + ) + // 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)", + ) + .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/commands/init.ts b/src/commands/init.ts index b69d7363202..3cd756ecdb7 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")) { @@ -161,7 +166,98 @@ export const command = new Command("init [feature]") .action(initAction); /** - * Init command action + * 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 */ @@ -176,24 +272,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); @@ -291,6 +324,12 @@ export async function initAction(feature: string, options: Options): Promise { logger.info(); config.writeProjectFile("firebase.json", setup.config); 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/index.ts b/src/deploy/index.ts index c7cae13b2c4..f2916523792 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,24 @@ 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", + "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", + ], }; export const TARGETS = { @@ -119,6 +139,7 @@ export const TARGETS = { apphosting: AppHostingTarget, auth: AuthTarget, ailogic: AiLogicTarget, + run: RunTarget, }; export type DeployOptions = Options & { dryRun?: boolean }; diff --git a/src/deploy/lifecycleHooks.ts b/src/deploy/lifecycleHooks.ts index 2d067a46afd..fb6b190bd46 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.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); @@ -180,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 new file mode 100644 index 00000000000..a7396bf899a --- /dev/null +++ b/src/deploy/run/args.ts @@ -0,0 +1,48 @@ +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", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", +]; + +export interface RunDeployOptions extends Options { + runtime?: string; + baseImage?: string; + clearRuntime?: boolean; + clearBaseImage?: boolean; + primaryRegion?: string; + region?: string; + serviceAccount?: string; +} + +export type RunConfig = RunSingle; + +export interface RunServiceSpec { + serviceId: string; + region: string; + source: string; + ignore: string[]; + existingService?: runv2.Service; + baseImageUri?: string; + clearBaseImage?: boolean; + appHostingConfig?: AppHostingYamlConfig; + storageSource?: runv2.StorageSource; + deployResponse?: runv2.Service; + message?: string; + serviceAccount?: string; +} + +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 new file mode 100644 index 00000000000..00299c745d0 --- /dev/null +++ b/src/deploy/run/deploy.spec.ts @@ -0,0 +1,304 @@ +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; + 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, "ensureRepositoryExists").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"]), + 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 runv2.Service); + createServiceStub = sinon + .stub(runv2, "createService") + .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: Payload = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + ignore: [], + baseImageUri: "dummy-base-image", + }, + ], + }, + }; + const context: Context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as unknown as Options; + + 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] as Omit< + runv2.Service, + 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"); + }); + + it("should update an 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" }], + }, + }, + }, + ], + }, + }; + const context: Context = { projectId: "project" }; + const options = { project: "project", projectNumber: "12345" } as unknown as Options; + + 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; + expect(payload.run?.services?.[0].deployResponse?.uri).to.equal("https://my-service.com"); + }); + + it("should map secrets, runtime env vars, VPC settings, and RunConfig scaling", async () => { + const appHostingConfig = AppHostingYamlConfig.empty(); + appHostingConfig.runConfig = { + cpu: 2, + memoryMiB: 1024, + minInstances: 1, + 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"] }, + MY_FULL_SECRET: { + 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 = { + 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: [ + { name: "mysvc", image: "old-img", env: [{ name: "OLD_VAR", value: "keep-me" }] }, + ], + }, + }, + }, + ], + }, + }; + 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(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 + >; + + 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; + 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", + 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", + }, + }, + }); + expect(containerEnv).to.deep.include({ + name: "MY_VERSIONED_FULL_SECRET", + valueSource: { + secretKeyRef: { + secret: "projects/custom-p/secrets/my-versioned-sec", + version: "3", + }, + }, + }); + }); + + it("should delete baseImageUri when service.clearBaseImage is true on existing service", async () => { + submitBuildStub.resolves({}); + const payload: Payload = { + run: { + services: [ + { + serviceId: "mysvc", + region: "us-central1", + source: ".", + ignore: [], + clearBaseImage: true, + 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 new file mode 100644 index 00000000000..6e45e14abd9 --- /dev/null +++ b/src/deploy/run/deploy.ts @@ -0,0 +1,456 @@ +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"; +import { archiveDirectory } from "../../archiveDirectory"; +import { getProjectNumber } from "../../getProjectNumber"; +import { EnvMap } from "../../apphosting/yaml"; +import { splitEnvVars, AppHostingRunConfig as RunConfig } from "../../apphosting/config"; +import { toCanonicalSecretResourcePath } from "../../apphosting/secrets"; +import { EnvVar } from "../../gcp/k8s"; +import { logger } from "../../logger"; + +/** + * 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 } = toCanonicalSecretResourcePath(String(val.secret), projectId); + newEnv.push({ + name: key, + valueSource: { + secretKeyRef: { + secret: secretPath, + version, + }, + }, + }); + } + } + + 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.vpcAccess) { + service.template.vpcAccess = runConfig.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, + serviceId?: string, +): void { + if (!service.template.containers) { + service.template.containers = []; + } + if (service.template.containers.length === 0) { + service.template.containers.push({ name: serviceId || "worker", image: "" }); + } + + 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); +} + +/** + * 1. Packages local source and uploads to the regional staging bucket. + */ +async function packageAndUploadSource( + projectId: string, + region: string, + service: RunServiceSpec, + options: RunDeployOptions, +): 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, + }; +} + +/** + * 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 [key, val] of Object.entries(buildEnvMap)) { + if (val.value !== undefined) { + buildEnv[key] = val.value; + } + } + + if (appHostingConfig?.scripts?.build || appHostingConfig?.buildConfig?.buildCommand) { + buildEnv["GOOGLE_NODE_RUN_SCRIPTS"] = (appHostingConfig.scripts?.build || + appHostingConfig.buildConfig?.buildCommand)!; + } + + return buildEnv; +} + +/** + * 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}`); + } + + return { + hasAbiu, + resolvedBaseImageUri: hasAbiu ? buildRes.baseImageUri || service.baseImageUri : undefined, + }; +} + +/** + * 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 (!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; + + if (service.clearBaseImage || !hasAbiu) { + delete container.baseImageUri; + } else if (resolvedBaseImageUri) { + container.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(); + if (message) { + newService.template.annotations["run.googleapis.com/description"] = message; + } + + const runtimeEnvMap = splitEnvVars(service.appHostingConfig?.env || {}).runtime; + applyAppHostingConfig( + projectId, + newService, + runtimeEnvMap, + service.appHostingConfig?.runConfig, + service.serviceId, + ); + + newService.traffic = [ + { + type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", + percent: 100, + }, + ]; + + const updateMask = ["template", "traffic"]; + if (newService.scaling) { + updateMask.push("scaling"); + } + + return { newService, updateMask }; +} + +/** + * 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; +} + +/** + * Deploys a single Cloud Run service from source. + */ +async function deployService( + context: Context, + options: RunDeployOptions, + 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.ensureRepositoryExists(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 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 + let existing = service.existingService; + 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 { 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; + } +} + +/** + * 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: RunDeployOptions, + 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/deploy/run/index.ts b/src/deploy/run/index.ts new file mode 100644 index 00000000000..41d74b4626f --- /dev/null +++ b/src/deploy/run/index.ts @@ -0,0 +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 new file mode 100644 index 00000000000..bf67e268481 --- /dev/null +++ b/src/deploy/run/prepare.spec.ts @@ -0,0 +1,316 @@ +import { expect } from "chai"; +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 throw FirebaseError if no run config is configured in firebase.json", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { get: () => undefined, path: (p: string) => p }, + } as unknown as Options; + + 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 load run service configuration from firebase.json", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "my-service", region: "us-central1", rootDir: "." }), + 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"); + }); + + 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: () => ({ serviceId: "my-service", rootDir: "." }), + 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: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), + path: (p: string) => p, + }, + } 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).to.have.length(1); + expect(payload.run?.services?.[0].baseImageUri).to.equal("some-uri"); + }); + + 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", + rootDir: ".", + }), + 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 support --runtime flag override", async () => { + const payload: Payload = {}; + const context: Context = {}; + const options = { + project: "project", + runtime: "nodejs22", + config: { + get: () => ({ serviceId: "mysvc", region: "us-central1", rootDir: "." }), + 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", rootDir: "." }), + 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", rootDir: "." }), + 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", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, + ], + 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", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, + ], + path: (p: string) => p, + }, + } as unknown as Options; + + await expect(prepare(context, options, payload)).to.be.rejectedWith( + FirebaseError, + "Cloud Run service(s) 'non-existent' not found in firebase.json.", + ); + }); + + 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", rootDir: "." }), + 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", rootDir: "." }), + 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", rootDir: "." }), + 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", rootDir: "." }, + { serviceId: "svc-2", region: "us-east1", rootDir: "." }, + ], + 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 new file mode 100644 index 00000000000..def0a2bdfaa --- /dev/null +++ b/src/deploy/run/prepare.ts @@ -0,0 +1,174 @@ +import { needProjectId } from "../../projectUtils"; +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, + RunDeployOptions, + RunServiceSpec, +} from "./args"; + +/** + * Validates CLI flags to ensure incompatible options are not specified simultaneously. + */ +function validateCliFlags(options: RunDeployOptions): { + runtimeOpt?: string; + clearOpt: boolean; +} { + const runtimeOpt = options.runtime || options.baseImage; + const clearOpt = !!(options.clearRuntime || options.clearBaseImage); + + if (runtimeOpt !== undefined && runtimeOpt !== "" && clearOpt) { + throw new FirebaseError( + "Cannot specify both --runtime/--base-image and --clear-runtime/--clear-base-image.", + ); + } + + 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.", + ); + } + + 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] : "")); + + const hasSpecificServiceFilter = runFilterTargets.some((t) => t.length > 0); + const targetedServiceIds = new Set(runFilterTargets.filter((t) => t.length > 0)); + + if (hasSpecificServiceFilter) { + 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( + `Cloud Run service(s) '${missingServiceIds.join(", ")}' not found in firebase.json. Configured services: ${Array.from(configuredServiceIds).join(", ")}`, + ); + } + configs = configs.filter((c) => targetedServiceIds.has(c.serviceId)); + } + + return configs; +} + +/** + * Resolves ABIU base image URI with precedence: + * 1. CLI flags (`--clear-runtime` / `--clear-base-image` vs `--runtime` / `--base-image`) + * 2. Existing Cloud Run service revision template (gcloud-style stickiness) + */ +function resolveBaseImage( + existingService: runv2.Service | undefined, + runtimeOpt?: string, + clearOpt?: boolean, +): { baseImageUri?: string; clearBaseImage: boolean } { + if (clearOpt || runtimeOpt === "") { + return { baseImageUri: undefined, clearBaseImage: true }; + } + if (runtimeOpt !== undefined) { + return { baseImageUri: runtimeOpt, 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: RunDeployOptions, + payload: Payload, +): Promise { + const projectId = needProjectId(options); + context.projectId = 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); + + await prereqs(options, projectId); + + const services: RunServiceSpec[] = []; + payload.run = { + 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 = + options.primaryRegion || + options.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: unknown) { + if ((err as { status?: number })?.status !== 404) { + throw err; + } + } + + const { baseImageUri, clearBaseImage } = resolveBaseImage( + existingService, + runtimeOpt, + clearOpt, + ); + + const sourceDir = options.config.path(config.rootDir || "."); + const yamlPath = path.join(sourceDir, "apphosting.yaml"); + let appHostingConfig: AppHostingYamlConfig | undefined; + if (fileExistsSync(yamlPath)) { + appHostingConfig = await AppHostingYamlConfig.loadFromFile(yamlPath); + } + + services.push({ + serviceId, + region, + source: sourceDir, + ignore: Array.from(new Set([...DEFAULT_RUN_IGNORE, ...(config.ignore || [])])), + existingService, + baseImageUri, + clearBaseImage, + appHostingConfig, + message: options.message as string | undefined, + serviceAccount: options.serviceAccount || config.serviceAccount, + }); + } +} diff --git a/src/deploy/run/prereqs.ts b/src/deploy/run/prereqs.ts new file mode 100644 index 00000000000..91ee338dea3 --- /dev/null +++ b/src/deploy/run/prereqs.ts @@ -0,0 +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 Promise.all([ + ensure(projectId, "run.googleapis.com", "run", true), + ensure(projectId, "cloudbuild.googleapis.com", "cloudbuild", true), + ensure(projectId, "storage.googleapis.com", "storage", true), + artifactregistry.ensureApiEnabled(projectId), + ]); +} diff --git a/src/deploy/run/release.spec.ts b/src/deploy/run/release.spec.ts new file mode 100644 index 00000000000..d738fb23da8 --- /dev/null +++ b/src/deploy/run/release.spec.ts @@ -0,0 +1,102 @@ +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 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: 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 new file mode 100644 index 00000000000..75269d87d50 --- /dev/null +++ b/src/deploy/run/release.ts @@ -0,0 +1,32 @@ +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: Context, options: Options, payload: Payload): 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: unknown) { + logger.debug( + `Failed to delete source archive: gs://${service.storageSource.bucket}/${service.storageSource.object}`, + err, + ); + } + } + + if (service.deployResponse?.uri) { + logger.info(`Service ${service.serviceId} is available at ${service.deployResponse.uri}`); + } + } +} diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 25934174b9a..1bfb251662f 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -366,6 +366,18 @@ export type AppHostingMultiple = AppHostingSingle[]; export type AppHostingConfig = AppHostingSingle | AppHostingMultiple; +export interface RunSingle extends Deployable { + serviceId: string; + region?: string; + rootDir?: string; + ignore?: string[]; + serviceAccount?: 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/firebaseConfigValidate.spec.ts b/src/firebaseConfigValidate.spec.ts index 327011a577b..56b3d149798 100644 --- a/src/firebaseConfigValidate.spec.ts +++ b/src/firebaseConfigValidate.spec.ts @@ -24,6 +24,23 @@ describe("firebaseConfigValidate", () => { expect(isValid).to.be.true; }); + it("should accept a valid run config", () => { + const config: FirebaseConfig = { + run: [ + { + serviceId: "my-service", + region: "us-central1", + rootDir: ".", + }, + ], + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + it("should report an extra top-level field", () => { // This config has an extra 'bananas' top-level property const config = { diff --git a/src/fsAsync.ts b/src/fsAsync.ts index d31ac488efc..feb2c3c906b 100644 --- a/src/fsAsync.ts +++ b/src/fsAsync.ts @@ -46,10 +46,11 @@ async function readdirRecursiveHelper(options: { let currentGitIgnoreStack = options.gitIgnoreStack || []; // Load and stack directory-specific .gitignore rules if supportGitIgnore is enabled if (options.supportGitIgnore) { - if (dirContents.find((n) => n.name === ".gitignore")?.isFile()) { - const localGitIgnore = join(options.path, ".gitignore"); + const hasGitIgnore = dirContents.find((n) => n.name === ".gitignore")?.isFile(); + if (hasGitIgnore) { + const localIgnorePath = join(options.path, ".gitignore"); try { - const lines = readFileSync(localGitIgnore) + const lines = readFileSync(localIgnorePath) .toString() .split("\n") .map((line) => line.trim()) @@ -63,7 +64,7 @@ async function readdirRecursiveHelper(options: { }, ]; } catch (e: unknown) { - logger.debug(`Error reading .gitignore file at ${localGitIgnore}:`, 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 c8e9353b925..3c3f803f26b 100644 --- a/src/gcp/artifactregistry.spec.ts +++ b/src/gcp/artifactregistry.spec.ts @@ -140,4 +140,58 @@ 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("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.ensureRepositoryExists(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.ensureRepositoryExists(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.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 6c589ad21c2..270366904f8 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"; @@ -12,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); } @@ -95,3 +102,69 @@ 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, + }, + }, + ); + 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; +} + +/** + * Ensures an Artifact Registry repository exists, creating it if not. + */ +export async function ensureRepositoryExists( + 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) { + 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 { + throw err; + } + } +} diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index c35e4b44efc..f7d8b7c63c4 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,287 @@ 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").callsFake(async (opts: any) => { + if (opts.onPoll) { + opts.onPoll({ + metadata: { + build: { status: "SUCCESS" }, + }, + }); + } + return { + metadata: { + build: { status: "SUCCESS" }, + }, + }; + }); + }); + + 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", + }, + }); + + 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", + }, + }); + + 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 () => { + 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", + ); + }); + + it("should throw FirebaseError when build status is not SUCCESS", async () => { + postStub.resolves({ + status: 200, + body: { + buildOperation: { + name: "projects/proj/locations/loc/operations/op123", + metadata: { + build: { id: "build-123" }, + }, + }, + }, + }); + pollStub.callsFake(async (opts: any) => { + if (opts.onPoll) { + opts.onPoll({ + metadata: { + build: { status: "FAILURE", statusDetail: "Buildpack compile error" }, + }, + }); + } + return { + metadata: { + build: { 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", () => { + 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"); + }); + }); + + 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 5b3a2117269..f6097825dd0 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"; @@ -31,7 +32,7 @@ export interface Scaling { } export interface Container { - name: string; + name?: string; image: string; command?: string[]; args?: string[]; @@ -63,7 +64,7 @@ export interface RevisionTemplate { vpcAccess?: { connector?: string; egress?: "ALL_TRAFFIC" | "PRIVATE_RANGES_ONLY"; - networkinterfaces?: Array<{ + networkInterfaces?: Array<{ network?: string; subnetwork?: string; tags?: string[]; @@ -84,6 +85,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 @@ -110,7 +118,9 @@ export interface Service { etag: string; template: RevisionTemplate; + traffic?: TrafficTarget[]; invokerIamDisabled?: boolean; + ingress?: string; // Is this redundant with the Build API? buildConfig?: BuildConfig; uri?: string; @@ -151,11 +161,29 @@ export interface Build { functionTarget?: string; storageSource: StorageSource; imageUri: string; - buildpacksBuild: BuildpacksBuild; + 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; + name?: string; + status?: string; + statusDetail?: string; + logUrl?: string; + }; + }; } export interface SubmitBuildResponse { - buildOperation: string; + buildOperation: string | BuildOperationObject; baseImageUri?: string; baseImageWarning?: string; } @@ -168,34 +196,96 @@ 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}`); + throw new FirebaseError(`Failed to submit build: ${res.status}`, { + status: res.status, + }); } - await pollOperation({ - apiOrigin: cloudbuildOrigin(), - apiVersion: "v1", - operationResourceName: res.body.buildOperation, - }); + const op = res.body.buildOperation; + 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({ + pollerName: "Cloud Build Poller", + apiOrigin: cloudbuildOrigin(), + apiVersion: "v1", + operationResourceName, + masterTimeout: 15 * 60 * 1000, + backoff: 2000, + maxBackoff: 10000, + onPoll: (opRes: any) => { + latestBuild = opRes?.metadata?.build || opRes; + }, + doneFn: (opRes: any) => { + const status = opRes?.status || opRes?.metadata?.build?.status; + return ( + status === "SUCCESS" || + status === "FAILURE" || + status === "INTERNAL_ERROR" || + status === "TIMEOUT" || + status === "CANCELLED" + ); + }, + }); + + if (latestBuild && latestBuild.status !== "SUCCESS") { + const detail = latestBuild.statusDetail ? `: ${latestBuild.statusDetail}` : ""; + const consoleLink = + latestBuild.logUrl || + `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}`, + ); + } + } + return { + baseImageUri: res.body.baseImageUri, + baseImageWarning: res.body.baseImageWarning, + }; } /** * Updates an existing Cloud Run service. * Tracks the long-running operation until completion. */ -export async function updateService(service: Omit): Promise { - const fieldMask = proto.fieldMasks( - service, - /* doNotRecurseIn...*/ "labels", - "annotations", - "tags", - ); - // Always update revision name to ensure null generates a new unique revision name. - fieldMask.push("template.revision"); +export async function updateService( + service: Omit, + updateMask?: string[], +): Promise { + let fieldMask: string[]; + if (updateMask) { + fieldMask = updateMask; + } else { + const rawMask = proto.fieldMasks( + service, + /* doNotRecurseIn...*/ + "labels", + "annotations", + "tags", + "scaling", + "template.labels", + "template.annotations", + "template.scaling", + ); + fieldMask = rawMask.filter( + (f) => + f !== "name" && (f !== "template.revision" || service.template?.revision !== undefined), + ); + } + const res = await client.patch, LongRunningOperation>( service.name, service, @@ -209,6 +299,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 +333,9 @@ export async function createService( apiOrigin: runOrigin(), apiVersion: API_VERSION, operationResourceName: res.body.name, + masterTimeout: 10 * 60 * 1000, + backoff: 1000, + maxBackoff: 5000, }); return svc; } @@ -645,11 +741,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", }; @@ -663,6 +761,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. diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 3d4ace315ec..9686cb09b47 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -61,3 +61,4 @@ export { actuate as agentSkillsActuate, AgentSkillsInfo, } from "./agentSkills"; +export { askQuestions as runAskQuestions, actuate as runActuate, RunInfo } from "./run"; diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts new file mode 100644 index 00000000000..eeb6ba0dac4 --- /dev/null +++ b/src/init/features/run.spec.ts @@ -0,0 +1,196 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as runFeature from "./run"; +import * as prompt from "../../prompt"; +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 { + 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, and rootDir", async () => { + const inputStub = sandbox.stub(prompt, "input"); + inputStub.onFirstCall().resolves("custom-service"); + inputStub.onSecondCall().resolves("us-central1"); + inputStub.onThirdCall().resolves("./src"); + + 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", + }); + }); + + it("should throw FirebaseError if projectId is missing", async () => { + const setup = createMockSetup(); + 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); + } + }); + }); + + 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 () => { + const setup = createMockSetup({ projectId: "test-project" }); + const config = new Config({}, {}); + + 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 () => { + const setup = createMockSetup({ + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + }, + }, + }); + const config = new Config({}, {}); + + 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 () => { + const setup = createMockSetup({ + projectId: "test-project", + featureInfo: { + run: { + serviceId: "my-svc", + region: "us-central1", + rootDir: ".", + }, + }, + }); + const config = new Config({}, {}); + sandbox.stub(config, "writeProjectFile"); + 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.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: ".", + }, + }, + }); + 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", + featureInfo: { + run: { + serviceId: "second-svc", + region: "us-central1", + rootDir: "./app2", + }, + }, + }); + const config = new Config( + { + run: [{ serviceId: "first-svc", region: "us-central1", rootDir: "./app1" }], + }, + {}, + ); + sandbox.stub(config, "writeProjectFile"); + existsSyncStub.returns(true); + getServiceStub.resolves({ uri: "https://second-svc.a.run.app" }); + + 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 new file mode 100644 index 00000000000..573c036a49d --- /dev/null +++ b/src/init/features/run.ts @@ -0,0 +1,181 @@ +import * as path from "path"; +import { existsSync } from "fs"; +import { Setup } from "../index"; +import { Config } from "../../config"; +import { input } from "../../prompt"; +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; + region: string; + rootDir: string; +} + +/** + * 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; + if (!projectId) { + throw new FirebaseError("Project ID must be set before initializing Cloud Run.", { exit: 1 }); + } + + logBullet("Configuring Cloud Run..."); + + const defaultServiceId = + options?.service || + options?.serviceId || + 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, + })); + + 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: 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: ".", + })); + + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.run = { + serviceId, + region, + rootDir, + }; +} + +import * as runv2 from "../../gcp/runv2"; +import { logger } from "../../logger"; + +/** + * 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; + if (!runInfo) { + return; + } + const projectId = setup.projectId; + if (!projectId) { + throw new FirebaseError("Project ID must be set before initializing Cloud Run.", { exit: 1 }); + } + + const { serviceId, region, rootDir } = runInfo; + + logBullet("Setting up Cloud Run configuration..."); + + // 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 = { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + template: { + containers: [ + { + image: "us-docker.pkg.dev/cloudrun/container/hello", + }, + ], + }, + 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, + rootDir, + ignore: DEFAULT_RUN_IGNORE, + }; + + upsertRunConfig(runConfig, config); + config.writeProjectFile("firebase.json", config.src); + + // 3. Create placeholder apphosting.yaml + 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}`); + await config.askWriteProjectFile( + apphostingYamlPath, + readTemplateSync("init/apphosting/apphosting.yaml"), + ); + } + + 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; + } + 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]); + } +} diff --git a/src/init/index.ts b/src/init/index.ts index dda64f48834..cde3e7444f0 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,10 +135,23 @@ 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])); +/** + * 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) {