@intentius/chant 0.52.1 → 0.53.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/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +52 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -63
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +38 -26
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +24 -2
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +55 -28
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +8 -1
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +29 -5
- package/src/cli/registry.ts +15 -0
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +65 -0
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +22 -167
- package/src/terraform/aws-resources.test.ts +55 -16
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +55 -90
- package/src/terraform/types.ts +6 -0
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
package/src/cdk/graph.ts
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a CDK cloud assembly into the same dependency graph the Terraform
|
|
3
|
+
* advisor scores (#1056).
|
|
4
|
+
*
|
|
5
|
+
* Two jobs, both exact rather than heuristic:
|
|
6
|
+
*
|
|
7
|
+
* **Grouping.** CloudFormation resources are the wrong unit to rank. One L2
|
|
8
|
+
* construct emits several of them — a `Function` emits a role, a policy and the
|
|
9
|
+
* function — and they carve together or not at all. Every resource carries its
|
|
10
|
+
* emitting construct in `Metadata["aws:cdk:path"]`, and `tree.json` says which
|
|
11
|
+
* ancestor of that path is the construct a person actually wrote. So resources
|
|
12
|
+
* fold up to the shallowest L2 (or L3) ancestor and that construct ranks once.
|
|
13
|
+
* This is the CDK analogue of Terraform sub-resources folding into their parent.
|
|
14
|
+
*
|
|
15
|
+
* **Edges.** `Ref`, `Fn::GetAtt` and `Fn::Sub` are intra-stack; `Fn::ImportValue`
|
|
16
|
+
* resolves through the exporting stack's `Outputs` to the construct that
|
|
17
|
+
* actually produces the value, so a cross-stack dependency lands as a real edge
|
|
18
|
+
* between two constructs in both directions rather than a dangling name. There
|
|
19
|
+
* is no expression AST to consult and no regex over HCL: the template is JSON,
|
|
20
|
+
* and an intrinsic is a JSON object with a known key.
|
|
21
|
+
*
|
|
22
|
+
* Pure — no filesystem, no CDK CLI. `assembly.ts` does the reading.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { indexTree } from "./assembly";
|
|
26
|
+
import { NESTED_STACK_TYPE, SCAFFOLDING_TYPES, isScaffoldingParameter, resolveCfnTier } from "./tier-map";
|
|
27
|
+
import type { CdkStack, CdkTreeNode, CfnResource, CloudAssembly } from "./types";
|
|
28
|
+
import type { CarveUnitMember, ScoreSignals } from "../terraform/score";
|
|
29
|
+
import type { TfEdge, TfGraph, TfNode } from "../terraform/types";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a construct is, from its jsii class name.
|
|
33
|
+
*
|
|
34
|
+
* - `l1` — a `Cfn*` class: one construct, one CloudFormation resource.
|
|
35
|
+
* - `l2` — a curated service construct (`aws-cdk-lib.aws_s3.Bucket`).
|
|
36
|
+
* - `l3` — a pattern or a hand-written grouping construct. A Composite
|
|
37
|
+
* candidate (#1000), ranked whole instead of leaf by leaf.
|
|
38
|
+
* - `nested` — a `NestedStack`: a template of its own.
|
|
39
|
+
* - `container` — App/Stage/Stack. Never a scoring unit; the ranking lives
|
|
40
|
+
* inside it.
|
|
41
|
+
* - `unknown` — no tree metadata to go on.
|
|
42
|
+
*
|
|
43
|
+
* A user's own construct class is not jsii-compiled, so tree metadata reports
|
|
44
|
+
* its nearest jsii ancestor: `constructs.Construct` for a grouping construct,
|
|
45
|
+
* `aws-cdk-lib.Stack` for a stack subclass. That is why stacks are recognized
|
|
46
|
+
* by their manifest artifact rather than by fqn — the fqn alone cannot tell a
|
|
47
|
+
* user's stack from a user's L3.
|
|
48
|
+
*/
|
|
49
|
+
export type ConstructLevel = "l1" | "l2" | "l3" | "nested" | "container" | "unknown";
|
|
50
|
+
|
|
51
|
+
const CONTAINER_FQNS = new Set([
|
|
52
|
+
"aws-cdk-lib.App",
|
|
53
|
+
"aws-cdk-lib.Stack",
|
|
54
|
+
"aws-cdk-lib.Stage",
|
|
55
|
+
"aws-cdk-lib.TreeMetadata",
|
|
56
|
+
"@aws-cdk/core.App",
|
|
57
|
+
"@aws-cdk/core.Stack",
|
|
58
|
+
"@aws-cdk/core.Stage",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const NESTED_STACK_FQNS = new Set(["aws-cdk-lib.NestedStack", "@aws-cdk/core.NestedStack"]);
|
|
62
|
+
|
|
63
|
+
/** The leaf ids CDK gives an L2's own L1 child. Not constructs anyone wrote. */
|
|
64
|
+
const IMPLICIT_LEAF_IDS = new Set(["Resource", "Default"]);
|
|
65
|
+
|
|
66
|
+
export function constructLevel(fqn: string | undefined): ConstructLevel {
|
|
67
|
+
if (!fqn) return "unknown";
|
|
68
|
+
if (CONTAINER_FQNS.has(fqn)) return "container";
|
|
69
|
+
if (NESTED_STACK_FQNS.has(fqn)) return "nested";
|
|
70
|
+
const cls = fqn.slice(fqn.lastIndexOf(".") + 1);
|
|
71
|
+
if (cls.startsWith("Cfn")) return "l1";
|
|
72
|
+
if (/^(aws-cdk-lib\.aws_|@aws-cdk\/aws-)/.test(fqn)) return "l2";
|
|
73
|
+
return "l3";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One CloudFormation resource, tied back to the construct that emitted it. */
|
|
77
|
+
interface ResourceEntry {
|
|
78
|
+
logicalId: string;
|
|
79
|
+
type: string;
|
|
80
|
+
stack: string;
|
|
81
|
+
/** Construct path from `aws:cdk:path`, or a synthetic one when absent. */
|
|
82
|
+
path: string;
|
|
83
|
+
resource: CfnResource;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A scoring unit: one construct plus every CloudFormation resource under it. */
|
|
87
|
+
interface Unit {
|
|
88
|
+
path: string;
|
|
89
|
+
kind: "resource" | "module";
|
|
90
|
+
level: ConstructLevel;
|
|
91
|
+
members: ResourceEntry[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A reference found inside a template, before it is resolved to a unit. */
|
|
95
|
+
interface RawRef {
|
|
96
|
+
/** Logical ID in the referring stack, or an export name when `crossStack`. */
|
|
97
|
+
target: string;
|
|
98
|
+
attr: string;
|
|
99
|
+
via?: string;
|
|
100
|
+
crossStack?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const AWS_PSEUDO = /^AWS::/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every reference in a JSON value, plus whether the value carries a construct
|
|
107
|
+
* that makes it dynamic (`Fn::If`, a template `Condition`). Walks the JSON
|
|
108
|
+
* looking for CloudFormation intrinsics — the whole grammar is `{"Fn::X": ...}`
|
|
109
|
+
* and `{"Ref": "..."}`, so there is nothing to tokenize.
|
|
110
|
+
*/
|
|
111
|
+
function refsIn(value: unknown, via: string | undefined, out: RawRef[], flags: { conditional: boolean }): void {
|
|
112
|
+
if (Array.isArray(value)) {
|
|
113
|
+
for (const item of value) refsIn(item, via, out, flags);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!value || typeof value !== "object") return;
|
|
117
|
+
const obj = value as Record<string, unknown>;
|
|
118
|
+
const keys = Object.keys(obj);
|
|
119
|
+
|
|
120
|
+
if (keys.length === 1) {
|
|
121
|
+
const [key] = keys;
|
|
122
|
+
const arg = obj[key];
|
|
123
|
+
if (key === "Ref" && typeof arg === "string") {
|
|
124
|
+
if (!AWS_PSEUDO.test(arg)) out.push({ target: arg, attr: "Ref", via });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key === "Fn::GetAtt") {
|
|
128
|
+
// Both encodings: ["LogicalId", "Attr"] and the "LogicalId.Attr" shorthand.
|
|
129
|
+
const parts = Array.isArray(arg) ? arg : typeof arg === "string" ? arg.split(".") : [];
|
|
130
|
+
const [id, ...rest] = parts;
|
|
131
|
+
if (typeof id === "string" && !AWS_PSEUDO.test(id)) {
|
|
132
|
+
out.push({ target: id, attr: rest.filter((p) => typeof p === "string").join(".") || "Ref", via });
|
|
133
|
+
}
|
|
134
|
+
// A GetAtt argument can itself be an intrinsic in the array form.
|
|
135
|
+
if (Array.isArray(arg)) for (const item of arg) if (typeof item !== "string") refsIn(item, via, out, flags);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (key === "Fn::ImportValue") {
|
|
139
|
+
if (typeof arg === "string") out.push({ target: arg, attr: "Export", via, crossStack: true });
|
|
140
|
+
else refsIn(arg, via, out, flags);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (key === "Fn::Sub") {
|
|
144
|
+
// `"...${LogicalId.Attr}..."` or `["...", { var: <expr> }]`.
|
|
145
|
+
const body = Array.isArray(arg) ? arg[0] : arg;
|
|
146
|
+
const vars = Array.isArray(arg) && arg[1] && typeof arg[1] === "object" ? (arg[1] as Record<string, unknown>) : {};
|
|
147
|
+
const declared = new Set(Object.keys(vars));
|
|
148
|
+
if (typeof body === "string") {
|
|
149
|
+
for (const match of body.matchAll(/\$\{([^}]+)\}/g)) {
|
|
150
|
+
const token = match[1].trim();
|
|
151
|
+
if (AWS_PSEUDO.test(token) || declared.has(token) || token.startsWith("!")) continue;
|
|
152
|
+
const dot = token.indexOf(".");
|
|
153
|
+
const id = dot > 0 ? token.slice(0, dot) : token;
|
|
154
|
+
out.push({ target: id, attr: dot > 0 ? token.slice(dot + 1) : "Ref", via });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const inner of Object.values(vars)) refsIn(inner, via, out, flags);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (key === "Fn::If") {
|
|
161
|
+
flags.conditional = true;
|
|
162
|
+
// The condition name is not a resource; its branches are.
|
|
163
|
+
if (Array.isArray(arg)) for (const branch of arg.slice(1)) refsIn(branch, via, out, flags);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [key, inner] of Object.entries(obj)) refsIn(inner, via ?? key, out, flags);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The construct path a resource declares, normalized (CDK writes a leading `/` in places). */
|
|
172
|
+
function declaredPath(resource: CfnResource): string | undefined {
|
|
173
|
+
const raw = resource.Metadata?.["aws:cdk:path"];
|
|
174
|
+
if (typeof raw !== "string" || raw.length === 0) return undefined;
|
|
175
|
+
return raw.replace(/^\/+/, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The property a CDK asset backs, when the resource is asset-backed at all.
|
|
180
|
+
* `""` means asset metadata is present but names no property; `undefined` means
|
|
181
|
+
* the resource carries no asset.
|
|
182
|
+
*/
|
|
183
|
+
function assetProperty(resource: CfnResource): string | undefined {
|
|
184
|
+
const metadata = resource.Metadata ?? {};
|
|
185
|
+
const prop = metadata["aws:asset:property"];
|
|
186
|
+
if (typeof prop === "string") return prop;
|
|
187
|
+
return Object.keys(metadata).some((k) => k.startsWith("aws:asset:")) ? "" : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Strip an implicit `Resource`/`Default` leaf: the construct a person wrote is
|
|
192
|
+
* the parent. Used only when tree metadata cannot say better.
|
|
193
|
+
*/
|
|
194
|
+
function ownerOf(path: string): string {
|
|
195
|
+
const segments = path.split("/");
|
|
196
|
+
if (segments.length > 1 && IMPLICIT_LEAF_IDS.has(segments[segments.length - 1])) segments.pop();
|
|
197
|
+
return segments.join("/");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The construct a resource ranks under: the shallowest ancestor inside the
|
|
202
|
+
* stack that is an L2, an L3 or a nested stack. With no such ancestor (a bare
|
|
203
|
+
* L1, or no tree metadata at all) it is the construct that owns the resource.
|
|
204
|
+
*/
|
|
205
|
+
function resolveUnit(
|
|
206
|
+
resourcePath: string,
|
|
207
|
+
stackPath: string,
|
|
208
|
+
tree: Map<string, CdkTreeNode>,
|
|
209
|
+
): { path: string; level: ConstructLevel } {
|
|
210
|
+
const inStack = resourcePath.startsWith(`${stackPath}/`) ? resourcePath.slice(stackPath.length + 1) : undefined;
|
|
211
|
+
if (inStack) {
|
|
212
|
+
const segments = inStack.split("/");
|
|
213
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
214
|
+
const candidate = `${stackPath}/${segments.slice(0, i).join("/")}`;
|
|
215
|
+
const level = constructLevel(tree.get(candidate)?.constructInfo?.fqn);
|
|
216
|
+
if (level === "l2" || level === "l3" || level === "nested") return { path: candidate, level };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const owner = ownerOf(resourcePath);
|
|
220
|
+
return { path: owner, level: constructLevel(tree.get(owner)?.constructInfo?.fqn) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every resource in a stack that describes infrastructure rather than synthesis. */
|
|
224
|
+
function realResources(stack: CdkStack): ResourceEntry[] {
|
|
225
|
+
const entries: ResourceEntry[] = [];
|
|
226
|
+
for (const [logicalId, resource] of Object.entries(stack.template.Resources ?? {})) {
|
|
227
|
+
const type = typeof resource?.Type === "string" ? resource.Type : "";
|
|
228
|
+
if (!type || SCAFFOLDING_TYPES.has(type)) continue;
|
|
229
|
+
entries.push({
|
|
230
|
+
logicalId,
|
|
231
|
+
type,
|
|
232
|
+
stack: stack.path,
|
|
233
|
+
path: declaredPath(resource) ?? `${stack.path}/${logicalId}`,
|
|
234
|
+
resource: resource ?? {},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return entries;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The unit's primary CloudFormation type — the one a carve would target. A
|
|
242
|
+
* construct's own resource sits at `<construct>/Resource` or `<construct>/Default`
|
|
243
|
+
* (or is the construct, for an L1); anything else is a supporting resource that
|
|
244
|
+
* carves along with it. With no obvious principal, the shallowest member wins,
|
|
245
|
+
* tie-broken by type so the answer does not depend on template key order.
|
|
246
|
+
*/
|
|
247
|
+
function primaryMember(unit: Unit): ResourceEntry | undefined {
|
|
248
|
+
const principal = unit.members.find(
|
|
249
|
+
(m) => m.path === unit.path || IMPLICIT_LEAF_IDS.has(m.path.slice(unit.path.length + 1)),
|
|
250
|
+
);
|
|
251
|
+
if (principal) return principal;
|
|
252
|
+
return [...unit.members].sort(
|
|
253
|
+
(a, b) => a.path.split("/").length - b.path.split("/").length || (a.type < b.type ? -1 : a.type > b.type ? 1 : 0),
|
|
254
|
+
)[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The assembly's dependency graph, plus the signals only a CDK reader can see. */
|
|
258
|
+
export interface CdkCarveGraph {
|
|
259
|
+
graph: TfGraph;
|
|
260
|
+
/** Per-node scoring signals, keyed by construct path. */
|
|
261
|
+
signals: Map<string, ScoreSignals>;
|
|
262
|
+
diagnostics: string[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Placeholder values `cdk synth` writes when a context lookup could not be
|
|
267
|
+
* answered. A template holding one describes an account that does not exist.
|
|
268
|
+
*/
|
|
269
|
+
const DUMMY_MARKERS = ["dummy-value-for-", "vpc-12345678", "dummy1a", "ami-1234", "s-12345"];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Is the assembly a faithful picture of an account, or a guess? Either the
|
|
273
|
+
* manifest admits an unresolved context query, or a template still carries the
|
|
274
|
+
* placeholder that query would have replaced.
|
|
275
|
+
*/
|
|
276
|
+
export function dummyAssemblyReason(assembly: CloudAssembly): string | undefined {
|
|
277
|
+
const missing = assembly.manifest.missing ?? [];
|
|
278
|
+
if (missing.length > 0) {
|
|
279
|
+
const providers = [...new Set(missing.map((m) => m.provider).filter((p): p is string => !!p))].sort();
|
|
280
|
+
return (
|
|
281
|
+
`The assembly was synthesized with ${missing.length} unresolved context lookup(s)` +
|
|
282
|
+
`${providers.length ? ` (${providers.join(", ")})` : ""}, so its templates hold placeholder values rather ` +
|
|
283
|
+
"than this account's. Prime the context and re-synthesize before advising."
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
for (const stack of assembly.stacks) {
|
|
287
|
+
const text = JSON.stringify(stack.template);
|
|
288
|
+
const hit = DUMMY_MARKERS.find((marker) => text.includes(marker));
|
|
289
|
+
if (hit) {
|
|
290
|
+
return (
|
|
291
|
+
`${stack.templateFile} still holds the context placeholder \`${hit}\`, so it describes a lookup that ` +
|
|
292
|
+
"never resolved. Prime the context and re-synthesize before advising."
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Build the graph and the per-construct signals from a read assembly.
|
|
301
|
+
*
|
|
302
|
+
* Every resource folds into a construct, every construct becomes one node, and
|
|
303
|
+
* every intrinsic becomes an edge between two nodes. Nothing is inferred: a
|
|
304
|
+
* reference with no resolvable target is dropped rather than drawn.
|
|
305
|
+
*/
|
|
306
|
+
export function buildCdkGraph(assembly: CloudAssembly): CdkCarveGraph {
|
|
307
|
+
const tree = indexTree(assembly.tree);
|
|
308
|
+
const diagnostics = [...assembly.diagnostics];
|
|
309
|
+
|
|
310
|
+
// Resources, grouped into units.
|
|
311
|
+
const units = new Map<string, Unit>();
|
|
312
|
+
/** logical ID → unit address, per stack. */
|
|
313
|
+
const unitOfLogicalId = new Map<string, Map<string, string>>();
|
|
314
|
+
const resourcesOf = new Map<string, ResourceEntry[]>();
|
|
315
|
+
|
|
316
|
+
for (const stack of assembly.stacks) {
|
|
317
|
+
const byLogicalId = new Map<string, string>();
|
|
318
|
+
unitOfLogicalId.set(stack.path, byLogicalId);
|
|
319
|
+
const entries = realResources(stack);
|
|
320
|
+
resourcesOf.set(stack.path, entries);
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
const { path, level } = resolveUnit(entry.path, stack.path, tree);
|
|
323
|
+
const unit = units.get(path) ?? { path, kind: level === "l3" ? "module" : "resource", level, members: [] };
|
|
324
|
+
unit.members.push(entry);
|
|
325
|
+
units.set(path, unit);
|
|
326
|
+
byLogicalId.set(entry.logicalId, path);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Cross-stack exports: export name → the refs the exporting stack's output
|
|
331
|
+
// resolves to, so an ImportValue lands on a real construct rather than a name.
|
|
332
|
+
interface ExportEntry {
|
|
333
|
+
stack: string;
|
|
334
|
+
refs: RawRef[];
|
|
335
|
+
}
|
|
336
|
+
const exports = new Map<string, ExportEntry>();
|
|
337
|
+
for (const stack of assembly.stacks) {
|
|
338
|
+
for (const output of Object.values(stack.template.Outputs ?? {})) {
|
|
339
|
+
const name = output?.Export?.Name;
|
|
340
|
+
if (typeof name !== "string") continue;
|
|
341
|
+
const refs: RawRef[] = [];
|
|
342
|
+
refsIn(output.Value, "Value", refs, { conditional: false });
|
|
343
|
+
exports.set(name, { stack: stack.path, refs });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Edges.
|
|
348
|
+
const rawEdges: TfEdge[] = [];
|
|
349
|
+
const consumedExports = new Set<string>();
|
|
350
|
+
const push = (from: string, to: string, attrs: string[], via: string[], crossStack?: boolean): void => {
|
|
351
|
+
if (!from || !to || from === to) return;
|
|
352
|
+
rawEdges.push({ from, to, attrs, via, ...(crossStack ? { crossStack: true } : {}) });
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** Resolve a raw ref to the unit(s) it names, following exports across stacks. */
|
|
356
|
+
const targetsOf = (ref: RawRef, stackPath: string): Array<{ address: string; crossStack: boolean }> => {
|
|
357
|
+
if (ref.crossStack) {
|
|
358
|
+
const entry = exports.get(ref.target);
|
|
359
|
+
if (!entry) return [];
|
|
360
|
+
consumedExports.add(ref.target);
|
|
361
|
+
const local = unitOfLogicalId.get(entry.stack);
|
|
362
|
+
return entry.refs
|
|
363
|
+
.map((inner) => local?.get(inner.target))
|
|
364
|
+
.filter((address): address is string => !!address)
|
|
365
|
+
.map((address) => ({ address, crossStack: true }));
|
|
366
|
+
}
|
|
367
|
+
const address = unitOfLogicalId.get(stackPath)?.get(ref.target);
|
|
368
|
+
return address ? [{ address, crossStack: false }] : [];
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const dynamicUnits = new Set<string>();
|
|
372
|
+
/** unit address → the property the asset backs, `""` when CDK did not name one. */
|
|
373
|
+
const assetUnits = new Map<string, string>();
|
|
374
|
+
|
|
375
|
+
for (const stack of assembly.stacks) {
|
|
376
|
+
const parameters = new Set(Object.keys(stack.template.Parameters ?? {}));
|
|
377
|
+
for (const entry of resourcesOf.get(stack.path) ?? []) {
|
|
378
|
+
const from = unitOfLogicalId.get(stack.path)?.get(entry.logicalId);
|
|
379
|
+
if (!from) continue;
|
|
380
|
+
const refs: RawRef[] = [];
|
|
381
|
+
const flags = { conditional: !!entry.resource.Condition };
|
|
382
|
+
refsIn(entry.resource.Properties, undefined, refs, flags);
|
|
383
|
+
|
|
384
|
+
const asset = assetProperty(entry.resource);
|
|
385
|
+
if (asset !== undefined && !assetUnits.get(from)) assetUnits.set(from, asset);
|
|
386
|
+
|
|
387
|
+
for (const ref of refs) {
|
|
388
|
+
if (!ref.crossStack && parameters.has(ref.target)) {
|
|
389
|
+
// A template parameter, not a resource. The synthesizer's own
|
|
390
|
+
// bootstrap/asset parameters say nothing about the author's design.
|
|
391
|
+
if (isScaffoldingParameter(ref.target)) {
|
|
392
|
+
if (ref.target.startsWith("AssetParameters") && !assetUnits.has(from)) assetUnits.set(from, "");
|
|
393
|
+
} else {
|
|
394
|
+
flags.conditional = true;
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
for (const target of targetsOf(ref, stack.path)) {
|
|
399
|
+
push(from, target.address, [ref.attr], ref.via ? [ref.via] : [], target.crossStack);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// `DependsOn` is an ordering dependency, and just as real a cut.
|
|
404
|
+
const dependsOn = entry.resource.DependsOn;
|
|
405
|
+
for (const id of Array.isArray(dependsOn) ? dependsOn : dependsOn ? [dependsOn] : []) {
|
|
406
|
+
if (typeof id !== "string") continue;
|
|
407
|
+
const address = unitOfLogicalId.get(stack.path)?.get(id);
|
|
408
|
+
if (address) push(from, address, ["DependsOn"], ["DependsOn"]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (flags.conditional) dynamicUnits.add(from);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// `Outputs` blocks reference without being nodes — the CloudFormation twin of
|
|
416
|
+
// a Terraform `output` (#1638). An output whose export something imports is
|
|
417
|
+
// skipped: the direct cross-stack edge already stands for that cut, and
|
|
418
|
+
// counting both would charge one dependency twice.
|
|
419
|
+
const outputEdges: TfEdge[] = [];
|
|
420
|
+
for (const stack of assembly.stacks) {
|
|
421
|
+
for (const [outputName, output] of Object.entries(stack.template.Outputs ?? {})) {
|
|
422
|
+
const exportName = output?.Export?.Name;
|
|
423
|
+
if (typeof exportName === "string" && consumedExports.has(exportName)) continue;
|
|
424
|
+
const refs: RawRef[] = [];
|
|
425
|
+
refsIn(output?.Value, "Value", refs, { conditional: false });
|
|
426
|
+
for (const ref of refs) {
|
|
427
|
+
for (const target of targetsOf(ref, stack.path)) {
|
|
428
|
+
outputEdges.push({
|
|
429
|
+
from: `output.${stack.path}.${outputName}`,
|
|
430
|
+
to: target.address,
|
|
431
|
+
attrs: [ref.attr],
|
|
432
|
+
via: ["Value"],
|
|
433
|
+
fromKind: "output",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Nodes.
|
|
441
|
+
const nodes: TfNode[] = [];
|
|
442
|
+
const signals = new Map<string, ScoreSignals>();
|
|
443
|
+
const dummyReason = dummyAssemblyReason(assembly);
|
|
444
|
+
|
|
445
|
+
for (const unit of units.values()) {
|
|
446
|
+
const primary = primaryMember(unit);
|
|
447
|
+
const type = unit.kind === "module" ? undefined : primary?.type;
|
|
448
|
+
const nested = unit.level === "nested" || primary?.type === NESTED_STACK_TYPE;
|
|
449
|
+
const assetProp = assetUnits.get(unit.path);
|
|
450
|
+
const stacks = [...new Set(unit.members.map((m) => m.stack))].sort();
|
|
451
|
+
|
|
452
|
+
nodes.push({
|
|
453
|
+
address: unit.path,
|
|
454
|
+
kind: unit.kind,
|
|
455
|
+
...(type ? { type } : {}),
|
|
456
|
+
name: unit.path.split("/").pop() ?? unit.path,
|
|
457
|
+
instances: 1,
|
|
458
|
+
hasDynamic: dynamicUnits.has(unit.path),
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const members: CarveUnitMember[] = [...unit.members]
|
|
462
|
+
.sort((a, b) => (a.logicalId < b.logicalId ? -1 : a.logicalId > b.logicalId ? 1 : 0))
|
|
463
|
+
.map((m) => ({ id: m.logicalId, type: m.type, stack: m.stack, path: m.path }));
|
|
464
|
+
|
|
465
|
+
const notes: string[] = [];
|
|
466
|
+
if (unit.kind === "module") {
|
|
467
|
+
notes.push(
|
|
468
|
+
"L3 construct subtree — a Composite candidate (#1000). Its leaves rank here as one unit rather than separately.",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (assetProp !== undefined) {
|
|
472
|
+
notes.push(
|
|
473
|
+
`Asset-backed${assetProp ? ` (${assetProp})` : ""} — carving it moves the CDK bundling too, not just the resource.`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (unit.members.length > 1) {
|
|
477
|
+
notes.push(`${unit.members.length} CloudFormation resources fold into this construct.`);
|
|
478
|
+
}
|
|
479
|
+
if (stacks.length > 1) notes.push(`Spans ${stacks.length} stacks: ${stacks.join(", ")}.`);
|
|
480
|
+
if (type && !nested && resolveCfnTier(type) === null) {
|
|
481
|
+
notes.push(`No chant AWS lexicon target for ${type}.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const disqualified = dummyReason
|
|
485
|
+
? dummyReason
|
|
486
|
+
: nested
|
|
487
|
+
? "Nested stack — its resources live in a template this advisor does not descend into, so carving it means " +
|
|
488
|
+
"carving that whole template. Advise the nested assembly on its own."
|
|
489
|
+
: undefined;
|
|
490
|
+
|
|
491
|
+
signals.set(unit.path, {
|
|
492
|
+
...(assetProp !== undefined && !disqualified ? { penalties: { asset: -10 } } : {}),
|
|
493
|
+
...(notes.length ? { notes } : {}),
|
|
494
|
+
members,
|
|
495
|
+
...(disqualified ? { disqualified } : {}),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (dummyReason) diagnostics.push(dummyReason);
|
|
500
|
+
|
|
501
|
+
// Merge duplicate edges (same pair), then order by code point so the report
|
|
502
|
+
// is stable across platforms and template key order.
|
|
503
|
+
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
504
|
+
const merged = new Map<string, TfEdge>();
|
|
505
|
+
for (const edge of [...rawEdges, ...outputEdges]) {
|
|
506
|
+
const key = `${edge.from}|${edge.to}|${edge.fromKind ?? ""}`;
|
|
507
|
+
const prev = merged.get(key);
|
|
508
|
+
if (prev) {
|
|
509
|
+
prev.attrs = [...new Set([...prev.attrs, ...edge.attrs])].sort(cmp);
|
|
510
|
+
prev.via = [...new Set([...prev.via, ...edge.via])].sort(cmp);
|
|
511
|
+
if (edge.crossStack) prev.crossStack = true;
|
|
512
|
+
} else {
|
|
513
|
+
merged.set(key, { ...edge, attrs: [...new Set(edge.attrs)].sort(cmp), via: [...new Set(edge.via)].sort(cmp) });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
graph: {
|
|
519
|
+
nodes: nodes.sort((a, b) => cmp(a.address, b.address)),
|
|
520
|
+
edges: [...merged.values()].sort((a, b) => cmp(a.from, b.from) || cmp(a.to, b.to)),
|
|
521
|
+
},
|
|
522
|
+
signals,
|
|
523
|
+
diagnostics,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFormation-type → native-spec tier for the CDK carve advisor (#1056).
|
|
3
|
+
*
|
|
4
|
+
* The Terraform path's hardest layer — translating `aws_s3_bucket` into
|
|
5
|
+
* `AWS::S3::Bucket` and a tier — does not exist here: a synthesized template is
|
|
6
|
+
* already in CloudFormation type space, which is the space
|
|
7
|
+
* `AWS_CARVE_TYPES.nativeType` is written in. So this is the same table read
|
|
8
|
+
* from the other end, not a second table to keep in step. Adding an AWS type to
|
|
9
|
+
* the carve-out table lights it up for both advisors at once.
|
|
10
|
+
*
|
|
11
|
+
* `mapsTo` is the CloudFormation type itself, because that identity is the
|
|
12
|
+
* point: chant's AWS lexicon constructors take CloudFormation PascalCase props,
|
|
13
|
+
* so a CDK resource needs no translation to become one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { AWS_CARVE_TYPES } from "../terraform/aws-resources";
|
|
17
|
+
import type { TierInfo } from "../terraform/tier-map";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CloudFormation type → tier. Two Terraform types can name one CloudFormation
|
|
21
|
+
* type (`aws_lb` and `aws_alb` are the same load balancer); the first entry
|
|
22
|
+
* wins, and since aliases share a tier by construction there is nothing to
|
|
23
|
+
* reconcile.
|
|
24
|
+
*/
|
|
25
|
+
export const CFN_TIER_MAP: Record<string, TierInfo> = (() => {
|
|
26
|
+
const map: Record<string, TierInfo> = {};
|
|
27
|
+
for (const entry of AWS_CARVE_TYPES) {
|
|
28
|
+
if (map[entry.nativeType]) continue;
|
|
29
|
+
map[entry.nativeType] = { tier: entry.tier, mapsTo: entry.nativeType };
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
/** The chant AWS lexicon constructor a CloudFormation type would carve into. */
|
|
35
|
+
export const CFN_CTOR: Record<string, string> = (() => {
|
|
36
|
+
const map: Record<string, string> = {};
|
|
37
|
+
for (const entry of AWS_CARVE_TYPES) map[entry.nativeType] ??= entry.ctor;
|
|
38
|
+
return map;
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
export function resolveCfnTier(cfnType: string): TierInfo | null {
|
|
42
|
+
return CFN_TIER_MAP[cfnType] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A nested stack is an assembly of its own: its resources live in a separate
|
|
47
|
+
* template that this advisor does not descend into, so carving the
|
|
48
|
+
* `AWS::CloudFormation::Stack` resource means carving everything under it.
|
|
49
|
+
*/
|
|
50
|
+
export const NESTED_STACK_TYPE = "AWS::CloudFormation::Stack";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* CloudFormation resources CDK synthesizes for its own bookkeeping. They
|
|
54
|
+
* describe the synthesis, not the infrastructure, so they never appear in the
|
|
55
|
+
* ranking.
|
|
56
|
+
*/
|
|
57
|
+
export const SCAFFOLDING_TYPES = new Set(["AWS::CDK::Metadata"]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Template parameters CDK adds for bootstrap and asset plumbing. A reference to
|
|
61
|
+
* one is not a sign the resource is parameterized by its author — it is the
|
|
62
|
+
* synthesizer talking to itself — so these do not mark a construct dynamic.
|
|
63
|
+
*/
|
|
64
|
+
export function isScaffoldingParameter(name: string): boolean {
|
|
65
|
+
return (
|
|
66
|
+
name === "BootstrapVersion" ||
|
|
67
|
+
name === "CdkBootstrapVersion" ||
|
|
68
|
+
name.startsWith("AssetParameters") ||
|
|
69
|
+
name.startsWith("BootstrapVersion")
|
|
70
|
+
);
|
|
71
|
+
}
|