@intentius/chant-lexicon-terraform 0.124.31 → 0.124.33

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 (149) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.d.ts +2 -0
  20. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  21. package/dist/composites/terraform-adopt-op.js +153 -0
  22. package/dist/composites/terraform-apply-op.d.ts +2 -0
  23. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  24. package/dist/composites/terraform-apply-op.js +184 -0
  25. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  26. package/dist/composites/terraform-watch-op.js +219 -0
  27. package/dist/config.js +118 -0
  28. package/dist/describe-resources.js +984 -0
  29. package/dist/generated/index.js +1 -0
  30. package/dist/hcl/descend.js +208 -0
  31. package/dist/hcl/edges.js +287 -0
  32. package/dist/hcl/jsonencode.js +442 -0
  33. package/dist/hcl/marker.js +212 -0
  34. package/dist/hcl/parse.js +372 -0
  35. package/dist/hcl/references.js +156 -0
  36. package/dist/hcl/roots.js +98 -0
  37. package/dist/hcl/suppressions.js +189 -0
  38. package/dist/hcl/value.js +72 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +46 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/lint/audit-catalog.js +501 -0
  44. package/dist/lint/audit-lineage.js +442 -0
  45. package/dist/lint/post-synth/blocks.js +87 -0
  46. package/dist/lint/post-synth/fixtures/load.js +94 -0
  47. package/dist/lint/post-synth/flat-attributes.js +135 -0
  48. package/dist/lint/post-synth/index.js +90 -0
  49. package/dist/lint/post-synth/module-source.js +153 -0
  50. package/dist/lint/post-synth/scope.js +104 -0
  51. package/dist/lint/post-synth/terragrunt.js +63 -0
  52. package/dist/lint/post-synth/tf001.js +138 -0
  53. package/dist/lint/post-synth/tf002.js +143 -0
  54. package/dist/lint/post-synth/tf003.js +60 -0
  55. package/dist/lint/post-synth/tf004.js +54 -0
  56. package/dist/lint/post-synth/tf005.js +68 -0
  57. package/dist/lint/post-synth/tf006.js +43 -0
  58. package/dist/lint/post-synth/tf007.js +66 -0
  59. package/dist/lint/post-synth/tf008.js +64 -0
  60. package/dist/lint/post-synth/tf009.js +49 -0
  61. package/dist/lint/post-synth/tf010.js +40 -0
  62. package/dist/lint/post-synth/tf011.js +38 -0
  63. package/dist/lint/post-synth/tf012.js +36 -0
  64. package/dist/lint/post-synth/tf013.js +53 -0
  65. package/dist/lint/post-synth/tf014.js +61 -0
  66. package/dist/lint/post-synth/tf015.js +62 -0
  67. package/dist/lint/post-synth/tf016.js +66 -0
  68. package/dist/lint/post-synth/tf017.js +39 -0
  69. package/dist/lint/post-synth/tf018.js +63 -0
  70. package/dist/lint/post-synth/tf019.js +60 -0
  71. package/dist/lint/post-synth/tf020.js +103 -0
  72. package/dist/lint/post-synth/tf021.js +117 -0
  73. package/dist/lint/post-synth/tf022.js +51 -0
  74. package/dist/lint/post-synth/tf024.js +62 -0
  75. package/dist/lint/post-synth/tf025.js +83 -0
  76. package/dist/lint/post-synth/tf026.js +74 -0
  77. package/dist/lint/post-synth/tf027.js +66 -0
  78. package/dist/lint/post-synth/tf028.js +83 -0
  79. package/dist/lint/post-synth/tf029.js +73 -0
  80. package/dist/lint/post-synth/tf030.js +253 -0
  81. package/dist/lint/post-synth/tf031.js +332 -0
  82. package/dist/lint/post-synth/tf032.js +201 -0
  83. package/dist/lint/post-synth/tf033.js +126 -0
  84. package/dist/lint/post-synth/tf034.js +45 -0
  85. package/dist/lint/post-synth/tf035.js +63 -0
  86. package/dist/lint/post-synth/tf036.js +97 -0
  87. package/dist/lint/post-synth/tf037.js +80 -0
  88. package/dist/lint/post-synth/tf038.js +63 -0
  89. package/dist/lint/post-synth/tf039.js +55 -0
  90. package/dist/lint/post-synth/tf040.js +67 -0
  91. package/dist/lint/post-synth/tf041.js +53 -0
  92. package/dist/lint/post-synth/tf042.js +38 -0
  93. package/dist/lint/post-synth/tf043.js +45 -0
  94. package/dist/lint/post-synth/tf044.js +45 -0
  95. package/dist/lint/post-synth/tf045.js +44 -0
  96. package/dist/lint/rules/fixtures/load.js +50 -0
  97. package/dist/lint/rules/index.js +4 -0
  98. package/dist/lint/rules/plan-before-apply.js +136 -0
  99. package/dist/lint/secret-shape.js +149 -0
  100. package/dist/live-ownership.js +57 -0
  101. package/dist/lsp/completions.js +95 -0
  102. package/dist/lsp/context.js +358 -0
  103. package/dist/lsp/hover.js +45 -0
  104. package/dist/lsp/option-keys.js +93 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.js +30 -0
  107. package/dist/op/activities/live-detect.js +143 -0
  108. package/dist/op/activities/pin-rollout.js +45 -0
  109. package/dist/op/activities/terraform.js +1270 -0
  110. package/dist/op/activity-contracts.js +162 -0
  111. package/dist/op/adoption.js +272 -0
  112. package/dist/op/builders.js +125 -0
  113. package/dist/op/resolve-root-mode.js +77 -0
  114. package/dist/package-cli.js +16 -0
  115. package/dist/pin/edit-ts.js +76 -0
  116. package/dist/pin/edit.js +118 -0
  117. package/dist/pin/forge.js +65 -0
  118. package/dist/pin/index.js +16 -0
  119. package/dist/pin/rollout.d.ts.map +1 -1
  120. package/dist/pin/rollout.js +382 -0
  121. package/dist/pin/scan.js +287 -0
  122. package/dist/pin/source.js +171 -0
  123. package/dist/pin/waves.js +109 -0
  124. package/dist/plan-digest.js +67 -0
  125. package/dist/plugin.js +323 -0
  126. package/dist/serializer.js +23 -0
  127. package/dist/state-ownership.js +29 -0
  128. package/dist/terragrunt/affected.d.ts.map +1 -1
  129. package/dist/terragrunt/affected.js +290 -0
  130. package/dist/terragrunt/index.js +10 -0
  131. package/dist/terragrunt/mocks.js +270 -0
  132. package/dist/terragrunt/run.js +264 -0
  133. package/dist/terragrunt/units.js +195 -0
  134. package/dist/terragrunt/wave.js +125 -0
  135. package/dist/validate-cli.js +4 -0
  136. package/dist/validate.js +31 -0
  137. package/package.json +10 -10
  138. package/src/codegen/package.ts +2 -2
  139. package/src/components/capability-plugin.test.ts +97 -2
  140. package/src/components/capability-plugin.ts +117 -12
  141. package/src/components/pr-loop.tofu.test.ts +51 -11
  142. package/src/composites/terraform-adopt-op.test.ts +6 -0
  143. package/src/composites/terraform-adopt-op.ts +7 -2
  144. package/src/composites/terraform-apply-op.test.ts +6 -0
  145. package/src/composites/terraform-apply-op.ts +7 -2
  146. package/src/index.ts +1 -0
  147. package/src/pin/rollout.ts +2 -1
  148. package/src/terragrunt/affected.test.ts +11 -0
  149. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,984 @@
