@intentius/chant 0.52.2 → 0.53.1
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/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/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/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/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- 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 +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -66
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +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 +30 -35
- 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/package.json +1 -1
- 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/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.ts +9 -5
- 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.ts +28 -24
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/handlers/carve-emit.ts +8 -3
- 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/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +11 -13
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +38 -10
- package/src/cli/registry.ts +9 -1
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +53 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- 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 +19 -167
- package/src/terraform/aws-resources.test.ts +38 -22
- package/src/terraform/aws-resources.ts +1 -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 +45 -107
- package/src/terraform/types.ts +6 -0
|
@@ -159,3 +159,134 @@ describe("adoptFromState", () => {
|
|
|
159
159
|
expect(adoptFromState({ type: "random_pet", name: "x", attributes: { id: "abc" } })).toBeNull();
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `kubernetes_manifest` adoption (#999). The Terraform type names no kind: the
|
|
165
|
+
* target is whatever the manifest body says it is, so these assertions are
|
|
166
|
+
* about reading the body, not about a per-type property mapping.
|
|
167
|
+
*/
|
|
168
|
+
describe("adoptFromState — kubernetes_manifest", () => {
|
|
169
|
+
const configMap: StateResource = {
|
|
170
|
+
type: "kubernetes_manifest",
|
|
171
|
+
name: "app_config",
|
|
172
|
+
attributes: {
|
|
173
|
+
manifest: {
|
|
174
|
+
apiVersion: "v1",
|
|
175
|
+
kind: "ConfigMap",
|
|
176
|
+
metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
|
|
177
|
+
data: { LOG_LEVEL: "info" },
|
|
178
|
+
},
|
|
179
|
+
field_manager: null,
|
|
180
|
+
wait: null,
|
|
181
|
+
computed_fields: ["metadata.labels", "metadata.annotations"],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
test("adopts the manifest body verbatim through the lexicon escape hatch", () => {
|
|
186
|
+
const out = adoptFromState(configMap)!;
|
|
187
|
+
expect(out.fileName).toBe("app_config.ts");
|
|
188
|
+
expect(out.mapped).toBe(true);
|
|
189
|
+
// The kind came out of the body, not out of the Terraform type.
|
|
190
|
+
expect(out.nativeType).toBe("v1 ConfigMap");
|
|
191
|
+
|
|
192
|
+
expect(out.content).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
|
|
193
|
+
expect(out.content).toContain("export const app_config = k8sManifest({");
|
|
194
|
+
expect(out.content).toContain('apiVersion: "v1"');
|
|
195
|
+
expect(out.content).toContain('kind: "ConfigMap"');
|
|
196
|
+
expect(out.content).toContain('namespace: "web"');
|
|
197
|
+
// A key that is not an identifier is quoted, so the emitted source parses.
|
|
198
|
+
expect(out.content).toContain('"app.kubernetes.io/name": "web"');
|
|
199
|
+
expect(out.content).toContain('LOG_LEVEL: "info"');
|
|
200
|
+
// Provider knobs are reported, never smuggled into the object.
|
|
201
|
+
expect(out.content).toContain("Provider-behaviour attributes not part of the object: computed_fields");
|
|
202
|
+
expect(out.content).not.toContain("computed_fields:");
|
|
203
|
+
expect(out.folded).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a CRD body needs no per-type mapping — one rule covers every kind", () => {
|
|
207
|
+
const out = adoptFromState({
|
|
208
|
+
type: "kubernetes_manifest",
|
|
209
|
+
name: "cert",
|
|
210
|
+
attributes: {
|
|
211
|
+
manifest: {
|
|
212
|
+
apiVersion: "cert-manager.io/v1",
|
|
213
|
+
kind: "Certificate",
|
|
214
|
+
metadata: { name: "web-tls", namespace: "web" },
|
|
215
|
+
spec: {
|
|
216
|
+
secretName: "web-tls",
|
|
217
|
+
dnsNames: ["web.example.com"],
|
|
218
|
+
issuerRef: { name: "letsencrypt", kind: "ClusterIssuer" },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
})!;
|
|
223
|
+
expect(out.nativeType).toBe("cert-manager.io/v1 Certificate");
|
|
224
|
+
expect(out.content).toContain('apiVersion: "cert-manager.io/v1"');
|
|
225
|
+
expect(out.content).toContain('kind: "Certificate"');
|
|
226
|
+
expect(out.content).toContain('secretName: "web-tls"');
|
|
227
|
+
expect(out.content).toContain('"web.example.com",');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("falls back to the computed object, without the fields the API server owns", () => {
|
|
231
|
+
const out = adoptFromState({
|
|
232
|
+
type: "kubernetes_manifest",
|
|
233
|
+
name: "ns",
|
|
234
|
+
attributes: {
|
|
235
|
+
object: {
|
|
236
|
+
apiVersion: "v1",
|
|
237
|
+
kind: "Namespace",
|
|
238
|
+
metadata: {
|
|
239
|
+
name: "web",
|
|
240
|
+
uid: "8f1e",
|
|
241
|
+
resourceVersion: "4711",
|
|
242
|
+
creationTimestamp: "2026-01-01T00:00:00Z",
|
|
243
|
+
labels: {},
|
|
244
|
+
},
|
|
245
|
+
status: { phase: "Active" },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
})!;
|
|
249
|
+
expect(out.content).toContain('name: "web"');
|
|
250
|
+
expect(out.content).not.toContain("resourceVersion");
|
|
251
|
+
expect(out.content).not.toContain("creationTimestamp");
|
|
252
|
+
expect(out.content).not.toContain("uid");
|
|
253
|
+
expect(out.content).not.toContain("status");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a deferred input is reported in the source, not substituted", () => {
|
|
257
|
+
// The survivor value sits inside the body, so the boundary report names the
|
|
258
|
+
// enclosing `manifest` attribute and state resolves it to an object —
|
|
259
|
+
// there is no scalar for a `params.<name>` reference to replace.
|
|
260
|
+
const params: DeferredParam[] = [
|
|
261
|
+
{ name: "manifest", tfAttr: "manifest", survivor: "aws_eks_cluster.main", attrs: ["endpoint"] },
|
|
262
|
+
];
|
|
263
|
+
const out = adoptFromState(configMap, params)!;
|
|
264
|
+
expect(out.parameterized).toEqual([]);
|
|
265
|
+
expect(out.content).toContain("Deferred deploy-time input: manifest read aws_eks_cluster.main.endpoint");
|
|
266
|
+
expect(out.content).toContain('declared as build param "manifest" in chant.config.ts');
|
|
267
|
+
expect(out.content).not.toContain("params.");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a name Terraform allows but TypeScript does not becomes an identifier", () => {
|
|
271
|
+
const out = adoptFromState({
|
|
272
|
+
type: "kubernetes_manifest",
|
|
273
|
+
name: "app-config",
|
|
274
|
+
attributes: { manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config" } } },
|
|
275
|
+
})!;
|
|
276
|
+
expect(out.fileName).toBe("app_config.ts");
|
|
277
|
+
expect(out.content).toContain("export const app_config = k8sManifest({");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a body without apiVersion/kind is refused rather than emitted headless", () => {
|
|
281
|
+
expect(
|
|
282
|
+
adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: { manifest: { metadata: { name: "x" } } } }),
|
|
283
|
+
).toBeNull();
|
|
284
|
+
expect(adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: {} })).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a typed kubernetes resource is ranked but not adoptable", () => {
|
|
288
|
+
expect(canAdoptFromState("kubernetes_manifest")).toBe(true);
|
|
289
|
+
expect(canAdoptFromState("kubernetes_config_map")).toBe(false);
|
|
290
|
+
expect(adoptFromState({ type: "kubernetes_config_map", name: "c", attributes: {} })).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -4,75 +4,23 @@
|
|
|
4
4
|
* a TF resource is created through the provider API, not CloudFormation, so it
|
|
5
5
|
* is not in any CFN stack — but its resolved attributes ARE in the state file.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* without a mapping are preserved in a reference comment, never dropped.
|
|
13
|
-
*
|
|
14
|
-
* Deferred outbound inputs (#998): an attribute whose value the Terraform
|
|
15
|
-
* source read from a survivor (`vpc_id = aws_vpc.main.id`) is not a fact of
|
|
16
|
-
* the carved resource — it is a deploy-time input. Those props are emitted as
|
|
17
|
-
* `params.<name>` references instead of frozen literals, with the state's
|
|
18
|
-
* resolved value as the declared default (see `carve-emit.ts`'s scaffold).
|
|
7
|
+
* The mapping itself belongs to the carve provider that owns the Terraform type
|
|
8
|
+
* (`carve-provider.ts`, #2016) — aws maps snake_case provider attributes to
|
|
9
|
+
* CloudFormation PascalCase props over the AWS carve-out table. This module is
|
|
10
|
+
* the dispatch: it resolves the provider and hands off. A new provider adds a
|
|
11
|
+
* file under `providers/`, never an edit here.
|
|
19
12
|
*/
|
|
20
13
|
|
|
21
|
-
import {
|
|
22
|
-
AWS_LEXICON_IMPORT,
|
|
23
|
-
awsCarveType,
|
|
24
|
-
applyAwsMapper,
|
|
25
|
-
applyAwsFold,
|
|
26
|
-
unmappedFoldAttrs,
|
|
27
|
-
} from "./aws-resources";
|
|
28
14
|
import { canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
15
|
+
import { resolveEmitProvider, type AdoptedSource, type DeferredParam } from "./carve-provider";
|
|
29
16
|
import type { StateResource } from "./state";
|
|
30
17
|
|
|
31
|
-
|
|
32
|
-
export
|
|
18
|
+
export { PARAMS_IMPORT } from "./emit-source";
|
|
19
|
+
export type { AdoptedSource, DeferredParam, FoldedContribution } from "./carve-provider";
|
|
33
20
|
|
|
34
21
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* in `carve-emit.ts`; consumed here (source substitution) and by the scaffold
|
|
38
|
-
* (`chant.config.ts` `buildParams` declaration).
|
|
39
|
-
*/
|
|
40
|
-
export interface DeferredParam {
|
|
41
|
-
/** Build-parameter name, source-referenceable (`params.<name>`). */
|
|
42
|
-
name: string;
|
|
43
|
-
/** The carved resource's own Terraform attribute the value enters through. */
|
|
44
|
-
tfAttr: string;
|
|
45
|
-
/** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
|
|
46
|
-
survivor: string;
|
|
47
|
-
/** Survivor attribute(s) read, e.g. `["id"]`. */
|
|
48
|
-
attrs: string[];
|
|
49
|
-
/** The state-resolved value — the parameter's declared default. */
|
|
50
|
-
default?: string | number | boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** What one folded sub-resource contributed to the parent's emitted props (#1637). */
|
|
54
|
-
export interface FoldedContribution {
|
|
55
|
-
/** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
|
|
56
|
-
address: string;
|
|
57
|
-
/** CFN properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
|
|
58
|
-
props: string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface AdoptedSource {
|
|
62
|
-
fileName: string;
|
|
63
|
-
content: string;
|
|
64
|
-
/** True when at least one attribute was mapped to a native prop. */
|
|
65
|
-
mapped: boolean;
|
|
66
|
-
nativeType: string;
|
|
67
|
-
/** Deferred params actually substituted into the emitted props (#998). */
|
|
68
|
-
parameterized: string[];
|
|
69
|
-
/** Folded sub-resources and the props each one joined into the parent (#1637). */
|
|
70
|
-
folded: FoldedContribution[];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Is this Terraform type adoptable from state (has a native constructor)? The
|
|
75
|
-
* same table gates the live path, so both are the shared `canCarveEmit`.
|
|
22
|
+
* Is this Terraform type adoptable from state (a provider emits it)? The same
|
|
23
|
+
* gate serves the live path, so both are the shared `canCarveEmit`.
|
|
76
24
|
*/
|
|
77
25
|
export function canAdoptFromState(tfType: string): boolean {
|
|
78
26
|
return canCarveEmit(tfType);
|
|
@@ -83,117 +31,21 @@ export function supportedStateAdoptionTypes(): string[] {
|
|
|
83
31
|
return carveEmitTypes();
|
|
84
32
|
}
|
|
85
33
|
|
|
86
|
-
/** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
|
|
87
|
-
class ParamRef {
|
|
88
|
-
constructor(
|
|
89
|
-
readonly name: string,
|
|
90
|
-
readonly type: "string" | "number" | "boolean",
|
|
91
|
-
) {}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
34
|
/**
|
|
95
|
-
* Render chant source for a state-adopted resource
|
|
96
|
-
*
|
|
97
|
-
*
|
|
35
|
+
* Render chant source for a state-adopted resource, through the provider that
|
|
36
|
+
* owns its Terraform type. `params` carries the deferred outbound inputs the
|
|
37
|
+
* boundary report found (#998) and `folded` the carve set's sub-resources read
|
|
38
|
+
* from the same state file (#1637); what a provider does with them is its own
|
|
39
|
+
* business, and both are reported back on the {@link AdoptedSource}.
|
|
98
40
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the value came from a survivor, so it stays overridable per build.
|
|
102
|
-
*
|
|
103
|
-
* `folded` carries the carve set's sub-resources (`aws_s3_bucket_versioning`
|
|
104
|
-
* and friends), read from the same state file. Their mappable attributes join
|
|
105
|
-
* the parent's props (#1637) — a fold that only announced itself and left the
|
|
106
|
-
* emitted resource without the versioning or public-access block the Terraform
|
|
107
|
-
* declared was a silent loss of configuration. A sub-resource's setting wins
|
|
108
|
-
* over the parent's own legacy in-state block: it is the one the config
|
|
109
|
-
* actually declares.
|
|
41
|
+
* Null means no registered provider emits this type — the caller has already
|
|
42
|
+
* refused it at the emit gate, so this is the belt-and-braces path.
|
|
110
43
|
*/
|
|
111
44
|
export function adoptFromState(
|
|
112
45
|
resource: StateResource,
|
|
113
46
|
params: DeferredParam[] = [],
|
|
114
47
|
folded: StateResource[] = [],
|
|
115
48
|
): AdoptedSource | null {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
|
|
120
|
-
|
|
121
|
-
const contributions: FoldedContribution[] = [];
|
|
122
|
-
const foldedUnmapped: Record<string, Record<string, unknown>> = {};
|
|
123
|
-
for (const sub of folded) {
|
|
124
|
-
const address = `${sub.type}.${sub.name}`;
|
|
125
|
-
const fold = applyAwsFold(sub.type, sub.attributes);
|
|
126
|
-
// No fold mapping for this sub-resource type: it still carves with the
|
|
127
|
-
// parent, so report its attributes rather than dropping them on the floor.
|
|
128
|
-
const rest = fold ? fold.unmapped : unmappedFoldAttrs(sub.attributes);
|
|
129
|
-
if (fold) Object.assign(props, fold.props);
|
|
130
|
-
if (Object.keys(rest).length) foldedUnmapped[address] = rest;
|
|
131
|
-
contributions.push({ address, props: Object.keys(fold?.props ?? {}) });
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Substitute deferred inputs: only plain (untransformed) field mappings can
|
|
135
|
-
// carry a parameter reference — a transform ran against the literal at emit
|
|
136
|
-
// time and cannot re-run at build. Everything else keeps the state literal.
|
|
137
|
-
const parameterized: string[] = [];
|
|
138
|
-
for (const param of params) {
|
|
139
|
-
const spec = entry.fields[param.tfAttr];
|
|
140
|
-
if (typeof spec !== "string" || !(spec in props)) continue;
|
|
141
|
-
const type = typeof param.default === "number" ? "number" : typeof param.default === "boolean" ? "boolean" : "string";
|
|
142
|
-
props[spec] = new ParamRef(param.name, type);
|
|
143
|
-
parameterized.push(param.name);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const unmapped: Record<string, unknown> = {};
|
|
147
|
-
for (const [k, v] of Object.entries(resource.attributes)) {
|
|
148
|
-
if (!mappedKeys.includes(k)) unmapped[k] = v;
|
|
149
|
-
}
|
|
150
|
-
// A folded sub-resource's leftovers are keyed by its address, so the comment
|
|
151
|
-
// says which block a stray attribute came from.
|
|
152
|
-
for (const [address, attrs] of Object.entries(foldedUnmapped)) unmapped[address] = attrs;
|
|
153
|
-
|
|
154
|
-
const L: string[] = [];
|
|
155
|
-
L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
|
|
156
|
-
L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
|
|
157
|
-
for (const c of contributions) {
|
|
158
|
-
const into = c.props.length ? c.props.join(", ") : "nothing mappable — see the reference comment below";
|
|
159
|
-
L.push(`// Folded in ${c.address} -> ${into}`);
|
|
160
|
-
}
|
|
161
|
-
L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
|
|
162
|
-
if (parameterized.length) {
|
|
163
|
-
L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
|
|
164
|
-
L.push(`import { params } from "${PARAMS_IMPORT}";`);
|
|
165
|
-
}
|
|
166
|
-
L.push("");
|
|
167
|
-
L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
|
|
168
|
-
if (Object.keys(unmapped).length) {
|
|
169
|
-
L.push("");
|
|
170
|
-
L.push("/* Unmapped Terraform attributes (reconcile to native props before building):");
|
|
171
|
-
L.push(JSON.stringify(unmapped, null, 2));
|
|
172
|
-
L.push("*/");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
fileName: `${resource.name}.ts`,
|
|
177
|
-
content: L.join("\n") + "\n",
|
|
178
|
-
mapped: Object.keys(props).length > 0,
|
|
179
|
-
nativeType: entry.nativeType,
|
|
180
|
-
parameterized,
|
|
181
|
-
folded: contributions,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Render a JS object literal with stable key order and given indent. */
|
|
186
|
-
function renderObject(obj: Record<string, unknown>, indent: number): string {
|
|
187
|
-
const keys = Object.keys(obj);
|
|
188
|
-
if (keys.length === 0) return "{}";
|
|
189
|
-
const pad = " ".repeat(indent);
|
|
190
|
-
const body = keys
|
|
191
|
-
.map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
|
|
192
|
-
.join("\n");
|
|
193
|
-
return `{\n${body}\n}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
|
|
197
|
-
function renderValue(value: unknown): string {
|
|
198
|
-
return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
|
|
49
|
+
const provider = resolveEmitProvider(resource.type);
|
|
50
|
+
return provider?.adopt?.(resource, params, folded) ?? null;
|
|
199
51
|
}
|
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
|
|
3
|
-
import {
|
|
3
|
+
import { tierMap, foldParentOf, identityAttrOf, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
4
4
|
import { canAdoptFromState } from "./adopt-state";
|
|
5
5
|
|
|
6
6
|
describe("AWS carve-out table", () => {
|
|
7
7
|
test("advise and emit cover exactly the same AWS types (no cliff)", () => {
|
|
8
8
|
// Every AWS type the advisor ranks (tier map) must be emittable, and vice
|
|
9
9
|
// versa. Non-AWS entries (kubernetes_manifest) are excluded.
|
|
10
|
-
const tierAws = Object.keys(
|
|
10
|
+
const tierAws = Object.keys(tierMap()).filter((t) => t.startsWith("aws_")).sort();
|
|
11
11
|
const emitAws = AWS_CARVE_TYPES.map((t) => t.tfType).sort();
|
|
12
12
|
expect(tierAws).toEqual(emitAws);
|
|
13
13
|
for (const t of emitAws) expect(canAdoptFromState(t)).toBe(true);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
test("the emit gate is narrower than the tier map, which also ranks k8s (#2015)", () => {
|
|
17
|
-
// `carve advise` bands kubernetes
|
|
18
|
-
// gate both emit paths share must
|
|
19
|
-
|
|
17
|
+
// `carve advise` bands every typed kubernetes resource; emit has a path for
|
|
18
|
+
// kubernetes_manifest only (#999), so the gate both emit paths share must
|
|
19
|
+
// refuse the rest.
|
|
20
|
+
expect(tierMap().kubernetes_config_map).toBeDefined();
|
|
20
21
|
expect(canCarveEmit("kubernetes_config_map")).toBe(false);
|
|
21
|
-
expect(canCarveEmit("kubernetes_manifest")).toBe(
|
|
22
|
+
expect(canCarveEmit("kubernetes_manifest")).toBe(true);
|
|
22
23
|
expect(canCarveEmit("random_pet")).toBe(false);
|
|
23
24
|
expect(canCarveEmit("aws_s3_bucket")).toBe(true);
|
|
24
|
-
expect(carveEmitTypes()).toEqual(AWS_CARVE_TYPES.map((t) => t.tfType).sort());
|
|
25
|
+
expect(carveEmitTypes()).toEqual([...AWS_CARVE_TYPES.map((t) => t.tfType), "kubernetes_manifest"].sort());
|
|
25
26
|
// State adoption and live emit gate on the same table.
|
|
26
27
|
for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test("canBridge rejects a dotted identity attribute (#2015)", () => {
|
|
30
31
|
// A data source body is flat `attr = value`; a dotted path is not HCL.
|
|
31
|
-
expect(
|
|
32
|
+
expect(identityAttrOf("kubernetes_manifest")).toContain(".");
|
|
32
33
|
expect(canBridge("kubernetes_manifest")).toBe(false);
|
|
33
34
|
expect(canBridge("aws_s3_bucket")).toBe(true);
|
|
34
35
|
// No identity entry at all is fine — the bridge writes a TODO comment.
|
|
@@ -126,7 +127,7 @@ describe("applyAwsMapper", () => {
|
|
|
126
127
|
describe("folded sub-resource mappers (#1637)", () => {
|
|
127
128
|
test("every fold mapper is for a type that actually folds", () => {
|
|
128
129
|
for (const tfType of Object.keys(AWS_FOLD_MAPPERS)) {
|
|
129
|
-
expect(
|
|
130
|
+
expect(foldParentOf(tfType)).toBeDefined();
|
|
130
131
|
}
|
|
131
132
|
});
|
|
132
133
|
|
|
@@ -226,12 +227,27 @@ describe("folded sub-resource mappers (#1637)", () => {
|
|
|
226
227
|
|
|
227
228
|
describe("tier + identity coverage maps (#998)", () => {
|
|
228
229
|
test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
// kubernetes_manifest has no fixed kind — the body names it — so the family
|
|
231
|
+
// is all `mapsTo` can say. Every other entry is a real three-part entity
|
|
232
|
+
// type from the generated operation surface: `k8s:Namespace` was in no
|
|
233
|
+
// table chant addresses by (#999).
|
|
234
|
+
expect(tierMap().kubernetes_manifest).toEqual({ tier: 1, mapsTo: "K8s::*" });
|
|
235
|
+
for (const [t, entity] of Object.entries({
|
|
236
|
+
kubernetes_deployment: "K8s::Apps::Deployment",
|
|
237
|
+
kubernetes_config_map: "K8s::Core::ConfigMap",
|
|
238
|
+
kubernetes_service: "K8s::Core::Service",
|
|
239
|
+
kubernetes_namespace: "K8s::Core::Namespace",
|
|
240
|
+
kubernetes_priority_class: "K8s::Scheduling::PriorityClass",
|
|
241
|
+
kubernetes_cron_job: "K8s::Batch::CronJob",
|
|
242
|
+
})) {
|
|
243
|
+
expect(tierMap()[t]).toEqual({ tier: 2, mapsTo: entity });
|
|
244
|
+
expect(tierMap()[`${t}_v1`]).toEqual(tierMap()[t]);
|
|
233
245
|
}
|
|
234
|
-
|
|
246
|
+
for (const [t, info] of Object.entries(tierMap())) {
|
|
247
|
+
if (!t.startsWith("kubernetes_") || t === "kubernetes_manifest") continue;
|
|
248
|
+
expect(info.mapsTo).toMatch(/^K8s::[A-Za-z]+::[A-Za-z]+$/);
|
|
249
|
+
}
|
|
250
|
+
expect(tierMap().kubernetes_horizontal_pod_autoscaler_v2).toEqual(tierMap().kubernetes_horizontal_pod_autoscaler);
|
|
235
251
|
});
|
|
236
252
|
|
|
237
253
|
test("S3 and ECR sub-resources fold into their parent", () => {
|
|
@@ -239,17 +255,17 @@ describe("tier + identity coverage maps (#998)", () => {
|
|
|
239
255
|
"aws_s3_bucket_website_configuration", "aws_s3_bucket_cors_configuration", "aws_s3_bucket_logging",
|
|
240
256
|
"aws_s3_bucket_ownership_controls", "aws_s3_bucket_notification",
|
|
241
257
|
]) {
|
|
242
|
-
expect(
|
|
258
|
+
expect(foldParentOf(t)).toBe("aws_s3_bucket");
|
|
243
259
|
}
|
|
244
|
-
expect(
|
|
245
|
-
expect(
|
|
260
|
+
expect(foldParentOf("aws_ecr_lifecycle_policy")).toBe("aws_ecr_repository");
|
|
261
|
+
expect(foldParentOf("aws_ecr_repository_policy")).toBe("aws_ecr_repository");
|
|
246
262
|
});
|
|
247
263
|
|
|
248
264
|
test("identity attrs cover the expanded types, including a dotted non-AWS path", () => {
|
|
249
|
-
expect(
|
|
250
|
-
expect(
|
|
251
|
-
expect(
|
|
252
|
-
expect(
|
|
253
|
-
expect(
|
|
265
|
+
expect(identityAttrOf("aws_elasticache_cluster")).toBe("cluster_id");
|
|
266
|
+
expect(identityAttrOf("aws_ecs_task_definition")).toBe("family");
|
|
267
|
+
expect(identityAttrOf("aws_route53_record")).toBe("name");
|
|
268
|
+
expect(identityAttrOf("aws_eks_node_group")).toBe("node_group_name");
|
|
269
|
+
expect(identityAttrOf("kubernetes_manifest")).toBe("manifest.metadata.name");
|
|
254
270
|
});
|
|
255
271
|
});
|
|
@@ -322,7 +322,7 @@ export function awsCarveType(tfType: string): AwsCarveType | undefined {
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
* How a folded sub-resource (see `
|
|
325
|
+
* How a folded sub-resource (see the provider's `foldsInto`) joins its parent's emitted
|
|
326
326
|
* properties (#1637). Terraform splits configuration the CloudFormation shape
|
|
327
327
|
* keeps inside the parent resource, so the fold is not just a carve-set
|
|
328
328
|
* membership claim: the sub-resource's attributes have to land in the parent's
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readdirSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
carveEmitLexicons,
|
|
6
|
+
carveProviders,
|
|
7
|
+
registerCarveProvider,
|
|
8
|
+
resolveCarveProvider,
|
|
9
|
+
resolveEmitProvider,
|
|
10
|
+
type CarveProvider,
|
|
11
|
+
} from "./carve-provider";
|
|
12
|
+
import { canBridge, canCarveEmit, carveEmitTypes, foldParentOf, identityAttrOf, resolveTier, tierMap } from "./tier-map";
|
|
13
|
+
import { adoptFromState } from "./adopt-state";
|
|
14
|
+
|
|
15
|
+
const fake: CarveProvider = {
|
|
16
|
+
name: "test-fake",
|
|
17
|
+
tfTypePrefixes: ["fake_"],
|
|
18
|
+
lexicon: "fakelex",
|
|
19
|
+
tiers: { fake_widget: { tier: 1, mapsTo: "fake:Widget" }, fake_gizmo: { tier: 3, mapsTo: "fake:Gizmo" } },
|
|
20
|
+
identityAttrs: { fake_widget: "widget_name", fake_nested: "spec.metadata.name" },
|
|
21
|
+
foldsInto: { fake_widget_setting: "fake_widget" },
|
|
22
|
+
emitTypes: ["fake_widget"],
|
|
23
|
+
adopt: (resource) => ({
|
|
24
|
+
fileName: `${resource.name}.ts`,
|
|
25
|
+
content: `// fake: ${resource.type}.${resource.name}\n`,
|
|
26
|
+
mapped: true,
|
|
27
|
+
nativeType: "fake:Widget",
|
|
28
|
+
parameterized: [],
|
|
29
|
+
folded: [],
|
|
30
|
+
}),
|
|
31
|
+
liveSelectorType: (tfType) => (tfType === "fake_widget" ? "fake:Widget" : undefined),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("carve provider registry (#2016)", () => {
|
|
35
|
+
test("core ships aws, gcp and kubernetes; aws and kubernetes emit", () => {
|
|
36
|
+
expect(carveProviders().map((p) => p.name)).toEqual(["aws", "gcp", "kubernetes"]);
|
|
37
|
+
expect(carveEmitLexicons()).toEqual(["aws", "k8s"]);
|
|
38
|
+
expect(resolveCarveProvider("aws_s3_bucket")?.name).toBe("aws");
|
|
39
|
+
expect(resolveCarveProvider("google_storage_bucket")?.name).toBe("gcp");
|
|
40
|
+
expect(resolveCarveProvider("kubernetes_config_map")?.name).toBe("kubernetes");
|
|
41
|
+
expect(resolveCarveProvider("random_pet")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a provider owns every type under its prefix for advise, only its emitTypes for emit", () => {
|
|
45
|
+
// kubernetes ranks every typed provider resource and emits only
|
|
46
|
+
// kubernetes_manifest; aws emits everything it ranks.
|
|
47
|
+
expect(resolveCarveProvider("kubernetes_config_map")?.name).toBe("kubernetes");
|
|
48
|
+
expect(resolveEmitProvider("kubernetes_config_map")).toBeUndefined();
|
|
49
|
+
expect(resolveEmitProvider("kubernetes_manifest")?.name).toBe("kubernetes");
|
|
50
|
+
expect(resolveEmitProvider("aws_s3_bucket")?.name).toBe("aws");
|
|
51
|
+
// A type under aws_ that the carve table does not list is ranked by nobody
|
|
52
|
+
// and emitted by nobody, but still resolves to the aws provider.
|
|
53
|
+
expect(resolveCarveProvider("aws_glue_job")?.name).toBe("aws");
|
|
54
|
+
expect(resolveEmitProvider("aws_glue_job")).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("registering a provider widens advise, emit and bridge without touching them", () => {
|
|
58
|
+
expect(resolveTier("fake_widget")).toBeNull();
|
|
59
|
+
const dispose = registerCarveProvider(fake);
|
|
60
|
+
try {
|
|
61
|
+
// Tier map, identity, folds — all derived live from the registry.
|
|
62
|
+
expect(resolveTier("fake_widget")).toEqual({ tier: 1, mapsTo: "fake:Widget" });
|
|
63
|
+
expect(tierMap().fake_gizmo).toEqual({ tier: 3, mapsTo: "fake:Gizmo" });
|
|
64
|
+
expect(identityAttrOf("fake_widget")).toBe("widget_name");
|
|
65
|
+
expect(foldParentOf("fake_widget_setting")).toBe("fake_widget");
|
|
66
|
+
// The bridge gate reads the same identity attrs: a dotted path is refused.
|
|
67
|
+
expect(canBridge("fake_widget")).toBe(true);
|
|
68
|
+
expect(canBridge("fake_nested")).toBe(false);
|
|
69
|
+
// Emit gate + the user-facing type list.
|
|
70
|
+
expect(canCarveEmit("fake_widget")).toBe(true);
|
|
71
|
+
expect(canCarveEmit("fake_gizmo")).toBe(false);
|
|
72
|
+
expect(carveEmitTypes()).toContain("fake_widget");
|
|
73
|
+
expect(carveEmitLexicons()).toEqual(["aws", "fakelex", "k8s"]);
|
|
74
|
+
// adopt-state dispatches to the provider, with no knowledge of it.
|
|
75
|
+
const adopted = adoptFromState({ type: "fake_widget", name: "w", attributes: { widget_name: "w1" } });
|
|
76
|
+
expect(adopted).toMatchObject({ fileName: "w.ts", nativeType: "fake:Widget" });
|
|
77
|
+
} finally {
|
|
78
|
+
dispose();
|
|
79
|
+
}
|
|
80
|
+
// Unregistering leaves the registry exactly as it was found.
|
|
81
|
+
expect(resolveTier("fake_widget")).toBeNull();
|
|
82
|
+
expect(carveEmitTypes()).not.toContain("fake_widget");
|
|
83
|
+
expect(carveEmitLexicons()).toEqual(["aws", "k8s"]);
|
|
84
|
+
expect(adoptFromState({ type: "fake_widget", name: "w", attributes: {} })).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the AWS entries are untouched by a second provider being present", () => {
|
|
88
|
+
const before = carveEmitTypes().filter((t) => t.startsWith("aws_"));
|
|
89
|
+
const dispose = registerCarveProvider(fake);
|
|
90
|
+
try {
|
|
91
|
+
expect(carveEmitTypes().filter((t) => t.startsWith("aws_"))).toEqual(before);
|
|
92
|
+
expect(resolveTier("aws_s3_bucket")).toEqual({ tier: 1, mapsTo: "AWS::S3::Bucket" });
|
|
93
|
+
expect(resolveEmitProvider("aws_s3_bucket")?.name).toBe("aws");
|
|
94
|
+
} finally {
|
|
95
|
+
dispose();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the longest prefix wins, so a narrower provider can claim a subset", () => {
|
|
100
|
+
const narrow: CarveProvider = {
|
|
101
|
+
name: "test-narrow",
|
|
102
|
+
tfTypePrefixes: ["fake_widget"],
|
|
103
|
+
lexicon: "narrowlex",
|
|
104
|
+
tiers: { fake_widget: { tier: 2, mapsTo: "narrow:Widget" } },
|
|
105
|
+
};
|
|
106
|
+
const disposeWide = registerCarveProvider(fake);
|
|
107
|
+
const disposeNarrow = registerCarveProvider(narrow);
|
|
108
|
+
try {
|
|
109
|
+
expect(resolveCarveProvider("fake_widget")?.name).toBe("test-narrow");
|
|
110
|
+
expect(resolveCarveProvider("fake_gizmo")?.name).toBe("test-fake");
|
|
111
|
+
} finally {
|
|
112
|
+
disposeNarrow();
|
|
113
|
+
disposeWide();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("emitTypes without an adopter is refused — it would accept on --env and fail on --state", () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
registerCarveProvider({
|
|
120
|
+
name: "test-no-adopt",
|
|
121
|
+
tfTypePrefixes: ["broken_"],
|
|
122
|
+
lexicon: "brokenlex",
|
|
123
|
+
tiers: {},
|
|
124
|
+
emitTypes: ["broken_thing"],
|
|
125
|
+
}),
|
|
126
|
+
).toThrow(/emitTypes but no adopt/);
|
|
127
|
+
expect(resolveCarveProvider("broken_thing")).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a provider claiming no prefix is refused — nothing would ever resolve to it", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
registerCarveProvider({ name: "test-no-prefix", tfTypePrefixes: [], lexicon: "x", tiers: {} }),
|
|
133
|
+
).toThrow(/claims no Terraform type prefix/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the seam pulls in no plugin loader and no lexicon package, so advise stays free", () => {
|
|
137
|
+
// The reason the registry lives in core rather than behind a LexiconPlugin
|
|
138
|
+
// capability: `carve advise` runs against a foreign Terraform tree with no
|
|
139
|
+
// chant project to name lexicons, and `carve emit --state` is offline.
|
|
140
|
+
// Either would become plugin-dependent if a provider module imported one.
|
|
141
|
+
const root = join(import.meta.dirname, ".");
|
|
142
|
+
const files = [
|
|
143
|
+
...readdirSync(root).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts")).map((f) => join(root, f)),
|
|
144
|
+
...readdirSync(join(root, "providers")).map((f) => join(root, "providers", f)),
|
|
145
|
+
];
|
|
146
|
+
expect(files.length).toBeGreaterThan(10); // not a vacuous pass
|
|
147
|
+
const offenders: string[] = [];
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
for (const [, spec] of readFileSync(file, "utf-8").matchAll(/\bfrom\s+"([^"]+)"/g)) {
|
|
150
|
+
if (spec.includes("chant-lexicon-") || spec.includes("cli/plugins")) offenders.push(`${file}: ${spec}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
expect(offenders).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|