@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The carve provider seam (#2016).
|
|
3
|
+
*
|
|
4
|
+
* `carve advise|emit|bridge` used to reach into the AWS carve table directly:
|
|
5
|
+
* the tier map derived from it, the emit gate was `awsCarveType(t) !== undefined`,
|
|
6
|
+
* the adopter called `applyAwsMapper`, and the emitted project's lexicon was a
|
|
7
|
+
* constant. A second provider — Kubernetes emit (#999), GCP (#2017) — had
|
|
8
|
+
* nowhere to land except a parallel path beside the AWS one.
|
|
9
|
+
*
|
|
10
|
+
* A provider owns one or more Terraform type prefixes and answers everything
|
|
11
|
+
* the carve commands ask about the types under them: how each ranks against a
|
|
12
|
+
* native spec, which HCL attribute carries the physical name, which
|
|
13
|
+
* sub-resources fold into a parent, which types emit can produce source for,
|
|
14
|
+
* and how to adopt one from `.tfstate`.
|
|
15
|
+
*
|
|
16
|
+
* ── Why the registry lives in core, not behind a `LexiconPlugin` capability ──
|
|
17
|
+
*
|
|
18
|
+
* `carve advise` runs against a Terraform directory that is not a chant project:
|
|
19
|
+
* there is no `chant.config.ts` naming lexicons, so there is nothing to load
|
|
20
|
+
* plugins from, and `carve emit --state` is deliberately offline (see the
|
|
21
|
+
* handler). Behind a plugin capability both would have to load plugins to learn
|
|
22
|
+
* that a type ranks tier 1. Providers are also Terraform-provider knowledge
|
|
23
|
+
* (`terraform-provider-aws` attribute names), which belongs beside the HCL
|
|
24
|
+
* parser rather than inside a lexicon.
|
|
25
|
+
*
|
|
26
|
+
* `registerCarveProvider` is exported, so a plugin that wants to contribute a
|
|
27
|
+
* provider at runtime still can — the seam does not foreclose it, it just does
|
|
28
|
+
* not require plugin loading for the paths that run without one.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { BUILTIN_CARVE_PROVIDERS } from "./providers";
|
|
32
|
+
import type { StateResource } from "./state";
|
|
33
|
+
|
|
34
|
+
export interface TierInfo {
|
|
35
|
+
tier: 1 | 2 | 3;
|
|
36
|
+
/** The native spec type a carve would target, for the report. */
|
|
37
|
+
mapsTo: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One deferred outbound input turned into a build parameter (#998). Derived
|
|
42
|
+
* from the boundary report's outbound edges + the state's resolved attributes
|
|
43
|
+
* in `carve-emit.ts`; consumed by a provider's `adopt` (source substitution)
|
|
44
|
+
* and by the scaffold (`chant.config.ts` `buildParams` declaration).
|
|
45
|
+
*/
|
|
46
|
+
export interface DeferredParam {
|
|
47
|
+
/** Build-parameter name, source-referenceable (`params.<name>`). */
|
|
48
|
+
name: string;
|
|
49
|
+
/** The carved resource's own Terraform attribute the value enters through. */
|
|
50
|
+
tfAttr: string;
|
|
51
|
+
/** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
|
|
52
|
+
survivor: string;
|
|
53
|
+
/** Survivor attribute(s) read, e.g. `["id"]`. */
|
|
54
|
+
attrs: string[];
|
|
55
|
+
/** The state-resolved value — the parameter's declared default. */
|
|
56
|
+
default?: string | number | boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What one folded sub-resource contributed to the parent's emitted props (#1637). */
|
|
60
|
+
export interface FoldedContribution {
|
|
61
|
+
/** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
|
|
62
|
+
address: string;
|
|
63
|
+
/** Native properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
|
|
64
|
+
props: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AdoptedSource {
|
|
68
|
+
fileName: string;
|
|
69
|
+
content: string;
|
|
70
|
+
/** True when at least one attribute was mapped to a native prop. */
|
|
71
|
+
mapped: boolean;
|
|
72
|
+
nativeType: string;
|
|
73
|
+
/** Deferred params actually substituted into the emitted props (#998). */
|
|
74
|
+
parameterized: string[];
|
|
75
|
+
/** Folded sub-resources and the props each one joined into the parent (#1637). */
|
|
76
|
+
folded: FoldedContribution[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CarveProvider {
|
|
80
|
+
/** Registry id, unique. Registering a second provider under it replaces the first. */
|
|
81
|
+
readonly name: string;
|
|
82
|
+
/**
|
|
83
|
+
* Terraform type prefixes this provider owns, e.g. `["aws_"]`. Resolution
|
|
84
|
+
* takes the longest match, so a narrower provider can claim a subset of a
|
|
85
|
+
* wider one's prefix.
|
|
86
|
+
*/
|
|
87
|
+
readonly tfTypePrefixes: readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* The chant lexicon emitted source targets — the scaffolded project's
|
|
90
|
+
* dependency and `--lexicon` flag, and the lexicon the live import runs
|
|
91
|
+
* against.
|
|
92
|
+
*/
|
|
93
|
+
readonly lexicon: string;
|
|
94
|
+
/** Terraform type → native tier, contributed to the advisor's tier map. */
|
|
95
|
+
readonly tiers: Readonly<Record<string, TierInfo>>;
|
|
96
|
+
/**
|
|
97
|
+
* Terraform type → the HCL attribute carrying the physical name. A dotted
|
|
98
|
+
* entry is a path into nested blocks (`manifest.metadata.name`); the bridge
|
|
99
|
+
* refuses those, since a data-source body is flat `attr = value`.
|
|
100
|
+
*/
|
|
101
|
+
readonly identityAttrs?: Readonly<Record<string, string>>;
|
|
102
|
+
/**
|
|
103
|
+
* Sub-resource Terraform type → parent type, for types Terraform splits out
|
|
104
|
+
* of the resource the native spec keeps them in.
|
|
105
|
+
*/
|
|
106
|
+
readonly foldsInto?: Readonly<Record<string, string>>;
|
|
107
|
+
/**
|
|
108
|
+
* Terraform types `carve emit` can produce chant source for — narrower than
|
|
109
|
+
* `tiers`, which advise also ranks types no emit path can adopt. Absent or
|
|
110
|
+
* empty means advise-only. Declaring types here obliges the provider to
|
|
111
|
+
* implement {@link adopt}: both emit paths gate on the same list (#2015), so
|
|
112
|
+
* a type the live path accepts must be adoptable from state too.
|
|
113
|
+
*/
|
|
114
|
+
readonly emitTypes?: readonly string[];
|
|
115
|
+
/** Adopt a resource from Terraform state into chant source. */
|
|
116
|
+
adopt?(resource: StateResource, params: DeferredParam[], folded: StateResource[]): AdoptedSource | null;
|
|
117
|
+
/**
|
|
118
|
+
* The native selector type the live (cloud→code) import filters on, e.g.
|
|
119
|
+
* `AWS::S3::Bucket`. Undefined when this type has no live adoption path —
|
|
120
|
+
* `carve emit --env` refuses it rather than importing the wrong thing.
|
|
121
|
+
*/
|
|
122
|
+
liveSelectorType?(tfType: string): string | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A provider declaring emit types without an adopter would accept a type on
|
|
127
|
+
* `--env` and fail it on `--state`, the advise↔emit cliff #2015 closed.
|
|
128
|
+
*/
|
|
129
|
+
function validate(provider: CarveProvider): void {
|
|
130
|
+
if (provider.emitTypes?.length && !provider.adopt) {
|
|
131
|
+
throw new Error(`Carve provider "${provider.name}" declares emitTypes but no adopt() — the --state path would refuse what --env accepts.`);
|
|
132
|
+
}
|
|
133
|
+
if (!provider.tfTypePrefixes.length) {
|
|
134
|
+
throw new Error(`Carve provider "${provider.name}" claims no Terraform type prefix, so no type would ever resolve to it.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const providers: CarveProvider[] = [...BUILTIN_CARVE_PROVIDERS];
|
|
139
|
+
for (const p of providers) validate(p);
|
|
140
|
+
|
|
141
|
+
/** Bumped on every registry mutation, so the derived indexes rebuild. */
|
|
142
|
+
let revision = 0;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Register a carve provider. Returns a function that removes it again, so a
|
|
146
|
+
* test (or a plugin unloading) leaves the registry as it found it.
|
|
147
|
+
*/
|
|
148
|
+
export function registerCarveProvider(provider: CarveProvider): () => void {
|
|
149
|
+
validate(provider);
|
|
150
|
+
const existing = providers.findIndex((p) => p.name === provider.name);
|
|
151
|
+
if (existing >= 0) providers.splice(existing, 1);
|
|
152
|
+
providers.push(provider);
|
|
153
|
+
revision++;
|
|
154
|
+
return () => {
|
|
155
|
+
const at = providers.indexOf(provider);
|
|
156
|
+
if (at < 0) return;
|
|
157
|
+
providers.splice(at, 1);
|
|
158
|
+
revision++;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every registered provider, in registration order. */
|
|
163
|
+
export function carveProviders(): readonly CarveProvider[] {
|
|
164
|
+
return providers;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RegistryIndex {
|
|
168
|
+
/** Prefixes longest-first, so the narrowest claim wins. */
|
|
169
|
+
prefixes: Array<{ prefix: string; provider: CarveProvider }>;
|
|
170
|
+
tiers: Record<string, TierInfo>;
|
|
171
|
+
identityAttrs: Record<string, string>;
|
|
172
|
+
foldsInto: Record<string, string>;
|
|
173
|
+
/** Terraform type → the provider that emits it. */
|
|
174
|
+
emitters: Map<string, CarveProvider>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let cached: { revision: number; index: RegistryIndex } | null = null;
|
|
178
|
+
|
|
179
|
+
function index(): RegistryIndex {
|
|
180
|
+
if (cached && cached.revision === revision) return cached.index;
|
|
181
|
+
const built: RegistryIndex = {
|
|
182
|
+
prefixes: [],
|
|
183
|
+
tiers: {},
|
|
184
|
+
identityAttrs: {},
|
|
185
|
+
foldsInto: {},
|
|
186
|
+
emitters: new Map(),
|
|
187
|
+
};
|
|
188
|
+
for (const provider of providers) {
|
|
189
|
+
for (const prefix of provider.tfTypePrefixes) built.prefixes.push({ prefix, provider });
|
|
190
|
+
Object.assign(built.tiers, provider.tiers);
|
|
191
|
+
if (provider.identityAttrs) Object.assign(built.identityAttrs, provider.identityAttrs);
|
|
192
|
+
if (provider.foldsInto) Object.assign(built.foldsInto, provider.foldsInto);
|
|
193
|
+
for (const tfType of provider.emitTypes ?? []) built.emitters.set(tfType, provider);
|
|
194
|
+
}
|
|
195
|
+
built.prefixes.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
196
|
+
cached = { revision, index: built };
|
|
197
|
+
return built;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The provider owning this Terraform type, by longest prefix match. */
|
|
201
|
+
export function resolveCarveProvider(tfType: string): CarveProvider | undefined {
|
|
202
|
+
return index().prefixes.find((p) => tfType.startsWith(p.prefix))?.provider;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The provider that can emit chant source for this Terraform type. Narrower
|
|
207
|
+
* than {@link resolveCarveProvider}: a provider owns every type under its
|
|
208
|
+
* prefix for advise, and only the ones on its `emitTypes` list for emit.
|
|
209
|
+
*/
|
|
210
|
+
export function resolveEmitProvider(tfType: string): CarveProvider | undefined {
|
|
211
|
+
return index().emitters.get(tfType);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every Terraform type any registered provider can emit, sorted, for user-facing hints. */
|
|
215
|
+
export function carveEmitTypes(): string[] {
|
|
216
|
+
return [...index().emitters.keys()].sort();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The lexicons emit can adopt into, sorted — what the CLI loads plugins for. */
|
|
220
|
+
export function carveEmitLexicons(): string[] {
|
|
221
|
+
return [...new Set([...index().emitters.values()].map((p) => p.lexicon))].sort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Terraform resource type → native tier, merged over every provider. Read-only: it is the cached index. */
|
|
225
|
+
export function carveTierMap(): Readonly<Record<string, TierInfo>> {
|
|
226
|
+
return index().tiers;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The HCL attribute carrying this type's physical name, if its provider declares one. */
|
|
230
|
+
export function carveIdentityAttr(tfType: string): string | undefined {
|
|
231
|
+
return index().identityAttrs[tfType];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The parent Terraform type this sub-resource folds into, if any. */
|
|
235
|
+
export function carveFoldParent(tfType: string): string | undefined {
|
|
236
|
+
return index().foldsInto[tfType];
|
|
237
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
|
-
import { resolveCarveSet, boundaryReport } from "./carve";
|
|
3
|
+
import { resolveCarveSet, boundaryReport, dedupeEdges, type BoundaryEdge } from "./carve";
|
|
4
4
|
import type { Hcl2JsonTree } from "./types";
|
|
5
5
|
|
|
6
6
|
// #197 worked example: a bucket a Lambda reads, with a versioning sub-resource.
|
|
@@ -131,3 +131,57 @@ describe("boundaryReport", () => {
|
|
|
131
131
|
expect(boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
// #2025: dedupeEdges joins survivor/carved with a separator no Terraform
|
|
136
|
+
// address can contain. A survivor/carved pair with no separator at all would
|
|
137
|
+
// collide across the two edges below (both concatenate to the same string),
|
|
138
|
+
// so this pins the separator's job, not just the byte that used to be a NUL.
|
|
139
|
+
describe("dedupeEdges", () => {
|
|
140
|
+
const edge = (over: Partial<BoundaryEdge>): BoundaryEdge => ({
|
|
141
|
+
direction: "inbound",
|
|
142
|
+
survivor: "a",
|
|
143
|
+
carved: "b",
|
|
144
|
+
attrs: [],
|
|
145
|
+
via: [],
|
|
146
|
+
bridge: "tf-data-source",
|
|
147
|
+
required: "immediately",
|
|
148
|
+
...over,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("does not merge distinct pairs whose plain concatenation collides", () => {
|
|
152
|
+
const edges = [
|
|
153
|
+
edge({ survivor: "aws_instance.ab", carved: "aws_s3_bucket.x", attrs: ["arn"] }),
|
|
154
|
+
edge({ survivor: "aws_instance.a", carved: "baws_s3_bucket.x", attrs: ["id"] }),
|
|
155
|
+
];
|
|
156
|
+
// Both concatenate (survivor + carved, no separator) to the same string.
|
|
157
|
+
expect(edges[0].survivor + edges[0].carved).toBe(edges[1].survivor + edges[1].carved);
|
|
158
|
+
|
|
159
|
+
const result = dedupeEdges(edges);
|
|
160
|
+
expect(result).toHaveLength(2);
|
|
161
|
+
// Sorted by survivor: "aws_instance.a" sorts before "aws_instance.ab".
|
|
162
|
+
expect(result.map((e) => e.attrs)).toEqual([["id"], ["arn"]]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("merges edges that share the same survivor/carved pair, unioning attrs and via", () => {
|
|
166
|
+
const edges = [
|
|
167
|
+
edge({ survivor: "aws_lambda_function.api", carved: "aws_s3_bucket.assets", attrs: ["arn"], via: ["env"] }),
|
|
168
|
+
edge({
|
|
169
|
+
survivor: "aws_lambda_function.api",
|
|
170
|
+
carved: "aws_s3_bucket.assets",
|
|
171
|
+
attrs: ["bucket"],
|
|
172
|
+
via: ["tags"],
|
|
173
|
+
}),
|
|
174
|
+
];
|
|
175
|
+
const result = dedupeEdges(edges);
|
|
176
|
+
expect(result).toHaveLength(1);
|
|
177
|
+
expect(result[0].attrs).toEqual(["arn", "bucket"]);
|
|
178
|
+
expect(result[0].via).toEqual(["env", "tags"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the separator is outside the alphabet a Terraform address component can use", () => {
|
|
182
|
+
// Mirrors graph.ts's NAME (`/^[A-Za-z_][A-Za-z0-9_-]*$/`), the grammar an
|
|
183
|
+
// address segment must match, plus the literal `.` that joins segments.
|
|
184
|
+
const addressChars = /^[A-Za-z0-9_.-]*$/;
|
|
185
|
+
expect(addressChars.test("")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/terraform/carve.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source rendering shared by constructor-shaped carve providers (#2016).
|
|
3
|
+
*
|
|
4
|
+
* A provider that adopts into a typed chant constructor call — aws today, gcp
|
|
5
|
+
* next (#2017) — renders the same literal: an import, an exported `new Ctor({
|
|
6
|
+
* ...props })`, and a reference comment carrying whatever the mapping could not
|
|
7
|
+
* place. Only the lexicon import path and the property mapping differ, so the
|
|
8
|
+
* rendering lives here rather than being copied per provider.
|
|
9
|
+
*
|
|
10
|
+
* A provider whose native shape is not a constructor call (a Kubernetes
|
|
11
|
+
* manifest, #999) renders its own source and does not use this.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The core subpath the emitted source reads build parameters from. */
|
|
15
|
+
export const PARAMS_IMPORT = "@intentius/chant/params";
|
|
16
|
+
|
|
17
|
+
/** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
|
|
18
|
+
export class ParamRef {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly name: string,
|
|
21
|
+
readonly type: "string" | "number" | "boolean",
|
|
22
|
+
) {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render a JS object literal with stable key order and given indent. */
|
|
26
|
+
export function renderObject(obj: Record<string, unknown>, indent: number): string {
|
|
27
|
+
const keys = Object.keys(obj);
|
|
28
|
+
if (keys.length === 0) return "{}";
|
|
29
|
+
const pad = " ".repeat(indent);
|
|
30
|
+
const body = keys
|
|
31
|
+
.map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
|
|
32
|
+
.join("\n");
|
|
33
|
+
return `{\n${body}\n}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
|
|
37
|
+
export function renderValue(value: unknown): string {
|
|
38
|
+
return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
|
|
39
|
+
}
|
|
@@ -2,6 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
3
|
import { boundaryReport } from "./carve";
|
|
4
4
|
import { graduationPlan, stampOwnershipIntoSource, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
|
|
5
|
+
import { LABEL_OWNERSHIP_KEYS } from "../ownership";
|
|
5
6
|
import type { Hcl2JsonTree } from "./types";
|
|
6
7
|
|
|
7
8
|
const bucketTree: Hcl2JsonTree = {
|
|
@@ -40,6 +41,23 @@ describe("graduationPlan", () => {
|
|
|
40
41
|
expect(runbook).toMatch(/lifecycle diff --live/);
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
test("a k8s carve graduates in labels and kubectl, not tags and CloudFormation (#999)", () => {
|
|
45
|
+
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
46
|
+
const plan = graduationPlan(report, { stack: "assets", env: "prod", lexicon: "k8s" });
|
|
47
|
+
|
|
48
|
+
// The channel the k8s serializer actually merges in, not the AWS tag keys.
|
|
49
|
+
expect(plan.markerKind).toBe("labels");
|
|
50
|
+
expect(plan.ownershipTags).toEqual({
|
|
51
|
+
[LABEL_OWNERSHIP_KEYS.managedBy]: "chant",
|
|
52
|
+
[LABEL_OWNERSHIP_KEYS.stack]: "assets",
|
|
53
|
+
[LABEL_OWNERSHIP_KEYS.env]: "prod",
|
|
54
|
+
});
|
|
55
|
+
const runbook = plan.steps.join("\n");
|
|
56
|
+
expect(runbook).toContain("kubectl apply -f");
|
|
57
|
+
expect(runbook).toContain("--lexicon k8s");
|
|
58
|
+
expect(runbook).not.toContain("cloudformation deploy");
|
|
59
|
+
});
|
|
60
|
+
|
|
43
61
|
test("warns when outbound edges leave deferred inputs to wire", () => {
|
|
44
62
|
// Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
|
|
45
63
|
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_lambda_function.api")!;
|
|
@@ -113,4 +131,23 @@ describe("stampOwnershipIntoSource", () => {
|
|
|
113
131
|
test("returns null when there is no constructor to stamp", () => {
|
|
114
132
|
expect(stampOwnershipIntoSource("// nothing here\n", TAGS)).toBeNull();
|
|
115
133
|
});
|
|
134
|
+
|
|
135
|
+
test("refuses a carved manifest rather than inventing a top-level Tags field (#999)", () => {
|
|
136
|
+
// The props of a `k8sManifest` call ARE the object. A `Tags: [...]` line
|
|
137
|
+
// spliced before the closing `});` would be a field the API server never
|
|
138
|
+
// heard of, applied as if an author had written it.
|
|
139
|
+
const src = [
|
|
140
|
+
'import { k8sManifest } from "@intentius/chant-lexicon-k8s";',
|
|
141
|
+
"",
|
|
142
|
+
"export const app_config = k8sManifest({",
|
|
143
|
+
' apiVersion: "v1",',
|
|
144
|
+
' kind: "ConfigMap",',
|
|
145
|
+
" metadata: {",
|
|
146
|
+
' name: "app-config",',
|
|
147
|
+
" },",
|
|
148
|
+
"});",
|
|
149
|
+
"",
|
|
150
|
+
].join("\n");
|
|
151
|
+
expect(stampOwnershipIntoSource(src, TAGS)).toBeNull();
|
|
152
|
+
});
|
|
116
153
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* emitted source now carries.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { ownershipEntries, type ChannelKeys, type OwnershipMarker } from "../ownership";
|
|
17
|
+
import { ownershipEntries, LABEL_OWNERSHIP_KEYS, type ChannelKeys, type OwnershipMarker } from "../ownership";
|
|
18
18
|
import type { CarveReport } from "./carve";
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -32,8 +32,10 @@ export const DEFAULT_TAG_OWNERSHIP_KEYS: ChannelKeys = {
|
|
|
32
32
|
export interface GraduationPlan {
|
|
33
33
|
target: string;
|
|
34
34
|
marker: OwnershipMarker;
|
|
35
|
-
/** The ownership
|
|
35
|
+
/** The ownership entries the emitted resource must carry to be chant-owned. */
|
|
36
36
|
ownershipTags: Record<string, string>;
|
|
37
|
+
/** What that channel is called on this target: `tags`, or `labels` for k8s. */
|
|
38
|
+
markerKind: string;
|
|
37
39
|
/** The finalized, ordered apply runbook. */
|
|
38
40
|
steps: string[];
|
|
39
41
|
warnings: string[];
|
|
@@ -46,12 +48,48 @@ export interface GraduationOptions {
|
|
|
46
48
|
env?: string;
|
|
47
49
|
/** Override the tag channel (e.g. for a non-AWS lexicon). */
|
|
48
50
|
channel?: ChannelKeys;
|
|
51
|
+
/**
|
|
52
|
+
* The lexicon the emitted source targets, from the carve provider that
|
|
53
|
+
* adopted the type. It decides how the build and the apply are spelled: a
|
|
54
|
+
* CloudFormation template and `aws cloudformation deploy` for aws, a manifest
|
|
55
|
+
* and `kubectl apply` for k8s (#999). Unknown falls back to the aws wording,
|
|
56
|
+
* which is what every carve was before there was a second provider.
|
|
57
|
+
*/
|
|
58
|
+
lexicon?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How the marker, the build output and the BYOL apply are spelled, per emitted
|
|
63
|
+
* lexicon. A Kubernetes object carries the marker as labels — the same
|
|
64
|
+
* `LABEL_OWNERSHIP_KEYS` the k8s serializer merges in — so a runbook promising
|
|
65
|
+
* `chant:managed-by` tags would name a channel nothing stamps.
|
|
66
|
+
*/
|
|
67
|
+
function applyVocabulary(
|
|
68
|
+
lexicon: string | undefined,
|
|
69
|
+
target: string,
|
|
70
|
+
stack: string,
|
|
71
|
+
): { channel: ChannelKeys; markerKind: string; build: string; apply: string } {
|
|
72
|
+
if (lexicon === "k8s") {
|
|
73
|
+
return {
|
|
74
|
+
channel: LABEL_OWNERSHIP_KEYS,
|
|
75
|
+
markerKind: "labels",
|
|
76
|
+
build: `chant build <emitted-src> --lexicon k8s > ${target}.yaml`,
|
|
77
|
+
apply: `kubectl apply -f ${target}.yaml`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
channel: DEFAULT_TAG_OWNERSHIP_KEYS,
|
|
82
|
+
markerKind: "tags",
|
|
83
|
+
build: `chant build <emitted-src> -o ${target}.template.json`,
|
|
84
|
+
apply: `aws cloudformation deploy --template-file ${target}.template.json --stack-name ${stack} ...`,
|
|
85
|
+
};
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}): GraduationPlan {
|
|
52
89
|
const stack = opts.stack ?? (report.target.split(".").slice(1).join(".") || report.target);
|
|
53
90
|
const marker: OwnershipMarker = { stack, env: opts.env };
|
|
54
|
-
const
|
|
91
|
+
const vocabulary = applyVocabulary(opts.lexicon, report.target, stack);
|
|
92
|
+
const ownershipTags = ownershipEntries(opts.channel ?? vocabulary.channel, marker);
|
|
55
93
|
|
|
56
94
|
const warnings: string[] = [];
|
|
57
95
|
if (report.outbound.length) {
|
|
@@ -65,18 +103,27 @@ export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}
|
|
|
65
103
|
.join(", ");
|
|
66
104
|
|
|
67
105
|
const steps = [
|
|
68
|
-
`1. Confirm the emitted source builds spec-true:
|
|
69
|
-
`2. Add the ownership marker so chant owns the resource —
|
|
106
|
+
`1. Confirm the emitted source builds spec-true: ${vocabulary.build}`,
|
|
107
|
+
`2. Add the ownership marker so chant owns the resource — ${vocabulary.markerKind}: ${tagList}`,
|
|
70
108
|
`3. Apply with your lifecycle (BYOL), e.g.:`,
|
|
71
|
-
`
|
|
109
|
+
` ${vocabulary.apply}`,
|
|
72
110
|
` or graduate to an ApplyOp for a durable, gated apply.`,
|
|
73
111
|
`4. Verify chant now owns it: chant lifecycle diff --live <env> (expect: unchanged, owned)`,
|
|
74
112
|
`Rollback before this point: terraform import ${report.target} <physical-id>`,
|
|
75
113
|
];
|
|
76
114
|
|
|
77
|
-
return { target: report.target, marker, ownershipTags, steps, warnings };
|
|
115
|
+
return { target: report.target, marker, ownershipTags, markerKind: vocabulary.markerKind, steps, warnings };
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A constructor-shaped emitted file: `export const x = new Ctor(`. Tag-based
|
|
120
|
+
* ownership is a property of that shape — a carved `kubernetes_manifest` is a
|
|
121
|
+
* `k8sManifest({ ... })` call whose props are the manifest, where a top-level
|
|
122
|
+
* `Tags` array would be an invented field on the object, not a marker (#999).
|
|
123
|
+
* Such a file is refused rather than stamped.
|
|
124
|
+
*/
|
|
125
|
+
const CONSTRUCTOR_SHAPE = /^export const \w+ = new \w+\(/m;
|
|
126
|
+
|
|
80
127
|
/**
|
|
81
128
|
* Stamp the ownership tags into an emitted chant source file (`carve apply
|
|
82
129
|
* --write-source`). The emitted source is machine-generated (`adoptFromState`),
|
|
@@ -89,6 +136,7 @@ export function stampOwnershipIntoSource(
|
|
|
89
136
|
content: string,
|
|
90
137
|
tags: Record<string, string>,
|
|
91
138
|
): { content: string; changed: boolean } | null {
|
|
139
|
+
if (!CONSTRUCTOR_SHAPE.test(content)) return null;
|
|
92
140
|
const entries = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
|
|
93
141
|
const keys = new Set(Object.keys(tags));
|
|
94
142
|
const lines = content.split("\n");
|
package/src/terraform/graph.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* graph building stays unit-testable on hand-written fixtures.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { identityAttrOf } from "./tier-map";
|
|
15
15
|
import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -143,12 +143,12 @@ function blockHasMeta(block: unknown, key: string): boolean {
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* The resource's physical name, if its identity attribute is a plain literal
|
|
146
|
-
* (not interpolated). A dotted
|
|
146
|
+
* (not interpolated). A dotted identity attribute walks nested blocks —
|
|
147
147
|
* hcl2json renders a nested block as a one-element array, so arrays step
|
|
148
148
|
* through their first element (`manifest.metadata.name`).
|
|
149
149
|
*/
|
|
150
150
|
function literalIdentity(block: unknown, type: string): string | undefined {
|
|
151
|
-
const attr =
|
|
151
|
+
const attr = identityAttrOf(type);
|
|
152
152
|
if (!attr || !block || typeof block !== "object") return undefined;
|
|
153
153
|
let value: unknown = block;
|
|
154
154
|
for (const segment of attr.split(".")) {
|