@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import { registerCarveProvider, type CarveProvider } from "../../terraform/carve-provider";
|
|
8
|
+
import type { ImportResult, LiveImportOptions } from "./import";
|
|
9
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The carve provider seam (#2016), driven end to end by a provider core has
|
|
13
|
+
* never heard of. Nothing in `carve-emit.ts` or `adopt-state.ts` mentions
|
|
14
|
+
* `fake_`, `fakelex` or `fake:Widget` — if emit can carry this estate to
|
|
15
|
+
* emitted source, a scaffolded project and a live selector, then adding a real
|
|
16
|
+
* provider (#999 kubernetes, #2017 gcp) is a file under `terraform/providers/`.
|
|
17
|
+
*/
|
|
18
|
+
let parserAvailable = false;
|
|
19
|
+
try {
|
|
20
|
+
await loadHcl2json();
|
|
21
|
+
parserAvailable = true;
|
|
22
|
+
} catch {
|
|
23
|
+
parserAvailable = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ESTATE = `
|
|
27
|
+
resource "fake_widget" "hero" { widget_name = "hero-widget" }
|
|
28
|
+
resource "fake_widget_setting" "hero" {
|
|
29
|
+
widget = fake_widget.hero.id
|
|
30
|
+
colour = "blue"
|
|
31
|
+
}
|
|
32
|
+
resource "fake_consumer" "reader" { widget = fake_widget.hero.id }
|
|
33
|
+
resource "fake_gizmo" "spare" { size = 3 }
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const TFSTATE = JSON.stringify({
|
|
37
|
+
version: 4,
|
|
38
|
+
resources: [
|
|
39
|
+
{
|
|
40
|
+
mode: "managed",
|
|
41
|
+
type: "fake_widget",
|
|
42
|
+
name: "hero",
|
|
43
|
+
instances: [{ attributes: { id: "hero-widget", widget_name: "hero-widget" } }],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
mode: "managed",
|
|
47
|
+
type: "fake_widget_setting",
|
|
48
|
+
name: "hero",
|
|
49
|
+
instances: [{ attributes: { id: "hero-widget", colour: "blue" } }],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** A provider with an emit path: adopt from state, and a live selector type. */
|
|
55
|
+
const fakeProvider: CarveProvider = {
|
|
56
|
+
name: "test-fake-emit",
|
|
57
|
+
tfTypePrefixes: ["fake_"],
|
|
58
|
+
lexicon: "fakelex",
|
|
59
|
+
tiers: {
|
|
60
|
+
fake_widget: { tier: 1, mapsTo: "fake:Widget" },
|
|
61
|
+
fake_widget_setting: { tier: 2, mapsTo: "fake:WidgetSetting" },
|
|
62
|
+
fake_gizmo: { tier: 2, mapsTo: "fake:Gizmo" },
|
|
63
|
+
},
|
|
64
|
+
identityAttrs: { fake_widget: "widget_name" },
|
|
65
|
+
foldsInto: { fake_widget_setting: "fake_widget" },
|
|
66
|
+
emitTypes: ["fake_widget"],
|
|
67
|
+
adopt: (resource, params, folded) => ({
|
|
68
|
+
fileName: `${resource.name}.widget.ts`,
|
|
69
|
+
content: `// fake provider adopted ${resource.type}.${resource.name}\nexport const ${resource.name} = "${resource.attributes.widget_name}";\n`,
|
|
70
|
+
mapped: true,
|
|
71
|
+
nativeType: "fake:Widget",
|
|
72
|
+
parameterized: params.map((p) => p.name),
|
|
73
|
+
folded: folded.map((f) => ({ address: `${f.type}.${f.name}`, props: ["Colour"] })),
|
|
74
|
+
}),
|
|
75
|
+
liveSelectorType: (tfType) => (tfType === "fake_widget" ? "fake:Widget" : undefined),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Same provider, state-only: no live adoption path at all. */
|
|
79
|
+
const stateOnlyProvider: CarveProvider = {
|
|
80
|
+
...fakeProvider,
|
|
81
|
+
name: "test-fake-state-only",
|
|
82
|
+
liveSelectorType: undefined,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const fakeImport = () =>
|
|
86
|
+
vi.fn(
|
|
87
|
+
async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
|
|
88
|
+
success: true,
|
|
89
|
+
generatedFiles: ["infra/hero.ts"],
|
|
90
|
+
warnings: [],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
let dispose: (() => void) | undefined;
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
dispose?.();
|
|
97
|
+
dispose = undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-provider-"));
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
104
|
+
writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
|
|
105
|
+
return await fn(dir);
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("carve emit through a registered provider (#2016)", () => {
|
|
112
|
+
test("without a provider the type is unknown to advise and refused by emit", async () => {
|
|
113
|
+
if (!parserAvailable) return;
|
|
114
|
+
await withEstate(async (dir) => {
|
|
115
|
+
const res = await carveEmit(
|
|
116
|
+
{ from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate") },
|
|
117
|
+
{ plugins: [], liveImport: fakeImport() },
|
|
118
|
+
);
|
|
119
|
+
expect(res.ok).toBe(false);
|
|
120
|
+
expect(res.error).toMatch(/no known native mapping/i);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("--state emits through the provider and scaffolds for its lexicon", async () => {
|
|
125
|
+
if (!parserAvailable) return;
|
|
126
|
+
dispose = registerCarveProvider(fakeProvider);
|
|
127
|
+
await withEstate(async (dir) => {
|
|
128
|
+
const out = join(dir, "carveout");
|
|
129
|
+
const liveImport = fakeImport();
|
|
130
|
+
const res = await carveEmit(
|
|
131
|
+
{ from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
132
|
+
{ plugins: [], liveImport },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(res.ok).toBe(true);
|
|
136
|
+
expect(res.source).toBe("tfstate");
|
|
137
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
138
|
+
|
|
139
|
+
// The provider named the file and wrote the content; core wrote it out.
|
|
140
|
+
expect(res.emittedFiles).toEqual([join(out, "src", "hero.widget.ts")]);
|
|
141
|
+
expect(readFileSync(res.emittedFiles![0], "utf-8")).toContain("fake provider adopted fake_widget.hero");
|
|
142
|
+
|
|
143
|
+
// The scaffold targets the provider's lexicon, not a hardcoded aws.
|
|
144
|
+
const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
|
|
145
|
+
expect(pkg.scripts.build).toBe("chant build src --lexicon fakelex");
|
|
146
|
+
expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-fakelex"]);
|
|
147
|
+
expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["fakelex"]');
|
|
148
|
+
|
|
149
|
+
// The provider's foldsInto drove the carve set, so the setting folded in.
|
|
150
|
+
expect(res.folded).toEqual([{ address: "fake_widget_setting.hero", props: ["Colour"] }]);
|
|
151
|
+
expect(formatCarveEmit(res)).toContain("fake_widget_setting.hero (Colour)");
|
|
152
|
+
// And its identityAttrs gave the graph the physical name.
|
|
153
|
+
expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["fake_consumer.reader"]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("--env uses the provider's live selector type and lexicon", async () => {
|
|
158
|
+
if (!parserAvailable) return;
|
|
159
|
+
dispose = registerCarveProvider(fakeProvider);
|
|
160
|
+
await withEstate(async (dir) => {
|
|
161
|
+
const liveImport = fakeImport();
|
|
162
|
+
const res = await carveEmit(
|
|
163
|
+
{ from: dir, select: "fake_widget.hero", env: "prod" },
|
|
164
|
+
{ plugins: [], liveImport },
|
|
165
|
+
);
|
|
166
|
+
expect(res.ok).toBe(true);
|
|
167
|
+
expect(res.selector).toEqual({ type: "fake:Widget" });
|
|
168
|
+
expect(liveImport.mock.calls[0][1]).toMatchObject({
|
|
169
|
+
environment: "prod",
|
|
170
|
+
selector: { type: "fake:Widget" },
|
|
171
|
+
lexicon: "fakelex",
|
|
172
|
+
});
|
|
173
|
+
expect(formatCarveEmit(res)).toContain("Adopted live as fake:Widget");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a ranked type the provider does not emit is refused identically on both paths", async () => {
|
|
178
|
+
if (!parserAvailable) return;
|
|
179
|
+
dispose = registerCarveProvider(fakeProvider);
|
|
180
|
+
await withEstate(async (dir) => {
|
|
181
|
+
const liveImport = fakeImport();
|
|
182
|
+
const live = await carveEmit({ from: dir, select: "fake_gizmo.spare", env: "prod" }, { plugins: [], liveImport });
|
|
183
|
+
const state = await carveEmit(
|
|
184
|
+
{ from: dir, select: "fake_gizmo.spare", statePath: join(dir, "terraform.tfstate") },
|
|
185
|
+
{ plugins: [], liveImport },
|
|
186
|
+
);
|
|
187
|
+
expect(live.ok).toBe(false);
|
|
188
|
+
expect(state.ok).toBe(false);
|
|
189
|
+
expect(live.error).toBe(state.error);
|
|
190
|
+
expect(live.error).toContain("fake_gizmo cannot be emitted yet");
|
|
191
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a state-only provider refuses --env instead of importing the wrong thing", async () => {
|
|
196
|
+
if (!parserAvailable) return;
|
|
197
|
+
dispose = registerCarveProvider(stateOnlyProvider);
|
|
198
|
+
await withEstate(async (dir) => {
|
|
199
|
+
const liveImport = fakeImport();
|
|
200
|
+
const res = await carveEmit({ from: dir, select: "fake_widget.hero", env: "prod" }, { plugins: [], liveImport });
|
|
201
|
+
expect(res.ok).toBe(false);
|
|
202
|
+
expect(res.error).toContain("no live adoption path");
|
|
203
|
+
expect(res.error).toContain("--state");
|
|
204
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { basename, join, resolve } from "path";
|
|
17
|
+
import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
|
|
17
18
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
18
19
|
import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
|
|
19
|
-
import {
|
|
20
|
+
import { carveEmitTypes, resolveTier } from "../../terraform/tier-map";
|
|
21
|
+
import { resolveEmitProvider } from "../../terraform/carve-provider";
|
|
20
22
|
import { readStateResource, type StateResource } from "../../terraform/state";
|
|
21
23
|
import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
22
24
|
import { adoptFromState, type DeferredParam, type FoldedContribution } from "../../terraform/adopt-state";
|
|
@@ -24,14 +26,6 @@ import { getChantVersion } from "./init";
|
|
|
24
26
|
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
25
27
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
* The lexicon emit targets. `canCarveEmit` admits only AWS carve-table types,
|
|
29
|
-
* so both the scaffolded project and the live import are AWS. Widening this
|
|
30
|
-
* needs the lexicon-agnostic emit seam (#999, #2016) — deriving it from
|
|
31
|
-
* `tier.mapsTo` does not work: a `k8s:Namespace` mapping has no `::` to split.
|
|
32
|
-
*/
|
|
33
|
-
const EMIT_LEXICON = "aws";
|
|
34
|
-
|
|
35
29
|
export interface CarveEmitOptions {
|
|
36
30
|
/** Terraform estate directory (`--from`). */
|
|
37
31
|
from?: string;
|
|
@@ -92,6 +86,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
92
86
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
93
87
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
94
88
|
}
|
|
89
|
+
if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "emit") };
|
|
95
90
|
|
|
96
91
|
let report: CarveReport | null;
|
|
97
92
|
let tfType: string | undefined;
|
|
@@ -106,23 +101,23 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
106
101
|
|
|
107
102
|
if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
|
|
108
103
|
|
|
109
|
-
|
|
110
|
-
if (!tier) {
|
|
104
|
+
if (!tfType || !resolveTier(tfType)) {
|
|
111
105
|
return {
|
|
112
106
|
ok: false,
|
|
113
107
|
error: `${opts.select} (${tfType ?? "unknown type"}) has no known native mapping, so it cannot be emitted. Advisor bands it "leave in Terraform".`,
|
|
114
108
|
};
|
|
115
109
|
}
|
|
116
110
|
|
|
117
|
-
// `carve advise` ranks every type
|
|
118
|
-
// for the ones
|
|
119
|
-
//
|
|
111
|
+
// `carve advise` ranks every type a provider declares; emit produces source
|
|
112
|
+
// only for the ones that provider can adopt. Both adoption paths refuse the
|
|
113
|
+
// rest here — before any file is written or any cloud call is made — so
|
|
120
114
|
// neither path can proceed on a type the other rejects.
|
|
121
|
-
|
|
115
|
+
const provider = resolveEmitProvider(tfType);
|
|
116
|
+
if (!provider) {
|
|
122
117
|
return {
|
|
123
118
|
ok: false,
|
|
124
119
|
error:
|
|
125
|
-
`${tfType} cannot be emitted yet (no
|
|
120
|
+
`${tfType} cannot be emitted yet (no carve provider adopts it).\n` +
|
|
126
121
|
`Supported types: ${carveEmitTypes().join(", ")}. ` +
|
|
127
122
|
`Coverage is expanding — see chant issue #1001.`,
|
|
128
123
|
};
|
|
@@ -156,7 +151,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
156
151
|
mkdirSync(srcDir, { recursive: true });
|
|
157
152
|
const outPath = join(srcDir, adopted.fileName);
|
|
158
153
|
writeFileSync(outPath, adopted.content);
|
|
159
|
-
const scaffolded = scaffoldProject(outDir,
|
|
154
|
+
const scaffolded = scaffoldProject(outDir, provider.lexicon, params);
|
|
160
155
|
|
|
161
156
|
const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
|
|
162
157
|
return {
|
|
@@ -172,19 +167,28 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
172
167
|
}
|
|
173
168
|
|
|
174
169
|
// ── Adoption path 2: live import (cloud→code) ──
|
|
175
|
-
// The
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
170
|
+
// The provider names the selector type its live export filters on — for aws a
|
|
171
|
+
// CloudFormation type, filtered by logical ID rather than the Terraform
|
|
172
|
+
// physical name, so `--live-name` (not `identity`) is what narrows a stack
|
|
173
|
+
// holding several of that type.
|
|
174
|
+
const selectorType = provider.liveSelectorType?.(tfType);
|
|
175
|
+
if (!selectorType) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error:
|
|
179
|
+
`${tfType} has no live adoption path — the ${provider.name} carve provider adopts it from state only.\n` +
|
|
180
|
+
`Re-run with --state <tfstate>.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
179
183
|
const selector: ResourceSelector = opts.liveName
|
|
180
|
-
? { type:
|
|
181
|
-
: { type:
|
|
184
|
+
? { type: selectorType, name: opts.liveName }
|
|
185
|
+
: { type: selectorType };
|
|
182
186
|
|
|
183
187
|
const emit = await deps.liveImport(deps.plugins, {
|
|
184
188
|
environment: opts.env!,
|
|
185
189
|
selector,
|
|
186
190
|
output: opts.output,
|
|
187
|
-
lexicon:
|
|
191
|
+
lexicon: provider.lexicon,
|
|
188
192
|
});
|
|
189
193
|
|
|
190
194
|
// A failed import emitted no source. Reporting success here would write a
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `chant carve advise` — the read-only
|
|
2
|
+
* `chant carve advise` — the read-only peelability advisor (#214, #1056).
|
|
3
3
|
*
|
|
4
|
-
* Points at
|
|
5
|
-
*
|
|
4
|
+
* Points at foreign infrastructure and reports which pieces are cheap to carve
|
|
5
|
+
* into native chant later and which should stay where they are. It emits
|
|
6
6
|
* nothing, patches nothing, and touches no live resource — pure analysis. The
|
|
7
|
-
* emit/boundary/apply phases
|
|
7
|
+
* emit/boundary/apply phases (`carve emit|bridge|apply`, #197) read Terraform
|
|
8
|
+
* only, and refuse a cloud assembly by name.
|
|
9
|
+
*
|
|
10
|
+
* Two sources, one report. A Terraform estate is parsed from `.tf`; a CDK cloud
|
|
11
|
+
* assembly is read from `cdk.out` (#1056). Which one is in `--from` is decided
|
|
12
|
+
* by looking, not by a second flag: a directory holding `manifest.json` plus a
|
|
13
|
+
* synthesized `*.template.json` is an assembly. Both produce the same bands,
|
|
14
|
+
* the same score arithmetic, and the same JSON shape.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
import { existsSync, statSync, writeFileSync } from "fs";
|
|
18
|
+
import { adviseCloudAssembly, CDK_DIALECT } from "../../cdk/advise";
|
|
19
|
+
import { isCloudAssembly } from "../../cdk/assembly";
|
|
11
20
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
12
|
-
import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
|
|
13
|
-
import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
|
|
21
|
+
import { scoreEstate, type Peelability, type PeelabilityBand, type ScoreOptions } from "../../terraform/score";
|
|
22
|
+
import { boundaryReport, type BoundaryEdge, type CarveDialect } from "../../terraform/carve";
|
|
14
23
|
import type { TfGraph } from "../../terraform/types";
|
|
15
24
|
|
|
16
25
|
export interface CarveAdviseOptions {
|
|
17
|
-
/** Terraform estate directory (from `--from`). */
|
|
26
|
+
/** Terraform estate directory or CDK cloud assembly (from `--from`). */
|
|
18
27
|
from?: string;
|
|
19
28
|
/** Opt-in `.tfstate` path (from `--state`): accurate fan-out instance counts. */
|
|
20
29
|
statePath?: string;
|
|
@@ -22,10 +31,19 @@ export interface CarveAdviseOptions {
|
|
|
22
31
|
reportFile?: string;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Which source the ranking came from. Terraform addresses and CDK construct
|
|
36
|
+
* paths are different namespaces, and the bridge patches have different names,
|
|
37
|
+
* so a report says which it is rather than leaving a reader to guess from the
|
|
38
|
+
* shape of an address.
|
|
39
|
+
*/
|
|
40
|
+
export type CarveSource = "terraform" | "cdk";
|
|
41
|
+
|
|
25
42
|
export interface CarveAdviseResult {
|
|
26
43
|
ok: boolean;
|
|
27
44
|
error?: string;
|
|
28
45
|
from?: string;
|
|
46
|
+
source?: CarveSource;
|
|
29
47
|
results?: Peelability[];
|
|
30
48
|
/**
|
|
31
49
|
* The parsed dependency graph the scores came from. Kept so the JSON report
|
|
@@ -33,37 +51,69 @@ export interface CarveAdviseResult {
|
|
|
33
51
|
* Not part of the JSON payload — `carveJson` derives from it.
|
|
34
52
|
*/
|
|
35
53
|
graph?: TfGraph;
|
|
54
|
+
/** The hooks the scores were produced with, so the boundary pass agrees. */
|
|
55
|
+
scoreOptions?: ScoreOptions;
|
|
56
|
+
/** Anything about the read itself worth saying out loud. */
|
|
57
|
+
diagnostics?: string[];
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
|
|
39
61
|
|
|
40
62
|
export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
|
|
41
63
|
if (!opts.from) {
|
|
42
|
-
return { ok: false, error: "chant carve advise requires --from <terraform-dir>" };
|
|
64
|
+
return { ok: false, error: "chant carve advise requires --from <terraform-dir | cdk.out>" };
|
|
43
65
|
}
|
|
44
66
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
45
67
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
46
68
|
}
|
|
69
|
+
|
|
70
|
+
const result = isCloudAssembly(opts.from) ? adviseCdk(opts) : await adviseTerraform(opts);
|
|
71
|
+
if (result.ok && opts.reportFile) {
|
|
72
|
+
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rank a CDK cloud assembly. No parser to install, no state file to read. */
|
|
78
|
+
function adviseCdk(opts: CarveAdviseOptions): CarveAdviseResult {
|
|
79
|
+
if (opts.statePath) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error:
|
|
83
|
+
`${opts.from} is a CDK cloud assembly, and --state is a Terraform option. ` +
|
|
84
|
+
"A synthesized template already carries its own instance counts.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const advice = adviseCloudAssembly(opts.from!);
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
from: opts.from,
|
|
92
|
+
source: "cdk",
|
|
93
|
+
results: advice.results,
|
|
94
|
+
graph: advice.graph,
|
|
95
|
+
scoreOptions: advice.scoreOptions,
|
|
96
|
+
diagnostics: advice.diagnostics,
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `Failed to read the CDK cloud assembly in ${opts.from}: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function adviseTerraform(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
|
|
47
107
|
if (opts.statePath && !existsSync(opts.statePath)) {
|
|
48
108
|
return { ok: false, error: `State file not found: ${opts.statePath}` };
|
|
49
109
|
}
|
|
50
|
-
|
|
51
|
-
let graph: TfGraph;
|
|
52
|
-
let results: Peelability[];
|
|
53
110
|
try {
|
|
54
|
-
graph = await parseTerraformDir(opts.from
|
|
55
|
-
results
|
|
111
|
+
const graph = await parseTerraformDir(opts.from!, { statePath: opts.statePath });
|
|
112
|
+
return { ok: true, from: opts.from, source: "terraform", results: scoreEstate(graph), graph };
|
|
56
113
|
} catch (err) {
|
|
57
114
|
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
58
115
|
return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
|
|
59
116
|
}
|
|
60
|
-
|
|
61
|
-
const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
|
|
62
|
-
if (opts.reportFile) {
|
|
63
|
-
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return result;
|
|
67
117
|
}
|
|
68
118
|
|
|
69
119
|
/**
|
|
@@ -81,6 +131,16 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
|
|
|
81
131
|
* not).
|
|
82
132
|
* - A reader that does not know the version it is handed should refuse the
|
|
83
133
|
* report rather than half-read it.
|
|
134
|
+
*
|
|
135
|
+
* The CDK source (#1056) stays inside that promise and does not bump the
|
|
136
|
+
* version. Every field an existing reader keys on keeps its type and meaning:
|
|
137
|
+
* `address` is still an opaque string identifier (a construct path instead of a
|
|
138
|
+
* Terraform address), `kind` is still `"resource"` or `"module"` (an L3
|
|
139
|
+
* construct subtree is a Composite candidate, which is what `module` already
|
|
140
|
+
* means), `score`/`band`/`breakdown` are the same arithmetic. What is new is
|
|
141
|
+
* additive and ignorable: the top-level `source` discriminator, per-resource
|
|
142
|
+
* `notes` and `members`, and two more `bridge` values in a list that was
|
|
143
|
+
* already declared open-ended.
|
|
84
144
|
*/
|
|
85
145
|
export const CARVE_REPORT_VERSION = 1;
|
|
86
146
|
|
|
@@ -113,10 +173,23 @@ export interface CarveJsonResource extends Peelability {
|
|
|
113
173
|
export interface CarveJsonReport {
|
|
114
174
|
version: number;
|
|
115
175
|
from?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Which source was read (#1056). `terraform` — addresses are Terraform
|
|
178
|
+
* addresses, bridges are `data` sources. `cdk` — addresses are construct
|
|
179
|
+
* paths and bridges are reimports. Absent on a chant that predates the CDK
|
|
180
|
+
* advisor, which a reader should take as `terraform`.
|
|
181
|
+
*/
|
|
182
|
+
source: CarveSource;
|
|
116
183
|
advisory: string;
|
|
117
184
|
count: number;
|
|
118
185
|
/** Band name -> how many resources landed in it. */
|
|
119
186
|
bands: Record<string, number>;
|
|
187
|
+
/**
|
|
188
|
+
* Anything about the read the scores alone do not say — a missing `tree.json`,
|
|
189
|
+
* an assembly synthesized from unresolved context lookups. Present only when
|
|
190
|
+
* there is something to report.
|
|
191
|
+
*/
|
|
192
|
+
diagnostics?: string[];
|
|
120
193
|
resources: CarveJsonResource[];
|
|
121
194
|
}
|
|
122
195
|
|
|
@@ -126,20 +199,29 @@ export function carveJson(result: CarveAdviseResult): CarveJsonReport {
|
|
|
126
199
|
const counts = Object.fromEntries(
|
|
127
200
|
BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
|
|
128
201
|
);
|
|
202
|
+
const source = result.source ?? "terraform";
|
|
203
|
+
const dialect: CarveDialect = source === "cdk" ? CDK_DIALECT : "terraform";
|
|
129
204
|
return {
|
|
130
205
|
version: CARVE_REPORT_VERSION,
|
|
131
206
|
from: result.from,
|
|
207
|
+
source,
|
|
132
208
|
advisory: "read-only — emits nothing, patches nothing, touches no live resource",
|
|
133
209
|
count: results.length,
|
|
134
210
|
bands: counts,
|
|
135
|
-
|
|
211
|
+
...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}),
|
|
212
|
+
resources: results.map((r) => withBoundary(r, result.graph, dialect, result.scoreOptions)),
|
|
136
213
|
};
|
|
137
214
|
}
|
|
138
215
|
|
|
139
216
|
/** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
|
|
140
|
-
function withBoundary(
|
|
217
|
+
function withBoundary(
|
|
218
|
+
r: Peelability,
|
|
219
|
+
graph: TfGraph | undefined,
|
|
220
|
+
dialect: CarveDialect,
|
|
221
|
+
score: ScoreOptions | undefined,
|
|
222
|
+
): CarveJsonResource {
|
|
141
223
|
if (!graph) return r;
|
|
142
|
-
const report = boundaryReport(graph, r.address);
|
|
224
|
+
const report = boundaryReport(graph, r.address, { dialect, score });
|
|
143
225
|
if (!report) return r;
|
|
144
226
|
return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
|
|
145
227
|
}
|
|
@@ -147,52 +229,73 @@ function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
|
|
|
147
229
|
/** Human-readable banded, ranked summary. */
|
|
148
230
|
export function formatCarveReport(result: CarveAdviseResult): string {
|
|
149
231
|
const results = result.results ?? [];
|
|
232
|
+
const source = result.source ?? "terraform";
|
|
233
|
+
const cdk = source === "cdk";
|
|
234
|
+
|
|
150
235
|
if (results.length === 0) {
|
|
151
|
-
return
|
|
236
|
+
return cdk
|
|
237
|
+
? `No carvable constructs found in ${result.from}\n(the assembly synthesized no infrastructure beyond CDK's own scaffolding).`
|
|
238
|
+
: `No carvable resources found in ${result.from}\n(only data sources, providers, or unsupported types were present).`;
|
|
152
239
|
}
|
|
153
240
|
|
|
154
241
|
const lines: string[] = [];
|
|
155
|
-
lines.push(
|
|
156
|
-
lines.push(
|
|
242
|
+
lines.push(`${cdk ? "CDK cloud-assembly" : "Terraform"} carve-out advisory for ${result.from}`);
|
|
243
|
+
lines.push(
|
|
244
|
+
` ${results.length} ${cdk ? "construct(s)" : "resource(s)/module(s)"} scored. ` +
|
|
245
|
+
"Advises only — nothing is emitted or changed.",
|
|
246
|
+
);
|
|
247
|
+
for (const note of result.diagnostics ?? []) lines.push(` ! ${note}`);
|
|
157
248
|
lines.push("");
|
|
158
249
|
|
|
159
250
|
for (const band of BAND_ORDER) {
|
|
160
251
|
const inBand = results.filter((r) => r.band === band);
|
|
161
252
|
if (inBand.length === 0) continue;
|
|
162
|
-
lines.push(`${bandLabel(band)} (${inBand.length})`);
|
|
253
|
+
lines.push(`${bandLabel(band, cdk)} (${inBand.length})`);
|
|
163
254
|
for (const r of inBand) {
|
|
164
255
|
const target = r.mapsTo ? ` -> ${r.mapsTo}` : "";
|
|
165
256
|
lines.push(` ${String(r.score).padStart(3)} ${r.address}${target}`);
|
|
166
|
-
lines.push(` ${reasons(r)}`);
|
|
257
|
+
lines.push(` ${reasons(r, cdk)}`);
|
|
258
|
+
for (const note of r.notes ?? []) lines.push(` ${note}`);
|
|
167
259
|
}
|
|
168
260
|
lines.push("");
|
|
169
261
|
}
|
|
170
262
|
|
|
171
|
-
lines.push(
|
|
263
|
+
lines.push(
|
|
264
|
+
`Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in ${cdk ? "CDK" : "Terraform"}`,
|
|
265
|
+
);
|
|
172
266
|
return lines.join("\n").trimEnd();
|
|
173
267
|
}
|
|
174
268
|
|
|
175
|
-
function bandLabel(band: PeelabilityBand): string {
|
|
269
|
+
function bandLabel(band: PeelabilityBand, cdk: boolean): string {
|
|
176
270
|
switch (band) {
|
|
177
271
|
case "clean leaf":
|
|
178
272
|
return "CLEAN LEAF — carve now";
|
|
179
273
|
case "carvable w/ edits":
|
|
180
274
|
return "CARVABLE — has boundary work";
|
|
181
275
|
case "leave in Terraform":
|
|
182
|
-
|
|
276
|
+
// The band's name is part of the JSON contract and does not change per
|
|
277
|
+
// source; only the heading a person reads does.
|
|
278
|
+
return cdk ? "LEAVE IN CDK" : "LEAVE IN TERRAFORM";
|
|
183
279
|
}
|
|
184
280
|
}
|
|
185
281
|
|
|
186
282
|
/** One-line explanation of what drove a score, from its breakdown. */
|
|
187
|
-
function reasons(r: Peelability): string {
|
|
283
|
+
function reasons(r: Peelability, cdk = false): string {
|
|
188
284
|
const b = r.breakdown;
|
|
189
|
-
if (b.tier === null)
|
|
285
|
+
if (b.tier === null) {
|
|
286
|
+
return cdk ? "no chant AWS lexicon target for this type" : "no known native mapping (unsupported provider/type)";
|
|
287
|
+
}
|
|
288
|
+
// A node that scored 0 with every penalty at 0 was disqualified outright, not
|
|
289
|
+
// scored down. Reciting the arithmetic would credit terms that never applied;
|
|
290
|
+
// the stated reason follows on the next line.
|
|
291
|
+
if (r.score === 0 && Object.values(b.penalties).every((p) => p === 0)) return "not carvable as it stands";
|
|
190
292
|
const parts: string[] = [];
|
|
191
|
-
if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
|
|
192
|
-
if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (one-line rewrite each)`);
|
|
293
|
+
if (b.inbound) parts.push(`${b.inbound} inbound (${cdk ? "a reimport each" : "data-source patch each"})`);
|
|
294
|
+
if (b.outputs) parts.push(`${b.outputs} ${cdk ? "stack output(s)" : "output block(s)"} reading it (one-line rewrite each)`);
|
|
193
295
|
if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
|
|
194
296
|
if (b.tier > 1) parts.push(`tier ${b.tier} map`);
|
|
195
|
-
if (b.hasDynamic) parts.push("count/for_each/data present");
|
|
297
|
+
if (b.hasDynamic) parts.push(cdk ? "condition/parameter present" : "count/for_each/data present");
|
|
196
298
|
if (b.instances > 1) parts.push(`${b.instances} instances`);
|
|
299
|
+
if (b.penalties.asset) parts.push("asset-backed");
|
|
197
300
|
return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
|
|
198
301
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { carveEmit, formatCarveEmit } from "../commands/carve-emit";
|
|
2
2
|
import { liveImportFromPlugins } from "../commands/import";
|
|
3
|
+
import { carveEmitLexicons, resolveEmitProvider } from "../../terraform/carve-provider";
|
|
3
4
|
import { loadPlugins } from "../plugins";
|
|
4
5
|
import { formatError } from "../format";
|
|
5
6
|
import type { CommandContext } from "../registry";
|
|
@@ -16,12 +17,16 @@ export async function runCarveEmit(ctx: CommandContext): Promise<number> {
|
|
|
16
17
|
const { args } = ctx;
|
|
17
18
|
|
|
18
19
|
// Load plugins only for the live path; the offline --state path needs none.
|
|
19
|
-
//
|
|
20
|
-
//
|
|
20
|
+
// Which lexicon comes from the carve provider seam (#2016), resolved off the
|
|
21
|
+
// selected address's type without parsing the estate — so a second provider
|
|
22
|
+
// needs no edit here. A type no provider emits falls back to every lexicon
|
|
23
|
+
// emit can adopt into, and carveEmit refuses the type properly afterwards.
|
|
21
24
|
let plugins = ctx.plugins;
|
|
22
25
|
if (!args.statePath && args.env) {
|
|
26
|
+
const tfType = args.selectAddress?.split(".")[0] ?? "";
|
|
27
|
+
const lexicon = resolveEmitProvider(tfType)?.lexicon;
|
|
23
28
|
try {
|
|
24
|
-
plugins = await loadPlugins([
|
|
29
|
+
plugins = await loadPlugins(lexicon ? [lexicon] : carveEmitLexicons());
|
|
25
30
|
} catch {
|
|
26
31
|
// fall through with whatever ctx provided; carveEmit surfaces a clear error
|
|
27
32
|
}
|