@intentius/chant-lexicon-terraform 0.59.0 → 0.61.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.
Files changed (40) hide show
  1. package/dist/describe-resources.d.ts +59 -0
  2. package/dist/describe-resources.d.ts.map +1 -1
  3. package/dist/hcl/edges.d.ts +124 -0
  4. package/dist/hcl/edges.d.ts.map +1 -0
  5. package/dist/hcl/parse.d.ts +42 -0
  6. package/dist/hcl/parse.d.ts.map +1 -1
  7. package/dist/hcl/roots.d.ts +6 -0
  8. package/dist/hcl/roots.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/terraform.d.ts +44 -8
  14. package/dist/op/activities/terraform.d.ts.map +1 -1
  15. package/dist/op/adoption.d.ts +110 -35
  16. package/dist/op/adoption.d.ts.map +1 -1
  17. package/package.json +2 -2
  18. package/src/__fixtures__/ACCEPTANCE.md +92 -58
  19. package/src/__fixtures__/graph-roots/README.md +21 -0
  20. package/src/__fixtures__/graph-roots/app/main.tf +41 -0
  21. package/src/__fixtures__/graph-roots/app/modules/cdn/main.tf +7 -0
  22. package/src/__fixtures__/graph-roots/network/main.tf +16 -0
  23. package/src/__fixtures__/live-estate/README.md +65 -19
  24. package/src/__fixtures__/live-estate/adoptable.tf +13 -0
  25. package/src/__fixtures__/live-ls.json +5 -5
  26. package/src/__fixtures__/live-plan.json +67 -35
  27. package/src/composites/terraform-adopt-op.acceptance.test.ts +36 -50
  28. package/src/composites/terraform-apply-op.acceptance.test.ts +4 -2
  29. package/src/describe-resources.live.test.ts +76 -4
  30. package/src/describe-resources.test.ts +35 -0
  31. package/src/describe-resources.ts +110 -3
  32. package/src/hcl/edges.test.ts +207 -0
  33. package/src/hcl/edges.ts +316 -0
  34. package/src/hcl/parse.ts +53 -0
  35. package/src/hcl/roots.ts +14 -0
  36. package/src/index.ts +1 -0
  37. package/src/op/activities/choudoufu.test.ts +3 -3
  38. package/src/op/activities/terraform.ts +70 -21
  39. package/src/op/adoption.test.ts +129 -11
  40. package/src/op/adoption.ts +181 -49
