Skip to content

Commit fc16c19

Browse files
committed
feat: add terraform hcl generation utilities
1 parent 61552be commit fc16c19

File tree

3 files changed

+256
-1
lines changed

3 files changed

+256
-1
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { expect } from "chai";
2+
import * as tf from "./terraform";
3+
4+
describe("terraform iac", () => {
5+
describe("expr", () => {
6+
it("should return an HCLExpression object", () => {
7+
expect(tf.expr("var.foo")).to.deep.equal({
8+
"@type": "HCLExpression",
9+
value: "var.foo",
10+
});
11+
});
12+
});
13+
14+
describe("copyField", () => {
15+
it("should copy a field to attributes, converting its name to lower snake case", () => {
16+
const attrs: any = {};
17+
tf.copyField(attrs, { camelCaseField: "value" }, "camelCaseField");
18+
expect(attrs).to.deep.equal({ camel_case_field: "value" });
19+
});
20+
21+
it("should optionally transform the field value", () => {
22+
const attrs: any = {};
23+
tf.copyField(attrs, { field: 42 }, "field", (v) => (v as number) * 2);
24+
expect(attrs).to.deep.equal({ field: 84 });
25+
});
26+
27+
it("should not set anything if the source field is missing", () => {
28+
const attrs: any = {};
29+
tf.copyField(attrs, { other: 123 } as any, "field");
30+
expect(attrs).to.deep.equal({});
31+
});
32+
});
33+
34+
describe("renameField", () => {
35+
it("should copy a field to a different attribute name", () => {
36+
const attrs: any = {};
37+
tf.renameField(attrs, { field: "value" }, "new_field", "field");
38+
expect(attrs).to.deep.equal({ new_field: "value" });
39+
});
40+
});
41+
42+
describe("serviceAccount", () => {
43+
it("should append the IAM domain for short service accounts", () => {
44+
expect(tf.serviceAccount("my-sa@")).to.equal("my-sa@${var.project}.iam.gserviceaccount.com");
45+
});
46+
47+
it("should return identical string if not ending in @", () => {
48+
expect(tf.serviceAccount("foo@bar.com")).to.equal("foo@bar.com");
49+
});
50+
});
51+
52+
describe("serializeValue", () => {
53+
it("should serialize strings correctly and inject variable", () => {
54+
expect(tf.serializeValue("foo")).to.equal('"foo"');
55+
expect(tf.serializeValue("proj: {{ params.PROJECT_ID }}")).to.equal('"proj: ${var.project}"');
56+
});
57+
58+
it("should throw for other parameterized fields", () => {
59+
expect(() => tf.serializeValue("param: {{ params.OTHER }}")).to.throw(
60+
"Generalized parameterized fields are not supported in terraform yet",
61+
);
62+
});
63+
64+
it("should serialize numbers and booleans", () => {
65+
expect(tf.serializeValue(42)).to.equal("42");
66+
expect(tf.serializeValue(true)).to.equal("true");
67+
});
68+
69+
it("should serialize null properly", () => {
70+
expect(tf.serializeValue(null)).to.equal("null");
71+
});
72+
73+
it("should serialize an expression without quotes", () => {
74+
expect(tf.serializeValue(tf.expr("var.abc"))).to.equal("var.abc");
75+
});
76+
77+
it("should serialize simple arrays inline", () => {
78+
expect(tf.serializeValue([1, 2, 3])).to.equal("[1, 2, 3]");
79+
});
80+
81+
it("should serialize arrays of objects with line breaks", () => {
82+
expect(tf.serializeValue([{ a: 1 }])).to.equal("[\n {\n a = 1\n }\n]");
83+
});
84+
85+
it("should serialize nested objects with proper indentation", () => {
86+
expect(tf.serializeValue({ a: { b: 2 } })).to.equal("{\n a = {\n b = 2\n }\n}");
87+
});
88+
});
89+
90+
describe("blockToString", () => {
91+
it("should stringify a block without labels", () => {
92+
expect(
93+
tf.blockToString({
94+
type: "locals",
95+
attributes: { foo: "bar" },
96+
}),
97+
).to.equal('locals {\n foo = "bar"\n}');
98+
});
99+
100+
it("should stringify a block with labels", () => {
101+
expect(
102+
tf.blockToString({
103+
type: "resource",
104+
labels: ["google_function", "my_func"],
105+
attributes: { name: "test" },
106+
}),
107+
).to.equal('resource "google_function" "my_func" {\n name = "test"\n}');
108+
});
109+
});
110+
});

