@intentius/chant-lexicon-terraform 0.60.0 → 0.62.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/dist/composites/terraform-adopt-op.d.ts +18 -2
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +59 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/hcl/edges.d.ts +124 -0
- package/dist/hcl/edges.d.ts.map +1 -0
- package/dist/hcl/parse.d.ts +42 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/roots.d.ts +6 -0
- package/dist/hcl/roots.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/terraform.d.ts +44 -8
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/adoption.d.ts +110 -35
- package/dist/op/adoption.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +135 -60
- package/src/__fixtures__/graph-roots/README.md +21 -0
- package/src/__fixtures__/graph-roots/app/main.tf +41 -0
- package/src/__fixtures__/graph-roots/app/modules/cdn/main.tf +7 -0
- package/src/__fixtures__/graph-roots/network/main.tf +16 -0
- package/src/__fixtures__/live-estate/README.md +65 -19
- package/src/__fixtures__/live-estate/adoptable.tf +13 -0
- package/src/__fixtures__/live-ls.json +5 -5
- package/src/__fixtures__/live-plan.json +67 -35
- package/src/composites/terraform-adopt-op.acceptance.test.ts +101 -50
- package/src/composites/terraform-adopt-op.test.ts +33 -11
- package/src/composites/terraform-adopt-op.ts +20 -2
- package/src/composites/terraform-apply-op.acceptance.test.ts +4 -2
- package/src/describe-resources.live.test.ts +76 -4
- package/src/describe-resources.test.ts +35 -0
- package/src/describe-resources.ts +110 -3
- package/src/hcl/edges.test.ts +207 -0
- package/src/hcl/edges.ts +316 -0
- package/src/hcl/parse.ts +53 -0
- package/src/hcl/roots.ts +14 -0
- package/src/index.ts +1 -0
- package/src/op/activities/choudoufu.test.ts +3 -3
- package/src/op/activities/terraform.ts +70 -21
- package/src/op/adoption.test.ts +129 -11
- package/src/op/adoption.ts +181 -49
|
@@ -13,6 +13,34 @@
|
|
|
13
13
|
* and the marker on the resource is the ownership answer. The two halves are
|
|
14
14
|
* two adapters over the same `observeEntities` harness, picked per root.
|
|
15
15
|
*
|
|
16
|
+
* ## Which of the two answered is on the wire (#2267)
|
|
17
|
+
*
|
|
18
|
+
* They are different claims. `terraform show -json` says what the last apply
|
|
19
|
+
* recorded; `choudoufu live-plan -json` says what the account holds now. A
|
|
20
|
+
* renderer that paints an observation as drift and cannot tell them apart
|
|
21
|
+
* paints a state read green, which reads as "the account matches" when all it
|
|
22
|
+
* says is "the apply finished". For an estate that has decided state is never
|
|
23
|
+
* the system of record, a state-backed observation is not a weaker answer, it
|
|
24
|
+
* is a different one.
|
|
25
|
+
*
|
|
26
|
+
* So the returned `ObservationResult` carries `sources`, keyed by chant entity
|
|
27
|
+
* name, with one of exactly two values:
|
|
28
|
+
*
|
|
29
|
+
* - `"state"` — this entity's root was read with `terraform show -json`.
|
|
30
|
+
* - `"live"` — this entity's root was read with `choudoufu live-plan -json`.
|
|
31
|
+
*
|
|
32
|
+
* Three guarantees a consumer may rely on. Every entity `describeResources`
|
|
33
|
+
* was asked about that belongs to a root has an entry, whatever verdict it
|
|
34
|
+
* came back with: present, absent and not-observed alike, because which read
|
|
35
|
+
* was attempted is a fact independent of what it found. The value equals the
|
|
36
|
+
* declared entity's own `props.mode` (`./hcl/parse.ts`), since one fact
|
|
37
|
+
* decides both, so `props.mode` is also a valid join key for a consumer
|
|
38
|
+
* holding the declared graph and no observation. And a project mixing stock
|
|
39
|
+
* and live roots gets both values in one document, which is why this is a map
|
|
40
|
+
* and not a field on the result as a whole.
|
|
41
|
+
*
|
|
42
|
+
* An entity with no root gets no entry, for the same reason it gets no read.
|
|
43
|
+
*
|
|
16
44
|
* ## The state file is the ownership answer, on a stock root
|
|
17
45
|
*
|
|
18
46
|
* Every other lexicon in chant stamps a tag or a label at synthesis and reads
|
|
@@ -54,6 +82,7 @@
|
|
|
54
82
|
* | `bound[]`, any other source | present, `owned` by derivation, record, or cache; noted as such, and no marker is surfaced because none was read |
|
|
55
83
|
* | `unowned[]` with `adopt_*` | present, `unknown`; an adoptable match, carrying the exact two tag values #2105 would write |
|
|
56
84
|
* | `unowned[]` without | present, `foreign`; a live resource is in the way at a declared identity and the plan will not touch it |
|
|
85
|
+
* | `adoptable[]` | present, `unknown`; the same adoptable verdict, for a live resource the sweep content-matched to a declaration that carries no identity of its own (choudoufu #962, chant #2241) |
|
|
57
86
|
* | `omissions[]` | not-observed, `ABSENT` excepted (see below) |
|
|
58
87
|
*
|
|
59
88
|
* The ownership channel this declares is `./live-ownership.ts`'s
|
|
@@ -282,6 +311,27 @@ export interface LivePlanUnownedRow {
|
|
|
282
311
|
adoptAddress?: string;
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
/**
|
|
315
|
+
* One `adoptable[]` entry: a live resource the estate-wide sweep matched to a
|
|
316
|
+
* declared instance by content rather than by reading a declared identity
|
|
317
|
+
* (choudoufu #962). The declaration names no identity at all, so this is the
|
|
318
|
+
* only row the document has for it and the paired omission says
|
|
319
|
+
* `NEEDS_DISCOVERY`.
|
|
320
|
+
*
|
|
321
|
+
* Same fields as {@link LivePlanUnownedRow} minus `heldBy`, which cannot
|
|
322
|
+
* apply: a row here carries no marker for any estate, or the sweep would have
|
|
323
|
+
* called it foreign instead of unclaimed.
|
|
324
|
+
*/
|
|
325
|
+
export interface LivePlanAdoptableRow {
|
|
326
|
+
addr: string;
|
|
327
|
+
type?: string;
|
|
328
|
+
identity?: string;
|
|
329
|
+
adoptEstate?: string;
|
|
330
|
+
adoptAddress?: string;
|
|
331
|
+
/** `matched[]`: the arguments the declaration and the live resource agreed on exactly. */
|
|
332
|
+
matched?: Array<{ attribute: string; value: string }>;
|
|
333
|
+
}
|
|
334
|
+
|
|
285
335
|
/** A `live-plan -json` document, indexed by declared instance address. */
|
|
286
336
|
export interface LivePlanIndex {
|
|
287
337
|
/** `estate`: the estate every section was computed against. */
|
|
@@ -289,6 +339,13 @@ export interface LivePlanIndex {
|
|
|
289
339
|
bound: Map<string, LivePlanBoundRow>;
|
|
290
340
|
omissions: Map<string, LivePlanOmissionRow>;
|
|
291
341
|
unowned: Map<string, LivePlanUnownedRow>;
|
|
342
|
+
adoptable: Map<string, LivePlanAdoptableRow>;
|
|
343
|
+
/**
|
|
344
|
+
* `swept[]`: the resource types the estate-wide sweep listed in full. Empty
|
|
345
|
+
* on every observation read, which asks for no sweep, so an empty
|
|
346
|
+
* `adoptable` beside it says nothing was asked rather than nothing found.
|
|
347
|
+
*/
|
|
348
|
+
swept: string[];
|
|
292
349
|
/** Every address any section named, so a block can find its own instances. */
|
|
293
350
|
addresses: string[];
|
|
294
351
|
/** `diagnostics[]` summaries, for the observation's run-level notes. */
|
|
@@ -307,6 +364,8 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
|
|
|
307
364
|
bound: new Map(),
|
|
308
365
|
omissions: new Map(),
|
|
309
366
|
unowned: new Map(),
|
|
367
|
+
adoptable: new Map(),
|
|
368
|
+
swept: asArray(doc.swept).filter((t): t is string => typeof t === "string"),
|
|
310
369
|
addresses: [],
|
|
311
370
|
diagnostics: [],
|
|
312
371
|
};
|
|
@@ -348,6 +407,24 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
|
|
|
348
407
|
});
|
|
349
408
|
}
|
|
350
409
|
|
|
410
|
+
for (const entry of asArray(doc.adoptable)) {
|
|
411
|
+
const row = asRecord(entry);
|
|
412
|
+
const addr = asString(row.addr);
|
|
413
|
+
if (!addr) continue;
|
|
414
|
+
const matched = asArray(row.matched)
|
|
415
|
+
.map((m) => asRecord(m))
|
|
416
|
+
.filter((m) => asString(m.attribute))
|
|
417
|
+
.map((m) => ({ attribute: asString(m.attribute)!, value: asString(m.value) ?? "" }));
|
|
418
|
+
index.adoptable.set(addr, {
|
|
419
|
+
addr,
|
|
420
|
+
...(asString(row.type) ? { type: asString(row.type) } : {}),
|
|
421
|
+
...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
|
|
422
|
+
...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
|
|
423
|
+
...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
|
|
424
|
+
...(matched.length > 0 ? { matched } : {}),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
351
428
|
for (const entry of asArray(doc.diagnostics)) {
|
|
352
429
|
const row = asRecord(entry);
|
|
353
430
|
const summary = asString(row.summary);
|
|
@@ -355,7 +432,12 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
|
|
|
355
432
|
}
|
|
356
433
|
|
|
357
434
|
index.addresses = [
|
|
358
|
-
...new Set([
|
|
435
|
+
...new Set([
|
|
436
|
+
...index.bound.keys(),
|
|
437
|
+
...index.omissions.keys(),
|
|
438
|
+
...index.unowned.keys(),
|
|
439
|
+
...index.adoptable.keys(),
|
|
440
|
+
]),
|
|
359
441
|
].sort();
|
|
360
442
|
return index;
|
|
361
443
|
}
|
|
@@ -419,7 +501,7 @@ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
|
|
|
419
501
|
/** One instance's verdict, before a block aggregates its instances. */
|
|
420
502
|
type InstanceVerdict =
|
|
421
503
|
| { kind: "owned"; row: LivePlanBoundRow }
|
|
422
|
-
| { kind: "adoptable"; row:
|
|
504
|
+
| { kind: "adoptable"; row: LivePlanAdoptableRow }
|
|
423
505
|
| { kind: "foreign"; row: LivePlanUnownedRow }
|
|
424
506
|
| { kind: "absent" }
|
|
425
507
|
| { kind: "unobserved"; reason: UnobservedReason; detail: string };
|
|
@@ -428,6 +510,11 @@ type InstanceVerdict =
|
|
|
428
510
|
* Classify one instance address against the document, `unowned[]` first: a
|
|
429
511
|
* declared instance that also carries a `UNOWNED` omission is answered by the
|
|
430
512
|
* section that has the verdict, not by the one that has the apology.
|
|
513
|
+
* `adoptable[]` sits under `bound[]` for the same reason, one rung further
|
|
514
|
+
* down: a content match is the answer for an instance nothing else could
|
|
515
|
+
* answer for, and its paired omission is always `NEEDS_DISCOVERY`, which on
|
|
516
|
+
* its own would read as unsupported-kind and hide a real, actionable verdict
|
|
517
|
+
* (choudoufu #962, chant #2241).
|
|
431
518
|
*/
|
|
432
519
|
function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
|
|
433
520
|
const unowned = index.unowned.get(address);
|
|
@@ -440,6 +527,11 @@ function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVe
|
|
|
440
527
|
const bound = index.bound.get(address);
|
|
441
528
|
if (bound) return { kind: "owned", row: bound };
|
|
442
529
|
|
|
530
|
+
const matched = index.adoptable.get(address);
|
|
531
|
+
if (matched && (matched.adoptEstate || matched.adoptAddress)) {
|
|
532
|
+
return { kind: "adoptable", row: matched };
|
|
533
|
+
}
|
|
534
|
+
|
|
443
535
|
const omission = index.omissions.get(address);
|
|
444
536
|
if (omission) {
|
|
445
537
|
const verdict = omissionVerdict(omission);
|
|
@@ -516,6 +608,13 @@ function readLiveResource(
|
|
|
516
608
|
...(adoptable.row.type ? { resourceType: adoptable.row.type } : {}),
|
|
517
609
|
...(adoptable.row.adoptEstate ? { adoptTofuEstate: adoptable.row.adoptEstate } : {}),
|
|
518
610
|
...(adoptable.row.adoptAddress ? { adoptTofuAddress: adoptable.row.adoptAddress } : {}),
|
|
611
|
+
// Present only on a content match: the arguments the sweep compared,
|
|
612
|
+
// which is the whole evidence for a match nobody read an identity
|
|
613
|
+
// for, and the thing an operator checks before letting a tag write
|
|
614
|
+
// claim the resource.
|
|
615
|
+
...(adoptable.row.matched?.length
|
|
616
|
+
? { matchedOn: adoptable.row.matched.map((m) => `${m.attribute}=${m.value}`) }
|
|
617
|
+
: {}),
|
|
519
618
|
},
|
|
520
619
|
},
|
|
521
620
|
queried,
|
|
@@ -1091,12 +1190,20 @@ export async function describeResources(
|
|
|
1091
1190
|
|
|
1092
1191
|
const parts = [];
|
|
1093
1192
|
const notes: string[] = [];
|
|
1193
|
+
// Which of the two reads answered, per entity (#2267). Filled for every
|
|
1194
|
+
// entity of the root before the read runs, so the answer is on the wire
|
|
1195
|
+
// whatever the verdict came back as: present, absent, or not-observed. A
|
|
1196
|
+
// renderer joins observations to nodes, and painting `terraform show -json`
|
|
1197
|
+
// over state the same green as a live read of the account is the drift lie
|
|
1198
|
+
// an overlay exists to prevent.
|
|
1199
|
+
const sources: Record<string, string> = {};
|
|
1094
1200
|
for (const [root, declared] of byRoot) {
|
|
1095
1201
|
// One fact decides the whole read, and the parse already recorded it on
|
|
1096
1202
|
// every entity of the root (#2103). A root is live when its binary is
|
|
1097
1203
|
// choudoufu AND it declares an estate, so a single entity carrying
|
|
1098
1204
|
// `mode: "live"` settles it for the root.
|
|
1099
1205
|
const live = declared.some((entity) => entity.mode === "live");
|
|
1206
|
+
for (const entity of declared) sources[entity.name] = live ? "live" : "state";
|
|
1100
1207
|
if (live) {
|
|
1101
1208
|
notes.push(
|
|
1102
1209
|
`terraform.roots.${root} is a live root: ownership came from choudoufu's tofu-estate/tofu-address markers via \`live-plan -json\`, not from a state file`,
|
|
@@ -1136,5 +1243,5 @@ export async function describeResources(
|
|
|
1136
1243
|
}
|
|
1137
1244
|
}
|
|
1138
1245
|
|
|
1139
|
-
return observation(resources, unobserved, merged.queried, merged.notes);
|
|
1246
|
+
return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources);
|
|
1140
1247
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference resolution and the graph IR it feeds (chant #2265, #2266).
|
|
3
|
+
*
|
|
4
|
+
* Everything here goes through `renderTerraformRoots` and then core's own
|
|
5
|
+
* `buildGraphIr`, which is the exact pair `chant graph --format ir` runs, so
|
|
6
|
+
* what is asserted is what a renderer receives. The fixture is
|
|
7
|
+
* `src/__fixtures__/graph-roots/`: two roots, a local child module, and one
|
|
8
|
+
* instance of every decision these issues asked to have pinned.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
14
|
+
import { buildGraphIr, entityReferences, entityStack, type IREdge } from "@intentius/chant/graph-ir";
|
|
15
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
16
|
+
import { renderTerraformRoots } from "./roots";
|
|
17
|
+
import { referenceFromAccessor } from "./edges";
|
|
18
|
+
import type { TerraformEntity } from "./parse";
|
|
19
|
+
|
|
20
|
+
const TREE = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__", "graph-roots");
|
|
21
|
+
|
|
22
|
+
let entities: Map<string, Declarable>;
|
|
23
|
+
let edges: IREdge[];
|
|
24
|
+
let groups: ReturnType<typeof buildGraphIr>["groups"];
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
const rendered = await renderTerraformRoots({
|
|
28
|
+
projectRoot: TREE,
|
|
29
|
+
roots: { app: { dir: "./app" }, network: { dir: "./network" } },
|
|
30
|
+
});
|
|
31
|
+
expect(rendered.warnings).toEqual([]);
|
|
32
|
+
entities = rendered.entities;
|
|
33
|
+
const ir = buildGraphIr(entities);
|
|
34
|
+
edges = ir.edges;
|
|
35
|
+
groups = ir.groups;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The edge between two nodes through one consumer attribute, if there is one. */
|
|
39
|
+
function edge(from: string, to: string, viaAttr: string): IREdge | undefined {
|
|
40
|
+
return edges.find((e) => e.from === from && e.to === to && e.viaAttr === viaAttr);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("referenceFromAccessor", () => {
|
|
44
|
+
it("classifies every form that becomes an edge", () => {
|
|
45
|
+
expect(referenceFromAccessor("aws_vpc.main.id")).toEqual({ address: "aws_vpc.main", attr: "id" });
|
|
46
|
+
expect(referenceFromAccessor("module.cdn.url")).toEqual({ address: "module.cdn", attr: "url" });
|
|
47
|
+
expect(referenceFromAccessor("data.aws_ami.ubuntu.id")).toEqual({
|
|
48
|
+
address: "data.aws_ami.ubuntu",
|
|
49
|
+
attr: "id",
|
|
50
|
+
});
|
|
51
|
+
expect(referenceFromAccessor("var.region")).toEqual({ address: "var.region" });
|
|
52
|
+
expect(referenceFromAccessor("local.tags")).toEqual({ address: "local.tags" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reads no reference out of the meta-arguments that are not one", () => {
|
|
56
|
+
// `count.index`, `each.value`, `path.module`, `terraform.workspace` and
|
|
57
|
+
// `self.x` all look like `<head>.<name>` and none of them names a block.
|
|
58
|
+
expect(referenceFromAccessor("count.index")).toBeUndefined();
|
|
59
|
+
expect(referenceFromAccessor("each.value")).toBeUndefined();
|
|
60
|
+
expect(referenceFromAccessor("path.module")).toBeUndefined();
|
|
61
|
+
expect(referenceFromAccessor("terraform.workspace")).toBeUndefined();
|
|
62
|
+
expect(referenceFromAccessor("self.private_ip")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("edges out of a parsed estate (#2265)", () => {
|
|
67
|
+
it("emits the whole edge set, and only it", () => {
|
|
68
|
+
expect(
|
|
69
|
+
edges.map((e) => [e.from, e.to, e.viaAttr ?? "", e.toAttr ?? ""].join(" ")),
|
|
70
|
+
).toEqual([
|
|
71
|
+
"app/aws_instance.web app/aws_s3_bucket.assets depends_on ",
|
|
72
|
+
"app/aws_instance.web app/data.aws_iam_policy.boundary tags arn",
|
|
73
|
+
"app/aws_instance.web app/module.cdn depends_on ",
|
|
74
|
+
"app/aws_instance.web app/var.subnets count ",
|
|
75
|
+
"app/aws_instance.web app/var.subnets subnet_id ",
|
|
76
|
+
"app/module.cdn/aws_cloudfront_distribution.cdn app/module.cdn/var.bucket origin_id ",
|
|
77
|
+
"app/module.cdn app/aws_s3_bucket.assets bucket id",
|
|
78
|
+
"app/output.cdn_url app/module.cdn value url",
|
|
79
|
+
"app/provider.aws app/var.region region ",
|
|
80
|
+
"network/aws_vpc.main network/locals tags ",
|
|
81
|
+
"network/aws_vpc.main network/var.cidr cidr_block ",
|
|
82
|
+
"network/output.vpc_id network/aws_vpc.main value id",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("makes a `module` reference an edge to the call block, in both directions of use", () => {
|
|
87
|
+
// The call reads a resource; an output reads the call.
|
|
88
|
+
expect(edge("app/module.cdn", "app/aws_s3_bucket.assets", "bucket")).toEqual({
|
|
89
|
+
from: "app/module.cdn",
|
|
90
|
+
to: "app/aws_s3_bucket.assets",
|
|
91
|
+
kind: "ref",
|
|
92
|
+
viaAttr: "bucket",
|
|
93
|
+
toAttr: "id",
|
|
94
|
+
});
|
|
95
|
+
expect(edge("app/output.cdn_url", "app/module.cdn", "value")?.toAttr).toBe("url");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("makes `depends_on` an edge with no producer attribute", () => {
|
|
99
|
+
const ordering = edges.filter((e) => e.viaAttr === "depends_on");
|
|
100
|
+
expect(ordering.map((e) => e.to).sort()).toEqual(["app/aws_s3_bucket.assets", "app/module.cdn"]);
|
|
101
|
+
for (const e of ordering) expect(e.toAttr).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps a `count` block one node, and its edges block-to-block", () => {
|
|
105
|
+
const web = entities.get("app/aws_instance.web") as TerraformEntity;
|
|
106
|
+
// `count = length(var.subnets)` over a list: the declaration is one block,
|
|
107
|
+
// so it is one node whatever the count evaluates to, and the expansion is
|
|
108
|
+
// recorded rather than left to be inferred from a node count.
|
|
109
|
+
expect(web.props.expansion).toBe("count");
|
|
110
|
+
expect([...entities.keys()].filter((k) => k.startsWith("app/aws_instance.web"))).toEqual([
|
|
111
|
+
"app/aws_instance.web",
|
|
112
|
+
]);
|
|
113
|
+
// The meta-argument is an expression like any other, so it carries an edge.
|
|
114
|
+
expect(edge("app/aws_instance.web", "app/var.subnets", "count")).toBeDefined();
|
|
115
|
+
// ...and reading `var.subnets[count.index]` elsewhere in the block is a
|
|
116
|
+
// second edge through a second attribute, not a second instance.
|
|
117
|
+
expect(edge("app/aws_instance.web", "app/var.subnets", "subnet_id")).toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("makes `var` and `local` edges, and resolves a local to the block that declares it", () => {
|
|
121
|
+
expect(edge("network/aws_vpc.main", "network/var.cidr", "cidr_block")).toBeDefined();
|
|
122
|
+
expect(edge("network/aws_vpc.main", "network/locals", "tags")).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("names the consumer attribute on every edge and the producer attribute only when one was read", () => {
|
|
126
|
+
for (const e of edges) expect(e.viaAttr).toBeTruthy();
|
|
127
|
+
// `data.aws_iam_policy.boundary.arn` named exactly one producer attribute.
|
|
128
|
+
expect(edge("app/aws_instance.web", "app/data.aws_iam_policy.boundary", "tags")?.toAttr).toBe("arn");
|
|
129
|
+
// `[aws_s3_bucket.assets]` named none.
|
|
130
|
+
expect(edge("app/aws_instance.web", "app/aws_s3_bucket.assets", "depends_on")?.toAttr).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("resolves a child module's references inside the child's own scope", () => {
|
|
134
|
+
// `var.bucket` in `module.cdn` is the CHILD's variable. The root declares
|
|
135
|
+
// no `var.bucket` at all, so a scope-blind resolution would have produced
|
|
136
|
+
// no edge here rather than the wrong one; the assertion that matters is
|
|
137
|
+
// that the edge lands on the child's key.
|
|
138
|
+
expect(
|
|
139
|
+
edge(
|
|
140
|
+
"app/module.cdn/aws_cloudfront_distribution.cdn",
|
|
141
|
+
"app/module.cdn/var.bucket",
|
|
142
|
+
"origin_id",
|
|
143
|
+
),
|
|
144
|
+
).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("draws no edge for a `provider` meta-argument", () => {
|
|
148
|
+
// `provider = aws.replica` names a block by type AND alias, which this
|
|
149
|
+
// lexicon's `provider.<type>` key cannot be resolved to without guessing.
|
|
150
|
+
expect(edges.some((e) => e.viaAttr === "provider")).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("draws no edge between two roots", () => {
|
|
154
|
+
const rootOf = (id: string): string => id.split("/")[0];
|
|
155
|
+
for (const e of edges) expect(rootOf(e.from)).toBe(rootOf(e.to));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("publishes the references on the entity, in core's lexicon-neutral shape", () => {
|
|
159
|
+
// The channel core reads (#2265): an entity key, plus the two attribute
|
|
160
|
+
// names `IREdge` carries. Nothing terraform-shaped crosses the boundary.
|
|
161
|
+
expect(entityReferences(entities.get("app/output.cdn_url")!)).toEqual([
|
|
162
|
+
{ to: "app/module.cdn", viaAttr: "value", toAttr: "url" },
|
|
163
|
+
]);
|
|
164
|
+
// An entity that references nothing carries no channel at all.
|
|
165
|
+
expect(entityReferences(entities.get("app/var.region")!)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops a reference to something outside the graph rather than dangling it", () => {
|
|
169
|
+
// Only ids that are nodes become edges, so an entity key that never made
|
|
170
|
+
// it into the IR cannot leave a half-edge behind.
|
|
171
|
+
const ids = new Set([...entities.keys()]);
|
|
172
|
+
for (const e of edges) {
|
|
173
|
+
expect(ids.has(e.from)).toBe(true);
|
|
174
|
+
expect(ids.has(e.to)).toBe(true);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("grouping by root (#2266)", () => {
|
|
180
|
+
it("keys byStack by root name, one entry per declared root", () => {
|
|
181
|
+
expect(Object.keys(groups.byStack ?? {})).toEqual(["app", "network"]);
|
|
182
|
+
expect(groups.byStack?.["network"]).toEqual([
|
|
183
|
+
"network/aws_vpc.main",
|
|
184
|
+
"network/locals",
|
|
185
|
+
"network/output.vpc_id",
|
|
186
|
+
"network/var.cidr",
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps byLexicon saying terraform", () => {
|
|
191
|
+
expect(Object.keys(groups.byLexicon ?? {})).toEqual(["terraform"]);
|
|
192
|
+
expect(groups.byLexicon?.["terraform"].length).toBe(
|
|
193
|
+
(groups.byStack?.["app"].length ?? 0) + (groups.byStack?.["network"].length ?? 0),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("puts every node in exactly one root's bucket, the one its id is prefixed with", () => {
|
|
198
|
+
for (const [root, ids] of Object.entries(groups.byStack ?? {})) {
|
|
199
|
+
for (const id of ids) expect(id.startsWith(`${root}/`)).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("says which unit an entity belongs to on the entity, not by convention on its id", () => {
|
|
204
|
+
expect(entityStack(entities.get("app/module.cdn/var.bucket")!)).toBe("app");
|
|
205
|
+
expect(entityStack(entities.get("network/aws_vpc.main")!)).toBe("network");
|
|
206
|
+
});
|
|
207
|
+
});
|