diff --git a/src/commands/internaltesting-functions-discover.ts b/src/commands/internaltesting-functions-discover.ts index fe756df2b21..ed9ffb03a7a 100644 --- a/src/commands/internaltesting-functions-discover.ts +++ b/src/commands/internaltesting-functions-discover.ts @@ -41,13 +41,15 @@ export const command = new Command("internaltesting:functions:discover") } } - const wantBuilds = await loadCodebases( - fnConfig, - options, + const result = await loadCodebases({ + projectId, + projectDir: options.config.projectDir, + projectAlias: options.projectAlias, + config: fnConfig, firebaseConfig, runtimeConfig, - undefined, // no filters - ); + }); + const wantBuilds = result.builds; logger.info(JSON.stringify(wantBuilds, null, 2)); return wantBuilds; diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index d0ee5d59f0b..f4d528de341 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -9,10 +9,13 @@ import * as serviceusage from "../../gcp/serviceusage"; import * as prompt from "../../prompt"; import { RuntimeDelegate } from "./runtimes"; import { FirebaseError } from "../../error"; -import { Options } from "../../options"; import { ValidatedConfig } from "../../functions/projectConfig"; +import * as functionsEnv from "../../functions/env"; import { BEFORE_CREATE_EVENT, BEFORE_SIGN_IN_EVENT } from "../../functions/events/v1"; import { latest } from "./runtimes/supported"; +import * as path from "path"; +import * as prepareFunctionsUpload from "./prepareFunctionsUpload"; +import * as remoteSource from "./remoteSource"; describe("prepare", () => { const ENDPOINT_BASE: Omit = { @@ -33,6 +36,11 @@ describe("prepare", () => { let sandbox: sinon.SinonSandbox; let runtimeDelegateStub: RuntimeDelegate; let discoverBuildStub: sinon.SinonStub; + let getRemoteSourceStub: sinon.SinonStub; + let loadUserEnvsStub: sinon.SinonStub; + + const EXTRACTED_SOURCE_DIR = "/tmp/extracted-source"; + const DEFAULT_ENVS = { FOO: "bar" }; beforeEach(() => { sandbox = sinon.createSandbox(); @@ -58,6 +66,9 @@ describe("prepare", () => { }), ); sandbox.stub(runtimes, "getRuntimeDelegate").resolves(runtimeDelegateStub); + getRemoteSourceStub = sandbox.stub(remoteSource, "getRemoteSource").resolves(EXTRACTED_SOURCE_DIR); + sandbox.stub(remoteSource, "requireFunctionsYaml"); + loadUserEnvsStub = sandbox.stub(functionsEnv, "loadUserEnvs").returns(DEFAULT_ENVS); }); afterEach(() => { @@ -68,16 +79,16 @@ describe("prepare", () => { const config: ValidatedConfig = [ { source: "source", codebase: "codebase", prefix: "my-prefix", runtime: "nodejs22" }, ]; - const options = { - config: { - path: (p: string) => p, - }, - projectId: "project", - } as unknown as Options; const firebaseConfig = { projectId: "project" }; const runtimeConfig = {}; - const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + const { builds } = await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); expect(Object.keys(builds.codebase.endpoints)).to.deep.equal(["my-prefix-test"]); }); @@ -86,16 +97,16 @@ describe("prepare", () => { const config: ValidatedConfig = [ { source: "source", codebase: "codebase", runtime: "nodejs20" }, ]; - const options = { - config: { - path: (p: string) => p, - }, - projectId: "project", - } as unknown as Options; const firebaseConfig = { projectId: "project" }; const runtimeConfig = {}; - const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + const { builds } = await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); expect(builds.codebase.runtime).to.equal("nodejs20"); }); @@ -109,16 +120,16 @@ describe("prepare", () => { runtime: "nodejs22", }, ]; - const options = { - config: { - path: (p: string) => p, - }, - projectId: "project", - } as unknown as Options; const firebaseConfig = { projectId: "project" }; const runtimeConfig = { firebase: firebaseConfig, customKey: "customValue" }; - await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); expect(discoverBuildStub.calledOnce).to.be.true; const callArgs = discoverBuildStub.firstCall.args; @@ -135,22 +146,150 @@ describe("prepare", () => { runtime: "nodejs22", }, ]; - const options = { - config: { - path: (p: string) => p, - }, - projectId: "project", - } as unknown as Options; const firebaseConfig = { projectId: "project" }; const runtimeConfig = { firebase: firebaseConfig, customKey: "customValue" }; - await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); expect(discoverBuildStub.calledOnce).to.be.true; const callArgs = discoverBuildStub.firstCall.args; expect(callArgs[0]).to.deep.equal(runtimeConfig); expect(callArgs[0]).to.have.property("customKey", "customValue"); }); + + it("should handle remote sources by extracting and discovering", async () => { + const config: ValidatedConfig = [ + { + codebase: "remote", + remoteSource: { repository: "user/repo", ref: "main" }, + runtime: "nodejs20", + }, + ]; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = { firebase: firebaseConfig }; + + const { builds, sourceDirs, envs } = await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); + + expect(remoteSource.getRemoteSource).to.have.been.calledWith( + /* repository= */ "user/repo", + /* ref= */ "main", + /* destDir= */ sinon.match.string, + /* subDir= */ undefined, + ); + expect(builds.remote).to.exist; + expect(sourceDirs.remote).to.equal(EXTRACTED_SOURCE_DIR); + expect(envs.remote).to.deep.equal({}); + }); + + it("should pass configDir to loadUserEnvs for remote sources", async () => { + const config: ValidatedConfig = [ + { + codebase: "remote", + remoteSource: { repository: "user/repo", ref: "main" }, + runtime: "nodejs20", + configDir: "config-dir", + }, + ]; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = { firebase: firebaseConfig }; + + const { envs, sourceDirs } = await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); + + expect(sourceDirs.remote).to.equal(EXTRACTED_SOURCE_DIR); + expect(functionsEnv.loadUserEnvs).to.have.been.calledWith( + sinon.match({ + functionsSource: EXTRACTED_SOURCE_DIR, + configDir: path.resolve("/project", "config-dir"), + }), + ); + expect(envs.remote).to.deep.equal(DEFAULT_ENVS); + }); + + it("should pass dir to getRemoteSource for monorepo support", async () => { + const config: ValidatedConfig = [ + { + codebase: "remote", + remoteSource: { repository: "user/repo", ref: "v1.0.0", dir: "packages/functions" }, + runtime: "nodejs20", + }, + ]; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = { firebase: firebaseConfig }; + + await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); + + expect(remoteSource.getRemoteSource).to.have.been.calledWith( + "user/repo", + "v1.0.0", + sinon.match.string, + "packages/functions", + ); + }); + + it("should require functions.yaml for remote sources", async () => { + const config: ValidatedConfig = [ + { + codebase: "remote", + remoteSource: { repository: "user/repo", ref: "main" }, + runtime: "nodejs20", + }, + ]; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = { firebase: firebaseConfig }; + + await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); + + expect(remoteSource.requireFunctionsYaml).to.have.been.calledWith(EXTRACTED_SOURCE_DIR); + }); + + it("should not call getRemoteSource for local sources", async () => { + const config: ValidatedConfig = [ + { source: "functions", codebase: "local", runtime: "nodejs20" }, + ]; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = { firebase: firebaseConfig }; + + await prepare.loadCodebases({ + projectId: "project", + projectDir: "/project", + config, + firebaseConfig, + runtimeConfig, + }); + + expect(remoteSource.getRemoteSource).to.not.have.been.called; + expect(remoteSource.requireFunctionsYaml).to.not.have.been.called; + }); }); describe("inferDetailsFromExisting", () => { diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 0cc513dda0b..52aff5f3e80 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -1,7 +1,8 @@ import * as clc from "colorette"; +import * as path from "path"; import * as args from "./args"; -import * as proto from "../../gcp/proto"; + import * as backend from "./backend"; import * as build from "./build"; import * as ensureApiEnabled from "../../ensureApiEnabled"; @@ -21,7 +22,6 @@ import { storageOrigin, secretManagerOrigin, } from "../../api"; -import { Options } from "../../options"; import { EndpointFilter, endpointMatchesAnyFilter, @@ -41,22 +41,48 @@ import { configForCodebase, normalizeAndValidate, ValidatedConfig, - requireLocal, shouldUseRuntimeConfig, + isLocalConfig, } from "../../functions/projectConfig"; import { AUTH_BLOCKING_EVENTS } from "../../functions/events/v1"; import { generateServiceIdentity } from "../../gcp/serviceusage"; import { applyBackendHashToBackends } from "./cache/applyHash"; import { allEndpoints, Backend } from "./backend"; +import * as remoteSource from "./remoteSource"; import { assertExhaustive } from "../../functional"; import { prepareDynamicExtensions } from "../extensions/prepare"; import { Context as ExtContext, Payload as ExtPayload } from "../extensions/args"; +import * as tmp from "tmp"; import { DeployOptions } from ".."; import * as prompt from "../../prompt"; import * as experiments from "../../experiments"; export const EVENTARC_SOURCE_ENV = "EVENTARC_CLOUD_EVENT_SOURCE"; +/** + * The result of loading codebases. + * @internal + */ +export interface LoadedCodebases { + /** Map of codebase ID to the Build manifest discovered for that codebase. */ + builds: Record; + /** Map of codebase ID to the resolved absolute source directory on the local file system. */ + sourceDirs: Record; + /** Map of codebase ID to the loaded user environment variables. */ + envs: Record>; +} + +/** @internal */ +export interface DiscoveryContext { + projectId: string; + projectDir: string; + projectAlias?: string; + config: ValidatedConfig; + filters?: EndpointFilter[]; + firebaseConfig: args.FirebaseConfig; + runtimeConfig: Record; +} + /** * Prepare functions codebases for deploy. */ @@ -97,7 +123,7 @@ export async function prepare( let runtimeConfig: Record = { firebase: firebaseConfig }; const allowFunctionsConfig = experiments.isEnabled("legacyRuntimeConfigCommands"); - const targetedCodebaseConfigs = context.config!.filter((cfg) => codebases.includes(cfg.codebase)); + const targetedCodebaseConfigs = context.config.filter((cfg) => codebases.includes(cfg.codebase)); // Load runtime config if API is enabled and at least one targeted codebase uses it if ( @@ -111,14 +137,17 @@ export async function prepare( // Track whether legacy runtime config is present (i.e., any keys other than the default 'firebase'). // This drives GA4 metric `has_runtime_config` in the functions deploy reporter. context.hasRuntimeConfig = Object.keys(runtimeConfig).some((k) => k !== "firebase"); - - const wantBuilds = await loadCodebases( - context.config, - options, + // Phase 1. Load codebases + const loadedCodebases = await loadCodebases({ + projectId, + projectDir: options.config.projectDir, + projectAlias: options.projectAlias, + config: context.config, + filters: context.filters, firebaseConfig, runtimeConfig, - context.filters, - ); + }); + const { builds: wantBuilds, sourceDirs: loadedSourceDirs, envs: loadedEnvs } = loadedCodebases; // == Phase 1.5 Prepare extensions found in codebases if any if (Object.values(wantBuilds).some((b) => b.extensions)) { @@ -134,15 +163,20 @@ export async function prepare( const wantBackends: Record = {}; for (const [codebase, wantBuild] of Object.entries(wantBuilds)) { const config = configForCodebase(context.config, codebase); - const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId); - const localCfg = requireLocal(config, "Remote sources are not supported."); + const firebaseEnvs = functionsEnv.loadFirebaseEnvs(context.firebaseConfig, projectId); + + // Environment variables are now loaded in Phase 1 (loadCodebases) + const userEnvs = loadedEnvs[codebase] || {}; + const userEnvOpt: functionsEnv.UserEnvsOpts = { - functionsSource: options.config.path(localCfg.source), + functionsSource: loadedSourceDirs[codebase], projectId: projectId, projectAlias: options.projectAlias, }; - proto.convertIfPresent(userEnvOpt, localCfg, "configDir", (cd) => options.config.path(cd)); - const userEnvs = functionsEnv.loadUserEnvs(userEnvOpt); + if (config.configDir) { + userEnvOpt.configDir = options.config.path(config.configDir); + } + const envs = { ...userEnvs, ...firebaseEnvs }; const { backend: wantBackend, envs: resolvedEnvs } = await build.resolveBackend({ @@ -153,7 +187,9 @@ export async function prepare( isEmulator: false, }); - functionsEnv.writeResolvedParams(resolvedEnvs, userEnvs, userEnvOpt); + if (isLocalConfig(config) || config.configDir) { + functionsEnv.writeResolvedParams(resolvedEnvs, userEnvs, userEnvOpt); + } let hasEnvsFromParams = false; wantBackend.environmentVariables = envs; @@ -188,7 +224,7 @@ export async function prepare( endpoint.codebase = codebase; } wantBackends[codebase] = wantBackend; - if (functionsEnv.hasUserEnvs(userEnvOpt) || hasEnvsFromParams) { + if (isLocalConfig(config) && (functionsEnv.hasUserEnvs(userEnvOpt) || hasEnvsFromParams)) { codebaseUsesEnvs.push(codebase); } @@ -217,26 +253,25 @@ export async function prepare( // ===Phase 3. Prepare source for upload. context.sources = {}; for (const [codebase, wantBackend] of Object.entries(wantBackends)) { - const cfg = configForCodebase(context.config, codebase); - const localCfg = requireLocal(cfg, "Remote sources are not supported."); - const sourceDirName = localCfg.source; - const sourceDir = options.config.path(sourceDirName); + const config = configForCodebase(context.config, codebase); + const sourceDir = loadedSourceDirs[codebase]; + if (!sourceDir) { + throw new FirebaseError(`Internal error: missing source directory for codebase ${codebase}.`); + } const source: args.Source = {}; if (backend.someEndpoint(wantBackend, () => true)) { - logLabeledBullet( - "functions", - `preparing ${clc.bold(sourceDirName)} directory for uploading...`, - ); + const sourceName = isLocalConfig(config) ? config.source : "remote source"; + logLabeledBullet("functions", `preparing ${clc.bold(sourceName)} directory for uploading...`); } if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2")) { - const packagedSource = await prepareFunctionsUpload(sourceDir, localCfg); + const packagedSource = await prepareFunctionsUpload(sourceDir, config); source.functionsSourceV2 = packagedSource?.pathToSource; source.functionsSourceV2Hash = packagedSource?.hash; } if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv1")) { - const configForUpload = shouldUseRuntimeConfig(localCfg) ? runtimeConfig : undefined; - const packagedSource = await prepareFunctionsUpload(sourceDir, localCfg, configForUpload); + const configForUpload = shouldUseRuntimeConfig(config) ? runtimeConfig : undefined; + const packagedSource = await prepareFunctionsUpload(sourceDir, config, configForUpload); source.functionsSourceV1 = packagedSource?.pathToSource; source.functionsSourceV1Hash = packagedSource?.hash; } @@ -446,40 +481,51 @@ export function resolveCpuAndConcurrency(want: backend.Backend): void { * Exported for use by an internal command (internaltesting:functions:discover) only. * @internal */ -export async function loadCodebases( - config: ValidatedConfig, - options: Options, - firebaseConfig: args.FirebaseConfig, - runtimeConfig: Record, - filters?: EndpointFilter[], -): Promise> { - const codebases = targetCodebases(config, filters); - const projectId = needProjectId(options); +export async function loadCodebases(context: DiscoveryContext): Promise { + const codebases = targetCodebases(context.config, context.filters); + const projectId = context.projectId; const wantBuilds: Record = {}; + const sourceDirs: Record = {}; + const envs: Record> = {}; + for (const codebase of codebases) { - const codebaseConfig = configForCodebase(config, codebase); - const sourceDirName = codebaseConfig.source; - if (!sourceDirName) { - throw new FirebaseError( - `No functions code detected at default location (./functions), and no functions source defined in firebase.json`, - ); + const codebaseConfig = configForCodebase(context.config, codebase); + let sourceDir: string; + + if (isLocalConfig(codebaseConfig)) { + const sourceDirName = codebaseConfig.source; + if (!sourceDirName) { + throw new FirebaseError( + `No functions code detected at default location (./functions), and no functions source defined in firebase.json`, + ); + } + sourceDir = path.resolve(context.projectDir, sourceDirName); + } else { + const { repository, ref, dir } = codebaseConfig.remoteSource; + const tmpObj = tmp.dirSync({ + prefix: `firebase - functions - ${codebase} -`, + unsafeCleanup: true, + }); + sourceDir = await remoteSource.getRemoteSource(repository, ref, tmpObj.name, dir); + remoteSource.requireFunctionsYaml(sourceDir); } - const sourceDir = options.config.path(sourceDirName); + sourceDirs[codebase] = sourceDir; + const delegateContext: runtimes.DelegateContext = { projectId, sourceDir, - projectDir: options.config.projectDir, + projectDir: context.projectDir, runtime: codebaseConfig.runtime, }; const firebaseJsonRuntime = codebaseConfig.runtime; if (firebaseJsonRuntime && !supported.isRuntime(firebaseJsonRuntime as string)) { throw new FirebaseError( `Functions codebase ${codebase} has invalid runtime ` + - `${firebaseJsonRuntime} specified in firebase.json. Valid values are: \n` + - Object.keys(supported.RUNTIMES) - .map((s) => `- ${s}`) - .join("\n"), + `${firebaseJsonRuntime} specified in firebase.json. Valid values are:\n` + + Object.keys(supported.RUNTIMES) + .map((s) => `- ${s}`) + .join("\n"), ); } const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext); @@ -489,15 +535,15 @@ export async function loadCodebases( logger.debug(`Building ${runtimeDelegate.language} source`); await runtimeDelegate.build(); - const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId); + const firebaseEnvs = functionsEnv.loadFirebaseEnvs(context.firebaseConfig, projectId); logLabeledBullet( "functions", `Loading and analyzing source code for codebase ${codebase} to determine what to deploy`, ); const codebaseRuntimeConfig = shouldUseRuntimeConfig(codebaseConfig) - ? runtimeConfig - : { firebase: firebaseConfig }; + ? context.runtimeConfig + : { firebase: context.firebaseConfig }; const discoveredBuild = await runtimeDelegate.discoverBuild(codebaseRuntimeConfig, { ...firebaseEnvs, @@ -509,8 +555,26 @@ export async function loadCodebases( discoveredBuild.runtime = codebaseConfig.runtime; build.applyPrefix(discoveredBuild, codebaseConfig.prefix || ""); wantBuilds[codebase] = discoveredBuild; + + // Load user environment variables + const userEnvOpt: functionsEnv.UserEnvsOpts = { + functionsSource: sourceDir, + projectId: projectId, + projectAlias: context.projectAlias, + }; + if (codebaseConfig.configDir) { + userEnvOpt.configDir = path.resolve(context.projectDir, codebaseConfig.configDir); + } + + // For remote sources, environment variables are only loaded if 'configDir' is explicitly specified. + // For local sources, they default to loading from the source directory. + let userEnvs: Record = {}; + if (codebaseConfig.configDir || isLocalConfig(codebaseConfig)) { + userEnvs = functionsEnv.loadUserEnvs(userEnvOpt); + } + envs[codebase] = userEnvs; } - return wantBuilds; + return { builds: wantBuilds, sourceDirs, envs }; } // Genkit almost always requires an API key, so warn if the customer is about to deploy diff --git a/src/downloadUtils.ts b/src/downloadUtils.ts index 4d3addff762..9479f65f6f0 100644 --- a/src/downloadUtils.ts +++ b/src/downloadUtils.ts @@ -31,12 +31,13 @@ export async function downloadToTmp(remoteUrl: string, auth: boolean = false): P } const total = parseInt(res.response.headers.get("content-length") || "0", 10); - const totalMb = Math.ceil(total / 1000000); - const bar = new ProgressBar(`Progress: :bar (:percent of ${totalMb}MB)`, { total, head: ">" }); - - res.body.on("data", (chunk: string) => { - bar.tick(chunk.length); - }); + if (total > 0) { + const totalMb = Math.ceil(total / 1000000); + const bar = new ProgressBar(`Progress: :bar (:percent of ${totalMb}MB)`, { total, head: ">" }); + res.body.on("data", (chunk: string) => { + bar.tick(chunk.length); + }); + } await new Promise((resolve) => { writeStream.on("finish", resolve); diff --git a/src/functions/projectConfig.spec.ts b/src/functions/projectConfig.spec.ts index 107442222dc..41f2f11fa29 100644 --- a/src/functions/projectConfig.spec.ts +++ b/src/functions/projectConfig.spec.ts @@ -34,14 +34,14 @@ describe("projectConfig", () => { // @ts-expect-error invalid function config for test expect(() => projectConfig.validate([{ runtime: "nodejs22" }])).to.throw( FirebaseError, - /codebase source must be specified/, + /Must specify either 'source' or 'remoteSource'/, ); }); it("fails validation given config w/ empty source", () => { expect(() => projectConfig.validate([{ source: "" }])).to.throw( FirebaseError, - /codebase source must be specified/, + /Must specify either 'source' or 'remoteSource'/, ); }); @@ -256,14 +256,14 @@ describe("projectConfig", () => { // @ts-expect-error invalid function config for test expect(() => projectConfig.normalizeAndValidate({ runtime: "nodejs22" })).to.throw( FirebaseError, - /codebase source must be specified/, + /Must specify either 'source' or 'remoteSource'/, ); }); it("fails validation given singleton config w empty source", () => { expect(() => projectConfig.normalizeAndValidate({ source: "" })).to.throw( FirebaseError, - /codebase source must be specified/, + /Must specify either 'source' or 'remoteSource'/, ); }); @@ -271,7 +271,7 @@ describe("projectConfig", () => { // @ts-expect-error invalid function config for test expect(() => projectConfig.normalizeAndValidate([{ runtime: "nodejs22" }])).to.throw( FirebaseError, - /codebase source must be specified/, + /Must specify either 'source' or 'remoteSource'/, ); }); diff --git a/src/functions/projectConfig.ts b/src/functions/projectConfig.ts index a9da9e0dafe..0bfecaca4de 100644 --- a/src/functions/projectConfig.ts +++ b/src/functions/projectConfig.ts @@ -87,7 +87,7 @@ function validateSingle(config: FunctionConfig): ValidatedSingle { } if (!source && !remoteSource) { throw new FirebaseError( - "codebase source must be specified. Must specify either 'source' or 'remoteSource' in a functions config.", + "Must specify either 'source' or 'remoteSource' in functions configuration.", ); }