Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/commands/functions-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Command } from "../command";
import { FirebaseError } from "../error";
import * as iac from "../functions/iac/export";
import { normalizeAndValidate, configForCodebase } from "../functions/projectConfig";
import * as clc from "colorette";
import { logger } from "../logger";

const EXPORTERS: Record<string, iac.Exporter> = {
internal: iac.getInternalIac,
};

export const command = new Command("functions:export")
.description("export Cloud Functions code and configuration")
.option("--format <format>", `Format of the output. Can be ${Object.keys(EXPORTERS).join(", ")}.`)
.option(
"--codebase <codebase>",
"Optional codebase to export. If not specified, exports the default or only codebase.",
)
.action(async (options: any) => {

Check warning on line 19 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
Comment thread
inlined marked this conversation as resolved.
Comment thread
inlined marked this conversation as resolved.
if (!options.format || !Object.keys(EXPORTERS).includes(options.format)) {

Check warning on line 20 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .format on an `any` value

Check warning on line 20 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 20 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .format on an `any` value
throw new FirebaseError(`Must specify --format as ${Object.keys(EXPORTERS).join(", ")}.`);
}

const config = normalizeAndValidate(options.config?.src?.functions);

Check warning on line 24 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .config on an `any` value

Check warning on line 24 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `FunctionsConfig | undefined`
let codebaseConfig;
if (options.codebase) {

Check warning on line 26 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .codebase on an `any` value
codebaseConfig = configForCodebase(config, options.codebase);

Check warning on line 27 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .codebase on an `any` value

Check warning on line 27 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string`
} else if (config.length === 1) {
codebaseConfig = config[0];
} else {
codebaseConfig = configForCodebase(config, "default");
}
Comment thread
inlined marked this conversation as resolved.
Comment thread
inlined marked this conversation as resolved.

if (!codebaseConfig.source) {
throw new FirebaseError("Codebase does not have a local source directory.");
}

const manifest = await EXPORTERS[options.format](options, codebaseConfig);

Check warning on line 38 in src/commands/functions-export.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .format on an `any` value

for (const [file, contents] of Object.entries(manifest)) {
logger.info(`Manifest file: ${clc.bold(file)}`);
logger.info(contents);
}
});
3 changes: 3 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ export function load(client: CLIClient): CLIClient {
client.functions.config.set = loadCommand("functions-config-set");
client.functions.config.unset = loadCommand("functions-config-unset");
client.functions.delete = loadCommand("functions-delete");
if (experiments.isEnabled("functionsiac")) {
client.functions.export = loadCommand("functions-export");
}
client.functions.log = loadCommand("functions-log");
client.functions.shell = loadCommand("functions-shell");
client.functions.list = loadCommand("functions-list");
Expand Down
4 changes: 4 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export const ALL_EXPERIMENTS = experiments({
"Functions created using the V2 API target Cloud Run Functions (not production ready)",
public: false,
},
functionsiac: {
shortDescription: "Exports functions IaC code",
public: false,
},
functionsrunapionly: {
shortDescription: "Use Cloud Run API to list v2 functions",
public: false,
Expand Down
90 changes: 90 additions & 0 deletions src/functions/iac/export.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as yaml from "js-yaml";

import * as exportIac from "./export";
import * as runtimes from "../../deploy/functions/runtimes";
import * as supported from "../../deploy/functions/runtimes/supported";
import * as functionsConfig from "../../functionsConfig";
import * as functionsEnv from "../../functions/env";
import * as projectUtils from "../../projectUtils";
import * as projectConfig from "../projectConfig";
describe("export", () => {
let needProjectIdStub: sinon.SinonStub;

const mockDelegate = {
language: "nodejs",
runtime: supported.latest("nodejs"),
validate: sinon.stub(),
build: sinon.stub(),
discoverBuild: sinon.stub(),
bin: "node",
watch: sinon.stub(),
} as const;

beforeEach(() => {
sinon.stub(functionsConfig, "getFirebaseConfig").resolves({ projectId: "my-project" });
sinon.stub(functionsEnv, "loadFirebaseEnvs").returns({});
sinon.stub(runtimes, "getRuntimeDelegate").resolves(mockDelegate);
sinon.stub(supported, "guardVersionSupport");
needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("my-project");
});

afterEach(() => {
sinon.restore();
mockDelegate.validate.reset();
mockDelegate.build.reset();
mockDelegate.discoverBuild.reset();
});

describe("getInternalIac", () => {
it("should return functions.yaml with discovered build", async () => {
const mockBuild = { endpoints: { "my-func": { platform: "gcfv1" } } };
mockDelegate.discoverBuild.resolves(mockBuild);

const options = { config: { path: (s: string) => s, projectDir: "dir" } };
const codebase: projectConfig.ValidatedSingle = {
source: "src",
codebase: "default",
runtime: supported.latest("nodejs") as supported.ActiveRuntime,
};

const result = await exportIac.getInternalIac(options, codebase);

expect(needProjectIdStub.calledOnce).to.be.true;
expect(mockDelegate.validate.calledOnce).to.be.true;
expect(mockDelegate.build.calledOnce).to.be.true;
expect(mockDelegate.discoverBuild.calledOnce).to.be.true;
expect(result).to.deep.equal({
"functions.yaml": yaml.dump(mockBuild),
});
});

it("should throw if codebase has no source", async () => {
const options = { config: { path: (s: string) => s, projectDir: "dir" } };
const codebase: projectConfig.ValidatedSingle = {
codebase: "default",
runtime: supported.latest("nodejs") as supported.ActiveRuntime,
} as unknown as projectConfig.ValidatedSingle;

await expect(exportIac.getInternalIac(options, codebase)).to.be.rejectedWith(
"Cannot export a codebase with no source",
);
});

it("should throw an error if discoverBuild fails", async () => {
mockDelegate.discoverBuild.rejects(new Error("Failed to discover build"));

const options = { config: { path: (s: string) => s, projectDir: "dir" } };
const codebase: projectConfig.ValidatedSingle = {
source: "src",
codebase: "default",
runtime: supported.latest("nodejs") as supported.ActiveRuntime,
};

await expect(exportIac.getInternalIac(options, codebase)).to.be.rejectedWith(
"Failed to discover build",
);
});
});
});
Comment thread
inlined marked this conversation as resolved.
55 changes: 55 additions & 0 deletions src/functions/iac/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as runtimes from "../../deploy/functions/runtimes";
import * as supported from "../../deploy/functions/runtimes/supported";
import * as functionsConfig from "../../functionsConfig";
import * as projectConfig from "../projectConfig";
import * as functionsEnv from "../../functions/env";
import { logger } from "../../logger";
import * as yaml from "js-yaml";
import { needProjectId } from "../../projectUtils";
import { FirebaseError } from "../../error";

export type Exporter = (
options: any,
Comment thread
inlined marked this conversation as resolved.
codebase: projectConfig.ValidatedSingle,
) => Promise<Record<string, string>>;

/**
* Exports the functions.yaml format of the codebase.
*/
Comment thread
inlined marked this conversation as resolved.
export async function getInternalIac(
options: any,
Comment thread
inlined marked this conversation as resolved.
Comment thread
inlined marked this conversation as resolved.
codebase: projectConfig.ValidatedSingle,
): Promise<Record<string, string>> {
if (!codebase.source) {
throw new FirebaseError("Cannot export a codebase with no source");
}
const projectId = needProjectId(options);

const firebaseConfig = await functionsConfig.getFirebaseConfig(options);
const firebaseEnvs = functionsEnv.loadFirebaseEnvs(firebaseConfig, projectId);

const delegateContext: runtimes.DelegateContext = {
projectId,
sourceDir: options.config.path(codebase.source),
projectDir: options.config.projectDir,
runtime: codebase.runtime,
};
Comment thread
inlined marked this conversation as resolved.

const runtimeDelegate = await runtimes.getRuntimeDelegate(delegateContext);
logger.debug(`Validating ${runtimeDelegate.language} source`);
supported.guardVersionSupport(runtimeDelegate.runtime);
await runtimeDelegate.validate();

logger.debug(`Building ${runtimeDelegate.language} source`);
await runtimeDelegate.build();

logger.debug(`Discovering ${runtimeDelegate.language} source`);
const build = await runtimeDelegate.discoverBuild(
Comment thread
inlined marked this conversation as resolved.
{}, // Assume empty runtimeConfig
firebaseEnvs,
);

return {
"functions.yaml": yaml.dump(build),
};
}
Loading