Skip to content

Commit 61552be

Browse files
committed
feat: add functions:export command with internal format support
1 parent 5b722c2 commit 61552be

5 files changed

Lines changed: 165 additions & 0 deletions

File tree

src/commands/functions-export.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Command } from "../command";
2+
import { FirebaseError } from "../error";
3+
import * as iac from "../functions/iac/export";
4+
import { normalizeAndValidate, configForCodebase } from "../functions/projectConfig";
5+
import * as clc from "colorette";
6+
import { logger } from "../logger";
7+
8+
const EXPORTERS: Record<string, iac.Exporter> = {
9+
internal: iac.getInternalIac,
10+
};
11+
12+
export const command = new Command("functions:export")
13+
.description("export Cloud Functions code and configuration")
14+
.option("--format <format>", `Format of the output. Can be ${Object.keys(EXPORTERS).join(", ")}.`)
15+
.option(
16+
"--codebase <codebase>",
17+
"Optional codebase to export. If not specified, exports the default or only codebase.",
18+
)
19+
.action(async (options: any) => {
20+
if (!options.format || !Object.keys(EXPORTERS).includes(options.format)) {
21+
throw new FirebaseError(`Must specify --format as ${Object.keys(EXPORTERS).join(", ")}.`);
22+
}
23+
24+
const config = normalizeAndValidate(options.config?.src?.functions);
25+
let codebaseConfig;
26+
if (options.codebase) {
27+
codebaseConfig = configForCodebase(config, options.codebase);
28+
} else {
29+
if (config.length === 1) {
30+
codebaseConfig = config[0];
31+
} else {
32+
codebaseConfig = configForCodebase(config, "default");
33+
}
34+
}
35+
36+
if (!codebaseConfig.source) {
37+
throw new FirebaseError("Codebase does not have a local source directory.");
38+
}
39+
40+
const manifest = await EXPORTERS[options.format](options, codebaseConfig);
41+
42+
for (const [file, contents] of Object.entries(manifest)) {
43+
logger.info(`Manifest file: ${clc.bold(file)}`);
44+
logger.info(contents);
45+
}
46+
});

src/commands/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ export function load(client: CLIClient): CLIClient {
151151
client.functions.config.set = loadCommand("functions-config-set");
152152
client.functions.config.unset = loadCommand("functions-config-unset");
153153
client.functions.delete = loadCommand("functions-delete");
154+
if (experiments.isEnabled("functionsiac")) {
155+
client.functions.export = loadCommand("functions-export");
156+
}
154157
client.functions.log = loadCommand("functions-log");
155158
client.functions.shell = loadCommand("functions-shell");
156159
client.functions.list = loadCommand("functions-list");

src/experiments.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export const ALL_EXPERIMENTS = experiments({
6969
"Functions created using the V2 API target Cloud Run Functions (not production ready)",
7070
public: false,
7171
},
72+
functionsiac: {
73+
shortDescription: "Exports functions IaC code",
74+
public: false,
75+
},
7276
functionsrunapionly: {
7377
shortDescription: "Use Cloud Run API to list v2 functions",
7478
public: false,

src/functions/iac/export.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as yaml from "js-yaml";
4+
5+
import * as exportIac from "./export";
6+
import * as runtimes from "../../deploy/functions/runtimes";
7+
import * as supported from "../../deploy/functions/runtimes/supported";
8+
import * as functionsConfig from "../../functionsConfig";
9+
import * as functionsEnv from "../../functions/env";
10+
import * as projectUtils from "../../projectUtils";
11+
import * as projectConfig from "../projectConfig";
12+
describe("export", () => {
13+
let needProjectIdStub: sinon.SinonStub;
14+
15+
const mockDelegate = {
16+
language: "nodejs",
17+
runtime: "nodejs18",
18+
validate: sinon.stub(),
19+
build: sinon.stub(),
20+
discoverBuild: sinon.stub(),
21+
};
22+
23+
beforeEach(() => {
24+
sinon.stub(functionsConfig, "getFirebaseConfig").resolves({ projectId: "my-project" });
25+
sinon.stub(functionsEnv, "loadFirebaseEnvs").returns({});
26+
sinon.stub(runtimes, "getRuntimeDelegate").resolves(mockDelegate as any);
27+
sinon.stub(supported, "guardVersionSupport");
28+
needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("my-project");
29+
});
30+
31+
afterEach(() => {
32+
sinon.restore();
33+
mockDelegate.validate.reset();
34+
mockDelegate.build.reset();
35+
mockDelegate.discoverBuild.reset();
36+
});
37+
38+
describe("getInternalIac", () => {
39+
it("should return functions.yaml with discovered build", async () => {
40+
const mockBuild = { endpoints: { "my-func": { platform: "gcfv1" } } };
41+
mockDelegate.discoverBuild.resolves(mockBuild);
42+
43+
const options = { config: { path: (s: string) => s, projectDir: "dir" } };
44+
const codebase: projectConfig.ValidatedSingle = {
45+
source: "src",
46+
codebase: "default",
47+
runtime: "nodejs18",
48+
};
49+
50+
const result = await exportIac.getInternalIac(options, codebase);
51+
52+
expect(needProjectIdStub.calledOnce).to.be.true;
53+
expect(mockDelegate.validate.calledOnce).to.be.true;
54+
expect(mockDelegate.build.calledOnce).to.be.true;
55+
expect(mockDelegate.discoverBuild.calledOnce).to.be.true;
56+
expect(result).to.deep.equal({
57+
"functions.yaml": yaml.dump(mockBuild),
58+
});
59+
});
60+
});
61+
});

src/functions/iac/export.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as runtimes from "../../deploy/functions/runtimes";
2+
import * as supported from "../../deploy/functions/runtimes/supported";
3+
import * as functionsConfig from "../../functionsConfig";
4+
import * as projectConfig from "../projectConfig";
5+
import * as functionsEnv from "../../functions/env";
6+
import { logger } from "../../logger";
7+
import * as yaml from "js-yaml";
8+
import { needProjectId } from "../../projectUtils";
9+
10+
export type Exporter = (
11+
options: any,
12+
codebase: projectConfig.ValidatedSingle,
13+
) => Promise<Record<string, string>>;
14+
15+
/**
16+
*
17+
*/
18+
export async function getInternalIac(
19+
options: any,
20+
codebase: projectConfig.ValidatedSingle,
21+
): Promise<Record<string, string>> {
22+
const projectId = needProjectId(options);
23+
24+
const firebaseConfig = await functionsConfig.getFirebaseConfig(options);
25+
const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId);
26+
27+
const delegateContext: runtimes.DelegateContext = {
28+
projectId,
29+
sourceDir: options.config.path(codebase.source!),
30+
projectDir: options.config.projectDir,
31+
runtime: codebase.runtime,
32+
};
33+
34+
const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext);
35+
logger.debug(`Validating ${runtimeDelegate.language} source`);
36+
supported.guardVersionSupport(runtimeDelegate.runtime);
37+
await runtimeDelegate.validate();
38+
39+
logger.debug(`Building ${runtimeDelegate.language} source`);
40+
await runtimeDelegate.build();
41+
42+
logger.debug(`Discovering ${runtimeDelegate.language} source`);
43+
const build = await runtimeDelegate.discoverBuild(
44+
{}, // Assume empty runtimeConfig
45+
firebaseEnvs,
46+
);
47+
48+
return {
49+
"functions.yaml": yaml.dump(build),
50+
};
51+
}

0 commit comments

Comments
 (0)