-
Notifications
You must be signed in to change notification settings - Fork 1.2k
new IaC export command supporting functions.yaml and first TF steps #10115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
15f274c
df7f3e1
ef51dd0
bce3705
198b073
0269b32
2a5e70a
840c16b
fd5449b
2675eaa
6a47fda
17f2a11
ddb7616
6055b8a
ba13449
5ea66a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) => { | ||
| if (!options.format || !Object.keys(EXPORTERS).includes(options.format)) { | ||
|
Check warning on line 21 in src/commands/functions-export.ts
|
||
| 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
|
||
| let codebaseConfig; | ||
| if (options.codebase) { | ||
| codebaseConfig = configForCodebase(config, options.codebase); | ||
|
Check warning on line 28 in src/commands/functions-export.ts
|
||
| } 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); | ||
|
|
||
| for (const [file, contents] of Object.entries(manifest)) { | ||
| logger.info(`Manifest file: ${clc.bold(file)}`); | ||
| logger.info(contents); | ||
| } | ||
| }); | ||
| 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"); | ||
| }); | ||
| }); | ||
| }); |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The References
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }); | ||
|
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"), | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.