@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32
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/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.js +152 -0
- package/dist/composites/terraform-apply-op.js +183 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- 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
|
+
}
|