@cdktn/provider-generator 0.25.0-pre.9 → 0.25.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.
Files changed (68) hide show
  1. package/README.md +12 -0
  2. package/build/__tests__/edge-provider-schema/builder.js +3 -3
  3. package/build/__tests__/edge-provider-schema/cli.js +18 -8
  4. package/build/__tests__/edge-provider-schema.test.js +18 -8
  5. package/build/__tests__/provider.test.js +44 -16
  6. package/build/get/__tests__/constructs-maker.test.js +91 -8
  7. package/build/get/__tests__/generator/complex-computed-types.test.js +18 -8
  8. package/build/get/__tests__/generator/deep-nested-attributes.test.js +18 -8
  9. package/build/get/__tests__/generator/description-escaping.test.js +18 -8
  10. package/build/get/__tests__/generator/empty-provider-resources.test.js +18 -8
  11. package/build/get/__tests__/generator/ephemeral-resources.test.js +18 -8
  12. package/build/get/__tests__/generator/export-sharding.test.js +18 -8
  13. package/build/get/__tests__/generator/import-style.test.js +18 -8
  14. package/build/get/__tests__/generator/module-docs-registry.test.d.ts +2 -0
  15. package/build/get/__tests__/generator/module-docs-registry.test.js +91 -0
  16. package/build/get/__tests__/generator/module-generator.test.js +18 -8
  17. package/build/get/__tests__/generator/nested-types.test.js +18 -8
  18. package/build/get/__tests__/generator/provider-functions-name-collision.test.d.ts +2 -0
  19. package/build/get/__tests__/generator/provider-functions-name-collision.test.js +89 -0
  20. package/build/get/__tests__/generator/provider-functions.test.js +18 -8
  21. package/build/get/__tests__/generator/provider.test.js +18 -8
  22. package/build/get/__tests__/generator/resource-types.test.js +18 -8
  23. package/build/get/__tests__/generator/skipped-attributes.test.js +18 -8
  24. package/build/get/__tests__/generator/types.test.js +18 -8
  25. package/build/get/__tests__/generator/versions-file.test.js +25 -10
  26. package/build/get/__tests__/generator/write-only.test.js +18 -8
  27. package/build/get/__tests__/registry-class-invalidation.test.d.ts +2 -0
  28. package/build/get/__tests__/registry-class-invalidation.test.js +120 -0
  29. package/build/get/__tests__/target-versions.test.js +46 -8
  30. package/build/get/__tests__/util.js +20 -11
  31. package/build/get/constructs-maker.d.ts +17 -0
  32. package/build/get/constructs-maker.js +86 -11
  33. package/build/get/generator/emitter/struct-emitter.js +20 -10
  34. package/build/get/generator/loop-detection.js +2 -3
  35. package/build/get/generator/models/attribute-model.js +3 -3
  36. package/build/get/generator/models/attribute-type-model.js +3 -3
  37. package/build/get/generator/models/provider-function-model.js +3 -4
  38. package/build/get/generator/models/resource-model.d.ts +5 -1
  39. package/build/get/generator/models/resource-model.js +20 -10
  40. package/build/get/generator/models/supported-stored-classes.js +2 -3
  41. package/build/get/generator/module-generator.d.ts +3 -2
  42. package/build/get/generator/module-generator.js +15 -17
  43. package/build/get/generator/provider-generator.d.ts +15 -1
  44. package/build/get/generator/provider-generator.js +20 -5
  45. package/build/get/generator/resource-parser.d.ts +3 -1
  46. package/build/get/generator/resource-parser.js +16 -7
  47. package/build/get/generator/sanitized-comments.js +2 -3
  48. package/build/get/generator/skipped-attributes.js +2 -3
  49. package/build/index.js +3 -3
  50. package/build/util.js +3 -4
  51. package/package.json +32 -9
  52. package/src/__tests__/__snapshots__/provider.test.ts.snap +1309 -3206
  53. package/src/__tests__/provider.test.ts +31 -8
  54. package/src/get/__tests__/constructs-maker.test.ts +125 -1
  55. package/src/get/__tests__/generator/fixtures/provider-functions-name-collision.test.fixture.json +42 -0
  56. package/src/get/__tests__/generator/module-docs-registry.test.ts +89 -0
  57. package/src/get/__tests__/generator/provider-functions-name-collision.test.ts +84 -0
  58. package/src/get/__tests__/generator/versions-file.test.ts +10 -5
  59. package/src/get/__tests__/registry-class-invalidation.test.ts +123 -0
  60. package/src/get/__tests__/target-versions.test.ts +43 -0
  61. package/src/get/constructs-maker.ts +108 -7
  62. package/src/get/generator/emitter/struct-emitter.ts +2 -2
  63. package/src/get/generator/models/attribute-type-model.ts +1 -2
  64. package/src/get/generator/models/resource-model.ts +32 -9
  65. package/src/get/generator/module-generator.ts +20 -18
  66. package/src/get/generator/provider-generator.ts +27 -4
  67. package/src/get/generator/resource-parser.ts +21 -3
  68. package/tsconfig.json +4 -0
