@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,262 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
|
+
import { carveBridge } from "./carve-bridge";
|
|
7
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
8
|
+
import type { ImportResult, LiveImportOptions } from "./import";
|
|
9
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `carve emit` over the kubernetes provider (#999), end to end: a Terraform
|
|
13
|
+
* estate that manages Kubernetes objects through `kubernetes_manifest`, carved
|
|
14
|
+
* into a buildable chant project for the k8s lexicon.
|
|
15
|
+
*
|
|
16
|
+
* The point of the fixture is the second resource. A `kubernetes_manifest` has
|
|
17
|
+
* no fixed entity type — the kind is inside the body — so a cert-manager
|
|
18
|
+
* Certificate and a core ConfigMap go through the identical path, which is what
|
|
19
|
+
* "one rule covers every CRD" has to mean in practice.
|
|
20
|
+
*/
|
|
21
|
+
let parserAvailable = false;
|
|
22
|
+
try {
|
|
23
|
+
await loadHcl2json();
|
|
24
|
+
parserAvailable = true;
|
|
25
|
+
} catch {
|
|
26
|
+
parserAvailable = false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ESTATE = `
|
|
30
|
+
resource "kubernetes_namespace" "web" {
|
|
31
|
+
metadata {
|
|
32
|
+
name = "web"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resource "kubernetes_manifest" "app_config" {
|
|
37
|
+
manifest = {
|
|
38
|
+
apiVersion = "v1"
|
|
39
|
+
kind = "ConfigMap"
|
|
40
|
+
metadata = {
|
|
41
|
+
name = "app-config"
|
|
42
|
+
namespace = kubernetes_namespace.web.metadata[0].name
|
|
43
|
+
}
|
|
44
|
+
data = {
|
|
45
|
+
LOG_LEVEL = "info"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
resource "kubernetes_manifest" "web_cert" {
|
|
51
|
+
manifest = {
|
|
52
|
+
apiVersion = "cert-manager.io/v1"
|
|
53
|
+
kind = "Certificate"
|
|
54
|
+
metadata = {
|
|
55
|
+
name = "web-tls"
|
|
56
|
+
namespace = "web"
|
|
57
|
+
}
|
|
58
|
+
spec = {
|
|
59
|
+
secretName = "web-tls"
|
|
60
|
+
dnsNames = ["web.example.com"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
resource "kubernetes_config_map" "legacy" {
|
|
66
|
+
metadata {
|
|
67
|
+
name = "legacy"
|
|
68
|
+
namespace = "web"
|
|
69
|
+
}
|
|
70
|
+
data = {
|
|
71
|
+
OLD = "true"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const TFSTATE = JSON.stringify({
|
|
77
|
+
version: 4,
|
|
78
|
+
resources: [
|
|
79
|
+
{
|
|
80
|
+
mode: "managed",
|
|
81
|
+
type: "kubernetes_manifest",
|
|
82
|
+
name: "app_config",
|
|
83
|
+
instances: [
|
|
84
|
+
{
|
|
85
|
+
attributes: {
|
|
86
|
+
manifest: {
|
|
87
|
+
apiVersion: "v1",
|
|
88
|
+
kind: "ConfigMap",
|
|
89
|
+
metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
|
|
90
|
+
data: { LOG_LEVEL: "info" },
|
|
91
|
+
},
|
|
92
|
+
computed_fields: ["metadata.labels", "metadata.annotations"],
|
|
93
|
+
field_manager: null,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
mode: "managed",
|
|
100
|
+
type: "kubernetes_manifest",
|
|
101
|
+
name: "web_cert",
|
|
102
|
+
instances: [
|
|
103
|
+
{
|
|
104
|
+
attributes: {
|
|
105
|
+
manifest: {
|
|
106
|
+
apiVersion: "cert-manager.io/v1",
|
|
107
|
+
kind: "Certificate",
|
|
108
|
+
metadata: { name: "web-tls", namespace: "web" },
|
|
109
|
+
spec: { secretName: "web-tls", dnsNames: ["web.example.com"] },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
mode: "managed",
|
|
117
|
+
type: "kubernetes_config_map",
|
|
118
|
+
name: "legacy",
|
|
119
|
+
instances: [{ attributes: { id: "web/legacy", data: { OLD: "true" } } }],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const fakeImport = () =>
|
|
125
|
+
vi.fn(
|
|
126
|
+
async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
|
|
127
|
+
success: true,
|
|
128
|
+
generatedFiles: ["infra/main.ts"],
|
|
129
|
+
warnings: [],
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
134
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-k8s-"));
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
137
|
+
writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
|
|
138
|
+
return await fn(dir);
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("carve emit — kubernetes_manifest (#999)", () => {
|
|
145
|
+
test("carves a ConfigMap manifest into a buildable k8s project", async () => {
|
|
146
|
+
if (!parserAvailable) return;
|
|
147
|
+
await withEstate(async (dir) => {
|
|
148
|
+
const out = join(dir, "carveout");
|
|
149
|
+
const liveImport = fakeImport();
|
|
150
|
+
const res = await carveEmit(
|
|
151
|
+
{ from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
152
|
+
{ plugins: [], liveImport },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(res.ok).toBe(true);
|
|
156
|
+
expect(res.source).toBe("tfstate");
|
|
157
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
158
|
+
|
|
159
|
+
const source = readFileSync(join(out, "src", "app_config.ts"), "utf-8");
|
|
160
|
+
expect(source).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
|
|
161
|
+
expect(source).toContain("export const app_config = k8sManifest({");
|
|
162
|
+
expect(source).toContain('apiVersion: "v1"');
|
|
163
|
+
expect(source).toContain('kind: "ConfigMap"');
|
|
164
|
+
expect(source).toContain('"app.kubernetes.io/name": "web"');
|
|
165
|
+
expect(source).toContain("-> v1 ConfigMap");
|
|
166
|
+
|
|
167
|
+
// The scaffold targets the provider's lexicon, so the emitted project
|
|
168
|
+
// builds with `npm install && npm run build` as it stands.
|
|
169
|
+
const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
|
|
170
|
+
expect(pkg.scripts.build).toBe("chant build src --lexicon k8s");
|
|
171
|
+
expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-k8s"]);
|
|
172
|
+
expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["k8s"]');
|
|
173
|
+
|
|
174
|
+
// The namespace the manifest read is a survivor, so it is an outbound
|
|
175
|
+
// edge and becomes a declared build parameter.
|
|
176
|
+
expect(res.params?.map((p) => p.name)).toEqual(["manifest"]);
|
|
177
|
+
expect(source).toContain("Deferred deploy-time input: manifest read kubernetes_namespace.web");
|
|
178
|
+
// Nothing is substituted: state resolved `manifest` to the whole object.
|
|
179
|
+
expect(source).not.toContain("params.");
|
|
180
|
+
|
|
181
|
+
expect(res.manifestPath).toBe(join(out, "kubernetes_manifest-app_config.carve.json"));
|
|
182
|
+
expect(formatCarveEmit(res)).toContain("Adopted from Terraform state (offline).");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a CRD manifest carves through the same path as a core kind", async () => {
|
|
187
|
+
if (!parserAvailable) return;
|
|
188
|
+
await withEstate(async (dir) => {
|
|
189
|
+
const out = join(dir, "carveout");
|
|
190
|
+
const res = await carveEmit(
|
|
191
|
+
{ from: dir, select: "kubernetes_manifest.web_cert", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
192
|
+
{ plugins: [], liveImport: fakeImport() },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(res.ok).toBe(true);
|
|
196
|
+
const source = readFileSync(join(out, "src", "web_cert.ts"), "utf-8");
|
|
197
|
+
expect(source).toContain("-> cert-manager.io/v1 Certificate");
|
|
198
|
+
expect(source).toContain('apiVersion: "cert-manager.io/v1"');
|
|
199
|
+
expect(source).toContain('kind: "Certificate"');
|
|
200
|
+
expect(source).toContain('secretName: "web-tls"');
|
|
201
|
+
expect(source).toContain('"web.example.com",');
|
|
202
|
+
// No CRD-specific mapping was needed, so nothing was left behind.
|
|
203
|
+
expect(source).not.toContain("TODO");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("--env refuses: the kind is in the body, so a live export has nothing to filter on", async () => {
|
|
208
|
+
if (!parserAvailable) return;
|
|
209
|
+
await withEstate(async (dir) => {
|
|
210
|
+
const liveImport = fakeImport();
|
|
211
|
+
const res = await carveEmit(
|
|
212
|
+
{ from: dir, select: "kubernetes_manifest.app_config", env: "prod" },
|
|
213
|
+
{ plugins: [], liveImport },
|
|
214
|
+
);
|
|
215
|
+
expect(res.ok).toBe(false);
|
|
216
|
+
expect(res.error).toContain("no live adoption path");
|
|
217
|
+
expect(res.error).toContain("--state");
|
|
218
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a typed kubernetes resource ranks but is refused identically on both paths", async () => {
|
|
223
|
+
if (!parserAvailable) return;
|
|
224
|
+
await withEstate(async (dir) => {
|
|
225
|
+
const liveImport = fakeImport();
|
|
226
|
+
const live = await carveEmit(
|
|
227
|
+
{ from: dir, select: "kubernetes_config_map.legacy", env: "prod" },
|
|
228
|
+
{ plugins: [], liveImport },
|
|
229
|
+
);
|
|
230
|
+
const state = await carveEmit(
|
|
231
|
+
{ from: dir, select: "kubernetes_config_map.legacy", statePath: join(dir, "terraform.tfstate") },
|
|
232
|
+
{ plugins: [], liveImport },
|
|
233
|
+
);
|
|
234
|
+
expect(live.ok).toBe(false);
|
|
235
|
+
expect(state.error).toBe(live.error);
|
|
236
|
+
expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
|
|
237
|
+
expect(live.error).toContain("kubernetes_manifest");
|
|
238
|
+
expect(liveImport).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("bridge still refuses the carved manifest, so emit writes no half-bridged estate", async () => {
|
|
243
|
+
if (!parserAvailable) return;
|
|
244
|
+
await withEstate(async (dir) => {
|
|
245
|
+
const out = join(dir, "carveout");
|
|
246
|
+
const emitted = await carveEmit(
|
|
247
|
+
{ from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
248
|
+
{ plugins: [], liveImport: fakeImport() },
|
|
249
|
+
);
|
|
250
|
+
expect(emitted.ok).toBe(true);
|
|
251
|
+
|
|
252
|
+
// Emit does not unlock bridge: `manifest.metadata.name` is a path into
|
|
253
|
+
// nested blocks and a data-source body is flat `attr = value`, so the
|
|
254
|
+
// survivors' references cannot be repointed yet.
|
|
255
|
+
const bridged = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
|
|
256
|
+
expect(bridged.ok).toBe(false);
|
|
257
|
+
expect(bridged.error).toContain("cannot be bridged");
|
|
258
|
+
expect(bridged.error).toContain("manifest.metadata.name");
|
|
259
|
+
expect(existsSync(join(out, "kubernetes_manifest-app_config-runbook.md"))).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, vi } from "vitest";
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
6
|
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import { listCarveManifests } from "../../terraform/manifest";
|
|
7
8
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
8
9
|
import type { LexiconPlugin } from "../../lexicon";
|
|
9
10
|
|
|
@@ -35,12 +36,30 @@ resource "aws_lambda_function" "api" {
|
|
|
35
36
|
resource "random_pet" "suffix" {
|
|
36
37
|
length = 2
|
|
37
38
|
}
|
|
39
|
+
resource "kubernetes_config_map" "demo" {
|
|
40
|
+
metadata { name = "demo-config" }
|
|
41
|
+
}
|
|
38
42
|
`;
|
|
39
43
|
|
|
44
|
+
// A state file for the bucket, so the --state path reaches the same gate the
|
|
45
|
+
// --env path does rather than stopping at "not found in state".
|
|
46
|
+
const TFSTATE = JSON.stringify({
|
|
47
|
+
version: 4,
|
|
48
|
+
resources: [
|
|
49
|
+
{
|
|
50
|
+
mode: "managed",
|
|
51
|
+
type: "kubernetes_config_map",
|
|
52
|
+
name: "demo",
|
|
53
|
+
instances: [{ attributes: { id: "default/demo-config" } }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
|
|
40
58
|
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
41
59
|
const dir = mkdtempSync(join(tmpdir(), "chant-carve-emit-"));
|
|
42
60
|
try {
|
|
43
61
|
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
62
|
+
writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
|
|
44
63
|
return await fn(dir);
|
|
45
64
|
} finally {
|
|
46
65
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -142,6 +161,58 @@ describe("carveEmit — emit + boundary", () => {
|
|
|
142
161
|
});
|
|
143
162
|
});
|
|
144
163
|
|
|
164
|
+
test("a failed live import fails the emit and writes no carve manifest (#2015)", async () => {
|
|
165
|
+
if (!parserAvailable) return;
|
|
166
|
+
await withEstate(async (dir) => {
|
|
167
|
+
const out = join(dir, "carveout");
|
|
168
|
+
const li = vi.fn(
|
|
169
|
+
async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
|
|
170
|
+
success: false,
|
|
171
|
+
generatedFiles: [],
|
|
172
|
+
warnings: [],
|
|
173
|
+
error: "no stack in prod exports AWS::S3::Bucket",
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const res = await carveEmit(
|
|
177
|
+
{ from: dir, select: "aws_s3_bucket.assets", env: "prod", output: out },
|
|
178
|
+
{ plugins: noPlugins, liveImport: li },
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(res.ok).toBe(false);
|
|
182
|
+
expect(res.error).toContain("no stack in prod exports AWS::S3::Bucket");
|
|
183
|
+
expect(res.manifestPath).toBeUndefined();
|
|
184
|
+
// Bridge/apply compose from the manifest; one written here would make
|
|
185
|
+
// bridge excise a `.tf` block for a resource that was never emitted.
|
|
186
|
+
expect(listCarveManifests(out)).toEqual([]);
|
|
187
|
+
expect(existsSync(out)).toBe(false);
|
|
188
|
+
// No false "Adopted live" line on the failure path.
|
|
189
|
+
expect(formatCarveEmit(res)).not.toContain("Adopted live");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a type emit cannot carve is refused identically on --state and --env (#2015)", async () => {
|
|
194
|
+
if (!parserAvailable) return;
|
|
195
|
+
await withEstate(async (dir) => {
|
|
196
|
+
const li = fakeImport();
|
|
197
|
+
const live = await carveEmit(
|
|
198
|
+
{ from: dir, select: "kubernetes_config_map.demo", env: "prod" },
|
|
199
|
+
{ plugins: noPlugins, liveImport: li },
|
|
200
|
+
);
|
|
201
|
+
const state = await carveEmit(
|
|
202
|
+
{ from: dir, select: "kubernetes_config_map.demo", statePath: join(dir, "terraform.tfstate") },
|
|
203
|
+
{ plugins: noPlugins, liveImport: li },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(live.ok).toBe(false);
|
|
207
|
+
expect(state.ok).toBe(false);
|
|
208
|
+
expect(live.error).toBe(state.error);
|
|
209
|
+
expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
|
|
210
|
+
// advise ranks it (tier 2), so it gets past resolveTier — the emit gate
|
|
211
|
+
// is what refuses it, before the AWS exporter is ever reached.
|
|
212
|
+
expect(li).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
145
216
|
test("--live-name narrows the live selector to a CFN logical id", async () => {
|
|
146
217
|
if (!parserAvailable) return;
|
|
147
218
|
await withEstate(async (dir) => {
|