@@ -1,14 +1,42 @@
1
1
  /**
2
2
  * The adoption ledger: which live resources this estate's markers can claim,
3
- * read off `live-plan -json`'s document and rendered as text (#2105).
3
+ * read off `live-plan -json`'s document and rendered as text (#2105, #2241).
4
4
  *
5
5
  * Pure. No filesystem, no child process, no HCL parse — everything here is a
6
- * projection of GitHub issue #788's `unowned` section, which is the one place
7
- * choudoufu states both halves of the answer: the live resource sitting at a
8
- * declared instance's identity, and the `tofu-estate`/`tofu-address` pair that
9
- * would adopt it. Two tags is the whole ownership contract (choudoufu's
10
- * `live/MARKERS.md`), so those two values are the whole of what an adoption
11
- * needs to know.
6
+ * projection of two sections of choudoufu's document, which between them state
7
+ * both halves of the answer: the live resource an adoption would claim, and
8
+ * the `tofu-estate`/`tofu-address` pair that claims it. Two tags is the whole
9
+ * ownership contract (choudoufu's `live/MARKERS.md`), so those two values are
10
+ * the whole of what an adoption needs to know.
11
+ *
12
+ * ## The two sections, and why there are two
13
+ *
14
+ * `unowned[]` (choudoufu issue #788) is a live resource read at an identity
15
+ * the configuration itself declares: a log group's name is in the block, so an
16
+ * unmarked live one with that name is found by reading it.
17
+ *
18
+ * `adoptable[]` (choudoufu issue #962, shipped in v0.15.0) is a live resource
19
+ * the estate-wide sweep matched to a declared instance by content, for a
20
+ * declaration that carries no identity at all: EC2 assigns a VPC's id, so a
21
+ * declared `aws_vpc` lands in `omissions[]` as `NEEDS_DISCOVERY` and the live
22
+ * VPC standing at its `cidr_block` is found by comparing arguments. Each row
23
+ * carries `matched[]`, the arguments that agreed, and `adopt_command`, the
24
+ * paste-ready tagging command, so nothing on this path parses the human
25
+ * render any more. Before v0.15.0 the document had no row for that match at
26
+ * all and chant read the two regexes {@link parseAdoptionCommands} still
27
+ * holds, which is what chant #2168 measured and filed.
28
+ *
29
+ * The two sections are disjoint by construction, so this reads both and keys
30
+ * the union by declared address.
31
+ *
32
+ * ## What the empty ledger means
33
+ *
34
+ * `adoptable[]` and `swept[]` are populated only on a run that asked the
35
+ * estate-wide sweep the account-bounded question (`-adoption-only`, or
36
+ * `TOFU_LIVE_COLLECT_UNCLAIMED=1` on a `-json` run). {@link AdoptionLedger}
37
+ * carries `swept` for exactly that reason: an empty `adoptions` under an empty
38
+ * `swept` is "this run did not look", not "there is nothing to adopt", and
39
+ * {@link renderAdoptionLedger} says which.
12
40
  *
13
41
  * ## Why chant renders a ledger at all
14
42
  *
@@ -18,43 +46,64 @@
18
46
  * says why each unadoptable instance is unadoptable. But `-adoption-only` and
19
47
  * `-json` are refused together (`internal/command/live_plan.go`: "this run
20
48
  * cannot produce both reports at once"), and an Op that reports adoptables
21
- * needs the machine-readable document anyway — for the counts it publishes as
22
- * outcome attributes, and for the addresses an adoption step acts on. So the
49
+ * needs the machine-readable document anyway, for the counts it publishes as
50
+ * outcome attributes and for the addresses an adoption step acts on. So the
23
51
  * ledger below is rendered from the document that run already has, in the row
24
52
  * form `-adoption-only` prints, rather than paying for a third live read.
25
53
  *
26
54
  * ## Ambiguity
27
55
  *
28
- * `unowned[]` is one entry per live resource, keyed by the declared instance
29
- * whose identity found it, so two live resources at one declared identity are
30
- * two entries carrying the same `addr`. That is the ambiguous case: no single
31
- * tag write claims the address, and picking one of the two is a decision about
32
- * the estate rather than something a tool infers. {@link readAdoptionLedger}
33
- * separates those into `contested` and never lets them into `adoptions`, and
34
- * `TerraformAdoptOp` passes the contested list to its Adopt step so the Op's
35
- * result names what it refused as well as what it wrote.
56
+ * Both sections are one entry per live resource, keyed by the declared
57
+ * instance the resource was matched to, so two live resources at one
58
+ * declaration are two entries carrying the same `addr`. That is the ambiguous
59
+ * case: no single tag write claims the address, and picking one of the two is
60
+ * a decision about the estate rather than something a tool infers.
61
+ * {@link readAdoptionLedger} separates those into `contested` and never lets
62
+ * them into `adoptions`, and `TerraformAdoptOp` passes the contested list to
63
+ * its Adopt step so the Op's result names what it refused as well as what it
64
+ * wrote.
36
65
  */
37
66
 
67
+ /** One argument a content match rested on, as choudoufu's `adoptable[].matched[]`. */
68
+ export interface AdoptionMatch {
69
+ /** The argument's name in the declared block, `cidr_block` and the like. */
70
+ attribute: string;
71
+ /** The value both the declaration and the live resource carried. */
72
+ value: string;
73
+ }
74
+
38
75
  /** One live resource a marker write would bind to a declared instance. */