1
+ /**
2
+ * Live observation for declared terraform entities (#2087, #2104).
3
+ *
4
+ * `buildRoots()` turns each configured root module into one entity per HCL
5
+ * block, keyed `<root>/<address>` (`./hcl/parse.ts`). This reader answers the
6
+ * lifecycle question for those entities, and how it answers depends on one
7
+ * fact the parse already recorded on every entity: the root's `mode`.
8
+ *
9
+ * A **stock** root is read with `terraform show -json` over its state, and
10
+ * state membership is the ownership answer. A **live** root, meaning
11
+ * `terraform.binary` is `"choudoufu"` and the root declares an estate
12
+ * (#2103), has no state to show: it is read with `choudoufu live-plan -json`,
13
+ * and the marker on the resource is the ownership answer. The two halves are
14
+ * two adapters over the same `observeEntities` harness, picked per root.
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
+ *
44
+ * ## The state file is the ownership answer, on a stock root
45
+ *
46
+ * Every other lexicon in chant stamps a tag or a label at synthesis and reads
47
+ * it back off the live resource. Terraform stamps nothing, and there is
48
+ * nowhere to stamp: a resource's provider-side tags are the practitioner's
49
+ * own, and writing chant's marker into them would edit an estate this lexicon
50
+ * promises never to write. What terraform has instead is the thing chant
51
+ * elsewhere refuses to host, a trusted state file that already records
52
+ * exactly which addresses this configuration manages.
53
+ *
54
+ * So the answer here is state membership. An address `terraform show -json`
55
+ * returns is `owned`; anything else is `unknown`. Concretely that makes a
56
+ * declared `module` block `unknown`: the state carries the module's
57
+ * resources, never a row for the block itself, so chant can see the block is
58
+ * live without the state ever saying that block is managed. `unknown` never
59
+ * escalates to a delete, which is the correct posture for a thing chant did
60
+ * not read a verdict for.
61
+ *
62
+ * That places a stock terraform root on the trusted-state-file row of the
63
+ * third axis in docs/src/content/docs/concepts/lifecycle-models.mdx, where
64
+ * chant elsewhere sits on the live-marker row. `docs/pages/observation.mdx`
65
+ * is the reader's version of this paragraph.
66
+ *
67
+ * ## The marker is the ownership answer, on a live root
68
+ *
69
+ * choudoufu keeps no authoritative state file. Every taggable resource it
70
+ * manages carries `tofu-estate` and `tofu-address` tags written in the create
71
+ * call, prior state is rebuilt from the live system each run, and the state
72
+ * file, where one exists at all, is a disposable cache. So a live root is on
73
+ * the live-marker row, the row every other chant lexicon sits in.
74
+ *
75
+ * chant does not re-derive that verdict from the tags. `live-plan -json`
76
+ * (choudoufu issue #788) already splits every declared instance four ways,
77
+ * and those four are the classification:
78
+ *
79
+ * | live-plan section | chant |
80
+ * |---|---|
81
+ * | `bound[]`, `source: "marker"` | present, `owned`; the marker named this estate and this address, and it is surfaced as `ResourceMetadata.marker` |
82
+ * | `bound[]`, any other source | present, `owned` by derivation, record, or cache; noted as such, and no marker is surfaced because none was read |
83
+ * | `unowned[]` with `adopt_*` | present, `unknown`; an adoptable match, carrying the exact two tag values #2105 would write |
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) |
86
+ * | `omissions[]` | not-observed, `ABSENT` excepted (see below) |
87
+ *
88
+ * The ownership channel this declares is `./live-ownership.ts`'s
89
+ * `TERRAFORM_LIVE_MARKER_KEYS`, and its module doc holds the record of why
90
+ * the keys are declared while the verdicts are set here by hand rather than
91
+ * core's `OwnershipChannel` growing a classifier for them.
92
+ *
93
+ * ### `ABSENT` is absence, and the rest of the omissions are not
94
+ *
95
+ * An omission is choudoufu saying an instance is missing from prior state,
96
+ * with a reason. Most of those reasons are claims of ignorance and become
97
+ * NOT-OBSERVED, which is what the tri-state exists for. One is not:
98
+ * `ABSENT` means "the instance has a usable import identity and the provider
99
+ * reported, normally, that no such object exists", which is exactly chant's
100
+ * OBSERVED-ABSENT, spelled "in neither map". Reporting it as not-observed
101
+ * instead would mean a live root could never propose a create for a resource
102
+ * that genuinely is not there yet, which is the opposite of the honesty the
103
+ * contract is for. `UNOWNED` is the other exception, in the other direction:
104
+ * the `unowned[]` section answers for that address with a real verdict, so
105
+ * the paired omission is never read on its own.
106
+ *
107
+ * ### A block's instances, aggregated
108
+ *
109
+ * chant's entity is the HCL block, `aws_eip.pool`; the document's rows are
110
+ * instances, `aws_eip.pool[0]`. So a block's verdict is the aggregate of its
111
+ * instances, in this precedence: any instance not-observed makes the block
112
+ * not-observed, then foreign, then adoptable, then owned, and a block whose
113
+ * instances are all absent is absent. Partial knowledge is not knowledge,
114
+ * which is the same rule choudoufu applies to its own `INCOMPLETE_BLOCK`.
115
+ *
116
+ * ## What is readable at all
117
+ *
118
+ * `values.root_module.resources[]` (and, recursively, `child_modules[]`)
119
+ * carries `resource` and `data` blocks. A `terraform`, `provider`, `variable`,
120
+ * `output` or `locals` block has no row there and reads `unsupported-kind`.
121
+ * That is honest, not a gap to close by inventing an address for something
122
+ * that has none.
123
+ *
124
+ * A live root reads one block narrower. `live-plan`'s document is prior state
125
+ * for managed resources, so a `data` block has no row in it either and reads
126
+ * `unsupported-kind` there, where the stock reader can answer for it.
127
+ *
128
+ * ## Tri-state (#1089)
129
+ *
130
+ * A root whose `init` or `show` fails, or whose `live-plan` fails, reports
131
+ * EVERY entity declared in that root as not-observed with the mapped reason
132
+ * and the root named, never absent. A failed read must never render as a list
133
+ * of creates. Roots are read independently and merged (`mergeObservations`),
134
+ * so one broken backend does not un-observe a root that answered.
135
+ *
136
+ * ## Beyond the declared estate, on a live root
137
+ *
138
+ * `live-ls -estate -json` lists the whole estate off the Resource Groups
139
+ * Tagging API with no configuration read at all, which is a question a state
140
+ * file cannot answer: what does this estate hold that nothing declares? That
141
+ * is what makes `ambientKinds()`, `observeAmbient()` and `teardownOwned()`
142
+ * implementable on this lexicon for the first time. `-consistent` is always
143
+ * passed, because that index lags a tag write by about a minute
144
+ * (`live-ls`'s own help text) and a listing taken right after an apply can
145
+ * otherwise show a resource under both its old and new estate, or neither.
146
+ *
147
+ * ## Nothing from `values` is surfaced
148
+ *
149
+ * A state row's `values` are the resource's full attribute set, secrets
150
+ * included, and `sensitive_values` describes only what the configuration
151
+ * declared sensitive. So the attributes reported here are the row's identity
152
+ * (address, type, mode, provider, root) and nothing from `values` except the
153
+ * `id`, which is the physical id every observation carries.
154
+ */
155
+ import { mergeObservations, normalizeObservation, observation, observeEntities, } from "@intentius/chant/observation";
156
+ import { choudoufuLiveLs, choudoufuLivePlanDocument, terraformInit, terraformShow, } from "./op/activities/terraform.js";
157
+ import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE, callersOfKey } from "./hcl/parse.js";
158
+ // The channel keys live in their own module so `plugin.ts` can declare
159
+ // `ownershipChannel` without loading this reader. Re-exported here because
160
+ // this is where they are used.
161
+ export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership.js";
162
+ export { TERRAFORM_LIVE_MARKER_KEYS } from "./live-ownership.js";
163
+ function asRecord(value) {
164
+ return typeof value === "object" && value !== null && !Array.isArray(value)
165
+ ? value
166
+ : {};
167
+ }
168
+ function asArray(value) {
169
+ return Array.isArray(value) ? value : [];
170
+ }
171
+ function asString(value) {
172
+ return typeof value === "string" && value.length > 0 ? value : undefined;
173
+ }
174
+ /**
175
+ * Index a `terraform show -json` document by address.
176
+ *
177
+ * Terraform already writes fully qualified addresses inside `child_modules`
178
+ * (`module.cdn.null_resource.edge`, as `src/__fixtures__/show-state.json`
179
+ * records), so the module prefix is applied only when a row's own address is
180
+ * missing it. Belt and braces for an older `format_version`, never a second
181
+ * `module.cdn.` on top of the first.
182
+ */
183
+ export function indexStateResources(showJson) {
184
+ const rows = new Map();
185
+ const modules = new Set();
186
+ const walk = (module, prefix) => {
187
+ const node = asRecord(module);
188
+ for (const entry of asArray(node.resources)) {
189
+ const row = asRecord(entry);
190
+ const raw = asString(row.address);
191
+ if (!raw)
192
+ continue;
193
+ const address = prefix && !raw.startsWith(`${prefix}.`) ? `${prefix}.${raw}` : raw;
194
+ rows.set(address, {
195
+ address,
196
+ ...(asString(row.mode) ? { mode: asString(row.mode) } : {}),
197
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
198
+ ...(asString(row.provider_name) ? { providerName: asString(row.provider_name) } : {}),
199
+ ...(asString(asRecord(row.values).id) ? { id: asString(asRecord(row.values).id) } : {}),
200
+ });
201
+ }
202
+ for (const entry of asArray(node.child_modules)) {
203
+ const child = asRecord(entry);
204
+ const address = asString(child.address) ?? prefix;
205
+ if (address)
206
+ modules.add(address);
207
+ walk(child, address ?? "");
208
+ }
209
+ };
210
+ walk(asRecord(asRecord(showJson).values).root_module, "");
211
+ return { rows, modules };
212
+ }
213
+ /**
214
+ * The ownership verdict for one declared address: `owned` when the state
215
+ * carries a row for it, `unknown` otherwise. Exported because it is the whole
216
+ * of terraform's ownership channel, and a channel with one rule deserves one
217
+ * function to point at.
218
+ */
219
+ export function classifyStateOwnership(address, index) {
220
+ return index.rows.has(address) ? "owned" : "unknown";
221
+ }
222
+ /** True when the state carries at least one resource beneath `module.<name>`. */
223
+ function moduleIsLive(address, index) {
224
+ if (index.modules.has(address))
225
+ return true;
226
+ for (const key of index.rows.keys())
227
+ if (key.startsWith(`${address}.`))
228
+ return true;
229
+ return false;
230
+ }
231
+ /**
232
+ * Index choudoufu issue #788's document. Unknown fields are ignored and a
233
+ * missing section reads as empty: a document from a newer choudoufu must
234
+ * degrade to "this section said nothing", never to a throw.
235
+ */
236
+ export function indexLivePlan(document) {
237
+ const doc = asRecord(document);
238
+ const index = {
239
+ estate: asString(doc.estate) ?? "",
240
+ bound: new Map(),
241
+ omissions: new Map(),
242
+ unowned: new Map(),
243
+ adoptable: new Map(),
244
+ swept: asArray(doc.swept).filter((t) => typeof t === "string"),
245
+ addresses: [],
246
+ diagnostics: [],
247
+ };
248
+ for (const entry of asArray(doc.bound)) {
249
+ const row = asRecord(entry);
250
+ const addr = asString(row.addr);
251
+ if (!addr)
252
+ continue;
253
+ index.bound.set(addr, {
254
+ addr,
255
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
256
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
257
+ ...(asString(row.source) ? { source: asString(row.source) } : {}),
258
+ });
259
+ }
260
+ for (const entry of asArray(doc.omissions)) {
261
+ const row = asRecord(entry);
262
+ const addr = asString(row.addr);
263
+ if (!addr)
264
+ continue;
265
+ index.omissions.set(addr, {
266
+ addr,
267
+ reason: asString(row.reason) ?? "",
268
+ detail: asString(row.detail) ?? "",
269
+ });
270
+ }
271
+ for (const entry of asArray(doc.unowned)) {
272
+ const row = asRecord(entry);
273
+ const addr = asString(row.addr);
274
+ if (!addr)
275
+ continue;
276
+ index.unowned.set(addr, {
277
+ addr,
278
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
279
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
280
+ ...(asString(row.tofu_estate) ? { heldBy: asString(row.tofu_estate) } : {}),
281
+ ...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
282
+ ...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
283
+ });
284
+ }
285
+ for (const entry of asArray(doc.adoptable)) {
286
+ const row = asRecord(entry);
287
+ const addr = asString(row.addr);
288
+ if (!addr)
289
+ continue;
290
+ const matched = asArray(row.matched)
291
+ .map((m) => asRecord(m))
292
+ .filter((m) => asString(m.attribute))
293
+ .map((m) => ({ attribute: asString(m.attribute), value: asString(m.value) ?? "" }));
294
+ index.adoptable.set(addr, {
295
+ addr,
296
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
297
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
298
+ ...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
299
+ ...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
300
+ ...(matched.length > 0 ? { matched } : {}),
301
+ });
302
+ }
303
+ for (const entry of asArray(doc.diagnostics)) {
304
+ const row = asRecord(entry);
305
+ const summary = asString(row.summary);
306
+ if (summary)
307
+ index.diagnostics.push(`${asString(row.severity) ?? "warning"}: ${summary}`);
308
+ }
309
+ index.addresses = [
310
+ ...new Set([
311
+ ...index.bound.keys(),
312
+ ...index.omissions.keys(),
313
+ ...index.unowned.keys(),
314
+ ...index.adoptable.keys(),
315
+ ]),
316
+ ].sort();
317
+ return index;
318
+ }
319
+ /**
320
+ * choudoufu's omission reason codes, mapped onto chant's total vocabulary
321
+ * (`internal/live/projection/result.go`). `"absent"` is not an
322
+ * {@link UnobservedReason}: it is the tri-state's OBSERVED-ABSENT, the one
323
+ * omission that is a claim of knowledge rather than of ignorance.
324
+ *
325
+ * A code this table does not know maps to `read-failed`, which is the safe
326
+ * direction: no NOT-OBSERVED verdict ever becomes a create or a delete.
327
+ */
328
+ export const LIVE_PLAN_OMISSION_REASONS = {
329
+ // The provider was asked and reported, normally, that nothing is there.
330
+ ABSENT: "absent",
331
+ // The provider errored, or could not be reached in a usable order.
332
+ FAILED: "read-failed",
333
+ PARENT_UNAVAILABLE: "read-failed",
334
+ CYCLE: "read-failed",
335
+ INCOMPLETE_BLOCK: "read-failed",
336
+ LISTED_NOT_IMPORTABLE: "read-failed",
337
+ // Something exists at the identity and does not carry this estate's marker.
338
+ // Normally answered by the `unowned[]` section before this table is read;
339
+ // this entry is what happens if a document ever carries the omission alone.
340
+ UNOWNED: "read-failed",
341
+ // The mode cannot name, list or tag this instance at all: a server-assigned
342
+ // identity with no marker to find it by, a resource with no cloud object to
343
+ // read, or an address another declared instance has taken over.
344
+ NEEDS_DISCOVERY: "unsupported-kind",
345
+ UNREADABLE: "unsupported-kind",
346
+ SUPERSEDED: "unsupported-kind",
347
+ };
348
+ /** Does this text name a credentials or authorization failure rather than any other error? */
349
+ function readsAsCredentials(text) {
350
+ return /\b(no valid credential|credentials?|not authorized|unauthorized|accessdenied|access denied|expiredtoken|invalidclienttokenid|signature)\b/i.test(text);
351
+ }
352
+ /** The chant verdict for one omission: `no-credentials` when its detail says so. */
353
+ function omissionVerdict(row) {
354
+ const mapped = LIVE_PLAN_OMISSION_REASONS[row.reason] ?? "read-failed";
355
+ if (mapped === "read-failed" && readsAsCredentials(row.detail))
356
+ return "no-credentials";
357
+ return mapped;
358
+ }
359
+ /**
360
+ * Every instance address in the document belonging to the declared block
361
+ * `address`. Exported for the behaviour request builder (`./behaviour/`,
362
+ * #2360), which places the same blocks against the same document.
363
+ */
364
+ export function instancesOf(address, index) {
365
+ return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`));
366
+ }
367
+ /** True when the document names any instance under `module.<name>`, at any depth. */
368
+ function liveModuleMembers(address, index) {
369
+ return index.addresses.filter((a) => a === address || a.startsWith(`${address}.`) || a.startsWith(`${address}[`));
370
+ }
371
+ /**
372
+ * Classify one instance address against the document, `unowned[]` first: a
373
+ * declared instance that also carries a `UNOWNED` omission is answered by the
374
+ * section that has the verdict, not by the one that has the apology.
375
+ * `adoptable[]` sits under `bound[]` for the same reason, one rung further
376
+ * down: a content match is the answer for an instance nothing else could
377
+ * answer for, and its paired omission is always `NEEDS_DISCOVERY`, which on
378
+ * its own would read as unsupported-kind and hide a real, actionable verdict
379
+ * (choudoufu #962, chant #2241).
380
+ */
381
+ export function classifyLiveInstance(address, index) {
382
+ const unowned = index.unowned.get(address);
383
+ if (unowned) {
384
+ return unowned.adoptEstate || unowned.adoptAddress
385
+ ? { kind: "adoptable", row: unowned }
386
+ : { kind: "foreign", row: unowned };
387
+ }
388
+ const bound = index.bound.get(address);
389
+ if (bound)
390
+ return { kind: "owned", row: bound };
391
+ const matched = index.adoptable.get(address);
392
+ if (matched && (matched.adoptEstate || matched.adoptAddress)) {
393
+ return { kind: "adoptable", row: matched };
394
+ }
395
+ const omission = index.omissions.get(address);
396
+ if (omission) {
397
+ const verdict = omissionVerdict(omission);
398
+ if (verdict === "absent")
399
+ return { kind: "absent" };
400
+ return {
401
+ kind: "unobserved",
402
+ reason: verdict,
403
+ detail: `${address}: ${omission.reason}${omission.detail ? ` — ${omission.detail}` : ""}`,
404
+ };
405
+ }
406
+ return { kind: "absent" };
407
+ }
408
+ /**
409
+ * The declared block's verdict, aggregated over its instances in the
410
+ * precedence the module doc states: not-observed, then foreign, then
411
+ * adoptable, then owned, and all-absent last. A block whose instances are
412
+ * only partly known is not partly owned; it is unknown, which never becomes
413
+ * a delete.
414
+ */
415
+ function readLiveResource(entity, index, root, queried) {
416
+ const { address } = entity;
417
+ const instances = instancesOf(address, index);
418
+ if (instances.length === 0)
419
+ return { absent: true, queried };
420
+ const verdicts = instances.map((a) => classifyLiveInstance(a, index));
421
+ const attributes = { address, root, estate: index.estate };
422
+ if (instances.length > 1 || instances[0] !== address)
423
+ attributes.instances = instances;
424
+ const unobserved = verdicts.find((v) => v.kind === "unobserved");
425
+ if (unobserved && unobserved.kind === "unobserved") {
426
+ return { unobserved: { reason: unobserved.reason, detail: unobserved.detail }, queried };
427
+ }
428
+ const foreign = verdicts.find((v) => v.kind === "foreign");
429
+ if (foreign && foreign.kind === "foreign") {
430
+ return {
431
+ present: {
432
+ type: entity.type,
433
+ physicalId: foreign.row.identity ?? address,
434
+ status: "unowned",
435
+ ownership: "foreign",
436
+ attributes: {
437
+ ...attributes,
438
+ ...(foreign.row.type ? { resourceType: foreign.row.type } : {}),
439
+ ...(foreign.row.heldBy ? { heldBy: foreign.row.heldBy } : {}),
440
+ },
441
+ },
442
+ queried,
443
+ };
444
+ }
445
+ const adoptable = verdicts.find((v) => v.kind === "adoptable");
446
+ if (adoptable && adoptable.kind === "adoptable") {
447
+ // `unknown`, not `foreign`: the resource matches the declaration and one
448
+ // tag write claims it. The two values that write carries are the whole
449
+ // adoption proposal #2105 builds its Op on, so they ride the metadata
450
+ // rather than being re-derived from the address there. No `marker` is
451
+ // surfaced, because nothing was read off this resource: it carries none.
452
+ return {
453
+ present: {
454
+ type: entity.type,
455
+ physicalId: adoptable.row.identity ?? address,
456
+ status: "adoptable",
457
+ ownership: "unknown",
458
+ attributes: {
459
+ ...attributes,
460
+ ...(adoptable.row.type ? { resourceType: adoptable.row.type } : {}),
461
+ ...(adoptable.row.adoptEstate ? { adoptTofuEstate: adoptable.row.adoptEstate } : {}),
462
+ ...(adoptable.row.adoptAddress ? { adoptTofuAddress: adoptable.row.adoptAddress } : {}),
463
+ // Present only on a content match: the arguments the sweep compared,
464
+ // which is the whole evidence for a match nobody read an identity
465
+ // for, and the thing an operator checks before letting a tag write
466
+ // claim the resource.
467
+ ...(adoptable.row.matched?.length
468
+ ? { matchedOn: adoptable.row.matched.map((m) => `${m.attribute}=${m.value}`) }
469
+ : {}),
470
+ },
471
+ },
472
+ queried,
473
+ };
474
+ }
475
+ const owned = verdicts.filter((v) => v.kind === "owned");
476
+ if (owned.length === 0)
477
+ return { absent: true, queried };
478
+ const sources = [...new Set(owned.map((v) => v.row.source ?? "unknown"))].sort();
479
+ const byMarker = sources.length === 1 && sources[0] === "marker";
480
+ return {
481
+ present: {
482
+ type: entity.type,
483
+ physicalId: owned.length === 1 ? (owned[0].row.identity ?? address) : address,
484
+ status: "bound",
485
+ ownership: "owned",
486
+ // The marker is surfaced only where one was actually read. A `derived`
487
+ // bind computed the identity from the configuration and looked at no
488
+ // tag, so claiming a marker for it would be a guess.
489
+ ...(byMarker ? { marker: { stack: index.estate } } : {}),
490
+ attributes: {
491
+ ...attributes,
492
+ ...(owned[0].row.type ? { resourceType: owned[0].row.type } : {}),
493
+ boundBy: sources.join(","),
494
+ },
495
+ },
496
+ queried,
497
+ };
498
+ }
499
+ /**
500
+ * One live root's reader: `live-plan -json`, then address matching against
501
+ * the document. `terraform show` is never run here, and there is no state
502
+ * file to run it over.
503
+ */
504
+ function liveAdapter(root, cwd, deps) {
505
+ const where = cwd ? { cwd } : {};
506
+ let dir;
507
+ return {
508
+ async bind() {
509
+ const planned = await deps.livePlan({ root, ...where });
510
+ dir = planned.dir;
511
+ return indexLivePlan(planned.json);
512
+ },
513
+ classifyBindFailure(err) {
514
+ const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
515
+ const full = err instanceof Error ? err.message : String(err);
516
+ return {
517
+ reason: readsAsCredentials(full) ? "no-credentials" : "read-failed",
518
+ detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${message}`,
519
+ };
520
+ },
521
+ async read(index, entity) {
522
+ const declared = entity;
523
+ const { address } = declared;
524
+ const queried = `choudoufu live-plan -json (root "${root}", estate "${index.estate}", address "${address}")`;
525
+ if (entity.type === RESOURCE_TYPE)
526
+ return readLiveResource(declared, index, root, queried);
527
+ if (entity.type === DATA_TYPE) {
528
+ return {
529
+ unobserved: {
530
+ reason: "unsupported-kind",
531
+ detail: "live-plan's document is prior state for managed resources; a data block has no row in bound, omissions or unowned",
532
+ },
533
+ queried,
534
+ };
535
+ }
536
+ if (entity.type === MODULE_TYPE) {
537
+ const members = liveModuleMembers(address, index);
538
+ if (members.length === 0)
539
+ return { absent: true, queried };
540
+ // Same shape as the stock reader's module answer, for the same
541
+ // reason: the document names the module's resources and never the
542
+ // block itself, so the ownership channel has nothing to say about it.
543
+ return {
544
+ present: {
545
+ type: entity.type,
546
+ physicalId: address,
547
+ status: "module",
548
+ ownership: "unknown",
549
+ attributes: { address, root, estate: index.estate, instances: members },
550
+ },
551
+ queried,
552
+ };
553
+ }
554
+ return {
555
+ unobserved: {
556
+ reason: "unsupported-kind",
557
+ detail: `${entity.type} has no row in a live-plan document: only resource blocks do`,
558
+ },
559
+ queried,
560
+ };
561
+ },
562
+ };
563
+ }
564
+ /** Parse a `live-ls -json` document. A missing section reads as empty, never as a throw. */
565
+ export function readLiveLs(document) {
566
+ const doc = asRecord(document);
567
+ const items = [];
568
+ for (const entry of asArray(doc.items)) {
569
+ const row = asRecord(entry);
570
+ const id = asString(row.id);
571
+ const type = asString(row.type);
572
+ if (!id || !type)
573
+ continue;
574
+ const tags = {};
575
+ for (const [key, value] of Object.entries(asRecord(row.tags))) {
576
+ if (typeof value === "string")
577
+ tags[key] = value;
578
+ }
579
+ items.push({
580
+ id,
581
+ type,
582
+ ...(asString(row.address) ? { address: asString(row.address) } : {}),
583
+ ...(asString(row.slot) ? { slot: asString(row.slot) } : {}),
584
+ declared: row.declared === true,
585
+ ...(asString(row.source) ? { source: asString(row.source) } : {}),
586
+ tags,
587
+ });
588
+ }
589
+ const gaps = [];
590
+ for (const entry of asArray(doc.gaps)) {
591
+ const row = asRecord(entry);
592
+ const address = asString(row.address);
593
+ if (!address)
594
+ continue;
595
+ gaps.push({
596
+ address,
597
+ type: asString(row.type) ?? "",
598
+ rung: asString(row.rung) ?? "",
599
+ detail: asString(row.detail) ?? "",
600
+ });
601
+ }
602
+ return { estate: asString(doc.estate) ?? "", items, gaps };
603
+ }
604
+ /**
605
+ * A live-ls gap's rung, mapped onto chant's vocabulary. Both rungs mean the
606
+ * same thing to a reader of the listing: this instance can never appear in
607
+ * it, because the mechanism the listing uses cannot reach it. That is
608
+ * `unsupported-kind`, not a failure and not an absence.
609
+ */
610
+ function gapReason() {
611
+ return "unsupported-kind";
612
+ }
613
+ /**
614
+ * The entity key `buildRoots()` would have produced for a live resource's
615
+ * address.
616
+ *
617
+ * A live address is fully qualified with dots, `module.cdn.null_resource.edge`;
618
+ * the build keys a descended child module's block with a slash per call,
619
+ * `app/module.cdn/null_resource.edge` (#2112). Splitting the `module.<name>.`
620
+ * prefixes back out is what keeps a live row and the block that declared it
621
+ * on the same key.
622
+ */
623
+ export function entityKeyFor(root, address) {
624
+ const parts = address.split(".");
625
+ const segments = [];
626
+ let i = 0;
627
+ while (i + 1 < parts.length && parts[i] === "module") {
628
+ segments.push(`module.${parts[i + 1]}`);
629
+ i += 2;
630
+ }
631
+ segments.push(parts.slice(i).join("."));
632
+ return [root, ...segments].join("/");
633
+ }
634
+ /**
635
+ * The entity types this lexicon can enumerate beyond the declared estate
636
+ * (#1278). One: a `resource` block, whose live counterparts `live-ls` lists
637
+ * off the tag index. A `data`, `module` or `provider` block has no live
638
+ * counterpart to enumerate at all.
639
+ */
640
+ export function ambientKinds() {
641
+ return [RESOURCE_TYPE];
642
+ }
643
+ /**
644
+ * Every live root the project declares, in config order. Reads the project
645
+ * config the same way the activities do, so `terraform.roots` is read once
646
+ * and the same answer reaches both.
647
+ */
648
+ export async function liveRootNames(cwd) {
649
+ const { loadChantConfigUpward } = await import("@intentius/chant/config");
650
+ const { detectLiveEstate } = await import("./op/activities/live-detect.js");
651
+ const { dirname, resolve } = await import("node:path");
652
+ const start = resolve(cwd ?? process.cwd());
653
+ const { config, configPath } = await loadChantConfigUpward(start);
654
+ const projectRoot = configPath ? dirname(configPath) : start;
655
+ const namespace = config
656
+ .terraform;
657
+ if (namespace?.binary !== "choudoufu")
658
+ return [];
659
+ const live = [];
660
+ for (const [name, root] of Object.entries(namespace.roots ?? {})) {
661
+ const estate = detectLiveEstate(resolve(projectRoot, root.dir));
662
+ if (estate !== undefined)
663
+ live.push({ root: name, estate });
664
+ }
665
+ return live;
666
+ }
667
+ /**
668
+ * Report the resources this estate owns that nothing declares (#1278) — the
669
+ * owned-orphan set, which is `live-ls`'s `declared: false` rows.
670
+ *
671
+ * This is the question a state file cannot answer at all, because a state
672
+ * file knows only what it created. The tag index knows what the estate holds,
673
+ * whether or not any configuration still mentions it.
674
+ */
675
+ export async function observeAmbient(options, deps = REAL_DEPS) {
676
+ // The bound #1278 asks for: a project that declares no terraform resources
677
+ // is never made to enumerate an estate.
678
+ if (!options.kinds.includes(RESOURCE_TYPE))
679
+ return {};
680
+ const out = {};
681
+ for (const { root, estate } of await liveRoots(options)) {
682
+ const listed = await deps.liveLs({
683
+ root,
684
+ consistent: true,
685
+ ...(options.cwd ? { cwd: options.cwd } : {}),
686
+ });
687
+ const listing = readLiveLs(listed.json);
688
+ for (const item of listing.items) {
689
+ if (item.declared)
690
+ continue; // declared is `describeResources`'s business, not this one's
691
+ const address = item.address ?? item.id;
692
+ const name = entityKeyFor(root, address);
693
+ if (options.observed[name])
694
+ continue;
695
+ out[name] = {
696
+ type: RESOURCE_TYPE,
697
+ physicalId: item.id,
698
+ status: "orphan",
699
+ ownership: "owned",
700
+ marker: { stack: listing.estate || estate },
701
+ ambient: true,
702
+ attributes: {
703
+ address,
704
+ root,
705
+ estate: listing.estate || estate,
706
+ resourceType: item.type,
707
+ ...(item.slot ? { slot: item.slot } : {}),
708
+ ...(item.source ? { listedBy: item.source } : {}),
709
+ tags: item.tags,
710
+ },
711
+ };
712
+ }
713
+ }
714
+ return out;
715
+ }
716
+ /**
717
+ * Name what a teardown of this environment would remove from every live root
718
+ * (#1222): the estate's owned orphans, which are exactly the set choudoufu's
719
+ * own default `policy` verb (`undeclared_tagged = "delete"`) removes on the
720
+ * next apply.
721
+ *
722
+ * Candidates carry the requested identity verbatim, because that identity is
723
+ * what selected them: the roots enumerated here are this project's own, and
724
+ * their estates are what `terraform.roots` declares for this environment.
725
+ * The estate that actually answered rides `name`, so a reviewer of the plan
726
+ * can see which one it was.
727
+ *
728
+ * `live-ls`'s own `gaps[]` become holes rather than silence, per #1089: a
729
+ * declared instance the tag index can never serve is unknown, not clean.
730
+ */
731
+ export async function teardownOwned(options, deps = REAL_DEPS) {
732
+ const candidates = [];
733
+ const holes = [];
734
+ for (const { root, estate } of await liveRoots(options)) {
735
+ let listing;
736
+ try {
737
+ const listed = await deps.liveLs({
738
+ root,
739
+ consistent: true,
740
+ ...(options.cwd ? { cwd: options.cwd } : {}),
741
+ });
742
+ listing = readLiveLs(listed.json);
743
+ }
744
+ catch (err) {
745
+ // Nothing was read, so nothing is known about this root's estate. A
746
+ // hole, never an empty candidate list, which would read as "clean".
747
+ holes.push({
748
+ name: `terraform.roots.${root}`,
749
+ reason: "read-failed",
750
+ detail: `choudoufu live-ls (estate "${estate}"): ${err instanceof Error ? err.message : String(err)}`,
751
+ });
752
+ continue;
753
+ }
754
+ for (const item of listing.items) {
755
+ if (item.declared)
756
+ continue;
757
+ candidates.push({
758
+ name: entityKeyFor(root, item.address ?? item.id),
759
+ type: RESOURCE_TYPE,
760
+ physicalId: item.id,
761
+ marker: options.marker,
762
+ });
763
+ }
764
+ for (const gap of listing.gaps) {
765
+ holes.push({
766
+ name: entityKeyFor(root, gap.address),
767
+ type: RESOURCE_TYPE,
768
+ reason: gapReason(),
769
+ detail: `${gap.rung}: ${gap.detail}`,
770
+ });
771
+ }
772
+ }
773
+ return { candidates, ...(holes.length > 0 ? { holes } : {}) };
774
+ }
775
+ /** The live roots an estate-wide read runs over: the caller's list, or every one declared. */
776
+ async function liveRoots(options) {
777
+ const declared = await liveRootNames(options.cwd);
778
+ if (!options.roots)
779
+ return declared;
780
+ const wanted = new Set(options.roots);
781
+ return declared.filter((entry) => wanted.has(entry.root));
782
+ }
783
+ const REAL_DEPS = {
784
+ init: terraformInit,
785
+ show: terraformShow,
786
+ livePlan: choudoufuLivePlanDocument,
787
+ liveLs: choudoufuLiveLs,
788
+ };
789
+ /**
790
+ * Split a `<root>/<address>` entity key, or a descended child module's
791
+ * `<root>/module.<name>/<address>` (#2112). Only a fallback: `buildRoots()`
792
+ * records the root, the address and the call chain on `props`, and a
793
+ * duplicated address is keyed `…~2` there, so the props are the reliable
794
+ * source.
795
+ */
796
+ function fromEntityName(name) {
797
+ const slash = name.indexOf("/");
798
+ if (slash === -1)
799
+ return { root: "", address: name, callers: [] };
800
+ const last = name.lastIndexOf("/");
801
+ return {
802
+ root: name.slice(0, slash),
803
+ address: name.slice(last + 1).replace(/~\d+$/, ""),
804
+ callers: callersOfKey(name),
805
+ };
806
+ }
807
+ /**
808
+ * The address the live system knows a block by. For a root-module block that
809
+ * is the block's own address; for one inside a child module it is what
810
+ * `terraform show -json` writes in `child_modules[]` and what choudoufu's
811
+ * markers carry, the call chain and the address joined with dots:
812
+ * `module.cdn.null_resource.edge` (#2112). The entity KEY keeps its slashes
813
+ * (`app/module.cdn/null_resource.edge`), so what a build declares and what a
814
+ * read observes still line up one to one.
815
+ */
816
+ export function qualifiedAddress(address, callers) {
817
+ return callers.length === 0 ? address : `${callers.join(".")}.${address}`;
818
+ }
819
+ /**
820
+ * The root, qualified address and mode of one declared entity, from what
821
+ * `buildRoots()` recorded on its props. Exported for the behaviour request
822
+ * builder (#2360), which groups entities by root the same way.
823
+ */
824
+ export function declaredOf(name, entity) {
825
+ const props = entity?.props ?? {};
826
+ const fallback = fromEntityName(name);
827
+ const callers = Array.isArray(props.callers)
828
+ ? props.callers.filter((c) => typeof c === "string")
829
+ : fallback.callers;
830
+ return {
831
+ name,
832
+ type: entity?.entityType ?? "",
833
+ props,
834
+ root: asString(props.root) ?? fallback.root,
835
+ address: qualifiedAddress(asString(props.address) ?? fallback.address, callers),
836
+ ...(asString(props.mode) ? { mode: asString(props.mode) } : {}),
837
+ };
838
+ }
839
+ /** One root's reader: `init`, then `show` over state, then address matching. */
840
+ function adapter(root, cwd, deps) {
841
+ const where = cwd ? { cwd } : {};
842
+ let dir;
843
+ return {
844
+ async bind() {
845
+ // `init` first: `show` against an uninitialized root reports no state at
846
+ // all, which would read as "everything is absent", the exact failure
847
+ // the tri-state exists to prevent.
848
+ const initialized = await deps.init({ root, ...where });
849
+ dir = initialized.dir;
850
+ const shown = await deps.show({ root, ...where });
851
+ dir = shown.dir;
852
+ return indexStateResources(shown.json);
853
+ },
854
+ classifyBindFailure(err) {
855
+ const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
856
+ return {
857
+ reason: "read-failed",
858
+ detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${message}`,
859
+ };
860
+ },
861
+ async read(index, entity) {
862
+ const { address } = entity;
863
+ const queried = `terraform show -json (root "${root}", address "${address}")`;
864
+ if (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE) {
865
+ const row = index.rows.get(address);
866
+ if (!row)
867
+ return { absent: true, queried };
868
+ return {
869
+ present: {
870
+ type: entity.type,
871
+ physicalId: row.id ?? address,
872
+ status: row.mode ?? "managed",
873
+ ownership: classifyStateOwnership(address, index),
874
+ attributes: {
875
+ address,
876
+ root,
877
+ ...(row.type ? { resourceType: row.type } : {}),
878
+ ...(row.mode ? { mode: row.mode } : {}),
879
+ ...(row.providerName ? { provider: row.providerName } : {}),
880
+ },
881
+ },
882
+ queried,
883
+ };
884
+ }
885
+ if (entity.type === MODULE_TYPE) {
886
+ if (!moduleIsLive(address, index))
887
+ return { absent: true, queried };
888
+ // The state has resources under this module but no row for the block
889
+ // itself, so state membership, which is the whole ownership channel
890
+ // here, has nothing to say about it. See the module doc.
891
+ return {
892
+ present: {
893
+ type: entity.type,
894
+ physicalId: address,
895
+ status: "module",
896
+ ownership: classifyStateOwnership(address, index),
897
+ attributes: { address, root },
898
+ },
899
+ queried,
900
+ };
901
+ }
902
+ return {
903
+ unobserved: {
904
+ reason: "unsupported-kind",
905
+ detail: `${entity.type} has no row in terraform state: only resource and data blocks do`,
906
+ },
907
+ queried,
908
+ };
909
+ },
910
+ };
911
+ }
912
+ /**
913
+ * Observe every declared terraform entity, one `init` + `show` per configured
914
+ * root. Roots are read independently, so a broken backend on one never
915
+ * un-observes another (`mergeObservations`).
916
+ */
917
+ export async function describeResources(options, deps = REAL_DEPS) {
918
+ const byRoot = new Map();
919
+ const rootless = [];
920
+ for (const name of options.entityNames) {
921
+ const declared = declaredOf(name, options.entities.get(name));
922
+ if (!declared.root) {
923
+ rootless.push(declared);
924
+ continue;
925
+ }
926
+ const bucket = byRoot.get(declared.root);
927
+ if (bucket)
928
+ bucket.push(declared);
929
+ else
930
+ byRoot.set(declared.root, [declared]);
931
+ }
932
+ const parts = [];
933
+ const notes = [];
934
+ // Which of the two reads answered, per entity (#2267). Filled for every
935
+ // entity of the root before the read runs, so the answer is on the wire
936
+ // whatever the verdict came back as: present, absent, or not-observed. A
937
+ // renderer joins observations to nodes, and painting `terraform show -json`
938
+ // over state the same green as a live read of the account is the drift lie
939
+ // an overlay exists to prevent.
940
+ const sources = {};
941
+ for (const [root, declared] of byRoot) {
942
+ // One fact decides the whole read, and the parse already recorded it on
943
+ // every entity of the root (#2103). A root is live when its binary is
944
+ // choudoufu AND it declares an estate, so a single entity carrying
945
+ // `mode: "live"` settles it for the root.
946
+ const live = declared.some((entity) => entity.mode === "live");
947
+ for (const entity of declared)
948
+ sources[entity.name] = live ? "live" : "state";
949
+ if (live) {
950
+ notes.push(`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`);
951
+ }
952
+ const read = live
953
+ ? await observeEntities(declared, liveAdapter(root, options.cwd, deps))
954
+ : await observeEntities(declared, adapter(root, options.cwd, deps));
955
+ parts.push(normalizeObservation(read));
956
+ }
957
+ const merged = mergeObservations(parts);
958
+ merged.notes.push(...notes);
959
+ const resources = { ...merged.resources };
960
+ const unobserved = { ...merged.unobserved };
961
+ // An entity carrying no root came from somewhere other than `buildRoots()`;
962
+ // there is no state file to look it up in, and saying so is not absence.
963
+ for (const entity of rootless) {
964
+ unobserved[entity.name] = {
965
+ ...(entity.type ? { type: entity.type } : {}),
966
+ reason: "unsupported-kind",
967
+ detail: "no `terraform.roots` entry on this entity, so there is no root module state to read it from",
968
+ };
969
+ }
970
+ if (options.owned) {
971
+ for (const [name, meta] of Object.entries(merged.resources)) {
972
+ if (meta.ownership === "owned")
973
+ continue;
974
+ delete resources[name];
975
+ unobserved[name] = {
976
+ type: meta.type,
977
+ reason: "filtered",
978
+ detail: `this address read \`${meta.ownership ?? "unknown"}\` on the root's ownership channel (its state file, or its live markers) and --owned was requested`,
979
+ ...(merged.queried[name] ? { queried: merged.queried[name] } : {}),
980
+ };
981
+ }
982
+ }
983
+ return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources);
984
+ }