@@ -70,6 +70,27 @@ function resourceTypesPresentInSnapshot(
70
70
  return resources;
71
71
  }
72
72
 
73
+ /**
74
+ * Rewrites the registry host in any fully-qualified-name key. The host records
75
+ * which CLI fetched the schema - terraform's or tofu's - rather than anything
76
+ * about the generated code, so one snapshot serves both.
77
+ */
78
+ function stubRegistryHosts(value: any): any {
79
+ if (Array.isArray(value)) return value.map(stubRegistryHosts);
80
+ if (value && typeof value === "object") {
81
+ return Object.fromEntries(
82
+ Object.entries(value).map(([k, v]) => [
83
+ k.replace(
84
+ /^registry\.(?:terraform\.io|opentofu\.org)\//,
85
+ "STUBBED_REGISTRY/",
86
+ ),
87
+ stubRegistryHosts(v),
88
+ ]),
89
+ );
90
+ }
91
+ return value;
92
+ }
93
+
73
94
  describe("Provider", () => {
74
95
  it("generates a provider", async () => {
75
96
  const constraint = new TerraformProviderConstraint(
@@ -86,7 +107,7 @@ describe("Provider", () => {
86
107
  process.env.CDKTF_EXPERIMENTAL_PROVIDER_SCHEMA_CACHE_PATH,
87
108
  );
88
109
  await maker.generate([constraint]);
89
- const snapshot = directorySnapshot(workdir);
110
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
90
111
  expect(snapshot).toMatchSnapshot();
91
112
  });
92
113
  }, 600_000);
@@ -103,7 +124,7 @@ describe("Provider", () => {
103
124
  targetLanguage: Language.TYPESCRIPT,
104
125
  });
105
126
  await maker.generate([constraint]);
106
- const snapshot = directorySnapshot(workdir);
127
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
107
128
 
108
129
  const terraformResourceTypesPresent: string[] =
109
130
  resourceTypesPresentInSnapshot(snapshot, "datadog");
@@ -127,7 +148,7 @@ describe("Provider", () => {
127
148
  const constraint = new TerraformProviderConstraint({
128
149
  name: "dockerr",
129
150
  source: "registry.terraform.io/kreuzwerker/docker",
130
- version: "3.0.2",
151
+ version: "3.9.0",
131
152
  });
132
153
  return await mkdtemp(async (workdir) => {
133
154
  const jsiiPath = path.join(workdir, ".jsii");
@@ -140,7 +161,7 @@ describe("Provider", () => {
140
161
  process.env.CDKTF_EXPERIMENTAL_PROVIDER_SCHEMA_CACHE_PATH,
141
162
  );
142
163
  await maker.generate([constraint]);
143
- const snapshot = directorySnapshot(workdir);
164
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
144
165
 
145
166
  const terraformResourceTypesPresent: string[] =
146
167
  resourceTypesPresentInSnapshot(snapshot, "dockerr");
@@ -160,11 +181,13 @@ describe("Provider", () => {
160
181
  version: "2.51.0",
161
182
  source: "DrFaust92/bitbucket",
162
183
  });
184
+ // andsafe-AG/bitbucket is Terraform-registry only; zahiar/bitbucket is
185
+ // mirrored on both, so this runs under either CLI.
163
186
  const constraint2 = new TerraformProviderConstraint({
164
187
  name: "abitbucket",
165
- namespace: "andsafe-AG",
166
- version: "2.5.0",
167
- source: "andsafe-AG/bitbucket",
188
+ namespace: "zahiar",
189
+ version: "0.3.0",
190
+ source: "zahiar/bitbucket",
168
191
  });
169
192
  return await mkdtemp(async (workdir) => {
170
193
  const jsiiPath = path.join(workdir, ".jsii");
@@ -178,7 +201,7 @@ describe("Provider", () => {
178
201
  );
179
202
  await maker.generate([constraint, constraint2]);
180
203
  console.log("workdir", workdir);
181
- const snapshot = directorySnapshot(workdir);
204
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
182
205
  expect(snapshot).toMatchSnapshot();
183
206
  });
184
207
  }, 600_000);
@@ -8,7 +8,7 @@ import {
8
8
  ConstructsMakerModuleTarget,
9
9
  ConstructsMakerProviderTarget,
10
10
  } from "@cdktn/commons";
11
- import { determineGoModuleName } from "../constructs-maker";
11
+ import { ConstructsMaker, determineGoModuleName } from "../constructs-maker";
12
12
 
13
13
  describe("constructsMaker", () => {
14
14
  describe("determineGoModuleName", () => {
@@ -115,4 +115,128 @@ describe("constructsMaker", () => {
115
115
  expect(target.srcMakName).toEqual("security_group");
116
116
  });
117
117
  });
118
+
119
+ describe("removeOrphanedResourceFolders", () => {
120
+ let tmpDir: string;
121
+
122
+ const providerDir = (provider: string) =>
123
+ path.join(tmpDir, "providers", provider);
124
+
125
+ /**
126
+ * Lay out a provider folder containing the given resource directories plus
127
+ * the provider-level index files a real generation run writes.
128
+ */
129
+ const seedProvider = async (provider: string, resources: string[]) => {
130
+ for (const resource of resources) {
131
+ await fs.mkdirs(path.join(providerDir(provider), resource));
132
+ await fs.writeFile(
133
+ path.join(providerDir(provider), resource, "index.ts"),
134
+ "// generated\n",
135
+ );
136
+ }
137
+ await fs.writeFile(
138
+ path.join(providerDir(provider), "index.ts"),
139
+ "// generated by cdktn get\n",
140
+ );
141
+ await fs.writeFile(
142
+ path.join(providerDir(provider), "lazy-index.ts"),
143
+ "// generated by cdktn get\n",
144
+ );
145
+ };
146
+
147
+ const makeMaker = (targetLanguage: Language) =>
148
+ new ConstructsMaker({
149
+ codeMakerOutput: tmpDir,
150
+ targetLanguage,
151
+ });
152
+
153
+ beforeEach(async () => {
154
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cdktf.orphans"));
155
+ });
156
+
157
+ afterEach(async () => {
158
+ await fs.remove(tmpDir);
159
+ });
160
+
161
+ it("removes resource folders the current run did not emit", async () => {
162
+ // 'teams' and 'data-lake-pipeline' exist only in the previously generated
163
+ // provider version; the new schema no longer produces them.
164
+ await seedProvider("mongodbatlas", [
165
+ "cluster",
166
+ "teams",
167
+ "data-lake-pipeline",
168
+ ]);
169
+
170
+ const maker = makeMaker(Language.TYPESCRIPT);
171
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
172
+ await maker.removeOrphanedResourceFolders();
173
+
174
+ expect(
175
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "cluster")),
176
+ ).toBe(true);
177
+ expect(
178
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "teams")),
179
+ ).toBe(false);
180
+ expect(
181
+ await fs.pathExists(
182
+ path.join(providerDir("mongodbatlas"), "data-lake-pipeline"),
183
+ ),
184
+ ).toBe(false);
185
+ });
186
+
187
+ it("keeps provider-level files", async () => {
188
+ await seedProvider("mongodbatlas", ["cluster", "teams"]);
189
+
190
+ const maker = makeMaker(Language.TYPESCRIPT);
191
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
192
+ await maker.removeOrphanedResourceFolders();
193
+
194
+ expect(
195
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "index.ts")),
196
+ ).toBe(true);
197
+ expect(
198
+ await fs.pathExists(
199
+ path.join(providerDir("mongodbatlas"), "lazy-index.ts"),
200
+ ),
201
+ ).toBe(true);
202
+ });
203
+
204
+ it("leaves providers that were not regenerated untouched", async () => {
205
+ // filterAlreadyGenerated can skip a provider entirely, in which case it
206
+ // has no entry in emittedResourceFolders and must not be pruned.
207
+ await seedProvider("mongodbatlas", ["cluster"]);
208
+ await seedProvider("onepassword", ["item"]);
209
+
210
+ const maker = makeMaker(Language.TYPESCRIPT);
211
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
212
+ await maker.removeOrphanedResourceFolders();
213
+
214
+ expect(
215
+ await fs.pathExists(path.join(providerDir("onepassword"), "item")),
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("is a no-op for non-TypeScript targets", async () => {
220
+ // Other languages co-locate providers and modules, so a dropped resource
221
+ // is indistinguishable from a hand-written construct.
222
+ await seedProvider("mongodbatlas", ["cluster", "teams"]);
223
+
224
+ const maker = makeMaker(Language.PYTHON);
225
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
226
+ await maker.removeOrphanedResourceFolders();
227
+
228
+ expect(
229
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "teams")),
230
+ ).toBe(true);
231
+ });
232
+
233
+ it("does not throw when the provider folder is missing", async () => {
234
+ const maker = makeMaker(Language.TYPESCRIPT);
235
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
236
+
237
+ await expect(
238
+ maker.removeOrphanedResourceFolders(),
239
+ ).resolves.not.toThrow();
240
+ });
241
+ });
118
242
  });
@@ -0,0 +1,42 @@
1
+ {
2
+ "provider_schemas": {
3
+ "registry.terraform.io/hashicorp/example": {
4
+ "resource_schemas": {
5
+ "example_provider_functions": {
6
+ "version": 0,
7
+ "block": {
8
+ "attributes": {
9
+ "id": {
10
+ "type": "string",
11
+ "description_kind": "plain",
12
+ "computed": true
13
+ },
14
+ "name": {
15
+ "type": "string",
16
+ "description_kind": "plain",
17
+ "optional": true
18
+ }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ "functions": {
24
+ "sum": {
25
+ "description": "Adds up two numbers.",
26
+ "summary": "Sum numbers",
27
+ "return_type": "number",
28
+ "parameters": [
29
+ {
30
+ "name": "a",
31
+ "type": "number"
32
+ },
33
+ {
34
+ "name": "b",
35
+ "type": "number"
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,89 @@
1
+ // Copyright (c) HashiCorp, Inc
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { CodeMaker } from "codemaker";
6
+ import {
7
+ ConstructsMakerModuleTarget,
8
+ Language,
9
+ TerraformModuleConstraint,
10
+ TerraformTargetVersions,
11
+ } from "@cdktn/commons";
12
+ import { ModuleGenerator } from "../../generator/module-generator";
13
+ import { createTmpHelper } from "../util";
14
+
15
+ const tmp = createTmpHelper();
16
+
17
+ async function docsComment(
18
+ source: string,
19
+ targetVersions?: TerraformTargetVersions,
20
+ ): Promise<string> {
21
+ const workdir = tmp("module-docs-registry");
22
+ const target = ConstructsMakerModuleTarget.from(
23
+ new TerraformModuleConstraint(source),
24
+ Language.TYPESCRIPT,
25
+ ) as ConstructsMakerModuleTarget;
26
+ target.spec = { name: target.name, inputs: [], outputs: [] };
27
+
28
+ const code = new CodeMaker();
29
+ new ModuleGenerator(code, [target], targetVersions);
30
+ await code.save(workdir);
31
+
32
+ const emitted = fs.readFileSync(path.join(workdir, target.fileName), "utf-8");
33
+ const line = emitted
34
+ .split("\n")
35
+ .find((l) => l.includes("Docs at") || l.includes("Source at"));
36
+ return (line ?? "").trim();
37
+ }
38
+
39
+ describe("generated module docs links", () => {
40
+ const module = "terraform-aws-modules/vpc/aws@3.19.0";
41
+ const submodule =
42
+ "terraform-aws-modules/vpc/aws//modules/vpc-endpoints@3.19.0";
43
+
44
+ it("uses the Terraform Registry when nothing is declared", async () => {
45
+ expect(await docsComment(module)).toContain(
46
+ "https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/3.19.0",
47
+ );
48
+ });
49
+
50
+ it("uses the OpenTofu Registry when only OpenTofu is declared", async () => {
51
+ const comment = await docsComment(module, { opentofu: ">=1.6.0" });
52
+ expect(comment).toContain("Docs at OpenTofu Registry");
53
+ expect(comment).toContain(
54
+ "https://search.opentofu.org/module/terraform-aws-modules/vpc/aws/v3.19.0",
55
+ );
56
+ });
57
+
58
+ it("links an OpenTofu submodule with the singular segment", async () => {
59
+ expect(await docsComment(submodule, { opentofu: ">=1.6.0" })).toContain(
60
+ "https://search.opentofu.org/module/terraform-aws-modules/vpc/aws/v3.19.0/submodule/vpc-endpoints",
61
+ );
62
+ });
63
+
64
+ // Registries serve submodule pages only for a direct child of `modules/`
65
+ // (checked against rendered pages); anything else links to the module.
66
+ it.each([
67
+ [
68
+ "an examples path",
69
+ "terraform-aws-modules/vpc/aws//examples/complete@3.19.0",
70
+ ],
71
+ [
72
+ "a nested modules path",
73
+ "terraform-aws-modules/vpc/aws//modules/x/y@3.19.0",
74
+ ],
75
+ ])("links %s to the module page", async (_name, source) => {
76
+ expect(await docsComment(source)).toContain(
77
+ "{@link https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/3.19.0 ",
78
+ );
79
+ expect(await docsComment(source, { opentofu: ">=1.6.0" })).toContain(
80
+ "{@link https://search.opentofu.org/module/terraform-aws-modules/vpc/aws/v3.19.0 ",
81
+ );
82
+ });
83
+
84
+ it("keeps the Terraform submodule shape unchanged", async () => {
85
+ expect(await docsComment(submodule)).toContain(
86
+ "https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/3.19.0/submodules/vpc-endpoints",
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,84 @@
1
+ // Copyright (c) HashiCorp, Inc
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { CodeMaker } from "codemaker";
6
+ import { TerraformProviderGenerator } from "../../generator/provider-generator";
7
+ import { sanitizeClassOrNamespaceName } from "../../generator/resource-parser";
8
+ import { createTmpHelper } from "../util";
9
+
10
+ const tmp = createTmpHelper();
11
+
12
+ // A provider declaring provider-defined functions emits them to
13
+ // providers/<provider>/provider-functions/index.ts. A resource named
14
+ // <provider>_provider_functions maps to that same directory, so without a
15
+ // guard the resource overwrites the functions wrapper and the provider index
16
+ // exports the name twice.
17
+ describe("resource named like the provider functions submodule", () => {
18
+ let workdir: string;
19
+
20
+ beforeAll(async () => {
21
+ const code = new CodeMaker();
22
+ workdir = tmp("provider-functions-name-collision.test");
23
+ const spec = JSON.parse(
24
+ fs.readFileSync(
25
+ path.join(
26
+ __dirname,
27
+ "fixtures",
28
+ "provider-functions-name-collision.test.fixture.json",
29
+ ),
30
+ "utf-8",
31
+ ),
32
+ );
33
+ new TerraformProviderGenerator(code, spec).generateAll();
34
+ await code.save(workdir);
35
+ });
36
+
37
+ const read = (relPath: string) =>
38
+ fs.readFileSync(path.join(workdir, relPath), "utf-8");
39
+
40
+ test("the functions submodule holds the functions wrapper, not the resource", () => {
41
+ const output = read("providers/example/provider-functions/index.ts");
42
+ expect(output).toContain("class ExampleProviderFunctions");
43
+ expect(output).not.toContain("cdktn.TerraformResource");
44
+ });
45
+
46
+ test("the resource is emitted to its own directory", () => {
47
+ const output = read(
48
+ "providers/example/provider-functions-resource/index.ts",
49
+ );
50
+ expect(output).toContain("class ProviderFunctionsResource");
51
+ expect(output).toContain("cdktn.TerraformResource");
52
+ });
53
+
54
+ test("the provider index exports both, without duplicate names", () => {
55
+ const index = read("providers/example/index.ts");
56
+ expect(index).toContain(
57
+ "export * as providerFunctions from './provider-functions/index';",
58
+ );
59
+ expect(index).toContain(
60
+ "export * as providerFunctionsResource from './provider-functions-resource/index';",
61
+ );
62
+
63
+ const exportedNames = [...index.matchAll(/export \* as (\w+) from/g)].map(
64
+ (m) => m[1],
65
+ );
66
+ expect(new Set(exportedNames).size).toBe(exportedNames.length);
67
+ });
68
+ });
69
+
70
+ describe("sanitizeClassOrNamespaceName", () => {
71
+ test("suffixes a resource named provider_functions", () => {
72
+ expect(sanitizeClassOrNamespaceName("provider_functions")).toBe(
73
+ "provider_functions_resource",
74
+ );
75
+ });
76
+
77
+ test("still suffixes a resource named provider", () => {
78
+ expect(sanitizeClassOrNamespaceName("provider")).toBe("provider_resource");
79
+ });
80
+
81
+ test("leaves an unrelated resource name alone", () => {
82
+ expect(sanitizeClassOrNamespaceName("function_app")).toBe("function_app");
83
+ });
84
+ });
@@ -33,7 +33,8 @@ describe("versions.json file generation", () => {
33
33
  fqn: "kreuzwerker/docker",
34
34
  name: "docker",
35
35
  source: "kreuzwerker/docker",
36
- version: "2.16.0",
36
+ // OpenTofu rejects everything below 3.7.0 as unsigned; see #440.
37
+ version: "3.9.0",
37
38
  },
38
39
  ];
39
40
 
@@ -62,10 +63,14 @@ describe("versions.json file generation", () => {
62
63
  path.join(workdir, "versions.json"),
63
64
  "utf-8",
64
65
  );
65
- expect(Object.keys(JSON.parse(output))).toEqual(
66
- expect.arrayContaining(
67
- constraints.map((c) => `registry.terraform.io/${c.fqn}`),
68
- ),
66
+ // versions.json is keyed by fully qualified name, and the host is
67
+ // whichever registry the fetching CLI used - terraform's or tofu's - so
68
+ // assert on the provider part rather than pinning one registry.
69
+ const keys = Object.keys(JSON.parse(output)).map((k) =>
70
+ k.split("/").slice(1).join("/"),
71
+ );
72
+ expect(keys).toEqual(
73
+ expect.arrayContaining(constraints.map((c) => c.fqn)),
69
74
  );
70
75
  },
71
76
  );
@@ -0,0 +1,123 @@
1
+ // Copyright (c) HashiCorp, Inc
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ import * as fs from "fs-extra";
4
+ import * as path from "path";
5
+ import {
6
+ Language,
7
+ TerraformProviderConstraint,
8
+ TerraformTargetVersions,
9
+ } from "@cdktn/commons";
10
+ import { createTmpHelper } from "./util";
11
+
12
+ const mockReadSchema = jest.fn();
13
+ jest.mock("@cdktn/provider-schema", () => ({
14
+ readSchema: (...args: unknown[]) => mockReadSchema(...args),
15
+ }));
16
+
17
+ // Imported after the mock so ConstructsMaker picks up the mocked readSchema.
18
+ import { ConstructsMaker } from "../constructs-maker";
19
+
20
+ const tmp = createTmpHelper();
21
+
22
+ // No resources, so generation stays in TypeScript emission.
23
+ const fakeSchema = {
24
+ providerSchema: {
25
+ format_version: "0.2" as const,
26
+ provider_schemas: {
27
+ "registry.terraform.io/hashicorp/random": {
28
+ resource_schemas: null,
29
+ data_source_schemas: null,
30
+ },
31
+ },
32
+ provider_versions: { "registry.terraform.io/hashicorp/random": "3.2.0" },
33
+ cli_name: "terraform",
34
+ cli_version: "1.7.5",
35
+ },
36
+ };
37
+
38
+ describe("filterAlreadyGenerated: registry class", () => {
39
+ const constraint = new TerraformProviderConstraint("hashicorp/random@=3.2.0");
40
+ let outdir: string;
41
+
42
+ const maker = (targetVersions?: TerraformTargetVersions) =>
43
+ new ConstructsMaker({
44
+ codeMakerOutput: outdir,
45
+ targetLanguage: Language.TYPESCRIPT,
46
+ targetVersions,
47
+ });
48
+
49
+ /** A real previous `cdktn get`, so constraints.json is what generation writes. */
50
+ const generateWith = (targetVersions?: TerraformTargetVersions) =>
51
+ maker(targetVersions).generate([constraint]);
52
+
53
+ beforeEach(() => {
54
+ mockReadSchema.mockReset();
55
+ mockReadSchema.mockResolvedValue(fakeSchema);
56
+ outdir = tmp("registry-class");
57
+ });
58
+
59
+ it.each<
60
+ [
61
+ string,
62
+ TerraformTargetVersions | undefined,
63
+ TerraformTargetVersions | undefined,
64
+ ]
65
+ >([
66
+ ["nothing changed", undefined, undefined],
67
+ [
68
+ "both products declared, the same class as nothing",
69
+ undefined,
70
+ { terraform: ">=1.5.7", opentofu: ">=1.6.0" },
71
+ ],
72
+ [
73
+ "a range moving within the OpenTofu class",
74
+ { opentofu: ">=1.6.0" },
75
+ { opentofu: ">=1.8.0" },
76
+ ],
77
+ ])("skips regeneration: %s", async (_name, before, after) => {
78
+ await generateWith(before);
79
+
80
+ await expect(
81
+ maker(after).filterAlreadyGenerated([constraint]),
82
+ ).resolves.toEqual([]);
83
+ });
84
+
85
+ it("regenerates when the project switches to OpenTofu-only", async () => {
86
+ await generateWith(undefined);
87
+
88
+ await expect(
89
+ maker({ opentofu: ">=1.6.0" }).filterAlreadyGenerated([constraint]),
90
+ ).resolves.toEqual([constraint]);
91
+ });
92
+
93
+ it("regenerates when the project switches away from OpenTofu-only", async () => {
94
+ await generateWith({ opentofu: ">=1.6.0" });
95
+
96
+ await expect(
97
+ maker({ terraform: ">=1.5.7" }).filterAlreadyGenerated([constraint]),
98
+ ).resolves.toEqual([constraint]);
99
+ });
100
+
101
+ // constraints.json written before targetVersions was stamped has no such field;
102
+ // it must read as the Terraform class, which is what it was generated for.
103
+ describe("constraints.json from before targetVersions was recorded", () => {
104
+ beforeEach(async () => {
105
+ await generateWith({ terraform: ">=1.5.7" });
106
+ const file = path.join(outdir, "constraints.json");
107
+ const { targetVersions: _dropped, ...older } = fs.readJsonSync(file);
108
+ fs.writeJsonSync(file, older);
109
+ });
110
+
111
+ it("is fresh for a Terraform project", async () => {
112
+ await expect(
113
+ maker().filterAlreadyGenerated([constraint]),
114
+ ).resolves.toEqual([]);
115
+ });
116
+
117
+ it("is stale for an OpenTofu-only project", async () => {
118
+ await expect(
119
+ maker({ opentofu: ">=1.6.0" }).filterAlreadyGenerated([constraint]),
120
+ ).resolves.toEqual([constraint]);
121
+ });
122
+ });
123
+ });
@@ -3,8 +3,10 @@
3
3
  import * as fs from "fs-extra";
4
4
  import * as path from "path";
5
5
  import {
6
+ ConstructsMakerModuleTarget,
6
7
  Language,
7
8
  TerraformDependencyConstraint,
9
+ TerraformModuleConstraint,
8
10
  TerraformTargetVersions,
9
11
  } from "@cdktn/commons";
10
12
  import { createTmpHelper } from "./util";
@@ -204,3 +206,44 @@ describe("targetVersions threading through 'cdktn get'", () => {
204
206
  expect(toGenerate).toEqual([]);
205
207
  });
206
208
  });
209
+
210
+ describe("targetVersions reaching generated module bindings", () => {
211
+ const moduleConstraint = new TerraformModuleConstraint(
212
+ "terraform-aws-modules/vpc/aws@3.19.0",
213
+ );
214
+
215
+ const emittedSources = (dir: string): string[] =>
216
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
217
+ const full = path.join(dir, entry.name);
218
+ if (entry.isDirectory()) return emittedSources(full);
219
+ return entry.name.endsWith(".ts") ? [fs.readFileSync(full, "utf8")] : [];
220
+ });
221
+
222
+ beforeEach(() => {
223
+ mockReadSchema.mockReset();
224
+ const target = ConstructsMakerModuleTarget.from(
225
+ moduleConstraint,
226
+ Language.TYPESCRIPT,
227
+ ) as ConstructsMakerModuleTarget;
228
+ mockReadSchema.mockResolvedValue({
229
+ moduleSchema: {
230
+ [target.moduleKey]: { name: target.name, inputs: [], outputs: [] },
231
+ },
232
+ });
233
+ });
234
+
235
+ it("links an OpenTofu-only project's modules to the OpenTofu registry", async () => {
236
+ const workdir = tmp("target-versions-module.test");
237
+ const maker = new ConstructsMaker({
238
+ codeMakerOutput: workdir,
239
+ targetLanguage: Language.TYPESCRIPT,
240
+ targetVersions: { opentofu: ">=1.6.0" },
241
+ });
242
+
243
+ await maker.generate([moduleConstraint]);
244
+
245
+ expect(emittedSources(workdir).join("\n")).toContain(
246
+ "https://search.opentofu.org/module/terraform-aws-modules/vpc/aws/v3.19.0",
247
+ );
248
+ });
249
+ });