39
76
  export interface AdoptionCandidate {
40
- /** The declared instance address whose identity found the live resource. */
77
+ /** The declared instance address the live resource was matched to. */
41
78
  addr: string;
42
- /** The live resource's type, as choudoufu's `unowned[].type`. */
79
+ /** The live resource's type, as choudoufu's `type`. */
43
80
  type: string;
44
- /** The identity the live resource was read with — the handle a human, or a tagging call, needs. */
81
+ /** The identity the live resource was read with: the handle a human, or a tagging call, needs. */
45
82
  identity: string;
46
83
  /** The `tofu-estate` value that adopts it. */
47
84
  markerEstate: string;
48
85
  /** The `tofu-address` value that adopts it, escaped as choudoufu stores it. */
49
86
  markerAddress: string;
50
87
  /**
51
- * The paste-ready tagging command choudoufu printed for this address under
52
- * `live-plan -adoption-only`, when it printed one. Absent for a type whose
53
- * service has its own tagging call this fork does not spell out (IAM,
54
- * Route53, S3 and friends): the two marker values above are still the whole
55
- * contract, but the caller has to write them itself.
88
+ * The paste-ready tagging command choudoufu printed for this address, when
89
+ * it printed one. Absent for a type whose service has its own tagging call
90
+ * this fork does not spell out (IAM, Route53, S3 and friends): the two
91
+ * marker values above are still the whole contract, but the caller has to
92
+ * write them itself.
93
+ *
94
+ * An `adoptable[]` row carries its own (`adopt_command`, choudoufu #962). An
95
+ * `unowned[]` row does not, so a command for one of those comes from the
96
+ * `commands` map {@link parseAdoptionCommands} builds off the human render.
56
97
  */
57
98
  command?: string;
99
+ /**
100
+ * The arguments the declaration and the live resource agreed on exactly, in
101
+ * the order choudoufu's matcher compared them. Present on a content match
102
+ * (`adoptable[]`) and absent on a row found by reading a declared identity
103
+ * (`unowned[]`), which matched on the identity itself and has nothing else
104
+ * to name.
105
+ */
106
+ matched?: AdoptionMatch[];
58
107
  }
59
108
 
60
109
  /** What {@link readAdoptionLedger} found in one `live-plan -json` document. */
@@ -70,6 +119,13 @@ export interface AdoptionLedger {
70
119
  contested: AdoptionCandidate[];
71
120
  /** How many distinct declared addresses are contested. */
72
121
  ambiguous: number;
122
+ /**
123
+ * `swept[]`: every resource type the estate-wide sweep listed in full on the
124
+ * run that produced the document. Empty means the run never asked the
125
+ * account-bounded question, so an empty `adoptions` beside it is silence
126
+ * rather than a finding.
127
+ */
128
+ swept: string[];
73
129
  }
74
130
 
75
131
  /** `unowned[]`'s entry shape, per choudoufu's `views.StatelessUnowned` json tags. */
@@ -82,45 +138,93 @@ interface UnownedEntry {
82
138
  adopt_tofu_address?: unknown;
83
139
  }
84
140
 
141
+ /** `adoptable[]`'s entry shape, per choudoufu's `views.LivePlanAdoptable` json tags. */
142
+ interface AdoptableEntry extends UnownedEntry {
143
+ matched?: unknown;
144
+ adopt_command?: unknown;
145
+ }
146
+
85
147
  const str = (v: unknown): string => (typeof v === "string" ? v : "");
86
148
 
