@cdktn/provider-generator 0.25.0-pre.61 → 0.25.0-pre.64

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.
@@ -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,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
+ });
@@ -17,7 +17,11 @@ import {
17
17
  type LanguageOptions,
18
18
  type TerraformTargetVersions,
19
19
  } from "@cdktn/commons";
20
- import { DISPLAY_VERSION, Language } from "@cdktn/commons";
20
+ import {
21
+ DISPLAY_VERSION,
22
+ Language,
23
+ registryForTargetVersions,
24
+ } from "@cdktn/commons";
21
25
  import { TerraformProviderGenerator } from "./generator/provider-generator";
22
26
  import { ModuleGenerator } from "./generator/module-generator";
23
27
  import { glob } from "glob";
@@ -275,11 +279,13 @@ type ConstraintFile = {
275
279
  providers: Record<string, string>;
276
280
  cdktf: string;
277
281
  /**
278
- * The project's declared targetVersions (cdktf.json), stamped for
279
- * diagnostics/cache debugging. Purely informational - never read back to
280
- * decide whether cached output is stale, since it doesn't affect the
281
- * generated surface (the full surface is always generated; narrowing
282
- * happens at synth).
282
+ * The project's declared targetVersions (cdktf.json). Read back by
283
+ * `filterAlreadyGenerated` to compare registry *classes*: which registry the
284
+ * generated docs links point at follows from this, so output produced for the
285
+ * other class is stale. The version ranges themselves do not affect the
286
+ * generated surface - the full surface is always generated and narrowing
287
+ * happens at synth - so a range moving within a class is not a reason to
288
+ * regenerate.
283
289
  */
284
290
  targetVersions?: TerraformTargetVersions;
285
291
  /**
@@ -407,6 +413,22 @@ export class ConstructsMaker {
407
413
  return constraints;
408
414
  }
409
415
 
416
+ // Generated docs links, and the prose naming the registry around them, follow
417
+ // the project's declared targets, so output from the other registry class is
418
+ // stale even when every provider version still matches.
419
+ const previousRegistry = registryForTargetVersions(
420
+ previousConstraints.targetVersions,
421
+ );
422
+ const currentRegistry = registryForTargetVersions(
423
+ this.options.targetVersions,
424
+ );
425
+ if (previousRegistry.hostname !== currentRegistry.hostname) {
426
+ logger.info(
427
+ `The target registry has changed from ${previousRegistry.displayName} to ${currentRegistry.displayName}, generating all constraints.`,
428
+ );
429
+ return constraints;
430
+ }
431
+
410
432
  const constraintsToGenerate = constraints.filter((constraint) => {
411
433
  const constraintMatches =
412
434
  previousConstraints.providers![constraint.fqn] === constraint.version;
@@ -457,7 +479,7 @@ export class ConstructsMaker {
457
479
  ) {
458
480
  const endTSTimer = logTimespan(`Generate Typescript for ${target.name}`);
459
481
  target.spec = schema;
460
- new ModuleGenerator(this.code, [target]);
482
+ new ModuleGenerator(this.code, [target], this.options.targetVersions);
461
483
  endTSTimer();
462
484
  }
463
485
 
@@ -529,7 +551,21 @@ export class ConstructsMaker {
529
551
  );
530
552
  }
531
553
 
554
+ // Keys are fully qualified and the host is whichever registry the fetching CLI
555
+ // resolved from, so a provider regenerated this run can arrive under a different
556
+ // host than the file already holds. Carrying the old key forward as well would
557
+ // leave two entries for one provider, and the stale one sorts first.
558
+ const regenerated = new Set(
559
+ Object.keys(this.versions).map((fqpn) =>
560
+ fqpn.split("/").slice(1).join("/"),
561
+ ),
562
+ );
563
+
532
564
  const versions = allowedConstraints.reduce((acc, constraint) => {
565
+ if (regenerated.has(constraint.fqn)) {
566
+ return acc;
567
+ }
568
+
533
569
  const provider = Object.entries(previousVersions).find(([name]) =>
534
570
  // This could be more refined, but it's good enough for now
535
571
  name.endsWith(constraint.fqn),
@@ -3,12 +3,17 @@
3
3
  import { CodeMaker, toCamelCase } from "codemaker";
4
4
  import { AttributeModel } from "./models";
5
5
  import { sanitizedComment } from "./sanitized-comments";
6
- import { ConstructsMakerModuleTarget } from "@cdktn/commons";
6
+ import {
7
+ ConstructsMakerModuleTarget,
8
+ TerraformTargetVersions,
9
+ registryForTargetVersions,
10
+ } from "@cdktn/commons";
7
11
 
8
12
  export class ModuleGenerator {
9
13
  constructor(
10
14
  private readonly code: CodeMaker,
11
15
  private readonly targets: ConstructsMakerModuleTarget[],
16
+ private readonly targetVersions?: TerraformTargetVersions,
12
17
  ) {
13
18
  this.code.indentation = 2;
14
19
 
@@ -76,22 +81,19 @@ export class ModuleGenerator {
76
81
  // ... and more
77
82
  // ../module and ./module (local paths)
78
83
  const isNonRegistryModule = target.source.includes(".");
79
- let registryPath;
80
- // Submodules also exist (e.g. terraform-aws-modules/vpc/aws//modules/vpc-endpoints)
81
- // And linking directly to them in the Registry requires including the version
82
- // like e.g. https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest/submodules/vpc-endpoints
83
- if (target.source.includes("//")) {
84
- registryPath = target.source.replace(
85
- "//modules",
86
- `/${target.version || "latest"}/submodules`,
87
- );
88
- // terraform-aws-modules/vpc/aws//modules/vpc-endpoints
89
- // ->
90
- // terraform-aws-modules/vpc/aws/latest/submodules/vpc-endpoints
91
- } else {
92
- // not submodule specified, just append the version
93
- registryPath = `${target.source}/${target.version || "latest"}`;
94
- }
84
+ // Keyed off the project's declared targets rather than a fixed registry, so
85
+ // the link matches the product the project builds for.
86
+ const registry = registryForTargetVersions(this.targetVersions);
87
+ // Registries only have pages for a direct child of `modules/`, e.g.
88
+ // terraform-aws-modules/vpc/aws//modules/vpc-endpoints; any other `//` path
89
+ // links to the module itself.
90
+ const [moduleSource, subPath] = target.source.split("//", 2);
91
+ const submodule = subPath?.match(/^modules\/([^/]+)$/)?.[1];
92
+ const docsUrl = registry.moduleDocsUrl(
93
+ moduleSource,
94
+ target.version,
95
+ submodule,
96
+ );
95
97
 
96
98
  const comment = sanitizedComment(this.code);
97
99
  comment.line(`Defines an ${baseName} based on a Terraform module`);
@@ -99,7 +101,7 @@ export class ModuleGenerator {
99
101
  comment.line(
100
102
  isNonRegistryModule
101
103
  ? `Source at ${target.source}`
102
- : `Docs at Terraform Registry: {@link https://registry.terraform.io/modules/${registryPath} ${target.source}}`,
104
+ : `Docs at ${registry.displayName}: {@link ${docsUrl} ${target.source}}`,
103
105
  );
104
106
  comment.end();
105
107
  this.code.openBlock(`export class ${baseName} extends TerraformModule`);