src/functions/iac/terraform.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as utils from "../../utils";
2+
import { FirebaseError } from "../../error";
3+
import { Field } from "../../deploy/functions/build";
4+
5+
/**
6+
* Represents a raw HCL expression that should NOT be quoted.
7+
* Used for resource references, function calls, or arithmetic.
8+
*/
9+
export interface Expression {
10+
["@type"]: "HCLExpression";
11+
value: string;
12+
}
13+
14+
/**
15+
*
16+
*/
17+
export function expr(string: string): Expression {
18+
return { "@type": "HCLExpression", value: string };
19+
}
20+
21+
/**
22+
* Valid types for HCL attributes.
23+
*/
24+
export type Value =
25+
| string
26+
| number
27+
| boolean
28+
| null
29+
| Expression
30+
| Value[]
31+
| { [key: string]: Value };
32+
33+
/**
34+
* Represents a generic HCL block.
35+
* Structure: <type> "<label_1>" "<label_2>" { <body> }
36+
*/
37+
export interface Block {
38+
type: "output" | "resource" | "variable" | "data" | "locals";
39+
labels?: string[];
40+
attributes: Record<string, Value>;
41+
// TODO: nested blocks?
42+
}
43+
44+
/**
45+
*
46+
*/
47+
export function copyField<
48+
Kind extends string | number | boolean,
49+
Key extends string,
50+
T extends { [key in Key]?: Field<Kind> },
51+
>(
52+
attributes: Record<string, Value>,
53+
source: T,
54+
field: Key,
55+
transform: (v: NonNullable<Field<Kind>>) => Value = (v) => v,
56+
): void {
57+
renameField(attributes, source, utils.toLowerSnakeCase(field), field, transform);
58+
}
59+
60+
/**
61+
*
62+
*/
63+
export function renameField<
64+
Kind extends string | number | boolean,
65+
Key extends string,
66+
T extends { [key in Key]?: Field<Kind> },
67+
>(
68+
attributes: Record<string, Value>,
69+
source: T,
70+
attributeField: string,
71+
sourceField: Key,
72+
transform: (v: NonNullable<Field<Kind>>) => Value = (v) => v,
73+
): void {
74+
const val = source[sourceField];
75+
// Reset is always the behavior.
76+
if (val === null || val === undefined) {
77+
return;
78+
}
79+
80+
attributes[attributeField] = transform(val);
81+
}
82+
83+
/**
84+
*
85+
*/
86+
export function serviceAccount(sa: string): string {
87+
if (sa.endsWith("@")) {
88+
return `${sa}\${var.project}.iam.gserviceaccount.com`;
89+
}
90+
return sa;
91+
}
92+
93+
/**
94+
*
95+
*/
96+
export function serializeValue(value: Value, indentation = 0): string {
97+
if (typeof value === "string") {
98+
value = value.replace(/{{ *params\.PROJECT_ID *}}/g, "${var.project}");
99+
if (value.includes("{{ ")) {
100+
throw new FirebaseError(
101+
"Generalized parameterized fields are not supported in terraform yet",
102+
);
103+
}
104+
return JSON.stringify(value);
105+
} else if (typeof value === "number" || typeof value === "boolean") {
106+
return value.toString();
107+
} else if (value === null || value === undefined) {
108+
return "null";
109+
} else if (Array.isArray(value)) {
110+
if (value.some((e) => typeof e === "object")) {
111+
return `[\n${value.map((v) => " ".repeat(indentation + 1) + serializeValue(v, indentation + 1)).join(",\n")}\n${" ".repeat(indentation)}]`;
112+
}
113+
return `[${value.map((v) => serializeValue(v)).join(", ")}]`;
114+
} else if (typeof value === "object") {
115+
if (value["@type"] === "HCLExpression") {
116+
return (value as Expression).value;
117+
}
118+
const entries = Object.entries(value).map(
119+
([k, v]) => `${" ".repeat(indentation + 1)}${k} = ${serializeValue(v, indentation + 1)}`,
120+
);
121+
return `{\n${entries.join("\n")}\n${" ".repeat(indentation)}}`;
122+
}
123+
throw new FirebaseError(`Unsupported terraform value type ${typeof value}`, { exit: 1 });
124+
}
125+
126+
/**
127+
*
128+
*/
129+
export function blockToString(block: Block): string {
130+
const labels = (block.labels || []).map((l) => `"${l}"`).join(" ");
131+
return `${block.type} ${labels ? labels + " " : ""}${serializeValue(block.attributes)}`;
132+
}

src/utils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ export async function promptForDirectory(args: {
955955
default?: boolean;
956956
relativeTo?: string;
957957
}): Promise<string> {
958-
let dir: string = "";
958+
let dir = "";
959959
while (!dir) {
960960
const promptPath = await input(args.message);
961961
let target: string;
@@ -982,6 +982,9 @@ export async function promptForDirectory(args: {
982982
* It's a simplified version of a deep equal function, sufficient for comparing the structure
983983
* of the gemini-extension.json file. It doesn't handle special cases like RegExp, Date, or functions.
984984
*/
985+
/**
986+
*
987+
*/
985988
export function deepEqual(a: any, b: any): boolean {
986989
if (a === b) {
987990
return true;
@@ -1055,3 +1058,13 @@ export function resolveWithin(base: string, subPath: string, errMsg?: string): s
10551058
}
10561059
return abs;
10571060
}
1061+
1062+
/**
1063+
* Converts a string to lower snake case.
1064+
* Useful for converting camelCase for Python or Terraform
1065+
*/
1066+
export function toLowerSnakeCase(s: string): string {
1067+
return s
1068+
.replace(/[A-Z]/g, (letter, index) => `${index > 0 ? "_" : ""}${letter.toLowerCase()}`)
1069+
.replace(/-/g, "_");
1070+
}

0 commit comments

Comments
 (0)