@intentius/chant-lexicon-terraform 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/behaviour/index.d.ts +4 -0
- package/dist/behaviour/index.d.ts.map +1 -0
- package/dist/behaviour/predict.d.ts +112 -0
- package/dist/behaviour/predict.d.ts.map +1 -0
- package/dist/behaviour/reference-catalog.d.ts +79 -0
- package/dist/behaviour/reference-catalog.d.ts.map +1 -0
- package/dist/behaviour/request.d.ts +178 -0
- package/dist/behaviour/request.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +51 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/behaviour/index.ts +29 -0
- package/src/behaviour/predict.test.ts +381 -0
- package/src/behaviour/predict.ts +225 -0
- package/src/behaviour/reference-catalog.ts +185 -0
- package/src/behaviour/request.test.ts +352 -0
- package/src/behaviour/request.ts +622 -0
- package/src/describe-resources.ts +15 -6
- package/src/index.ts +17 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A behaviour engine's request from a terraform estate, declared or live (#2360).
|
|
3
|
+
*
|
|
4
|
+
* The epic (#2355) wants two predictions shown as a delta: what the file
|
|
5
|
+
* declares, and what the account holds. For a choudoufu root the second is
|
|
6
|
+
* readable — `live-ls -json` lists every resource the account holds under the
|
|
7
|
+
* estate's marker, `live-plan -json` says which declared instance each one is
|
|
8
|
+
* bound to and which declarations nothing answers for — and this module turns
|
|
9
|
+
* either reading into the request `PredictBehaviourOptions` describes:
|
|
10
|
+
* `entityNames`, `entities`, `edges`, `edgeCoverage`, `traffic`.
|
|
11
|
+
*
|
|
12
|
+
* One producer for both sides, on purpose. A delta is only a statement about
|
|
13
|
+
* the estate if both sides were assembled the same way, and the third review
|
|
14
|
+
* comment on #2360 is about exactly the asymmetry this avoids: the declared
|
|
15
|
+
* path had no containment producer, so a "one zone lost" verdict differed
|
|
16
|
+
* between the sides for a reason that was not drift. Here the same reference
|
|
17
|
+
* catalog (`./reference-catalog.ts`) runs over the same reconstruction
|
|
18
|
+
* (`reconstructEdges`, `packages/core/src/graph-refs.ts`) on both sides, and
|
|
19
|
+
* `edgeCoverage.containmentEdges` is populated the same way on each.
|
|
20
|
+
*
|
|
21
|
+
* ## What the live side is, and is not
|
|
22
|
+
*
|
|
23
|
+
* The two documents carry identities, types, addresses and markers. Neither
|
|
24
|
+
* carries a resource's attributes: a subnet's `vpc_id` as the account holds it
|
|
25
|
+
* is in no section of either. So a live node's arguments are the declaration's
|
|
26
|
+
* arguments — the body choudoufu applied — and its **references** are the
|
|
27
|
+
* declaration's references resolved against what is bound: a reference whose
|
|
28
|
+
* target the plan bound resolves to that target's live identity, and one whose
|
|
29
|
+
* target is absent from the account stays unresolved and is reported as
|
|
30
|
+
* `dangling`, naming the node, the argument and the address it points at. A
|
|
31
|
+
* resource the listing holds that nothing declares has no body at all; it is a
|
|
32
|
+
* node with an identity and a type, and its kind is counted into
|
|
33
|
+
* `unresolvedKinds` unless the catalog knows that kind references nothing.
|
|
34
|
+
*
|
|
35
|
+
* The drift this side sees is therefore **membership** drift: a resource added
|
|
36
|
+
* out of band under the estate's marker, a declared resource never applied, a
|
|
37
|
+
* resource at a declared identity that another estate holds. An attribute
|
|
38
|
+
* changed live — an instance resized by hand — is not in either document and
|
|
39
|
+
* is not claimed here; the size an engine is sent is the declared one on both
|
|
40
|
+
* sides. `describe-resources.ts`'s module doc has the same boundary for the
|
|
41
|
+
* observation read, for the same reason.
|
|
42
|
+
*
|
|
43
|
+
* One consequence of that worth stating outright, because it is the one a
|
|
44
|
+
* reader will hit first: a block with `count` or `for_each` is **one** node on
|
|
45
|
+
* both sides, whatever the account holds. The request's unit is the entity the
|
|
46
|
+
* caller asked about, and the caller asked about `aws_eip.pool`, not about two
|
|
47
|
+
* slots — inventing `aws_eip.pool[0]` and `[1]` as entities would put names in
|
|
48
|
+
* the report that are in no `entityNames` list and in no build. Every instance
|
|
49
|
+
* address the plan named rides on `props.live.instances` instead, so an engine
|
|
50
|
+
* that prices per instance has the count and one that does not is not silently
|
|
51
|
+
* handed a multiplier. An estate whose drift is a changed `count` is therefore
|
|
52
|
+
* a delta this path does not yet show.
|
|
53
|
+
*
|
|
54
|
+
* ## `edgeCoverage`, honestly
|
|
55
|
+
*
|
|
56
|
+
* `partial` whenever `dangling` or `unresolvedKinds` is non-empty, `complete`
|
|
57
|
+
* only when both are empty, and `dangling` carries `reconstructEdges`'s
|
|
58
|
+
* `DanglingRef` records through unflattened — `from` is the field that says
|
|
59
|
+
* which node's argument leaves the estate. `containmentEdges` comes from the
|
|
60
|
+
* reconstruction's `containmentEdges`, the traversable shape, and never from
|
|
61
|
+
* the `containment` pairs beside it. All three of those are #2360's review
|
|
62
|
+
* comments, applied.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type {
|
|
66
|
+
BehaviourEdgeCoverage,
|
|
67
|
+
PredictBehaviourOptions,
|
|
68
|
+
UnpredictedEntity,
|
|
69
|
+
} from "@intentius/chant/behaviour";
|
|
70
|
+
import type { EntityReference, IRNode } from "@intentius/chant/graph-ir";
|
|
71
|
+
import { reconstructEdges, type ReconstructedEdges } from "@intentius/chant/graph-refs";
|
|
72
|
+
import {
|
|
73
|
+
classifyLiveInstance,
|
|
74
|
+
declaredOf,
|
|
75
|
+
entityKeyFor,
|
|
76
|
+
instancesOf,
|
|
77
|
+
type LiveLsItem,
|
|
78
|
+
type LiveLsListing,
|
|
79
|
+
type LivePlanIndex,
|
|
80
|
+
type TerraformDeclared,
|
|
81
|
+
} from "../describe-resources";
|
|
82
|
+
import { RESOURCE_TYPE } from "../hcl/parse";
|
|
83
|
+
import { isUnresolvedKind, TERRAFORM_REFERENCE_CATALOG } from "./reference-catalog";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One entity as this builder needs it: the contract's `{ entityType, props }`
|
|
87
|
+
* plus the `references` the terraform build stamps beside `props`
|
|
88
|
+
* (`../hcl/edges.ts`, chant #2265). A caller holding the build's entities has
|
|
89
|
+
* them; a caller that copied only the two contract fields does not, and a
|
|
90
|
+
* block whose body holds a `${…}` reference with none resolved is counted as
|
|
91
|
+
* an unresolved kind rather than read as referencing nothing.
|
|
92
|
+
*/
|
|
93
|
+
export interface TerraformBehaviourEntity {
|
|
94
|
+
entityType: string;
|
|
95
|
+
props: Record<string, unknown>;
|
|
96
|
+
references?: readonly EntityReference[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One live root's two documents, already parsed. */
|
|
100
|
+
export interface TerraformLiveRead {
|
|
101
|
+
root: string;
|
|
102
|
+
listing: LiveLsListing;
|
|
103
|
+
plan: LivePlanIndex;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How a live node stands in the account. */
|
|
107
|
+
export type LiveResourceStatus = "bound" | "adoptable" | "foreign" | "orphan";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a live node's `props.live` carries: the facts the two documents state
|
|
111
|
+
* about it, kept apart from the declaration under `props` so a reader can
|
|
112
|
+
* tell which side said what.
|
|
113
|
+
*/
|
|
114
|
+
export interface LiveResourceFacts {
|
|
115
|
+
status: LiveResourceStatus;
|
|
116
|
+
ownership: "owned" | "unknown" | "foreign";
|
|
117
|
+
/** The import identity the plan bound (`vpc-…`, a bucket name), on a single-instance block. */
|
|
118
|
+
identity?: string;
|
|
119
|
+
/** The ARN or other stable identity the listing carries. */
|
|
120
|
+
arn?: string;
|
|
121
|
+
/** The region the ARN names, where it names one. */
|
|
122
|
+
region?: string;
|
|
123
|
+
/** Every instance address the plan named for the block, where there is more than the block itself. */
|
|
124
|
+
instances?: string[];
|
|
125
|
+
/** The estate holding a foreign resource, when its marker names one. */
|
|
126
|
+
heldBy?: string;
|
|
127
|
+
/** The marker tags the listing read off an undeclared resource. */
|
|
128
|
+
tags?: Record<string, string>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface TerraformBehaviourRequestOptions
|
|
132
|
+
extends Pick<PredictBehaviourOptions, "environment" | "buildOutput" | "traffic" | "region" | "stack" | "owned"> {
|
|
133
|
+
entityNames: readonly string[];
|
|
134
|
+
entities: ReadonlyMap<string, TerraformBehaviourEntity>;
|
|
135
|
+
/**
|
|
136
|
+
* Live reads, one per live root. A root with a read here is built from the
|
|
137
|
+
* account; every other root is built from its declaration. Omit for the
|
|
138
|
+
* declared side.
|
|
139
|
+
*/
|
|
140
|
+
live?: readonly TerraformLiveRead[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface TerraformBehaviourRequest {
|
|
144
|
+
/** The request, ready for `screenBehaviourRequest` and an engine-fronting `predictBehaviour`. */
|
|
145
|
+
request: PredictBehaviourOptions;
|
|
146
|
+
/** Which side each name in the request came from. */
|
|
147
|
+
sources: Record<string, "live" | "declared">;
|
|
148
|
+
/**
|
|
149
|
+
* Declared resources of a live root the account does not hold: the plan
|
|
150
|
+
* reported them `ABSENT`, or named no instance for them. Not in the request,
|
|
151
|
+
* because a prediction of the account cannot price what is not in it, and
|
|
152
|
+
* not `unpredicted`, because the contract has no reason for "not there".
|
|
153
|
+
* The declared side names them; the delta shows them as declared-only.
|
|
154
|
+
*/
|
|
155
|
+
absent: string[];
|
|
156
|
+
/**
|
|
157
|
+
* Declared names of a live root with no live verdict to build from — an
|
|
158
|
+
* instance the plan could not read, or one withheld by `owned` — each with
|
|
159
|
+
* the reason. Not in the request; a caller merges them into the report.
|
|
160
|
+
*/
|
|
161
|
+
unpredicted: Record<string, UnpredictedEntity>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The node `reconstructEdges` sees: the provider type as its kind, the substituted body as its attrs. */
|
|
165
|
+
type GraphNode = IRNode;
|
|
166
|
+
|
|
167
|
+
/** Code-unit order, so the request's bytes do not depend on the machine's locale. */
|
|
168
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
169
|
+
|
|
170
|
+
/** The block a document instance address belongs to: `aws_eip.pool[0]` is `aws_eip.pool`. */
|
|
171
|
+
export function blockAddressOf(instance: string): string {
|
|
172
|
+
return instance.replace(/\[[^\]]*\]$/, "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The region an ARN names, or undefined for a global one (`arn:aws:s3:::bucket`). */
|
|
176
|
+
export function regionOfArn(arn: string): string | undefined {
|
|
177
|
+
const parts = arn.split(":");
|
|
178
|
+
return parts[0] === "arn" && parts.length >= 6 && parts[3] ? parts[3] : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function asString(value: unknown): string | undefined {
|
|
182
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function bodyOf(props: Record<string, unknown>): Record<string, unknown> {
|
|
186
|
+
const body = props.body;
|
|
187
|
+
return typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function referencesOf(entity: TerraformBehaviourEntity | undefined): readonly EntityReference[] {
|
|
191
|
+
return Array.isArray(entity?.references) ? entity.references : [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when a body holds any `${…}` string at all, at any depth. */
|
|
195
|
+
function holdsReference(value: unknown, depth = 0): boolean {
|
|
196
|
+
if (depth > 12) return false;
|
|
197
|
+
if (typeof value === "string") return value.includes("${");
|
|
198
|
+
if (Array.isArray(value)) return value.some((v) => holdsReference(v, depth + 1));
|
|
199
|
+
if (typeof value === "object" && value !== null) {
|
|
200
|
+
return Object.values(value as Record<string, unknown>).some((v) => holdsReference(v, depth + 1));
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** `${addr}` followed by an attribute, an index, or the closing brace — the address itself, not a longer one. */
|
|
206
|
+
function mentions(text: string, address: string): boolean {
|
|
207
|
+
const escaped = address.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
208
|
+
return new RegExp(`\\$\\{${escaped}(?:[.\\[}\\s)\\],]|$)`).test(text);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One `${…}` and nothing else: the value the account holds *is* the target's identity. */
|
|
212
|
+
const WHOLE_INTERPOLATION = /^\$\{[^{}]*\}$/;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The body with every resolvable reference replaced by the identifier it
|
|
216
|
+
* stands for, so `reconstructEdges` can match it against the node that owns
|
|
217
|
+
* that identifier.
|
|
218
|
+
*
|
|
219
|
+
* A leaf string is replaced only when the reference **is** the whole value:
|
|
220
|
+
* the string is one `${…}` and nothing else, exactly one of the block's
|
|
221
|
+
* references under the same top-level argument is the one it names, and that
|
|
222
|
+
* reference resolves. Then the value in the account really is the target's
|
|
223
|
+
* identity, and matching it against the node holding that identity is a
|
|
224
|
+
* statement about the estate.
|
|
225
|
+
*
|
|
226
|
+
* Everything else is left alone, and the two cases it leaves alone are
|
|
227
|
+
* different. `"${a.id}-${b.id}"` and `"${a.id}-suffix"` are composed values —
|
|
228
|
+
* a name derived from an identity is not that identity, and substituting the
|
|
229
|
+
* bare identity into one would hand the resolver a value the account does not
|
|
230
|
+
* hold and get back an edge nothing points along. A string naming a reference
|
|
231
|
+
* that does not resolve keeps its `${…}`, which is what makes a reference to a
|
|
232
|
+
* target the account is missing come back from the resolver as `dangling` with
|
|
233
|
+
* the address in `value` rather than vanishing.
|
|
234
|
+
*/
|
|
235
|
+
export function substituteReferences(
|
|
236
|
+
body: Record<string, unknown>,
|
|
237
|
+
references: readonly EntityReference[],
|
|
238
|
+
addressOf: (to: string) => string | undefined,
|
|
239
|
+
resolve: (to: string) => string | undefined,
|
|
240
|
+
): Record<string, unknown> {
|
|
241
|
+
const leaf = (value: string, via: string): string => {
|
|
242
|
+
if (!WHOLE_INTERPOLATION.test(value)) return value;
|
|
243
|
+
const matched = references.filter((r) => {
|
|
244
|
+
if ((r.viaAttr ?? "") !== via) return false;
|
|
245
|
+
const address = addressOf(r.to);
|
|
246
|
+
return address !== undefined && mentions(value, address);
|
|
247
|
+
});
|
|
248
|
+
if (matched.length !== 1) return value;
|
|
249
|
+
return resolve(matched[0].to) ?? value;
|
|
250
|
+
};
|
|
251
|
+
const walk = (value: unknown, via: string, depth: number): unknown => {
|
|
252
|
+
if (depth > 12) return value;
|
|
253
|
+
if (typeof value === "string") return leaf(value, via);
|
|
254
|
+
if (Array.isArray(value)) return value.map((v) => walk(v, via, depth + 1));
|
|
255
|
+
if (typeof value === "object" && value !== null) {
|
|
256
|
+
const out: Record<string, unknown> = {};
|
|
257
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
258
|
+
out[key] = walk(inner, depth === 0 ? key : via, depth + 1);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
};
|
|
264
|
+
return walk(body, "", 0) as Record<string, unknown>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The provider type a resource block declares — `aws_subnet` — or undefined
|
|
269
|
+
* for any other block.
|
|
270
|
+
*
|
|
271
|
+
* Read off the address, because the block carries no `type` field:
|
|
272
|
+
* `../hcl/parse.ts` composes a resource's address as `${type}.${name}` and
|
|
273
|
+
* keeps it unqualified even inside a descended child module, where the calling
|
|
274
|
+
* chain lives on `props.callers` and the `module.<name>` segments live in the
|
|
275
|
+
* entity's key. An address with no dot is a live row's identity standing in
|
|
276
|
+
* for an address it had none of, and is not a type.
|
|
277
|
+
*
|
|
278
|
+
* This is the kind a node is given for `reconstructEdges`, and it is the same
|
|
279
|
+
* derivation augur's `terraformResourceType` runs to look a coverage row up,
|
|
280
|
+
* so the graph and the coverage table agree on what a block is. A live row
|
|
281
|
+
* states its own type instead, on `props.resourceType`, the name
|
|
282
|
+
* `observeAmbient` already uses for it.
|
|
283
|
+
*/
|
|
284
|
+
function providerTypeOf(entity: TerraformBehaviourEntity): string | undefined {
|
|
285
|
+
if (entity.entityType !== RESOURCE_TYPE) return undefined;
|
|
286
|
+
const stated = asString(entity.props.resourceType);
|
|
287
|
+
if (stated !== undefined) return stated;
|
|
288
|
+
const address = asString(entity.props.address);
|
|
289
|
+
if (address === undefined) return undefined;
|
|
290
|
+
const dot = address.indexOf(".");
|
|
291
|
+
return dot > 0 ? address.slice(0, dot) : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One root's worth of the request, on either side. */
|
|
295
|
+
interface RootPart {
|
|
296
|
+
nodes: GraphNode[];
|
|
297
|
+
entities: Map<string, TerraformBehaviourEntity>;
|
|
298
|
+
sources: Record<string, "live" | "declared">;
|
|
299
|
+
absent: string[];
|
|
300
|
+
unpredicted: Record<string, UnpredictedEntity>;
|
|
301
|
+
/** Kinds nothing was looked for on: no rule, or no body to look in. */
|
|
302
|
+
unresolvedKinds: Set<string>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function emptyPart(): RootPart {
|
|
306
|
+
return { nodes: [], entities: new Map(), sources: {}, absent: [], unpredicted: {}, unresolvedKinds: new Set() };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The verdict `describe-resources.ts` gives a block, aggregated over its instances in the same precedence. */
|
|
310
|
+
interface LiveVerdict {
|
|
311
|
+
kind: "present" | "absent" | "unobserved";
|
|
312
|
+
status?: LiveResourceStatus;
|
|
313
|
+
ownership?: LiveResourceFacts["ownership"];
|
|
314
|
+
identity?: string;
|
|
315
|
+
heldBy?: string;
|
|
316
|
+
instances: string[];
|
|
317
|
+
reason?: UnpredictedEntity["reason"];
|
|
318
|
+
detail?: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function liveVerdictOf(address: string, plan: LivePlanIndex): LiveVerdict {
|
|
322
|
+
const instances = instancesOf(address, plan);
|
|
323
|
+
if (instances.length === 0) return { kind: "absent", instances };
|
|
324
|
+
const verdicts = instances.map((a) => classifyLiveInstance(a, plan));
|
|
325
|
+
|
|
326
|
+
const unobserved = verdicts.find((v) => v.kind === "unobserved");
|
|
327
|
+
if (unobserved && unobserved.kind === "unobserved") {
|
|
328
|
+
return {
|
|
329
|
+
kind: "unobserved",
|
|
330
|
+
instances,
|
|
331
|
+
// `no-credentials` is an observation reason and not a prediction one;
|
|
332
|
+
// the credential wording stays in the detail.
|
|
333
|
+
reason: unobserved.reason === "no-credentials" ? "read-failed" : unobserved.reason,
|
|
334
|
+
detail: unobserved.detail,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const foreign = verdicts.find((v) => v.kind === "foreign");
|
|
338
|
+
if (foreign && foreign.kind === "foreign") {
|
|
339
|
+
return {
|
|
340
|
+
kind: "present",
|
|
341
|
+
status: "foreign",
|
|
342
|
+
ownership: "foreign",
|
|
343
|
+
instances,
|
|
344
|
+
...(foreign.row.identity ? { identity: foreign.row.identity } : {}),
|
|
345
|
+
...(foreign.row.heldBy ? { heldBy: foreign.row.heldBy } : {}),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const adoptable = verdicts.find((v) => v.kind === "adoptable");
|
|
349
|
+
if (adoptable && adoptable.kind === "adoptable") {
|
|
350
|
+
return {
|
|
351
|
+
kind: "present",
|
|
352
|
+
status: "adoptable",
|
|
353
|
+
ownership: "unknown",
|
|
354
|
+
instances,
|
|
355
|
+
...(adoptable.row.identity ? { identity: adoptable.row.identity } : {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const owned = verdicts.filter((v) => v.kind === "owned");
|
|
359
|
+
if (owned.length === 0) return { kind: "absent", instances };
|
|
360
|
+
const single = owned.length === 1 && instances.length === 1 ? owned[0] : undefined;
|
|
361
|
+
return {
|
|
362
|
+
kind: "present",
|
|
363
|
+
status: "bound",
|
|
364
|
+
ownership: "owned",
|
|
365
|
+
instances,
|
|
366
|
+
...(single && single.kind === "owned" && single.row.identity ? { identity: single.row.identity } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A live root: the declared resources the account holds, at their live
|
|
372
|
+
* identities, plus everything the listing holds that nothing declares.
|
|
373
|
+
*/
|
|
374
|
+
function liveRootPart(
|
|
375
|
+
root: string,
|
|
376
|
+
declared: TerraformDeclared[],
|
|
377
|
+
entities: ReadonlyMap<string, TerraformBehaviourEntity>,
|
|
378
|
+
read: TerraformLiveRead,
|
|
379
|
+
owned: boolean | undefined,
|
|
380
|
+
): RootPart {
|
|
381
|
+
const part = emptyPart();
|
|
382
|
+
const { listing, plan } = read;
|
|
383
|
+
const estate = plan.estate || listing.estate;
|
|
384
|
+
|
|
385
|
+
// The listing, by block address: a `count` block has one item per instance.
|
|
386
|
+
const listed = new Map<string, LiveLsItem[]>();
|
|
387
|
+
for (const item of listing.items) {
|
|
388
|
+
if (!item.address) continue;
|
|
389
|
+
const block = blockAddressOf(item.address);
|
|
390
|
+
(listed.get(block) ?? listed.set(block, []).get(block)!).push(item);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Live identity per entity key, for reference substitution below. Filled as
|
|
394
|
+
// declared blocks are placed; an orphan can be the target of nothing.
|
|
395
|
+
const identity = new Map<string, string | undefined>();
|
|
396
|
+
const placed: Array<{ name: string; entity: TerraformBehaviourEntity; declared: TerraformDeclared; facts: LiveResourceFacts }> = [];
|
|
397
|
+
|
|
398
|
+
for (const d of declared) {
|
|
399
|
+
const entity = entities.get(d.name);
|
|
400
|
+
if (!entity) continue;
|
|
401
|
+
if (entity.entityType !== RESOURCE_TYPE) {
|
|
402
|
+
// A variable, an output, a data block: not a thing the account holds,
|
|
403
|
+
// so the account has nothing to say about it. It rides through as
|
|
404
|
+
// declared, and augur declines it by name on both sides alike.
|
|
405
|
+
part.sources[d.name] = "live";
|
|
406
|
+
part.entities.set(d.name, entity);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const verdict = liveVerdictOf(d.address, plan);
|
|
410
|
+
if (verdict.kind === "absent") {
|
|
411
|
+
part.absent.push(d.name);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (verdict.kind === "unobserved") {
|
|
415
|
+
part.unpredicted[d.name] = { type: RESOURCE_TYPE, reason: verdict.reason!, detail: verdict.detail! };
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (owned && verdict.ownership !== "owned") {
|
|
419
|
+
part.unpredicted[d.name] = {
|
|
420
|
+
type: RESOURCE_TYPE,
|
|
421
|
+
reason: "filtered",
|
|
422
|
+
detail: `this address read \`${verdict.ownership}\` on the root's live markers and --owned was requested`,
|
|
423
|
+
};
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const items = listed.get(d.address) ?? [];
|
|
427
|
+
const single = items.length === 1 && verdict.instances.length === 1 ? items[0] : undefined;
|
|
428
|
+
const facts: LiveResourceFacts = {
|
|
429
|
+
status: verdict.status!,
|
|
430
|
+
ownership: verdict.ownership!,
|
|
431
|
+
...(verdict.identity ? { identity: verdict.identity } : {}),
|
|
432
|
+
...(single ? { arn: single.id } : {}),
|
|
433
|
+
...(single && regionOfArn(single.id) ? { region: regionOfArn(single.id) } : {}),
|
|
434
|
+
...(verdict.instances.length > 1 || verdict.instances[0] !== d.address ? { instances: verdict.instances } : {}),
|
|
435
|
+
...(verdict.heldBy ? { heldBy: verdict.heldBy } : {}),
|
|
436
|
+
};
|
|
437
|
+
identity.set(d.name, verdict.identity);
|
|
438
|
+
part.sources[d.name] = "live";
|
|
439
|
+
placed.push({ name: d.name, entity, declared: d, facts });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const addressOf = (to: string): string | undefined => asString(entities.get(to)?.props.address);
|
|
443
|
+
// A bound target resolves to its live identity; a target that is live but
|
|
444
|
+
// carries none (a marker read the tagging index had not settled, choudoufu
|
|
445
|
+
// #1014) resolves to its node id; a target not in the account resolves to
|
|
446
|
+
// nothing, and the reference stays in the body for the resolver to report.
|
|
447
|
+
const resolve = (to: string): string | undefined =>
|
|
448
|
+
identity.has(to) ? (identity.get(to) ?? to) : undefined;
|
|
449
|
+
|
|
450
|
+
for (const { name, entity, declared: d, facts } of placed) {
|
|
451
|
+
const kind = providerTypeOf(entity) ?? "";
|
|
452
|
+
const references = referencesOf(entity);
|
|
453
|
+
const body = bodyOf(entity.props);
|
|
454
|
+
if (references.length === 0 && holdsReference(body)) part.unresolvedKinds.add(kind);
|
|
455
|
+
else if (isUnresolvedKind(kind)) part.unresolvedKinds.add(kind);
|
|
456
|
+
part.nodes.push({
|
|
457
|
+
id: name,
|
|
458
|
+
kind,
|
|
459
|
+
lexicon: "terraform",
|
|
460
|
+
attrs: substituteReferences(body, references, addressOf, resolve),
|
|
461
|
+
...(facts.identity ? { physicalId: facts.identity } : {}),
|
|
462
|
+
});
|
|
463
|
+
part.entities.set(name, {
|
|
464
|
+
entityType: entity.entityType,
|
|
465
|
+
props: { ...entity.props, address: d.address, live: facts },
|
|
466
|
+
...(entity.references ? { references: entity.references } : {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// What the account holds that nothing declares: the owned orphans. No body,
|
|
471
|
+
// so nothing to look for references in.
|
|
472
|
+
for (const item of listing.items) {
|
|
473
|
+
if (item.declared) continue;
|
|
474
|
+
const address = item.address ? blockAddressOf(item.address) : item.id;
|
|
475
|
+
const name = entityKeyFor(root, address);
|
|
476
|
+
if (part.entities.has(name) || entities.has(name)) continue;
|
|
477
|
+
const facts: LiveResourceFacts = {
|
|
478
|
+
status: "orphan",
|
|
479
|
+
ownership: "owned",
|
|
480
|
+
arn: item.id,
|
|
481
|
+
...(regionOfArn(item.id) ? { region: regionOfArn(item.id) } : {}),
|
|
482
|
+
...(item.address && item.address !== address ? { instances: [item.address] } : {}),
|
|
483
|
+
tags: item.tags,
|
|
484
|
+
};
|
|
485
|
+
if (isUnresolvedKind(item.type)) part.unresolvedKinds.add(item.type);
|
|
486
|
+
part.sources[name] = "live";
|
|
487
|
+
part.nodes.push({ id: name, kind: item.type, lexicon: "terraform", attrs: {}, physicalId: item.id });
|
|
488
|
+
part.entities.set(name, {
|
|
489
|
+
entityType: RESOURCE_TYPE,
|
|
490
|
+
props: {
|
|
491
|
+
address,
|
|
492
|
+
root,
|
|
493
|
+
estate,
|
|
494
|
+
// `resourceType`, the name `observeAmbient` gives the same fact
|
|
495
|
+
// (`../describe-resources.ts`). An undeclared resource has no
|
|
496
|
+
// terraform address for a type to be read out of, so the listing's
|
|
497
|
+
// own statement of it is the only one there is.
|
|
498
|
+
resourceType: item.type,
|
|
499
|
+
mode: "live",
|
|
500
|
+
live: facts,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return part;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** A declared root: every entity as declared, references resolved to the entity keys they name. */
|
|
509
|
+
function declaredRootPart(
|
|
510
|
+
declared: TerraformDeclared[],
|
|
511
|
+
entities: ReadonlyMap<string, TerraformBehaviourEntity>,
|
|
512
|
+
): RootPart {
|
|
513
|
+
const part = emptyPart();
|
|
514
|
+
const addressOf = (to: string): string | undefined => asString(entities.get(to)?.props.address);
|
|
515
|
+
const resolve = (to: string): string | undefined => (entities.has(to) ? to : undefined);
|
|
516
|
+
for (const d of declared) {
|
|
517
|
+
const entity = entities.get(d.name);
|
|
518
|
+
if (!entity) continue;
|
|
519
|
+
part.sources[d.name] = "declared";
|
|
520
|
+
part.entities.set(d.name, entity);
|
|
521
|
+
const kind = providerTypeOf(entity);
|
|
522
|
+
if (kind === undefined) continue;
|
|
523
|
+
const references = referencesOf(entity);
|
|
524
|
+
const body = bodyOf(entity.props);
|
|
525
|
+
if (references.length === 0 && holdsReference(body)) part.unresolvedKinds.add(kind);
|
|
526
|
+
else if (isUnresolvedKind(kind)) part.unresolvedKinds.add(kind);
|
|
527
|
+
part.nodes.push({
|
|
528
|
+
id: d.name,
|
|
529
|
+
kind,
|
|
530
|
+
lexicon: "terraform",
|
|
531
|
+
attrs: substituteReferences(body, references, addressOf, resolve),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return part;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The coverage claim, from the reconstruction and the kinds nothing was
|
|
539
|
+
* looked for on. `partial` names what is missing; `complete` is claimed only
|
|
540
|
+
* when nothing is.
|
|
541
|
+
*/
|
|
542
|
+
export function edgeCoverageOf(
|
|
543
|
+
reconstructed: Pick<ReconstructedEdges, "dangling" | "containmentEdges">,
|
|
544
|
+
unresolvedKinds: readonly string[],
|
|
545
|
+
): BehaviourEdgeCoverage {
|
|
546
|
+
const dangling = reconstructed.dangling;
|
|
547
|
+
const kinds = [...unresolvedKinds].sort(byCodeUnit);
|
|
548
|
+
return {
|
|
549
|
+
verdict: dangling.length > 0 || kinds.length > 0 ? "partial" : "complete",
|
|
550
|
+
...(dangling.length > 0 ? { dangling } : {}),
|
|
551
|
+
...(kinds.length > 0 ? { unresolvedKinds: kinds } : {}),
|
|
552
|
+
containmentEdges: reconstructed.containmentEdges,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Build the request. Pure: the documents are already parsed and nothing here
|
|
558
|
+
* reads a clock, the environment or the filesystem, so the same declaration
|
|
559
|
+
* and the same two documents give the same request forever.
|
|
560
|
+
*/
|
|
561
|
+
export function terraformBehaviourRequest(options: TerraformBehaviourRequestOptions): TerraformBehaviourRequest {
|
|
562
|
+
const live = new Map((options.live ?? []).map((read) => [read.root, read]));
|
|
563
|
+
const byRoot = new Map<string, TerraformDeclared[]>();
|
|
564
|
+
const rootless: string[] = [];
|
|
565
|
+
for (const name of options.entityNames) {
|
|
566
|
+
const entity = options.entities.get(name);
|
|
567
|
+
const d = declaredOf(name, entity);
|
|
568
|
+
if (!d.root) {
|
|
569
|
+
rootless.push(name);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
(byRoot.get(d.root) ?? byRoot.set(d.root, []).get(d.root)!).push(d);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const parts: RootPart[] = [];
|
|
576
|
+
for (const [root, declared] of byRoot) {
|
|
577
|
+
const read = live.get(root);
|
|
578
|
+
parts.push(read ? liveRootPart(root, declared, options.entities, read, options.owned) : declaredRootPart(declared, options.entities));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const entities = new Map<string, TerraformBehaviourEntity>();
|
|
582
|
+
const sources: Record<string, "live" | "declared"> = {};
|
|
583
|
+
const absent: string[] = [];
|
|
584
|
+
const unpredicted: Record<string, UnpredictedEntity> = {};
|
|
585
|
+
const unresolved = new Set<string>();
|
|
586
|
+
const nodes: GraphNode[] = [];
|
|
587
|
+
for (const part of parts) {
|
|
588
|
+
for (const [name, entity] of part.entities) entities.set(name, entity);
|
|
589
|
+
Object.assign(sources, part.sources);
|
|
590
|
+
absent.push(...part.absent);
|
|
591
|
+
Object.assign(unpredicted, part.unpredicted);
|
|
592
|
+
for (const kind of part.unresolvedKinds) unresolved.add(kind);
|
|
593
|
+
nodes.push(...part.nodes);
|
|
594
|
+
}
|
|
595
|
+
// A name carrying no root came from somewhere other than `buildRoots()`. It
|
|
596
|
+
// is passed through as it came, so the engine-fronting lexicon reports it
|
|
597
|
+
// rather than this builder dropping it.
|
|
598
|
+
for (const name of rootless) {
|
|
599
|
+
const entity = options.entities.get(name);
|
|
600
|
+
if (entity) entities.set(name, entity);
|
|
601
|
+
sources[name] = "declared";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
nodes.sort((a, b) => byCodeUnit(a.id, b.id));
|
|
605
|
+
const reconstructed = reconstructEdges(nodes, TERRAFORM_REFERENCE_CATALOG);
|
|
606
|
+
|
|
607
|
+
const entityNames = [...new Set([...entities.keys(), ...rootless])].sort(byCodeUnit);
|
|
608
|
+
const request: PredictBehaviourOptions = {
|
|
609
|
+
environment: options.environment,
|
|
610
|
+
buildOutput: options.buildOutput,
|
|
611
|
+
entityNames,
|
|
612
|
+
entities: entities as Map<string, { entityType: string; props: Record<string, unknown> }>,
|
|
613
|
+
...(options.stack !== undefined ? { stack: options.stack } : {}),
|
|
614
|
+
...(options.region !== undefined ? { region: options.region } : {}),
|
|
615
|
+
...(options.owned !== undefined ? { owned: options.owned } : {}),
|
|
616
|
+
traffic: options.traffic,
|
|
617
|
+
edges: reconstructed.edges,
|
|
618
|
+
edgeCoverage: edgeCoverageOf(reconstructed, [...unresolved]),
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
return { request, sources, absent: absent.sort(byCodeUnit), unpredicted };
|
|
622
|
+
}
|
|
@@ -486,8 +486,12 @@ function omissionVerdict(row: LivePlanOmissionRow): UnobservedReason | "absent"
|
|
|
486
486
|
return mapped;
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
-
/**
|
|
490
|
-
|
|
489
|
+
/**
|
|
490
|
+
* Every instance address in the document belonging to the declared block
|
|
491
|
+
* `address`. Exported for the behaviour request builder (`./behaviour/`,
|
|
492
|
+
* #2360), which places the same blocks against the same document.
|
|
493
|
+
*/
|
|
494
|
+
export function instancesOf(address: string, index: LivePlanIndex): string[] {
|
|
491
495
|
return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`));
|
|
492
496
|
}
|
|
493
497
|
|
|
@@ -499,7 +503,7 @@ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
|
|
|
499
503
|
}
|
|
500
504
|
|
|
501
505
|
/** One instance's verdict, before a block aggregates its instances. */
|
|
502
|
-
type InstanceVerdict =
|
|
506
|
+
export type InstanceVerdict =
|
|
503
507
|
| { kind: "owned"; row: LivePlanBoundRow }
|
|
504
508
|
| { kind: "adoptable"; row: LivePlanAdoptableRow }
|
|
505
509
|
| { kind: "foreign"; row: LivePlanUnownedRow }
|
|
@@ -516,7 +520,7 @@ type InstanceVerdict =
|
|
|
516
520
|
* its own would read as unsupported-kind and hide a real, actionable verdict
|
|
517
521
|
* (choudoufu #962, chant #2241).
|
|
518
522
|
*/
|
|
519
|
-
function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
|
|
523
|
+
export function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
|
|
520
524
|
const unowned = index.unowned.get(address);
|
|
521
525
|
if (unowned) {
|
|
522
526
|
return unowned.adoptEstate || unowned.adoptAddress
|
|
@@ -1015,7 +1019,7 @@ const REAL_DEPS: TerraformReadDeps = {
|
|
|
1015
1019
|
};
|
|
1016
1020
|
|
|
1017
1021
|
/** A declared entity, plus the root, address and mode `buildRoots()` recorded on it. */
|
|
1018
|
-
interface TerraformDeclared extends DeclaredEntity {
|
|
1022
|
+
export interface TerraformDeclared extends DeclaredEntity {
|
|
1019
1023
|
root: string;
|
|
1020
1024
|
address: string;
|
|
1021
1025
|
/** `"live"` when the root runs under choudoufu with a declared estate (#2103). */
|
|
@@ -1053,7 +1057,12 @@ export function qualifiedAddress(address: string, callers: readonly string[]): s
|
|
|
1053
1057
|
return callers.length === 0 ? address : `${callers.join(".")}.${address}`;
|
|
1054
1058
|
}
|
|
1055
1059
|
|
|
1056
|
-
|
|
1060
|
+
/**
|
|
1061
|
+
* The root, qualified address and mode of one declared entity, from what
|
|
1062
|
+
* `buildRoots()` recorded on its props. Exported for the behaviour request
|
|
1063
|
+
* builder (#2360), which groups entities by root the same way.
|
|
1064
|
+
*/
|
|
1065
|
+
export function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
|
|
1057
1066
|
const props = entity?.props ?? {};
|
|
1058
1067
|
const fallback = fromEntityName(name);
|
|
1059
1068
|
const callers = Array.isArray(props.callers)
|