@intentius/chant 0.42.1 → 0.44.2
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/build.d.ts +15 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +10 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +8 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/governance.d.ts +1 -1
- package/dist/graph-detail.d.ts +31 -13
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +7 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +18 -0
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +2 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/observation.d.ts +33 -3
- package/dist/observation.d.ts.map +1 -1
- package/dist/reconcile.d.ts +5 -5
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +4 -3
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/rules-doc.ts +1 -1
- package/src/build.test.ts +95 -0
- package/src/build.ts +46 -1
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/lint.ts +14 -3
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.ts +32 -7
- package/src/cli/handlers/lifecycle.ts +15 -4
- package/src/cli/plugins.ts +17 -0
- package/src/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +3 -4
- package/src/components/cli-support.test.ts +52 -0
- package/src/components/cli-support.ts +13 -2
- package/src/components/deploy-units.test.ts +13 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/governance.test.ts +1 -1
- package/src/governance.ts +1 -1
- package/src/graph-detail.test.ts +117 -6
- package/src/graph-detail.ts +76 -18
- package/src/lexicon.ts +47 -0
- package/src/lifecycle/change-set.test.ts +26 -0
- package/src/lifecycle/change-set.ts +13 -0
- package/src/lifecycle/live-diff.test.ts +35 -0
- package/src/lifecycle/live-diff.ts +21 -0
- package/src/lifecycle/observe.ts +2 -1
- package/src/lint/component-checks.ts +8 -1
- package/src/observation.test.ts +54 -7
- package/src/observation.ts +53 -13
- package/src/reconcile.ts +7 -7
- package/src/terraform/aws-resources.test.ts +52 -2
- package/src/terraform/aws-resources.ts +95 -4
- package/src/terraform/graph.test.ts +17 -0
- package/src/terraform/graph.ts +12 -2
- package/src/terraform/tier-map.ts +60 -8
package/src/observation.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
29
29
|
|
|
30
30
|
describe("normalizeObservation", () => {
|
|
31
31
|
test("a bare map means 'I looked at everything'", () => {
|
|
32
|
-
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {} });
|
|
32
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {} });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("the envelope carries both halves", () => {
|
|
@@ -37,11 +37,27 @@ describe("normalizeObservation", () => {
|
|
|
37
37
|
expect(normalizeObservation(value)).toEqual({
|
|
38
38
|
resources: { a: meta() },
|
|
39
39
|
unobserved: { b: { reason: "read-failed" } },
|
|
40
|
+
queried: {},
|
|
40
41
|
});
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
test("undefined normalizes to
|
|
44
|
-
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {} });
|
|
44
|
+
test("undefined normalizes to empty maps", () => {
|
|
45
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {} });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the envelope carries the queried addresses through normalization (#1620)", () => {
|
|
49
|
+
const value = observation({}, {}, { web: "/apis/apps/v1/namespaces/default/deployments/web" });
|
|
50
|
+
expect(normalizeObservation(value).queried).toEqual({
|
|
51
|
+
web: "/apis/apps/v1/namespaces/default/deployments/web",
|
|
52
|
+
});
|
|
53
|
+
// Additive metadata only: an entity in `queried` and neither map is still
|
|
54
|
+
// OBSERVED-ABSENT — the tri-state does not shift.
|
|
55
|
+
expect(normalizeObservation(value).resources).toEqual({});
|
|
56
|
+
expect(normalizeObservation(value).unobserved).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the envelope omits an empty queried map (#1620)", () => {
|
|
60
|
+
expect(observation({ a: meta() }, {}, {})).toEqual({ observation: "v1", resources: { a: meta() } });
|
|
45
61
|
});
|
|
46
62
|
|
|
47
63
|
test("an entity literally named `observation` cannot be mistaken for the envelope", () => {
|
|
@@ -68,8 +84,8 @@ describe("unobservedAll", () => {
|
|
|
68
84
|
describe("mergeObservations (multi-stack)", () => {
|
|
69
85
|
test("present beats not-observed beats absent", () => {
|
|
70
86
|
const merged = mergeObservations([
|
|
71
|
-
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } } },
|
|
72
|
-
{ resources: { a: meta() }, unobserved: {} },
|
|
87
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {} },
|
|
88
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
73
89
|
]);
|
|
74
90
|
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
75
91
|
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
@@ -77,11 +93,19 @@ describe("mergeObservations (multi-stack)", () => {
|
|
|
77
93
|
|
|
78
94
|
test("an entity nobody looked for in any stack stays absent", () => {
|
|
79
95
|
const merged = mergeObservations([
|
|
80
|
-
{ resources: { a: meta() }, unobserved: {} },
|
|
81
|
-
{ resources: { b: meta() }, unobserved: {} },
|
|
96
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
97
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {} },
|
|
82
98
|
]);
|
|
83
99
|
expect(merged.unobserved).toEqual({});
|
|
84
100
|
});
|
|
101
|
+
|
|
102
|
+
test("queried addresses union across stacks (#1620)", () => {
|
|
103
|
+
const merged = mergeObservations([
|
|
104
|
+
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" } },
|
|
105
|
+
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" } },
|
|
106
|
+
]);
|
|
107
|
+
expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
|
|
108
|
+
});
|
|
85
109
|
});
|
|
86
110
|
|
|
87
111
|
describe("reason totality", () => {
|
|
@@ -98,6 +122,12 @@ describe("reason totality", () => {
|
|
|
98
122
|
formatUnobserved("widget", { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no mapping" }),
|
|
99
123
|
).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
|
|
100
124
|
});
|
|
125
|
+
|
|
126
|
+
test("formatUnobserved appends the queried address when the entry carries one (#1620)", () => {
|
|
127
|
+
expect(
|
|
128
|
+
formatUnobserved("web", { reason: "read-failed", detail: "HTTP 500", queried: "/apis/apps/v1/namespaces/default/deployments/web" }),
|
|
129
|
+
).toBe("web — read failed: HTTP 500 [queried /apis/apps/v1/namespaces/default/deployments/web]");
|
|
130
|
+
});
|
|
101
131
|
});
|
|
102
132
|
|
|
103
133
|
describe("boundedConcurrently", () => {
|
|
@@ -163,6 +193,23 @@ describe("observeEntities harness (#1201)", () => {
|
|
|
163
193
|
});
|
|
164
194
|
});
|
|
165
195
|
|
|
196
|
+
test("collects the queried address from every variant — the absent one especially (#1620)", async () => {
|
|
197
|
+
const result = await observeEntities(
|
|
198
|
+
[entity("a"), entity("b"), entity("c")],
|
|
199
|
+
adapterOf({
|
|
200
|
+
a: { present: meta(), queried: "region-1/a" },
|
|
201
|
+
b: { absent: true, queried: "region-1/b" },
|
|
202
|
+
c: { unobserved: { reason: "read-failed", detail: "boom" }, queried: "region-1/c" },
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
// The absent entity stays in neither map — additive metadata, tri-state unshifted.
|
|
206
|
+
expect(Object.keys(result.resources)).toEqual(["a"]);
|
|
207
|
+
expect(Object.keys(result.unobserved ?? {})).toEqual(["c"]);
|
|
208
|
+
expect(result.queried).toEqual({ a: "region-1/a", b: "region-1/b", c: "region-1/c" });
|
|
209
|
+
// The unobserved entry carries its own copy, so a row renders without a join.
|
|
210
|
+
expect(result.unobserved?.c.queried).toBe("region-1/c");
|
|
211
|
+
});
|
|
212
|
+
|
|
166
213
|
test("a bind failure marks every entity NOT-OBSERVED with the typed reason and declared type", async () => {
|
|
167
214
|
const result = await observeEntities(
|
|
168
215
|
[entity("a", "AWS::S3::Bucket"), entity("b", "AWS::S3::Bucket")],
|
package/src/observation.ts
CHANGED
|
@@ -79,6 +79,14 @@ export interface UnobservedEntity {
|
|
|
79
79
|
reason: UnobservedReason;
|
|
80
80
|
/** Human-readable detail: the command that failed, the missing binding key, the unsupported kind. */
|
|
81
81
|
detail?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The resolved address the read was issued against (#1620) — for k8s the
|
|
84
|
+
* request path (`/apis/apps/v1/namespaces/default/deployments/web`), for
|
|
85
|
+
* other substrates whatever names the endpoint/region/account actually
|
|
86
|
+
* asked. Optional and purely diagnostic: it never changes the verdict, it
|
|
87
|
+
* lets a consumer tell "looked in the wrong place" from "not there".
|
|
88
|
+
*/
|
|
89
|
+
queried?: string;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/**
|
|
@@ -92,6 +100,18 @@ export interface ObservationResult {
|
|
|
92
100
|
resources: Record<string, ResourceMetadata>;
|
|
93
101
|
/** NOT-OBSERVED, keyed by chant entity name. Omit or leave empty when everything asked about was looked at. */
|
|
94
102
|
unobserved?: Record<string, UnobservedEntity>;
|
|
103
|
+
/**
|
|
104
|
+
* The resolved query address per declared entity (#1620), keyed by chant
|
|
105
|
+
* entity name — what was actually asked of the provider, whatever the
|
|
106
|
+
* verdict came back as. Additive metadata over the tri-state, never part of
|
|
107
|
+
* it: classification still reads only `resources` and `unobserved`, and an
|
|
108
|
+
* entity in neither map is still OBSERVED-ABSENT whether or not it appears
|
|
109
|
+
* here. This map is the only place an ABSENT entity can carry its address —
|
|
110
|
+
* absence is spelled "in neither map", so there is no row to hang it on —
|
|
111
|
+
* which is exactly the entry that lets a consumer see that a defaulted
|
|
112
|
+
* namespace, endpoint or region was read, not the one the resource lives in.
|
|
113
|
+
*/
|
|
114
|
+
queried?: Record<string, string>;
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/**
|
|
@@ -100,10 +120,12 @@ export interface ObservationResult {
|
|
|
100
120
|
*/
|
|
101
121
|
export type DescribeResourcesResult = Record<string, ResourceMetadata> | ObservationResult;
|
|
102
122
|
|
|
103
|
-
/** Normalized form every consumer works with.
|
|
123
|
+
/** Normalized form every consumer works with. All maps always present. */
|
|
104
124
|
export interface NormalizedObservation {
|
|
105
125
|
resources: Record<string, ResourceMetadata>;
|
|
106
126
|
unobserved: Record<string, UnobservedEntity>;
|
|
127
|
+
/** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
|
|
128
|
+
queried: Record<string, string>;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
/** True when `value` is the versioned {@link ObservationResult} envelope. */
|
|
@@ -122,11 +144,13 @@ export function isObservationResult(value: unknown): value is ObservationResult
|
|
|
122
144
|
export function observation(
|
|
123
145
|
resources: Record<string, ResourceMetadata>,
|
|
124
146
|
unobserved?: Record<string, UnobservedEntity>,
|
|
147
|
+
queried?: Record<string, string>,
|
|
125
148
|
): ObservationResult {
|
|
126
149
|
return {
|
|
127
150
|
observation: "v1",
|
|
128
151
|
resources,
|
|
129
152
|
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
153
|
+
...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
|
|
130
154
|
};
|
|
131
155
|
}
|
|
132
156
|
|
|
@@ -137,11 +161,11 @@ export function observation(
|
|
|
137
161
|
* {@link unobservedAll} rather than returning nothing.
|
|
138
162
|
*/
|
|
139
163
|
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
140
|
-
if (!value) return { resources: {}, unobserved: {} };
|
|
164
|
+
if (!value) return { resources: {}, unobserved: {}, queried: {} };
|
|
141
165
|
if (isObservationResult(value)) {
|
|
142
|
-
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
|
|
166
|
+
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {}, queried: value.queried ?? {} };
|
|
143
167
|
}
|
|
144
|
-
return { resources: value, unobserved: {} };
|
|
168
|
+
return { resources: value, unobserved: {}, queried: {} };
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
/**
|
|
@@ -180,14 +204,16 @@ export function unobservedAll(
|
|
|
180
204
|
export function mergeObservations(parts: Iterable<NormalizedObservation>): NormalizedObservation {
|
|
181
205
|
const resources: Record<string, ResourceMetadata> = {};
|
|
182
206
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
207
|
+
const queried: Record<string, string> = {};
|
|
183
208
|
for (const part of parts) {
|
|
184
209
|
Object.assign(resources, part.resources);
|
|
185
210
|
Object.assign(unobserved, part.unobserved);
|
|
211
|
+
Object.assign(queried, part.queried);
|
|
186
212
|
}
|
|
187
213
|
// Present wins: a stack that could not be read does not un-observe a resource
|
|
188
214
|
// another stack returned.
|
|
189
215
|
for (const name of Object.keys(resources)) delete unobserved[name];
|
|
190
|
-
return { resources, unobserved };
|
|
216
|
+
return { resources, unobserved, queried };
|
|
191
217
|
}
|
|
192
218
|
|
|
193
219
|
/** One-line human phrasing of a reason, for CLI output. */
|
|
@@ -206,10 +232,11 @@ export function unobservedReasonText(reason: UnobservedReason): string {
|
|
|
206
232
|
}
|
|
207
233
|
}
|
|
208
234
|
|
|
209
|
-
/** `name — reason (detail)`, the shared rendering for CLI and plan output. */
|
|
235
|
+
/** `name — reason (detail) [queried address]`, the shared rendering for CLI and plan output. */
|
|
210
236
|
export function formatUnobserved(name: string, entry: UnobservedEntity): string {
|
|
211
237
|
const base = `${name}${entry.type ? ` (${entry.type})` : ""} — ${unobservedReasonText(entry.reason)}`;
|
|
212
|
-
|
|
238
|
+
const detailed = entry.detail ? `${base}: ${entry.detail}` : base;
|
|
239
|
+
return entry.queried ? `${detailed} [queried ${entry.queried}]` : detailed;
|
|
213
240
|
}
|
|
214
241
|
|
|
215
242
|
/* ------------------------------------------------------------------------- *
|
|
@@ -246,11 +273,16 @@ export interface DeclaredEntity {
|
|
|
246
273
|
* - `present` — a key in `resources`.
|
|
247
274
|
* - `absent` — in neither map (the provider was asked and reported it missing).
|
|
248
275
|
* - `unobserved` — a typed NOT-OBSERVED (unsupported kind, filtered, read error).
|
|
276
|
+
*
|
|
277
|
+
* Every variant may carry `queried` (#1620): the resolved address the read was
|
|
278
|
+
* issued against. The harness collects it into the result's `queried` map (and
|
|
279
|
+
* onto the unobserved entry), so even an absent verdict — which records
|
|
280
|
+
* nothing else — says where the provider was asked.
|
|
249
281
|
*/
|
|
250
282
|
export type EntityObservation =
|
|
251
|
-
| { present: ResourceMetadata }
|
|
252
|
-
| { absent: true }
|
|
253
|
-
| { unobserved: { reason: UnobservedReason; detail?: string } };
|
|
283
|
+
| { present: ResourceMetadata; queried?: string }
|
|
284
|
+
| { absent: true; queried?: string }
|
|
285
|
+
| { unobserved: { reason: UnobservedReason; detail?: string }; queried?: string };
|
|
254
286
|
|
|
255
287
|
/** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
|
|
256
288
|
export interface ObserverAdapter<Client> {
|
|
@@ -338,6 +370,7 @@ export async function observeEntities<Client>(
|
|
|
338
370
|
|
|
339
371
|
const resources: Record<string, ResourceMetadata> = {};
|
|
340
372
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
373
|
+
const queried: Record<string, string> = {};
|
|
341
374
|
const run = adapter.concurrently ?? ((items, fn) => boundedConcurrently(items, fn));
|
|
342
375
|
|
|
343
376
|
await run(declared, async (entity) => {
|
|
@@ -352,13 +385,20 @@ export async function observeEntities<Client>(
|
|
|
352
385
|
},
|
|
353
386
|
};
|
|
354
387
|
}
|
|
388
|
+
if (result.queried) queried[entity.name] = result.queried;
|
|
355
389
|
if ("present" in result) {
|
|
356
390
|
resources[entity.name] = result.present;
|
|
357
391
|
} else if ("unobserved" in result) {
|
|
358
|
-
unobserved[entity.name] = {
|
|
392
|
+
unobserved[entity.name] = {
|
|
393
|
+
type: entity.type,
|
|
394
|
+
...result.unobserved,
|
|
395
|
+
...(result.queried ? { queried: result.queried } : {}),
|
|
396
|
+
};
|
|
359
397
|
}
|
|
360
|
-
// `absent`:
|
|
398
|
+
// `absent`: the verdict records nothing — in neither map is how the
|
|
399
|
+
// contract spells absence — but its address, when the adapter supplied
|
|
400
|
+
// one, lands in `queried` (#1620) so the absence can explain itself.
|
|
361
401
|
});
|
|
362
402
|
|
|
363
|
-
return observation(resources, unobserved);
|
|
403
|
+
return observation(resources, unobserved, queried);
|
|
364
404
|
}
|
package/src/reconcile.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the plan renderer, and the guardrail framework (rename resolution + a removal
|
|
8
8
|
* cap + a pluggable check runner).
|
|
9
9
|
*
|
|
10
|
-
* A
|
|
10
|
+
* A consumer application (e.g. github-warden) builds its provider-specific resource diffing,
|
|
11
11
|
* live-state types, and domain guardrails on top of this, and drives them with
|
|
12
12
|
* the generic `runReconcile` loop + `Cycle` interface (below). It complements
|
|
13
13
|
* chant's `ownership.ts` marker contract: ownership markers make a `delete`
|
|
@@ -41,7 +41,7 @@ export interface ChangeSetEntry {
|
|
|
41
41
|
/** High-level resource category (e.g. "team", "member", "branch-protection"). */
|
|
42
42
|
resourceType: string;
|
|
43
43
|
/**
|
|
44
|
-
* Cross-provider governance category
|
|
44
|
+
* Cross-provider governance category. `resourceType` stays the
|
|
45
45
|
* provider-specific display string; the verb is the shared grammar SCM and
|
|
46
46
|
* cloud plans group by. Stamped by `runReconcile` from the cycle's `verb`.
|
|
47
47
|
*/
|
|
@@ -221,7 +221,7 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
221
221
|
for (const kind of ORDER) {
|
|
222
222
|
let group = byKind[kind];
|
|
223
223
|
if (group.length === 0) continue;
|
|
224
|
-
// Verb-aware grouping
|
|
224
|
+
// Verb-aware grouping: when entries carry governance verbs, order
|
|
225
225
|
// each section by verb (vocabulary order, unverbed entries last) so mixed
|
|
226
226
|
// plans read category-by-category. Stable, and line format is unchanged —
|
|
227
227
|
// a verbless change set renders exactly as before.
|
|
@@ -417,8 +417,8 @@ export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
|
417
417
|
/** Human-readable name, e.g. "branch-protection". */
|
|
418
418
|
name: string;
|
|
419
419
|
/**
|
|
420
|
-
* Cross-provider governance category this cycle reconciles
|
|
421
|
-
* SCM
|
|
420
|
+
* Cross-provider governance category this cycle reconciles. Every
|
|
421
|
+
* SCM reconciler cycles stamp one; cloud cycles must. Optional
|
|
422
422
|
* only so provider-external Cycle implementations don't break.
|
|
423
423
|
*/
|
|
424
424
|
verb?: GovernanceVerb;
|
|
@@ -436,7 +436,7 @@ export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
|
436
436
|
/** Per-cycle outcome recorded in the run result. */
|
|
437
437
|
export interface CycleResult {
|
|
438
438
|
name: string;
|
|
439
|
-
/** The cycle's governance verb
|
|
439
|
+
/** The cycle's governance verb, when it stamps one. */
|
|
440
440
|
verb?: GovernanceVerb;
|
|
441
441
|
/** Scope id this result is for (e.g. an org login). */
|
|
442
442
|
org: string;
|
|
@@ -560,7 +560,7 @@ export async function runReconcile<TClient, TConfig, TLive, TScope = unknown>(
|
|
|
560
560
|
}
|
|
561
561
|
|
|
562
562
|
const changeSet = diffFn(scopeId, desired, live, diffOptions);
|
|
563
|
-
// Stamp the cycle's governance verb
|
|
563
|
+
// Stamp the cycle's governance verb onto entries that don't
|
|
564
564
|
// carry one, so provider diffs stay verb-unaware.
|
|
565
565
|
if (cycle.verb) {
|
|
566
566
|
for (const e of changeSet.entries) e.verb ??= cycle.verb;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
|
|
3
|
-
import { TIER_MAP } from "./tier-map";
|
|
3
|
+
import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR } from "./tier-map";
|
|
4
4
|
import { canAdoptFromState } from "./adopt-state";
|
|
5
5
|
|
|
6
6
|
describe("AWS carve-out table", () => {
|
|
@@ -21,10 +21,21 @@ describe("AWS carve-out table", () => {
|
|
|
21
21
|
"aws_secretsmanager_secret", "aws_ssm_parameter", "aws_ecr_repository",
|
|
22
22
|
"aws_vpc", "aws_subnet", "aws_security_group", "aws_route53_zone",
|
|
23
23
|
"aws_cloudwatch_log_group",
|
|
24
|
+
// #998 coverage expansion: streaming, EKS, Lambda periphery, networking
|
|
25
|
+
// periphery, audit, DB groups/clusters, API stages, identity, EFS periphery.
|
|
26
|
+
"aws_kinesis_stream", "aws_eks_cluster", "aws_eks_node_group",
|
|
27
|
+
"aws_lambda_permission", "aws_lambda_event_source_mapping",
|
|
28
|
+
"aws_vpc_endpoint", "aws_route", "aws_route_table_association",
|
|
29
|
+
"aws_flow_log", "aws_cloudtrail", "aws_cloudwatch_dashboard",
|
|
30
|
+
"aws_rds_cluster", "aws_db_parameter_group", "aws_elasticache_subnet_group",
|
|
31
|
+
"aws_lb_listener_rule", "aws_api_gateway_stage", "aws_apigatewayv2_stage",
|
|
32
|
+
"aws_cognito_user_pool", "aws_appautoscaling_target",
|
|
33
|
+
"aws_efs_mount_target", "aws_efs_access_point",
|
|
34
|
+
"aws_sqs_queue_policy", "aws_sns_topic_policy",
|
|
24
35
|
]) {
|
|
25
36
|
expect(types.has(t)).toBe(true);
|
|
26
37
|
}
|
|
27
|
-
expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(
|
|
38
|
+
expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(60);
|
|
28
39
|
});
|
|
29
40
|
|
|
30
41
|
test("constructors are non-empty and consistent per native type", () => {
|
|
@@ -78,4 +89,43 @@ describe("applyAwsMapper", () => {
|
|
|
78
89
|
expect(props).toEqual({ VpcId: "vpc-1", CidrBlock: "10.0.1.0/24" });
|
|
79
90
|
expect(props).not.toHaveProperty("AvailabilityZone");
|
|
80
91
|
});
|
|
92
|
+
|
|
93
|
+
test("wraps a queue policy's queue_url and parses its policy document", () => {
|
|
94
|
+
const { props } = applyAwsMapper(awsCarveType("aws_sqs_queue_policy")!, {
|
|
95
|
+
queue_url: "https://sqs.us-east-1.amazonaws.com/1/q",
|
|
96
|
+
policy: '{"Version":"2012-10-17","Statement":[]}',
|
|
97
|
+
});
|
|
98
|
+
expect(props.Queues).toEqual(["https://sqs.us-east-1.amazonaws.com/1/q"]);
|
|
99
|
+
expect(props.PolicyDocument).toEqual({ Version: "2012-10-17", Statement: [] });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("tier + identity coverage maps (#998)", () => {
|
|
104
|
+
test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
|
|
105
|
+
expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
|
|
106
|
+
for (const t of ["kubernetes_deployment", "kubernetes_config_map", "kubernetes_service", "kubernetes_namespace"]) {
|
|
107
|
+
expect(TIER_MAP[t]).toMatchObject({ tier: 2 });
|
|
108
|
+
expect(TIER_MAP[`${t}_v1`]).toEqual(TIER_MAP[t]);
|
|
109
|
+
}
|
|
110
|
+
expect(TIER_MAP.kubernetes_horizontal_pod_autoscaler_v2).toEqual(TIER_MAP.kubernetes_horizontal_pod_autoscaler);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("S3 and ECR sub-resources fold into their parent", () => {
|
|
114
|
+
for (const t of [
|
|
115
|
+
"aws_s3_bucket_website_configuration", "aws_s3_bucket_cors_configuration", "aws_s3_bucket_logging",
|
|
116
|
+
"aws_s3_bucket_ownership_controls", "aws_s3_bucket_notification",
|
|
117
|
+
]) {
|
|
118
|
+
expect(FOLDS_INTO[t]).toBe("aws_s3_bucket");
|
|
119
|
+
}
|
|
120
|
+
expect(FOLDS_INTO.aws_ecr_lifecycle_policy).toBe("aws_ecr_repository");
|
|
121
|
+
expect(FOLDS_INTO.aws_ecr_repository_policy).toBe("aws_ecr_repository");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("identity attrs cover the expanded types, including a dotted non-AWS path", () => {
|
|
125
|
+
expect(IDENTITY_ATTR.aws_elasticache_cluster).toBe("cluster_id");
|
|
126
|
+
expect(IDENTITY_ATTR.aws_ecs_task_definition).toBe("family");
|
|
127
|
+
expect(IDENTITY_ATTR.aws_route53_record).toBe("name");
|
|
128
|
+
expect(IDENTITY_ATTR.aws_eks_node_group).toBe("node_group_name");
|
|
129
|
+
expect(IDENTITY_ATTR.kubernetes_manifest).toBe("manifest.metadata.name");
|
|
130
|
+
});
|
|
81
131
|
});
|
|
@@ -120,7 +120,7 @@ export const AWS_CARVE_TYPES: AwsCarveType[] = [
|
|
|
120
120
|
fields: { name: "AutoScalingGroupName", min_size: "MinSize", max_size: "MaxSize", desired_capacity: "DesiredCapacity", vpc_zone_identifier: "VPCZoneIdentifier", health_check_type: "HealthCheckType" } },
|
|
121
121
|
{ tfType: "aws_ecs_cluster", tier: 1, nativeType: "AWS::ECS::Cluster", ctor: "EcsCluster", identityAttr: "name",
|
|
122
122
|
fields: { name: "ClusterName" }, tags: true },
|
|
123
|
-
{ tfType: "aws_ecs_task_definition", tier: 3, nativeType: "AWS::ECS::TaskDefinition", ctor: "TaskDefinition",
|
|
123
|
+
{ tfType: "aws_ecs_task_definition", tier: 3, nativeType: "AWS::ECS::TaskDefinition", ctor: "TaskDefinition", identityAttr: "family",
|
|
124
124
|
fields: { family: "Family", cpu: "Cpu", memory: "Memory", network_mode: "NetworkMode", execution_role_arn: "ExecutionRoleArn", task_role_arn: "TaskRoleArn" }, tags: true },
|
|
125
125
|
{ tfType: "aws_ebs_volume", tier: 1, nativeType: "AWS::EC2::Volume", ctor: "EC2Volume",
|
|
126
126
|
fields: { availability_zone: "AvailabilityZone", size: "Size", type: "VolumeType", encrypted: "Encrypted", iops: "Iops" }, tags: true },
|
|
@@ -140,13 +140,13 @@ export const AWS_CARVE_TYPES: AwsCarveType[] = [
|
|
|
140
140
|
fields: { identifier: "DBInstanceIdentifier", engine: "Engine", engine_version: "EngineVersion", instance_class: "DBInstanceClass", allocated_storage: "AllocatedStorage", db_name: "DBName", multi_az: "MultiAZ", storage_encrypted: "StorageEncrypted" }, tags: true },
|
|
141
141
|
{ tfType: "aws_db_subnet_group", tier: 1, nativeType: "AWS::RDS::DBSubnetGroup", ctor: "RDSDBSubnetGroup", identityAttr: "name",
|
|
142
142
|
fields: { name: "DBSubnetGroupName", description: "DBSubnetGroupDescription", subnet_ids: "SubnetIds" }, tags: true },
|
|
143
|
-
{ tfType: "aws_elasticache_cluster", tier: 2, nativeType: "AWS::ElastiCache::CacheCluster", ctor: "CacheCluster",
|
|
144
|
-
fields: { engine: "Engine", node_type: "CacheNodeType", num_cache_nodes: "NumCacheNodes", engine_version: "EngineVersion" }, tags: true },
|
|
143
|
+
{ tfType: "aws_elasticache_cluster", tier: 2, nativeType: "AWS::ElastiCache::CacheCluster", ctor: "CacheCluster", identityAttr: "cluster_id",
|
|
144
|
+
fields: { cluster_id: "ClusterName", engine: "Engine", node_type: "CacheNodeType", num_cache_nodes: "NumCacheNodes", engine_version: "EngineVersion" }, tags: true },
|
|
145
145
|
{ tfType: "aws_elasticache_replication_group", tier: 2, nativeType: "AWS::ElastiCache::ReplicationGroup", ctor: "ReplicationGroup", identityAttr: "replication_group_id",
|
|
146
146
|
fields: { replication_group_id: "ReplicationGroupId", description: "ReplicationGroupDescription", node_type: "CacheNodeType", engine: "Engine" }, tags: true },
|
|
147
147
|
|
|
148
148
|
// ── DNS, CDN & API ──
|
|
149
|
-
{ tfType: "aws_route53_record", tier: 2, nativeType: "AWS::Route53::RecordSet", ctor: "RecordSet",
|
|
149
|
+
{ tfType: "aws_route53_record", tier: 2, nativeType: "AWS::Route53::RecordSet", ctor: "RecordSet", identityAttr: "name",
|
|
150
150
|
fields: { name: "Name", type: "Type", ttl: "TTL", zone_id: "HostedZoneId", records: "ResourceRecords" } },
|
|
151
151
|
{ tfType: "aws_api_gateway_rest_api", tier: 2, nativeType: "AWS::ApiGateway::RestApi", ctor: "RestApi", identityAttr: "name",
|
|
152
152
|
fields: { name: "Name", description: "Description" }, tags: true },
|
|
@@ -168,6 +168,97 @@ export const AWS_CARVE_TYPES: AwsCarveType[] = [
|
|
|
168
168
|
fields: { protocol: "Protocol", endpoint: "Endpoint", topic_arn: "TopicArn" } },
|
|
169
169
|
{ tfType: "aws_sfn_state_machine", tier: 3, nativeType: "AWS::StepFunctions::StateMachine", ctor: "StateMachine", identityAttr: "name",
|
|
170
170
|
fields: { name: "StateMachineName", role_arn: "RoleArn", type: "StateMachineType" }, tags: true },
|
|
171
|
+
{ tfType: "aws_sqs_queue_policy", tier: 2, nativeType: "AWS::SQS::QueuePolicy", ctor: "QueuePolicy",
|
|
172
|
+
fields: { policy: json("PolicyDocument"), queue_url: { prop: "Queues", transform: (v) => (v === undefined ? v : [v]) } } },
|
|
173
|
+
{ tfType: "aws_sns_topic_policy", tier: 2, nativeType: "AWS::SNS::TopicPolicy", ctor: "TopicPolicy",
|
|
174
|
+
fields: { policy: json("PolicyDocument"), arn: { prop: "Topics", transform: (v) => (v === undefined ? v : [v]) } } },
|
|
175
|
+
|
|
176
|
+
// ── Streaming ──
|
|
177
|
+
{ tfType: "aws_kinesis_stream", tier: 1, nativeType: "AWS::Kinesis::Stream", ctor: "KinesisStream", identityAttr: "name",
|
|
178
|
+
fields: { name: "Name", shard_count: "ShardCount", retention_period: "RetentionPeriodHours" }, tags: true },
|
|
179
|
+
{ tfType: "aws_kinesis_firehose_delivery_stream", tier: 3, nativeType: "AWS::KinesisFirehose::DeliveryStream", ctor: "DeliveryStream", identityAttr: "name",
|
|
180
|
+
fields: { name: "DeliveryStreamName" }, tags: true },
|
|
181
|
+
|
|
182
|
+
// ── EKS ──
|
|
183
|
+
{ tfType: "aws_eks_cluster", tier: 2, nativeType: "AWS::EKS::Cluster", ctor: "EKSCluster", identityAttr: "name",
|
|
184
|
+
fields: { name: "Name", role_arn: "RoleArn", version: "Version" }, tags: true },
|
|
185
|
+
{ tfType: "aws_eks_node_group", tier: 2, nativeType: "AWS::EKS::Nodegroup", ctor: "Nodegroup", identityAttr: "node_group_name",
|
|
186
|
+
fields: { cluster_name: "ClusterName", node_group_name: "NodegroupName", node_role_arn: "NodeRole", subnet_ids: "Subnets",
|
|
187
|
+
instance_types: "InstanceTypes", ami_type: "AmiType", capacity_type: "CapacityType", disk_size: "DiskSize" } },
|
|
188
|
+
|
|
189
|
+
// ── Lambda periphery ──
|
|
190
|
+
{ tfType: "aws_lambda_permission", tier: 1, nativeType: "AWS::Lambda::Permission", ctor: "Permission",
|
|
191
|
+
fields: { action: "Action", function_name: "FunctionName", principal: "Principal", source_arn: "SourceArn", source_account: "SourceAccount" } },
|
|
192
|
+
{ tfType: "aws_lambda_event_source_mapping", tier: 2, nativeType: "AWS::Lambda::EventSourceMapping", ctor: "EventSourceMapping",
|
|
193
|
+
fields: { event_source_arn: "EventSourceArn", function_name: "FunctionName", batch_size: "BatchSize", enabled: "Enabled",
|
|
194
|
+
starting_position: "StartingPosition", maximum_batching_window_in_seconds: "MaximumBatchingWindowInSeconds" } },
|
|
195
|
+
{ tfType: "aws_lambda_alias", tier: 1, nativeType: "AWS::Lambda::Alias", ctor: "LambdaAlias", identityAttr: "name",
|
|
196
|
+
fields: { name: "Name", function_name: "FunctionName", function_version: "FunctionVersion", description: "Description" } },
|
|
197
|
+
{ tfType: "aws_lambda_layer_version", tier: 2, nativeType: "AWS::Lambda::LayerVersion", ctor: "LayerVersion", identityAttr: "layer_name",
|
|
198
|
+
fields: { layer_name: "LayerName", description: "Description", compatible_runtimes: "CompatibleRuntimes",
|
|
199
|
+
compatible_architectures: "CompatibleArchitectures", license_info: "LicenseInfo" } },
|
|
200
|
+
|
|
201
|
+
// ── Networking (endpoints, routes, peering, flow logs) ──
|
|
202
|
+
{ tfType: "aws_vpc_endpoint", tier: 2, nativeType: "AWS::EC2::VPCEndpoint", ctor: "VPCEndpoint",
|
|
203
|
+
fields: { vpc_id: "VpcId", service_name: "ServiceName", vpc_endpoint_type: "VpcEndpointType", route_table_ids: "RouteTableIds",
|
|
204
|
+
subnet_ids: "SubnetIds", security_group_ids: "SecurityGroupIds", private_dns_enabled: "PrivateDnsEnabled", policy: json("PolicyDocument") }, tags: true },
|
|
205
|
+
{ tfType: "aws_route_table_association", tier: 2, nativeType: "AWS::EC2::SubnetRouteTableAssociation", ctor: "SubnetRouteTableAssociation",
|
|
206
|
+
fields: { subnet_id: "SubnetId", route_table_id: "RouteTableId" } },
|
|
207
|
+
{ tfType: "aws_route", tier: 2, nativeType: "AWS::EC2::Route", ctor: "EC2Route",
|
|
208
|
+
fields: { route_table_id: "RouteTableId", destination_cidr_block: "DestinationCidrBlock", destination_ipv6_cidr_block: "DestinationIpv6CidrBlock",
|
|
209
|
+
gateway_id: "GatewayId", nat_gateway_id: "NatGatewayId", transit_gateway_id: "TransitGatewayId",
|
|
210
|
+
vpc_peering_connection_id: "VpcPeeringConnectionId", network_interface_id: "NetworkInterfaceId" } },
|
|
211
|
+
{ tfType: "aws_egress_only_internet_gateway", tier: 1, nativeType: "AWS::EC2::EgressOnlyInternetGateway", ctor: "EgressOnlyInternetGateway",
|
|
212
|
+
fields: { vpc_id: "VpcId" } },
|
|
213
|
+
{ tfType: "aws_vpc_peering_connection", tier: 2, nativeType: "AWS::EC2::VPCPeeringConnection", ctor: "VPCPeeringConnection",
|
|
214
|
+
fields: { vpc_id: "VpcId", peer_vpc_id: "PeerVpcId", peer_owner_id: "PeerOwnerId", peer_region: "PeerRegion" }, tags: true },
|
|
215
|
+
{ tfType: "aws_flow_log", tier: 2, nativeType: "AWS::EC2::FlowLog", ctor: "FlowLog",
|
|
216
|
+
fields: { traffic_type: "TrafficType", log_destination: "LogDestination", log_destination_type: "LogDestinationType",
|
|
217
|
+
iam_role_arn: "DeliverLogsPermissionArn", log_group_name: "LogGroupName", max_aggregation_interval: "MaxAggregationInterval" }, tags: true },
|
|
218
|
+
{ tfType: "aws_key_pair", tier: 1, nativeType: "AWS::EC2::KeyPair", ctor: "KeyPair", identityAttr: "key_name",
|
|
219
|
+
fields: { key_name: "KeyName", public_key: "PublicKeyMaterial" }, tags: true },
|
|
220
|
+
|
|
221
|
+
// ── Audit & observability ──
|
|
222
|
+
{ tfType: "aws_cloudtrail", tier: 2, nativeType: "AWS::CloudTrail::Trail", ctor: "Trail", identityAttr: "name",
|
|
223
|
+
fields: { name: "TrailName", s3_bucket_name: "S3BucketName", s3_key_prefix: "S3KeyPrefix",
|
|
224
|
+
include_global_service_events: "IncludeGlobalServiceEvents", is_multi_region_trail: "IsMultiRegionTrail",
|
|
225
|
+
enable_logging: "IsLogging", enable_log_file_validation: "EnableLogFileValidation", kms_key_id: "KMSKeyId",
|
|
226
|
+
sns_topic_name: "SnsTopicName", cloud_watch_logs_group_arn: "CloudWatchLogsLogGroupArn", cloud_watch_logs_role_arn: "CloudWatchLogsRoleArn" }, tags: true },
|
|
227
|
+
{ tfType: "aws_cloudwatch_dashboard", tier: 1, nativeType: "AWS::CloudWatch::Dashboard", ctor: "CwDashboard", identityAttr: "dashboard_name",
|
|
228
|
+
fields: { dashboard_name: "DashboardName", dashboard_body: "DashboardBody" } },
|
|
229
|
+
|
|
230
|
+
// ── Databases & cache (groups, clusters) ──
|
|
231
|
+
{ tfType: "aws_elasticache_subnet_group", tier: 1, nativeType: "AWS::ElastiCache::SubnetGroup", ctor: "EcSubnetGroup", identityAttr: "name",
|
|
232
|
+
fields: { name: "CacheSubnetGroupName", description: "Description", subnet_ids: "SubnetIds" }, tags: true },
|
|
233
|
+
{ tfType: "aws_db_parameter_group", tier: 2, nativeType: "AWS::RDS::DBParameterGroup", ctor: "RDSDBParameterGroup", identityAttr: "name",
|
|
234
|
+
fields: { name: "DBParameterGroupName", family: "Family", description: "Description" }, tags: true },
|
|
235
|
+
{ tfType: "aws_rds_cluster", tier: 2, nativeType: "AWS::RDS::DBCluster", ctor: "DbCluster", identityAttr: "cluster_identifier",
|
|
236
|
+
fields: { cluster_identifier: "DBClusterIdentifier", engine: "Engine", engine_version: "EngineVersion", database_name: "DatabaseName",
|
|
237
|
+
master_username: "MasterUsername", backup_retention_period: "BackupRetentionPeriod", preferred_backup_window: "PreferredBackupWindow",
|
|
238
|
+
storage_encrypted: "StorageEncrypted", kms_key_id: "KmsKeyId", port: "Port" }, tags: true },
|
|
239
|
+
|
|
240
|
+
// ── Load balancing & API stages ──
|
|
241
|
+
{ tfType: "aws_lb_listener_rule", tier: 3, nativeType: "AWS::ElasticLoadBalancingV2::ListenerRule", ctor: "ListenerRule",
|
|
242
|
+
fields: { listener_arn: "ListenerArn", priority: "Priority" } },
|
|
243
|
+
{ tfType: "aws_api_gateway_stage", tier: 2, nativeType: "AWS::ApiGateway::Stage", ctor: "ApigwStage", identityAttr: "stage_name",
|
|
244
|
+
fields: { rest_api_id: "RestApiId", stage_name: "StageName", deployment_id: "DeploymentId", description: "Description" }, tags: true },
|
|
245
|
+
{ tfType: "aws_api_gateway_deployment", tier: 2, nativeType: "AWS::ApiGateway::Deployment", ctor: "ApigwDeployment",
|
|
246
|
+
fields: { rest_api_id: "RestApiId", description: "Description" } },
|
|
247
|
+
{ tfType: "aws_apigatewayv2_stage", tier: 2, nativeType: "AWS::ApiGatewayV2::Stage", ctor: "Apigwv2Stage", identityAttr: "name",
|
|
248
|
+
fields: { api_id: "ApiId", name: "StageName", auto_deploy: "AutoDeploy", description: "Description" } },
|
|
249
|
+
|
|
250
|
+
// ── Identity & scaling ──
|
|
251
|
+
{ tfType: "aws_cognito_user_pool", tier: 3, nativeType: "AWS::Cognito::UserPool", ctor: "UserPool", identityAttr: "name",
|
|
252
|
+
fields: { name: "UserPoolName", mfa_configuration: "MfaConfiguration", deletion_protection: "DeletionProtection" } },
|
|
253
|
+
{ tfType: "aws_appautoscaling_target", tier: 2, nativeType: "AWS::ApplicationAutoScaling::ScalableTarget", ctor: "ScalableTarget",
|
|
254
|
+
fields: { max_capacity: "MaxCapacity", min_capacity: "MinCapacity", resource_id: "ResourceId",
|
|
255
|
+
scalable_dimension: "ScalableDimension", service_namespace: "ServiceNamespace", role_arn: "RoleARN" } },
|
|
256
|
+
|
|
257
|
+
// ── EFS periphery ──
|
|
258
|
+
{ tfType: "aws_efs_mount_target", tier: 1, nativeType: "AWS::EFS::MountTarget", ctor: "EFSMountTarget",
|
|
259
|
+
fields: { file_system_id: "FileSystemId", subnet_id: "SubnetId", ip_address: "IpAddress", security_groups: "SecurityGroups" } },
|
|
260
|
+
{ tfType: "aws_efs_access_point", tier: 2, nativeType: "AWS::EFS::AccessPoint", ctor: "EFSAccessPoint",
|
|
261
|
+
fields: { file_system_id: "FileSystemId" } },
|
|
171
262
|
];
|
|
172
263
|
|
|
173
264
|
const BY_TYPE = new Map(AWS_CARVE_TYPES.map((t) => [t.tfType, t]));
|
|
@@ -140,6 +140,23 @@ describe("buildGraph", () => {
|
|
|
140
140
|
expect(g.edges).toEqual([]);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test("a dotted identity attr resolves through nested blocks (#998)", () => {
|
|
144
|
+
const tree: Hcl2JsonTree = {
|
|
145
|
+
resource: {
|
|
146
|
+
kubernetes_manifest: {
|
|
147
|
+
app_config: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config", namespace: "web" } } }],
|
|
148
|
+
templated: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "${var.name}" } } }],
|
|
149
|
+
},
|
|
150
|
+
aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const g = buildFixtureGraph(tree);
|
|
154
|
+
const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
|
|
155
|
+
expect(byAddr["kubernetes_manifest.app_config"].identity).toBe("app-config");
|
|
156
|
+
expect(byAddr["kubernetes_manifest.templated"].identity).toBeUndefined(); // interpolated
|
|
157
|
+
expect(byAddr["aws_s3_bucket.assets"].identity).toBe("myapp-assets-prod"); // flat attr unaffected
|
|
158
|
+
});
|
|
159
|
+
|
|
143
160
|
test("var / local references are not edges", () => {
|
|
144
161
|
const tree: Hcl2JsonTree = {
|
|
145
162
|
resource: {
|
package/src/terraform/graph.ts
CHANGED
|
@@ -140,11 +140,21 @@ function blockHasMeta(block: unknown, key: string): boolean {
|
|
|
140
140
|
return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The resource's physical name, if its identity attribute is a plain literal
|
|
145
|
+
* (not interpolated). A dotted `IDENTITY_ATTR` entry walks nested blocks —
|
|
146
|
+
* hcl2json renders a nested block as a one-element array, so arrays step
|
|
147
|
+
* through their first element (`manifest.metadata.name`).
|
|
148
|
+
*/
|
|
144
149
|
function literalIdentity(block: unknown, type: string): string | undefined {
|
|
145
150
|
const attr = IDENTITY_ATTR[type];
|
|
146
151
|
if (!attr || !block || typeof block !== "object") return undefined;
|
|
147
|
-
|
|
152
|
+
let value: unknown = block;
|
|
153
|
+
for (const segment of attr.split(".")) {
|
|
154
|
+
if (Array.isArray(value)) value = value[0];
|
|
155
|
+
if (!value || typeof value !== "object") return undefined;
|
|
156
|
+
value = (value as Record<string, unknown>)[segment];
|
|
157
|
+
}
|
|
148
158
|
if (typeof value !== "string" || value.includes("${")) return undefined;
|
|
149
159
|
return value;
|
|
150
160
|
}
|