Skip to content
Closed
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
47 changes: 47 additions & 0 deletions src/commands/functions-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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,
terraform: iac.getTerraformIac,
};

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 20 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.
if (!options.format || !Object.keys(EXPORTERS).includes(options.format)) {

Check warning on line 21 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 21 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 21 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 25 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 25 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 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
codebaseConfig = configForCodebase(config, options.codebase);

Check warning on line 28 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 28 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");
}
}

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 41 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
99 changes: 99 additions & 0 deletions src/functions/iac/export.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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: "nodejs18",
validate: sinon.stub(),
build: sinon.stub(),
discoverBuild: sinon.stub(),
};

beforeEach(() => {
sinon.stub(functionsConfig, "getFirebaseConfig").resolves({ projectId: "my-project" });
sinon.stub(functionsEnv, "loadFirebaseEnvs").returns({});
sinon.stub(runtimes, "getRuntimeDelegate").resolves(mockDelegate as any);
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: "nodejs18",
};

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),
});
});
});

describe("getTerraformIac", () => {
it("should return variables.tf and main.tf with generated terraform", async () => {
const mockBuild = {
endpoints: {
"my-func": {
platform: "gcfv1",
id: "my-func",
region: ["us-central1"],
entryPoint: "myFunc",
runtime: "nodejs18",
httpsTrigger: {},
},
"ignored-func": {
platform: "gcfv2", // Should be ignored
},
},
};
mockDelegate.discoverBuild.resolves(mockBuild);

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

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

expect(result["variables.tf"]).to.be.a("string");
expect(result["main.tf"]).to.be.a("string");

const mainTf = result["main.tf"];
expect(mainTf).to.include('resource "google_cloudfunctions_function" "my_func"');
expect(mainTf).to.include('resource "google_cloudfunctions_function_iam_binding" "my_func"');
expect(mainTf).to.not.include("ignored-func");
});
});
});
192 changes: 192 additions & 0 deletions src/functions/iac/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 * as tf from "./terraform";
import * as gcfv1 from "../../gcp/cloudfunctions";
import { needProjectId } from "../../projectUtils";

const STANDARD_TF_VARS: tf.Block[] = [
{
type: "variable",
labels: ["project"],
attributes: {
description: "The ID of the project to deploy to.",
},
},
{
type: "variable",
labels: ["location"],
attributes: {
description: "The location to deploy to. Default us-central1 (deprecated)",
default: "us-central1",
},
},
{
type: "variable",
labels: ["gcf_bucket"],
attributes: {
description: "The name of the bucket to deploy to.",
},
},
{
type: "variable",
labels: ["gcf_archive"],
attributes: {
description: "The name of the archive to deploy to.",
},
},
{
type: "variable",
labels: ["extension_id"],
attributes: {
description:
"The extension ID. Used for reverse compatibility when extensions ahve been ported. Injects an env var and adds a function name prefix",
default: null,
},
},
];

export type Exporter = (
options: any,
codebase: projectConfig.ValidatedSingle,
) => Promise<Record<string, string>>;
Comment on lines +53 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The options parameter in the Exporter type is any, which violates the repository's style guide (line 38). Please define a specific interface for the options object to improve type safety and code clarity.

References
  1. The style guide states to never use any or unknown as an escape hatch and to define proper interfaces/types. (link)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again. Options is an any


/**
*
*/
export async function getInternalIac(
options: any,
codebase: projectConfig.ValidatedSingle,
): Promise<Record<string, string>> {
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,
};

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(
{}, // Assume empty runtimeConfig
firebaseEnvs,
);

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

/**
*
*/
export async function getTerraformIac(
options: any,
codebase: projectConfig.ValidatedSingle,
): Promise<Record<string, string>> {
// HACK HACK HACK. This is the cheap way to convince existing code to use/parse
// the terraform interpolated values instead of trying to resolve them at build time.
// Need to create an extension to the contanier contract to support this properly
// (Would replace the FIREBASE_CONFIG and GCLOUD_PROEJCT env vars with a list of
// terraform vars possibly?)
Comment on lines +103 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This 'HACK' comment indicates a temporary workaround. To ensure this technical debt is addressed, it would be beneficial to convert this into a // TODO: comment and, if possible, link to a tracking issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix all hacks before removing the experiment. (This is the next change)

const firebaseConfig = {
authDomain: "${var.project}.firebaseapp.com",
// TOTALLY WRONG. THIS IS ONLY FOR OLD FORMATS.
databaseURL: "https://REALTIME_DATABASE_URLS_ARE_HARD_TO_INJECT.firebaseio.com",
storageBucket: "${var.project}.appspot.com",
};
const firebaseEnvs = {
FIREBASE_CONFIG: JSON.stringify(firebaseConfig),
GCLOUD_PROJECT: "${var.project}",
};

const delegateContext: runtimes.DelegateContext = {
// This is a hack to get the functions SDK to use terraform interpolation
// instead of trying to resolve the project ID at build time.
// TODO: do the same for region.
projectId: "${var.project}",
sourceDir: options.config.path(codebase.source!),
projectDir: options.config.projectDir,
runtime: codebase.runtime,
};

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(
{}, // Assume empty runtimeConfig
firebaseEnvs,
);

// Defining as a local here. Wil eventually be a copy from a data type that fetches
// the local firebase config.
const blocks: tf.Block[] = [
{
type: "locals",
attributes: { firebaseConfig },
},
];

for (const [name, ep] of Object.entries(build.endpoints)) {
if (ep.platform === "gcfv1") {
blocks.push(
...gcfv1.terraformFromEndpoint(
name,
ep,
tf.expr("var.gcf_bucket"),
tf.expr("var.gcf_archive"),
),
);
} else {
logger.debug(`Skipping ${name} because it is not a GCFv1 function`);
}
}

blocks.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
const leftLabels = left.labels || [];
const rightLabels = right.labels || [];
const len = Math.min(leftLabels.length, rightLabels.length);
for (let i = 0; i < len; i++) {
const labelCompare = leftLabels[i].localeCompare(rightLabels[i]);
if (labelCompare !== 0) {
return labelCompare;
}
}
if (leftLabels.length !== rightLabels.length) {
return leftLabels.length - rightLabels.length;
}

logger.warn("Unexpected: two blocks with identical types and labels");
return 0;
});
Comment thread
inlined marked this conversation as resolved.

return {
"variables.tf": STANDARD_TF_VARS.map(tf.blockToString).join("\n\n"),
"main.tf": blocks.map(tf.blockToString).join("\n\n"),
};
}
Loading
Loading