149
+ /** `matched[]` as choudoufu writes it, dropping anything that is not a pair of strings. */
150
+ function readMatched(raw: unknown): AdoptionMatch[] {
151
+ if (!Array.isArray(raw)) return [];
152
+ const out: AdoptionMatch[] = [];
153
+ for (const item of raw) {
154
+ const m = (item ?? {}) as { attribute?: unknown; value?: unknown };
155
+ const attribute = str(m.attribute);
156
+ if (attribute) out.push({ attribute, value: str(m.value) });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** `swept[]` as choudoufu writes it: resource type names, and nothing else. */
162
+ function readSwept(document: unknown): string[] {
163
+ const swept = (document as { swept?: unknown } | null | undefined)?.swept;
164
+ return Array.isArray(swept) ? swept.filter((t): t is string => typeof t === "string") : [];
165
+ }
166
+
87
167
  /**
88
- * Project a `live-plan -json` document's `unowned` section into the adoptable
89
- * and the contested sets.
168
+ * Project a `live-plan -json` document's `unowned` and `adoptable` sections
169
+ * into the adoptable and the contested sets.
90
170
  *
91
171
  * An entry counts as a candidate only when choudoufu offered both marker
92
- * values. Both empty means adoption was not this run's to offer — the resource
172
+ * values. Both empty means adoption was not this run's to offer: the resource
93
173
  * belongs to another estate (`tofu_estate` names it), or the run had no estate
94
- * name of its own — and such an entry is neither adoptable nor contested here,
174
+ * name of its own, and such an entry is neither adoptable nor contested here,
95
175
  * because there is no tag write to refuse.
96
176
  *
97
177
  * `commands` maps a declared address to the paste-ready tagging command
98
- * choudoufu printed for it, from {@link parseAdoptionCommands}; omit it when
99
- * the run had no `-adoption-only` render to read one out of.
178
+ * choudoufu printed for it, from {@link parseAdoptionCommands}. An
179
+ * `adoptable[]` row carries its own command in the document and never needs
180
+ * the map; omit the map entirely when the run had no `-adoption-only` render
181
+ * to read one out of.
100
182
  */
101
183
  export function readAdoptionLedger(document: unknown, commands?: ReadonlyMap<string, string>): AdoptionLedger {
102
- const unowned = (document as { unowned?: unknown } | null | undefined)?.unowned;
103
- if (!Array.isArray(unowned)) return { adoptions: [], contested: [], ambiguous: 0 };
104
-
184
+ const doc = (document ?? {}) as { unowned?: unknown; adoptable?: unknown };
185
+ const swept = readSwept(document);
105
186
  const byAddr = new Map<string, AdoptionCandidate[]>();
106
- for (const raw of unowned) {
187
+
188
+ const add = (candidate: AdoptionCandidate): void => {
189
+ const at = byAddr.get(candidate.addr);
190
+ if (at) at.push(candidate);
191
+ else byAddr.set(candidate.addr, [candidate]);
192
+ };
193
+
194
+ for (const raw of Array.isArray(doc.unowned) ? doc.unowned : []) {
107
195
  const e = (raw ?? {}) as UnownedEntry;
108
196
  const markerEstate = str(e.adopt_tofu_estate);
109
197
  const markerAddress = str(e.adopt_tofu_address);
110
198
  if (!markerEstate && !markerAddress) continue;
111
199
  const addr = str(e.addr);
112
200
  const command = commands?.get(addr);
113
- const candidate: AdoptionCandidate = {
201
+ add({
114
202
  addr,
115
203
  type: str(e.type),
116
204
  identity: str(e.identity),
117
205
  markerEstate,
118
206
  markerAddress,
119
207
  ...(command ? { command } : {}),
120
- };
121
- const at = byAddr.get(addr);
122
- if (at) at.push(candidate);
123
- else byAddr.set(addr, [candidate]);
208
+ });
209
+ }
210
+
211
+ for (const raw of Array.isArray(doc.adoptable) ? doc.adoptable : []) {
212
+ const e = (raw ?? {}) as AdoptableEntry;
213
+ const markerEstate = str(e.adopt_tofu_estate);
214
+ const markerAddress = str(e.adopt_tofu_address);
215
+ if (!markerEstate && !markerAddress) continue;
216
+ const addr = str(e.addr);
217
+ const command = str(e.adopt_command) || commands?.get(addr);
218
+ const matched = readMatched(e.matched);
219
+ add({
220
+ addr,
221
+ type: str(e.type),
222
+ identity: str(e.identity),
223
+ markerEstate,
224
+ markerAddress,
225
+ ...(command ? { command } : {}),
226
+ ...(matched.length > 0 ? { matched } : {}),
227
+ });
124
228
  }
125
229
 
126
230
  const adoptions: AdoptionCandidate[] = [];
@@ -134,7 +238,7 @@ export function readAdoptionLedger(document: unknown, commands?: ReadonlyMap<str
134
238
  contested.push(...candidates);
135
239
  }
136
240
  }
137
- return { adoptions, contested, ambiguous };
241
+ return { adoptions, contested, ambiguous, swept };
138
242
  }
139
243
 
140
244
  /**
@@ -153,6 +257,17 @@ export function readAdoptionLedger(document: unknown, commands?: ReadonlyMap<str
153
257
  * choudoufu already wrote.
154
258
  *
155
259
  * An address the render offered no command for is simply absent from the map.
260
+ *
261
+ * ## Which caller still needs this (#2241)
262
+ *
263
+ * One: the `unowned[]` half of {@link readAdoptionLedger}. choudoufu's
264
+ * `views.StatelessUnowned` has no command field, so a live resource found at
265
+ * an identity the configuration declares still gets its paste-ready command
266
+ * from the human render and from nowhere else. The `adoptable[]` half no
267
+ * longer reads a line of text: those rows carry `adopt_command` in the
268
+ * document itself since v0.15.0 (choudoufu #962), which is what made the
269
+ * adopt path stop resting on two regexes over a render nobody promised to
270
+ * keep stable.
156
271
  */
157
272
  export function parseAdoptionCommands(ledgerText: string): Map<string, string> {
158
273
  const commands = new Map<string, string>();
@@ -185,10 +300,17 @@ const plural = (n: number, one: string, many: string): string => (n === 1 ? one
185
300
  * One line per adoptable match, naming the declared address, the live resource
186
301
  * it binds, and the two tag values that adopt it, in the token forms
187
302
  * `-adoption-only` prints them in (`<addr> <- <type> <identity>`,
188
- * `tofu-estate=`, `tofu-address=`). Contested addresses follow, listed and not
189
- * offered. An empty ledger still renders a line: "nothing adoptable" is a
190
- * result a reader wants, and a section that vanishes reads as one that was
191
- * never computed.
303
+ * `tofu-estate=`, `tofu-address=`). A content match adds the arguments it
304
+ * rested on under its row, in the same `matched on:` form the human render
305
+ * uses, because "these two agreed on this cidr" is the whole evidence for a
306
+ * match nobody read an identity for. Contested addresses follow, listed and
307
+ * not offered.
308
+ *
309
+ * An empty ledger still renders a line: "nothing adoptable" is a result a
310
+ * reader wants, and a section that vanishes reads as one that was never
311
+ * computed. Which of the two empties it is comes from `swept`: a run that
312
+ * asked no estate-wide sweep found nothing because it did not look, and
313
+ * saying so is the difference between a report and a silence.
192
314
  */
193
315
  export function renderAdoptionLedger(ledger: AdoptionLedger, estate?: string): string {
194
316
  const where = estate ? `, estate ${JSON.stringify(estate)}` : "";
@@ -197,8 +319,14 @@ export function renderAdoptionLedger(ledger: AdoptionLedger, estate?: string): s
197
319
  if (ledger.adoptions.length === 0) {
198
320
  lines.push(`Adoptable now: nothing${where}`, "");
199
321
  lines.push(
200
- "No live resource sits at a declared identity carrying a marker this estate could write. " +
201
- "Anything the plan proposes creating, it proposes creating for real.",
322
+ ledger.swept.length === 0
323
+ ? "No estate-wide sweep ran on this plan, so nothing here says whether an unmarked live " +
324
+ "resource exists for any declared instance. This is silence, not a finding: ask the " +
325
+ "account-bounded question with an adoption run."
326
+ : "No live resource this run could claim was found, across " +
327
+ `${ledger.swept.length} swept resource ${plural(ledger.swept.length, "type", "types")} ` +
328
+ `(${ledger.swept.join(", ")}). Anything the plan proposes creating, it proposes ` +
329
+ "creating for real.",
202
330
  );
203
331
  } else {
204
332
  lines.push(
@@ -206,8 +334,9 @@ export function renderAdoptionLedger(ledger: AdoptionLedger, estate?: string): s
206
334
  "",
207
335
  );
208
336
  lines.push(
209
- "Each line is a live resource found at a declared resource's identity, carrying no marker for " +
210
- "this estate. Writing the two tags shown adopts it; nothing is bound until they are written, " +
337
+ "Each line is a live resource this estate could claim, found either at a declared resource's " +
338
+ "own identity or by matching a declaration's arguments, and carrying no marker for this " +
339
+ "estate. Writing the two tags shown adopts it; nothing is bound until they are written, " +
211
340
  "because ownership is the tofu-estate and tofu-address pair and nothing else.",
212
341
  "",
213
342
  );
@@ -216,6 +345,9 @@ export function renderAdoptionLedger(ledger: AdoptionLedger, estate?: string): s
216
345
  ` ${c.addr} <- ${c.type} ${c.identity} write: ` +
217
346
  `tofu-estate=${c.markerEstate} tofu-address=${c.markerAddress}`,
218
347
  );
348
+ if (c.matched?.length) {
349
+ lines.push(` matched on: ${c.matched.map((m) => `${m.attribute}=${m.value}`).join(", ")}`);
350
+ }
219
351
  }
220
352
  }
221
353