Skip to content
Open
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
24 changes: 21 additions & 3 deletions src/commands/functions-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
import { normalizeAndValidate, configForCodebase } from "../functions/projectConfig";
import * as clc from "colorette";
import { logger } from "../logger";
import { mkdirsSync } from "fs-extra";
import { writeFile } from "node:fs/promises";
import * as path from "node:path";

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

export const command = new Command("functions:export")
Expand All @@ -16,15 +20,19 @@
"--codebase <codebase>",
"Optional codebase to export. If not specified, exports the default or only codebase.",
)
.option(
"--out <out>",
"Optional directory to output the files to. If not specified, prints to stdout.",
)
.action(async (options: any) => {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (!options.format || !Object.keys(EXPORTERS).includes(options.format)) {

Check warning on line 28 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 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`

Check warning on line 28 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 32 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 32 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 34 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 35 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 35 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 {
Expand All @@ -35,10 +43,20 @@
throw new FirebaseError("Codebase does not have a local source directory.");
}

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

Check warning on line 46 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);
if (!options.out) {
for (const [file, contents] of Object.entries(manifest)) {
logger.info(`Manifest file: ${clc.bold(file)}`);
logger.info(contents);
}
} else {
mkdirsSync(options.out);
await Promise.all(
Object.entries(manifest).map(([file, contents]) => {
logger.info(`Writing manifest file: ${clc.bold(path.join(options.out, file))}`);
return writeFile(path.join(options.out, file), contents);
}),
);
}
});
38 changes: 38 additions & 0 deletions src/functions/iac/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,42 @@ describe("export", () => {
);
});
});

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"');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could you assert on the entire string, instead of includes, kind of like minion? This will also allow me to see a full example in case there are any missed edges.

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.

Did a bunch of work to create good looking formatted strings. Take another look.

expect(mainTf).to.not.include("ignored-func");
});
});
});
169 changes: 169 additions & 0 deletions src/functions/iac/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,79 @@ 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";
import { FirebaseError } from "../../error";

const STANDARD_PROVIDER_BLOCKS: tf.Block[] = [
{
type: "terraform",
attributes: {},
nestedBlocks: [
{
type: "required_providers",
attributes: {
google: {
source: "hashicorp/google",
version: "~> 5.0",
},
},
},
],
},

{
type: "provider",
labels: ["google"],
attributes: {
project: tf.expr("var.project"),
region: tf.expr("var.location"),
},
},
];

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 have been ported. Injects an env var and adds a function name prefix",
default: null,
Comment thread
inlined marked this conversation as resolved.
},
},
];

export type Exporter = (
options: any,
codebase: projectConfig.ValidatedSingle,
Expand Down Expand Up @@ -53,3 +123,102 @@ export async function getInternalIac(
"functions.yaml": yaml.dump(build),
};
}

/**
* Exports the codebase as a series of Terraform files.
*/
Comment thread
inlined marked this conversation as resolved.
export async function getTerraformIac(
options: any,
Comment thread
inlined marked this conversation as resolved.
codebase: projectConfig.ValidatedSingle,
): Promise<Record<string, string>> {
Comment thread
inlined marked this conversation as resolved.
// 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?)
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;
});

return {
"provider.tf": STANDARD_PROVIDER_BLOCKS.map(tf.blockToString).join("\n\n"),
"variables.tf": STANDARD_TF_VARS.map(tf.blockToString).join("\n\n"),
"main.tf": blocks.map(tf.blockToString).join("\n\n"),
};
}
2 changes: 1 addition & 1 deletion src/functions/iac/terraform.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe("terraform iac", () => {
expect(tf.blockToString(block)).to.equal(`resource "google_cloudfunctions_function" "my_func" {
count = other_resource.count

name = "test"
name = "test"
runtime = "nodejs20"

depends_on = [other_resource]
Expand Down
Loading
Loading