@flow-as-code/tf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +67 -0
- package/dist/emit.d.ts +45 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +350 -0
- package/dist/emit.js.map +1 -0
- package/dist/hcl.d.ts +49 -0
- package/dist/hcl.d.ts.map +1 -0
- package/dist/hcl.js +95 -0
- package/dist/hcl.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/template.d.ts +31 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +83 -0
- package/dist/template.js.map +1 -0
- package/dist/write.d.ts +12 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +27 -0
- package/dist/write.js.map +1 -0
- package/package.json +56 -0
- package/src/emit.ts +532 -0
- package/src/hcl.ts +107 -0
- package/src/index.ts +11 -0
- package/src/template.ts +88 -0
- package/src/write.ts +33 -0
package/src/emit.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// The Terraform/OpenTofu emitter. FlowDoc in, HCL plus .tftpl out, per
|
|
6
|
+
// docs/03-tf-emitter.md. One-way in v1: there is no HCL to FlowDoc direction.
|
|
7
|
+
//
|
|
8
|
+
// `emitTf` is pure: it takes documents and returns a path -> content map, so the
|
|
9
|
+
// goldens in conformance/emit-tf compare bytes with no filesystem involved.
|
|
10
|
+
// `writeTf` (write.ts) is the thin wrapper that puts them on disk.
|
|
11
|
+
//
|
|
12
|
+
// Verified provider arguments (checked 2026-08-31 against the provider docs and
|
|
13
|
+
// against `tofu validate` with hashicorp/aws 6.62.0 and hashicorp/awscc 1.99.0,
|
|
14
|
+
// see src/validate.test.ts):
|
|
15
|
+
// https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow
|
|
16
|
+
// https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow_module
|
|
17
|
+
// https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_version
|
|
18
|
+
// https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_alias
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type FlowDoc,
|
|
22
|
+
PACKAGE_NAMES,
|
|
23
|
+
type RefEntry,
|
|
24
|
+
SLUG_PATTERN,
|
|
25
|
+
collectRefs,
|
|
26
|
+
parseToken,
|
|
27
|
+
} from "@flow-as-code/core";
|
|
28
|
+
import { type HclLine, arg, blank, block, comment, objectArg, quote, renderFile } from "./hcl.js";
|
|
29
|
+
import { renderTemplate } from "./template.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The oldest core version the emitted configuration promises to work on, per
|
|
33
|
+
* docs/03-tf-emitter.md ("emit nothing Terraform 1.8+/OpenTofu 1.7+ do not both
|
|
34
|
+
* support"). It lands in every `versions.tf.example` as `required_version`, and
|
|
35
|
+
* the emit-tf CI job is asserted to run the gated suite against it, so the
|
|
36
|
+
* promise and the thing that checks the promise cannot drift apart.
|
|
37
|
+
*/
|
|
38
|
+
export const CORE_VERSION_FLOOR = "1.7.0";
|
|
39
|
+
|
|
40
|
+
/** Emission refused. Every problem found is listed, not just the first. */
|
|
41
|
+
export class EmitTfError extends Error {
|
|
42
|
+
readonly problems: readonly string[];
|
|
43
|
+
|
|
44
|
+
constructor(problems: readonly string[]) {
|
|
45
|
+
super(`Cannot emit terraform:\n - ${problems.join("\n - ")}`);
|
|
46
|
+
this.name = "EmitTfError";
|
|
47
|
+
this.problems = problems;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EmitTfOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Reference to terraform address expression, for references this emitter
|
|
54
|
+
* cannot resolve from the document set itself. A key is a token
|
|
55
|
+
* (`${cdref:queue:front-desk}`), the token body (`queue:front-desk`,
|
|
56
|
+
* `module:survey@prod`), or the generated variable name
|
|
57
|
+
* (`queue_front_desk_arn`); the three are tried in that order. Values are HCL
|
|
58
|
+
* expressions such as `aws_connect_queue.front_desk.arn`, never literal ARNs.
|
|
59
|
+
* Entries matching no reference in the set are ignored.
|
|
60
|
+
*/
|
|
61
|
+
addressMap?: Record<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Expression for the `instance_id` argument. Defaults to
|
|
64
|
+
* `var.connect_instance_id`, in which case variables.tf declaring that
|
|
65
|
+
* variable is emitted too. Pass something else (`aws_connect_instance.main.id`)
|
|
66
|
+
* to wire the flows into an instance you already manage; no variables.tf is
|
|
67
|
+
* emitted then.
|
|
68
|
+
*/
|
|
69
|
+
instanceIdExpression?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface EmitTfResult {
|
|
73
|
+
/** Relative POSIX path to file content, insertion-ordered by sorted path. */
|
|
74
|
+
files: Record<string, string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where a reference's address came from. */
|
|
78
|
+
type RefSource = "set" | "map" | "missing";
|
|
79
|
+
|
|
80
|
+
interface Resolved {
|
|
81
|
+
entry: RefEntry;
|
|
82
|
+
variable: string;
|
|
83
|
+
expression: string;
|
|
84
|
+
source: RefSource;
|
|
85
|
+
/** Address map key ignored because the document set resolves this itself. */
|
|
86
|
+
shadowed?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_INSTANCE_ID_EXPRESSION = "var.connect_instance_id";
|
|
90
|
+
const INSTANCE_ID_VARIABLE = "connect_instance_id";
|
|
91
|
+
|
|
92
|
+
const GENERATED_BY = [
|
|
93
|
+
`Generated by ${PACKAGE_NAMES.tf}. Do not edit by hand: edit the`,
|
|
94
|
+
"FlowDoc and re-emit. See docs/03-tf-emitter.md.",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Slug to HCL identifier: hyphens are the only character needing a change. */
|
|
98
|
+
/**
|
|
99
|
+
* A slug as a Terraform identifier. Slugs may begin with a digit (@flow-as-code/core's
|
|
100
|
+
* SLUG_PATTERN allows it) but Terraform identifiers may not: OpenTofu rejects
|
|
101
|
+
* `resource "aws_connect_contact_flow" "2fa_line"` with "Invalid resource
|
|
102
|
+
* name", so a leading digit is prefixed. Deterministic and collision-free
|
|
103
|
+
* against other slugs, since no slug can contain an underscore.
|
|
104
|
+
*/
|
|
105
|
+
const ident = (slug: string): string => {
|
|
106
|
+
const underscored = slug.replaceAll("-", "_");
|
|
107
|
+
return /^[0-9]/.test(underscored) ? `_${underscored}` : underscored;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** `<type>_<name>_arn`, with the module alias folded in. Stable by contract. */
|
|
111
|
+
function variableName(entry: RefEntry): string {
|
|
112
|
+
const alias = entry.alias === undefined ? "" : `_${ident(entry.alias)}`;
|
|
113
|
+
return `${entry.type}_${ident(entry.name)}${alias}_arn`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The bare identifier emitted when no address is known. Fails `validate`. */
|
|
117
|
+
const placeholder = (variable: string): string =>
|
|
118
|
+
`TODO_MISSING_ADDRESS_${variable.replace(/_arn$/, "")}`;
|
|
119
|
+
|
|
120
|
+
/** `queue:front-desk`, `module:survey@prod`: the token without its wrapper. */
|
|
121
|
+
const refKey = (entry: RefEntry): string =>
|
|
122
|
+
`${entry.type}:${entry.name}${entry.alias === undefined ? "" : `@${entry.alias}`}`;
|
|
123
|
+
|
|
124
|
+
const resourceType = (doc: FlowDoc): string =>
|
|
125
|
+
doc.kind === "module" ? "aws_connect_contact_flow_module" : "aws_connect_contact_flow";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Name of the locals entry a document's content is rendered with. Documents
|
|
129
|
+
* that reference something this set emits get their own, because putting those
|
|
130
|
+
* addresses in the shared map would make the map depend on resources the map
|
|
131
|
+
* itself feeds: terraform and tofu both refuse that as a dependency cycle
|
|
132
|
+
* (proved by the module-set case in src/validate.test.ts). Everything else
|
|
133
|
+
* shares `local.flow_refs`.
|
|
134
|
+
*/
|
|
135
|
+
const REFS_LOCAL = "flow_refs";
|
|
136
|
+
const refsLocalFor = (doc: FlowDoc): string =>
|
|
137
|
+
`${doc.kind === "module" ? "module" : "flow"}_refs_${ident(doc.name)}`;
|
|
138
|
+
|
|
139
|
+
/** A flow and a module may share a name, so a document key carries both. */
|
|
140
|
+
const docKey = (doc: FlowDoc): string => `${doc.kind}:${doc.name}`;
|
|
141
|
+
|
|
142
|
+
const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* An address expression is HCL the emitter pastes into `flow_refs.tf`
|
|
146
|
+
* unevaluated, so it is checked for the things that would corrupt the file or
|
|
147
|
+
* the deploy. Literal ARNs are rejected outright: references stay references
|
|
148
|
+
* (CLAUDE.md, docs/01-flowdoc-spec.md).
|
|
149
|
+
*/
|
|
150
|
+
function checkExpression(label: string, value: string, problems: string[]): void {
|
|
151
|
+
if (value.trim() === "") {
|
|
152
|
+
problems.push(`${label} is empty`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (/arn:aws/i.test(value)) {
|
|
156
|
+
problems.push(`${label} is a literal ARN (${value}); map to a terraform address instead`);
|
|
157
|
+
}
|
|
158
|
+
if (/[\n\r]/.test(value))
|
|
159
|
+
problems.push(`${label} spans multiple lines (${JSON.stringify(value)})`);
|
|
160
|
+
if (/#|\/\/|\/\*/.test(value)) problems.push(`${label} contains a comment marker (${value})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Documents in emission order, with the structural problems reported first. */
|
|
164
|
+
function checkDocs(docs: readonly FlowDoc[]): FlowDoc[] {
|
|
165
|
+
const problems: string[] = [];
|
|
166
|
+
if (docs.length === 0) problems.push("no documents to emit");
|
|
167
|
+
|
|
168
|
+
const seen = new Map<string, string>();
|
|
169
|
+
for (const doc of docs) {
|
|
170
|
+
if (!SLUG_PATTERN.test(doc.name)) {
|
|
171
|
+
problems.push(`document name "${doc.name}" is not a slug`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// `type` accepts every ConnectType except MODULE, and the module resource
|
|
175
|
+
// takes no `type` at all, so kind and connectType have to agree.
|
|
176
|
+
if (doc.kind === "module" && doc.connectType !== "MODULE") {
|
|
177
|
+
problems.push(`module "${doc.name}" has connectType ${doc.connectType}, expected MODULE`);
|
|
178
|
+
}
|
|
179
|
+
if (doc.kind === "flow" && doc.connectType === "MODULE") {
|
|
180
|
+
problems.push(`flow "${doc.name}" has connectType MODULE; emit it as kind "module"`);
|
|
181
|
+
}
|
|
182
|
+
const address = `${resourceType(doc)}.${ident(doc.name)}`;
|
|
183
|
+
const prior = seen.get(address);
|
|
184
|
+
if (prior !== undefined) problems.push(`two documents both emit ${address}`);
|
|
185
|
+
else seen.set(address, doc.name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (problems.length > 0) throw new EmitTfError(problems);
|
|
189
|
+
// Kind breaks the tie, because a flow and a module may share a name and the
|
|
190
|
+
// order must not depend on the order the caller passed them in.
|
|
191
|
+
return [...docs].sort((a, b) => byString(a.name, b.name) || byString(a.kind, b.kind));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every reference in the set, resolved. Documents in the set resolve their own
|
|
196
|
+
* references: a `${cdref:module:x@prod}` whose module x is emitted here points
|
|
197
|
+
* at the alias resource emitted with it, and a `${cdref:flow:y}` whose flow y is
|
|
198
|
+
* emitted here points at that flow. Everything else comes from the address map,
|
|
199
|
+
* and what the map does not cover gets a placeholder that fails `validate`.
|
|
200
|
+
*/
|
|
201
|
+
function resolveRefs(
|
|
202
|
+
docs: readonly FlowDoc[],
|
|
203
|
+
addressMap: Record<string, string>,
|
|
204
|
+
problems: string[],
|
|
205
|
+
): Resolved[] {
|
|
206
|
+
const flows = new Set(docs.filter((d) => d.kind === "flow").map((d) => d.name));
|
|
207
|
+
const modules = new Set(docs.filter((d) => d.kind === "module").map((d) => d.name));
|
|
208
|
+
|
|
209
|
+
const entries = new Map<string, RefEntry>();
|
|
210
|
+
for (const doc of docs) {
|
|
211
|
+
for (const entry of collectRefs(doc.content)) entries.set(entry.token, entry);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const byVariable = new Map<string, string>();
|
|
215
|
+
const resolved: Resolved[] = [];
|
|
216
|
+
for (const entry of [...entries.values()].sort((a, b) => byString(a.token, b.token))) {
|
|
217
|
+
const variable = variableName(entry);
|
|
218
|
+
const clash = byVariable.get(variable);
|
|
219
|
+
if (clash !== undefined) {
|
|
220
|
+
problems.push(`${entry.token} and ${clash} both map to local.flow_refs.${variable}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
byVariable.set(variable, entry.token);
|
|
224
|
+
|
|
225
|
+
const key = refKey(entry);
|
|
226
|
+
const supplied = addressMap[entry.token] ?? addressMap[key] ?? addressMap[variable];
|
|
227
|
+
if (supplied !== undefined) checkExpression(`address for ${entry.token}`, supplied, problems);
|
|
228
|
+
|
|
229
|
+
let inSet: string | undefined;
|
|
230
|
+
if (entry.type === "module" && entry.alias !== undefined && modules.has(entry.name)) {
|
|
231
|
+
inSet = `awscc_connect_contact_flow_module_alias.${ident(entry.name)}_${ident(entry.alias)}.contact_flow_module_alias_arn`;
|
|
232
|
+
} else if (entry.type === "flow" && flows.has(entry.name)) {
|
|
233
|
+
inSet = `aws_connect_contact_flow.${ident(entry.name)}.arn`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (inSet !== undefined) {
|
|
237
|
+
const shadowed = supplied === undefined ? undefined : key;
|
|
238
|
+
resolved.push({
|
|
239
|
+
entry,
|
|
240
|
+
variable,
|
|
241
|
+
expression: inSet,
|
|
242
|
+
source: "set",
|
|
243
|
+
...(shadowed ? { shadowed } : {}),
|
|
244
|
+
});
|
|
245
|
+
} else if (supplied !== undefined) {
|
|
246
|
+
resolved.push({ entry, variable, expression: supplied, source: "map" });
|
|
247
|
+
} else {
|
|
248
|
+
resolved.push({ entry, variable, expression: placeholder(variable), source: "missing" });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return resolved;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Aliases each emitted module needs, derived from the references to it. */
|
|
255
|
+
function aliasesByModule(docs: readonly FlowDoc[]): Map<string, string[]> {
|
|
256
|
+
const modules = new Set(docs.filter((d) => d.kind === "module").map((d) => d.name));
|
|
257
|
+
const aliases = new Map<string, Set<string>>();
|
|
258
|
+
for (const name of modules) aliases.set(name, new Set());
|
|
259
|
+
for (const doc of docs) {
|
|
260
|
+
for (const entry of collectRefs(doc.content)) {
|
|
261
|
+
if (entry.type !== "module" || entry.alias === undefined) continue;
|
|
262
|
+
aliases.get(entry.name)?.add(entry.alias);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new Map([...aliases].map(([name, set]) => [name, [...set].sort(byString)]));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const AWS_FLOW_DOC =
|
|
269
|
+
"https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow";
|
|
270
|
+
const AWS_MODULE_DOC =
|
|
271
|
+
"https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow_module";
|
|
272
|
+
const AWSCC_VERSION_DOC =
|
|
273
|
+
"https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_version";
|
|
274
|
+
const AWSCC_ALIAS_DOC =
|
|
275
|
+
"https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_alias";
|
|
276
|
+
const CREATE_FLOW_VERSION_DOC =
|
|
277
|
+
"https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateContactFlowVersion.html";
|
|
278
|
+
|
|
279
|
+
function flowsTf(
|
|
280
|
+
docs: readonly FlowDoc[],
|
|
281
|
+
instanceId: string,
|
|
282
|
+
inSetByDoc: ReadonlyMap<string, Resolved[]>,
|
|
283
|
+
): string {
|
|
284
|
+
const aliases = aliasesByModule(docs);
|
|
285
|
+
const hasModule = docs.some((d) => d.kind === "module");
|
|
286
|
+
const lines: HclLine[] = GENERATED_BY.map(comment);
|
|
287
|
+
lines.push(
|
|
288
|
+
comment(""),
|
|
289
|
+
comment("Flow content is rendered from flows/<name>.flow.tftpl with the"),
|
|
290
|
+
comment("addresses in flow_refs.tf. Provider, credential, and backend"),
|
|
291
|
+
comment("configuration is yours; versions.tf.example lists what this needs."),
|
|
292
|
+
comment(""),
|
|
293
|
+
comment(AWS_FLOW_DOC),
|
|
294
|
+
comment(AWS_MODULE_DOC),
|
|
295
|
+
);
|
|
296
|
+
if (hasModule) {
|
|
297
|
+
lines.push(
|
|
298
|
+
comment(""),
|
|
299
|
+
comment("Modules carry versions and aliases; full flows do not."),
|
|
300
|
+
comment('CreateContactFlowVersion "only supports creating versions for'),
|
|
301
|
+
comment('flows of type Campaign", so a flow updates its content in place'),
|
|
302
|
+
comment("while a module pins content behind an alias."),
|
|
303
|
+
comment(CREATE_FLOW_VERSION_DOC),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const doc of docs) {
|
|
308
|
+
const refs = (inSetByDoc.get(docKey(doc)) ?? []).length > 0 ? refsLocalFor(doc) : REFS_LOCAL;
|
|
309
|
+
const template = `templatefile("\${path.module}/flows/${doc.name}.flow.tftpl", local.${refs})`;
|
|
310
|
+
const body: HclLine[] = [arg("instance_id", instanceId), arg("name", quote(doc.name))];
|
|
311
|
+
// `type` exists on the flow resource only; the module resource has none.
|
|
312
|
+
if (doc.kind === "flow") body.push(arg("type", quote(doc.connectType)));
|
|
313
|
+
body.push(arg("content", template));
|
|
314
|
+
|
|
315
|
+
lines.push(blank(), block("resource", [resourceType(doc), ident(doc.name)], body));
|
|
316
|
+
if (doc.kind !== "module") continue;
|
|
317
|
+
|
|
318
|
+
const self = `aws_connect_contact_flow_module.${ident(doc.name)}.arn`;
|
|
319
|
+
lines.push(
|
|
320
|
+
blank(),
|
|
321
|
+
comment("Snapshot of the module content as it stands when this resource is"),
|
|
322
|
+
comment("created. Replace it after a content change so the aliases below"),
|
|
323
|
+
comment("serve the new content."),
|
|
324
|
+
comment(AWSCC_VERSION_DOC),
|
|
325
|
+
block(
|
|
326
|
+
"resource",
|
|
327
|
+
["awscc_connect_contact_flow_module_version", ident(doc.name)],
|
|
328
|
+
[arg("contact_flow_module_id", self)],
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
for (const alias of aliases.get(doc.name) ?? []) {
|
|
332
|
+
lines.push(
|
|
333
|
+
blank(),
|
|
334
|
+
comment(AWSCC_ALIAS_DOC),
|
|
335
|
+
block(
|
|
336
|
+
"resource",
|
|
337
|
+
["awscc_connect_contact_flow_module_alias", `${ident(doc.name)}_${ident(alias)}`],
|
|
338
|
+
[
|
|
339
|
+
arg("contact_flow_module_id", self),
|
|
340
|
+
arg(
|
|
341
|
+
"contact_flow_module_version",
|
|
342
|
+
`awscc_connect_contact_flow_module_version.${ident(doc.name)}.version`,
|
|
343
|
+
),
|
|
344
|
+
arg("name", quote(alias)),
|
|
345
|
+
],
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return renderFile(lines);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function flowRefsTf(
|
|
354
|
+
docs: readonly FlowDoc[],
|
|
355
|
+
resolved: readonly Resolved[],
|
|
356
|
+
inSetByDoc: ReadonlyMap<string, Resolved[]>,
|
|
357
|
+
): string {
|
|
358
|
+
const lines: HclLine[] = GENERATED_BY.map(comment);
|
|
359
|
+
lines.push(
|
|
360
|
+
comment(""),
|
|
361
|
+
comment("One entry per reference token in the emitted flow set, named"),
|
|
362
|
+
comment("<type>_<name>_arn with hyphens as underscores and the module alias"),
|
|
363
|
+
comment("folded in. Values are terraform addresses, never literal ARNs."),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const shared: HclLine[] = [];
|
|
367
|
+
for (const ref of resolved) {
|
|
368
|
+
// Addresses of resources this set emits are deliberately left out of the
|
|
369
|
+
// shared map; see refsLocalFor and the per-document entries below.
|
|
370
|
+
if (ref.source === "set") continue;
|
|
371
|
+
if (ref.source === "missing") {
|
|
372
|
+
shared.push(
|
|
373
|
+
comment(`TODO: no terraform address for ${ref.entry.token}.`),
|
|
374
|
+
comment(`Add one to the address map under key "${refKey(ref.entry)}"`),
|
|
375
|
+
comment("and re-emit. Until then this placeholder is an undeclared"),
|
|
376
|
+
comment("reference, so `terraform validate` and `tofu validate` fail."),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
shared.push(arg(ref.variable, ref.expression));
|
|
380
|
+
}
|
|
381
|
+
if (shared.length === 0) {
|
|
382
|
+
shared.push(comment("No reference resolves through this map."));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const body: HclLine[] = [objectArg(REFS_LOCAL, shared)];
|
|
386
|
+
for (const doc of docs) {
|
|
387
|
+
const own = inSetByDoc.get(docKey(doc)) ?? [];
|
|
388
|
+
if (own.length === 0) continue;
|
|
389
|
+
const entries: HclLine[] = [];
|
|
390
|
+
for (const ref of own) {
|
|
391
|
+
if (ref.shadowed !== undefined) {
|
|
392
|
+
entries.push(
|
|
393
|
+
comment(`Address map entry "${ref.shadowed}" ignored: this set emits`),
|
|
394
|
+
comment("the resource itself, so the reference resolves to it."),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
entries.push(arg(ref.variable, ref.expression));
|
|
398
|
+
}
|
|
399
|
+
body.push(
|
|
400
|
+
blank(),
|
|
401
|
+
comment(`${doc.name} references a resource this set emits. Its addresses`),
|
|
402
|
+
comment("go in a local of their own: in the shared map they would make"),
|
|
403
|
+
comment("that map depend on resources rendered from it, which both tools"),
|
|
404
|
+
comment("report as a dependency cycle."),
|
|
405
|
+
objectArg(refsLocalFor(doc), entries, {
|
|
406
|
+
open: `merge(local.${REFS_LOCAL}, {`,
|
|
407
|
+
close: "})",
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
lines.push(blank(), block("locals", [], body));
|
|
413
|
+
return renderFile(lines);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function variablesTf(): string {
|
|
417
|
+
const lines: HclLine[] = GENERATED_BY.map(comment);
|
|
418
|
+
lines.push(
|
|
419
|
+
blank(),
|
|
420
|
+
block(
|
|
421
|
+
"variable",
|
|
422
|
+
[INSTANCE_ID_VARIABLE],
|
|
423
|
+
[
|
|
424
|
+
arg(
|
|
425
|
+
"description",
|
|
426
|
+
quote("Identifier of the Amazon Connect instance that holds these flows."),
|
|
427
|
+
),
|
|
428
|
+
arg("type", "string"),
|
|
429
|
+
],
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
return renderFile(lines);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function versionsExample(needsAwscc: boolean): string {
|
|
436
|
+
const providers: HclLine[] = [
|
|
437
|
+
objectArg("aws", [arg("source", quote("hashicorp/aws")), arg("version", quote(">= 5.0"))]),
|
|
438
|
+
];
|
|
439
|
+
if (needsAwscc) {
|
|
440
|
+
providers.push(
|
|
441
|
+
blank(),
|
|
442
|
+
objectArg("awscc", [
|
|
443
|
+
arg("source", quote("hashicorp/awscc")),
|
|
444
|
+
arg("version", quote(">= 1.74")),
|
|
445
|
+
]),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const lines: HclLine[] = GENERATED_BY.map(comment);
|
|
450
|
+
lines.push(
|
|
451
|
+
comment(""),
|
|
452
|
+
comment("Example only. This file is not loaded by terraform or tofu, which"),
|
|
453
|
+
comment("read *.tf; copy what you need into your own configuration. No"),
|
|
454
|
+
comment("provider credentials and no backend configuration are emitted."),
|
|
455
|
+
comment(""),
|
|
456
|
+
comment("The version constraint below is what both tools understand:"),
|
|
457
|
+
comment("this output uses nothing newer than Terraform 1.8 / OpenTofu 1.7."),
|
|
458
|
+
);
|
|
459
|
+
if (needsAwscc) {
|
|
460
|
+
lines.push(
|
|
461
|
+
comment(""),
|
|
462
|
+
comment("awscc is needed for the module version and alias resources; it"),
|
|
463
|
+
comment("carries them and the aws provider does not."),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
lines.push(
|
|
467
|
+
blank(),
|
|
468
|
+
block(
|
|
469
|
+
"terraform",
|
|
470
|
+
[],
|
|
471
|
+
[
|
|
472
|
+
arg("required_version", quote(`>= ${CORE_VERSION_FLOOR}`)),
|
|
473
|
+
blank(),
|
|
474
|
+
block("required_providers", [], providers),
|
|
475
|
+
],
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
return renderFile(lines);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Emits a Terraform/OpenTofu configuration for a set of FlowDocs. Pure and
|
|
483
|
+
* deterministic: the same documents and options produce byte-identical files,
|
|
484
|
+
* and paths come back sorted.
|
|
485
|
+
*/
|
|
486
|
+
export function emitTf(docs: readonly FlowDoc[], options: EmitTfOptions = {}): EmitTfResult {
|
|
487
|
+
const ordered = checkDocs(docs);
|
|
488
|
+
const problems: string[] = [];
|
|
489
|
+
|
|
490
|
+
const instanceId = options.instanceIdExpression ?? DEFAULT_INSTANCE_ID_EXPRESSION;
|
|
491
|
+
if (options.instanceIdExpression !== undefined) {
|
|
492
|
+
checkExpression("instanceIdExpression", options.instanceIdExpression, problems);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const addressMap = options.addressMap ?? {};
|
|
496
|
+
for (const key of Object.keys(addressMap)) {
|
|
497
|
+
// A key in token form has to be a token we could actually have produced.
|
|
498
|
+
if (key.startsWith("${") && parseToken(key) === undefined) {
|
|
499
|
+
problems.push(`address map key "${key}" looks like a token but does not parse as one`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const resolved = resolveRefs(ordered, addressMap, problems);
|
|
504
|
+
if (problems.length > 0) throw new EmitTfError(problems);
|
|
505
|
+
|
|
506
|
+
// Which of each document's own references resolve to resources this set
|
|
507
|
+
// emits. They are what the per-document locals hold; see refsLocalFor.
|
|
508
|
+
const bySet = new Map(resolved.filter((r) => r.source === "set").map((r) => [r.entry.token, r]));
|
|
509
|
+
const inSetByDoc = new Map<string, Resolved[]>(
|
|
510
|
+
ordered.map((doc) => [
|
|
511
|
+
docKey(doc),
|
|
512
|
+
collectRefs(doc.content)
|
|
513
|
+
.map((entry) => bySet.get(entry.token))
|
|
514
|
+
.filter((r): r is Resolved => r !== undefined),
|
|
515
|
+
]),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const variables = new Map(resolved.map((r) => [r.entry.token, r.variable]));
|
|
519
|
+
const files: Record<string, string> = {
|
|
520
|
+
"flow_refs.tf": flowRefsTf(ordered, resolved, inSetByDoc),
|
|
521
|
+
"flows.tf": flowsTf(ordered, instanceId, inSetByDoc),
|
|
522
|
+
"versions.tf.example": versionsExample(ordered.some((d) => d.kind === "module")),
|
|
523
|
+
};
|
|
524
|
+
for (const doc of ordered) {
|
|
525
|
+
files[`flows/${doc.name}.flow.tftpl`] = renderTemplate(doc, variables);
|
|
526
|
+
}
|
|
527
|
+
if (options.instanceIdExpression === undefined) files["variables.tf"] = variablesTf();
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => byString(a, b))),
|
|
531
|
+
};
|
|
532
|
+
}
|
package/src/hcl.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// A very small HCL writer. It writes the handful of shapes this emitter needs
|
|
6
|
+
// (comments, `key = expression` arguments, and nested blocks) in the layout
|
|
7
|
+
// `terraform fmt` / `tofu fmt` produce, so emitted files are fmt-clean and stay
|
|
8
|
+
// byte-stable across runs.
|
|
9
|
+
//
|
|
10
|
+
// fmt aligns the `=` of a maximal run of consecutive argument lines, and a
|
|
11
|
+
// comment or a blank line ends the run. That rule is reproduced in `render`
|
|
12
|
+
// below and is asserted against a real `tofu fmt` in the gated validate tests.
|
|
13
|
+
|
|
14
|
+
/** One line of an HCL body. */
|
|
15
|
+
export type HclLine =
|
|
16
|
+
| { kind: "arg"; key: string; value: string }
|
|
17
|
+
| { kind: "objectArg"; key: string; body: HclLine[]; open?: string; close?: string }
|
|
18
|
+
| { kind: "block"; type: string; labels: string[]; body: HclLine[] }
|
|
19
|
+
| { kind: "comment"; text: string }
|
|
20
|
+
| { kind: "blank" };
|
|
21
|
+
|
|
22
|
+
export const arg = (key: string, value: string): HclLine => ({ kind: "arg", key, value });
|
|
23
|
+
export const comment = (text: string): HclLine => ({ kind: "comment", text });
|
|
24
|
+
export const blank = (): HclLine => ({ kind: "blank" });
|
|
25
|
+
export const block = (type: string, labels: string[], body: HclLine[]): HclLine => ({
|
|
26
|
+
kind: "block",
|
|
27
|
+
type,
|
|
28
|
+
labels,
|
|
29
|
+
body,
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* `key = { ... }`: an object value, not a block. `open` and `close` wrap the
|
|
33
|
+
* body in something else, such as a `merge(...)` call around the braces.
|
|
34
|
+
*/
|
|
35
|
+
export const objectArg = (
|
|
36
|
+
key: string,
|
|
37
|
+
body: HclLine[],
|
|
38
|
+
wrap?: { open: string; close: string },
|
|
39
|
+
): HclLine => ({ kind: "objectArg", key, body, ...wrap });
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A double-quoted HCL string with no template introducer left live. A quoted
|
|
43
|
+
* string in .tf is itself a template, so `${` and `%{` are escaped here too.
|
|
44
|
+
* The `$` replacements are functions because a string replacement would give
|
|
45
|
+
* `$` its substitution meaning and undo the escape.
|
|
46
|
+
*/
|
|
47
|
+
export function quote(value: string): string {
|
|
48
|
+
const escaped = value
|
|
49
|
+
.replaceAll("\\", "\\\\")
|
|
50
|
+
.replaceAll('"', '\\"')
|
|
51
|
+
.replaceAll("\n", "\\n")
|
|
52
|
+
.replaceAll("\r", "\\r")
|
|
53
|
+
.replaceAll("\t", "\\t")
|
|
54
|
+
.replaceAll("${", () => "$${")
|
|
55
|
+
.replaceAll("%{", () => "%%{");
|
|
56
|
+
return `"${escaped}"`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Renders body lines at the given indent, aligning the `=` within each run of
|
|
61
|
+
* consecutive argument lines the way fmt does.
|
|
62
|
+
*/
|
|
63
|
+
export function render(lines: readonly HclLine[], indent = ""): string[] {
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
if (line === undefined) continue;
|
|
68
|
+
if (line.kind === "blank") {
|
|
69
|
+
out.push("");
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (line.kind === "comment") {
|
|
73
|
+
out.push(`${indent}# ${line.text}`.trimEnd());
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (line.kind === "block") {
|
|
77
|
+
const labels = line.labels.map((l) => ` ${quote(l)}`).join("");
|
|
78
|
+
out.push(`${indent}${line.type}${labels} {`);
|
|
79
|
+
out.push(...render(line.body, `${indent} `));
|
|
80
|
+
out.push(`${indent}}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (line.kind === "objectArg") {
|
|
84
|
+
out.push(`${indent}${line.key} = ${line.open ?? "{"}`);
|
|
85
|
+
out.push(...render(line.body, `${indent} `));
|
|
86
|
+
out.push(`${indent}${line.close ?? "}"}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// An argument: gather the whole run so the `=` columns line up.
|
|
90
|
+
const run: { key: string; value: string }[] = [];
|
|
91
|
+
let j = i;
|
|
92
|
+
for (; j < lines.length; j += 1) {
|
|
93
|
+
const next = lines[j];
|
|
94
|
+
if (next === undefined || next.kind !== "arg") break;
|
|
95
|
+
run.push({ key: next.key, value: next.value });
|
|
96
|
+
}
|
|
97
|
+
const width = Math.max(...run.map((a) => a.key.length));
|
|
98
|
+
for (const a of run) out.push(`${indent}${a.key.padEnd(width)} = ${a.value}`);
|
|
99
|
+
i = j - 1;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A whole file: rendered lines plus the trailing newline fmt insists on. */
|
|
105
|
+
export function renderFile(lines: readonly HclLine[]): string {
|
|
106
|
+
return `${render(lines).join("\n").replace(/\n+$/, "")}\n`;
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Public surface of @flow-as-code/tf. FlowDoc in, Terraform/OpenTofu out, per
|
|
6
|
+
// docs/03-tf-emitter.md. One-way in v1: HCL back to FlowDoc is a provider-era
|
|
7
|
+
// feature and is not attempted here.
|
|
8
|
+
|
|
9
|
+
export { EmitTfError, type EmitTfOptions, type EmitTfResult, emitTf } from "./emit.js";
|
|
10
|
+
export { escapeTemplateText, renderTemplate } from "./template.js";
|
|
11
|
+
export { writeTf } from "./write.js";
|