From 51e5351b866809a642f5efcd9601f4eb0a284d87 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 8 Oct 2026 08:21:52 -0500 Subject: [PATCH 01/15] test(tests): drop Terraform-only assumptions from the integration harness Three assumptions in the integration layer only hold under Terraform, so the fixtures could not run against OpenTofu. versions.json is keyed by fully qualified provider name, whose host is whichever registry the fetching CLI used. Five assertions indexed those keys with a literal `registry.terraform.io/...`, which reads `undefined` under OpenTofu. Adds `providerVersion()` to the test helper, matching on the provider part the way the provider-generator unit test already does, and uses it at every site. Two of the five are in `onWindows` branches that no job runs today, since `windows_integration` is `if: false`; they are corrected so re-enabling that job does not reintroduce the problem. The helper still returns `undefined` for an absent provider, so the assertions stay real guards. `TestDriver.validate()` shelled out to a bare `terraform`. The CI image points that symlink at DEFAULT_TERRAFORM_VERSION, so the helper validated with 1.16.1 even on the 1.5.7 matrix rows, and would have run Terraform on an OpenTofu row. It now honours TERRAFORM_BINARY_NAME, which the workflow already exports. Pins the two provider-command targets to OpenTofu as well. They drive provider resolution through the CLI's own registry, so they are what would catch a regression in the registry selection added by #445. Their Terraform entries restate the `tested` default so the pin does not silently drop 1.5.7; the matrix grows by two jobs. Also normalises path separators in the matrix builder. `jest --listTests` emits Windows separators, so no target matched the `test/` prefix and every configured pin tripped the "not a discovered test target" guard, making the builder impossible to run outside CI. Verified every provider, module and exact version the fixtures pin is present on registry.opentofu.org, checking a known-absent provider and a nonsense path first to confirm that endpoint 404s rather than soft-404ing like search.opentofu.org. Co-Authored-By: Claude Opus 5 --- test/go/provider-add-command/test.ts | 13 +++++----- test/test-helper.ts | 26 ++++++++++++++++--- test/typescript/provider-add-command/test.ts | 13 +++++----- .../provider-upgrade-command/test.ts | 9 ++++--- tools/build-test-matrix.mjs | 18 ++++++++++++- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/test/go/provider-add-command/test.ts b/test/go/provider-add-command/test.ts index bcc9c90a5..965f22d29 100644 --- a/test/go/provider-add-command/test.ts +++ b/test/go/provider-add-command/test.ts @@ -3,6 +3,7 @@ import { onPosix, onWindows, + providerVersion, sanitizeTimestamps, TestDriver, } from "../../test-helper"; @@ -42,9 +43,9 @@ describe("provider add command", () => { // const genVersionsFile = JSON.parse( // driver.readLocalFile("generated/versions.json") // ); - // expect( - // genVersionsFile["registry.terraform.io/hashicorp/local"] - // ).toEqual("2.2.3"); + // expect(providerVersion(genVersionsFile, "hashicorp/local")).toEqual( + // "2.2.3", + // ); }, 240_000, ); @@ -73,9 +74,9 @@ describe("provider add command", () => { const genVersionsFile = JSON.parse( driver.readLocalFile("generated/versions.json"), ); - expect( - genVersionsFile["registry.terraform.io/hashicorp/local"], - ).toEqual("2.2.3"); + expect(providerVersion(genVersionsFile, "hashicorp/local")).toEqual( + "2.2.3", + ); }, 120_000, ); diff --git a/test/test-helper.ts b/test/test-helper.ts index e10787907..1d8538e50 100644 --- a/test/test-helper.ts +++ b/test/test-helper.ts @@ -48,6 +48,22 @@ export function packageJsonWithDependency(name: string, version?: string) { }); } +/** + * Looks up a provider version in a generated `versions.json`. Keys are fully qualified and carry the host of whichever + * registry the fetching CLI used, so match on the provider part instead of pinning one registry. + * + * @param versionsFile parsed contents of a generated `versions.json` + * @param fqn provider name as `namespace/name` + */ +export function providerVersion( + versionsFile: Record, + fqn: string, +): string | undefined { + return Object.entries(versionsFile).find( + ([key]) => key.split("/").slice(1).join("/") === fqn, + )?.[1]; +} + export class QueryableStack { private readonly stack: Record; constructor(stackInput: string) { @@ -366,10 +382,10 @@ export class TestDriver { }; /** - * runs terraform init and terraform validate in the output directory for the given stack name + * runs init and validate with the configured CLI in the output directory for the given stack name * @param stack the name of the stack to validate * @param baseDirectory an optional base directory for the cdktn project - * @returns the stdout of terraform validate + * @returns the stdout of validate */ async validate(stack: string, baseDirectory?: string) { const cwd = path.join( @@ -378,8 +394,10 @@ export class TestDriver { "stacks", stack, ); - await this.exec("terraform", ["init"], cwd); - const res = await this.exec("terraform", ["validate"], cwd); + // The image's bare `terraform` is the default version, so honour the CLI the matrix selected. + const binary = this.env.TERRAFORM_BINARY_NAME || "terraform"; + await this.exec(binary, ["init"], cwd); + const res = await this.exec(binary, ["validate"], cwd); return res.stdout; } diff --git a/test/typescript/provider-add-command/test.ts b/test/typescript/provider-add-command/test.ts index 826d687ff..65e32960c 100644 --- a/test/typescript/provider-add-command/test.ts +++ b/test/typescript/provider-add-command/test.ts @@ -5,6 +5,7 @@ import { onPosix, onWindows, packageJsonWithDependency, + providerVersion, } from "../../test-helper"; describe("provider add command", () => { @@ -83,9 +84,9 @@ describe("provider add command", () => { driver.readLocalFile(".gen/versions.json"), ); - expect( - genVersionsFile["registry.terraform.io/hashicorp/local"], - ).toEqual("2.2.3"); + expect(providerVersion(genVersionsFile, "hashicorp/local")).toEqual( + "2.2.3", + ); }, 240_000, ); @@ -114,9 +115,9 @@ describe("provider add command", () => { driver.readLocalFile(".gen/versions.json"), ); - expect( - genVersionsFile["registry.terraform.io/hashicorp/local"], - ).toEqual("2.2.3"); + expect(providerVersion(genVersionsFile, "hashicorp/local")).toEqual( + "2.2.3", + ); }, 120_000, ); diff --git a/test/typescript/provider-upgrade-command/test.ts b/test/typescript/provider-upgrade-command/test.ts index 4c2fa84da..cf8599c4f 100644 --- a/test/typescript/provider-upgrade-command/test.ts +++ b/test/typescript/provider-upgrade-command/test.ts @@ -5,6 +5,7 @@ import { onPosix, onWindows, packageJsonWithDependency, + providerVersion, } from "../../test-helper"; describe("provider upgrade command", () => { @@ -87,7 +88,7 @@ describe("provider upgrade command", () => { driver.readLocalFile(".gen/versions.json"), ); - expect(genVersionsFile["registry.terraform.io/hashicorp/random"]).toEqual( + expect(providerVersion(genVersionsFile, "hashicorp/random")).toEqual( "3.2.0", ); }); @@ -108,9 +109,9 @@ describe("provider upgrade command", () => { driver.readLocalFile(".gen/versions.json"), ); - expect( - genVersionsFile["registry.terraform.io/hashicorp/random"], - ).toEqual("3.2.0"); + expect(providerVersion(genVersionsFile, "hashicorp/random")).toEqual( + "3.2.0", + ); }, 120_000, ); diff --git a/tools/build-test-matrix.mjs b/tools/build-test-matrix.mjs index 783938150..03d9b1507 100644 --- a/tools/build-test-matrix.mjs +++ b/tools/build-test-matrix.mjs @@ -46,6 +46,18 @@ const pinnedRuntimes = { { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], + // These resolve providers through the CLI's own registry, so they are the ones that catch a regression in which + // registry we pick. The Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. + "typescript/provider-add-command/test.ts": [ + { product: "terraform", version: "1.5.7" }, + { product: "terraform", version: "1.16.1" }, + { product: "opentofu", version: "1.12.6" }, + ], + "typescript/provider-upgrade-command/test.ts": [ + { product: "terraform", version: "1.5.7" }, + { product: "terraform", version: "1.16.1" }, + { product: "opentofu", version: "1.12.6" }, + ], }; /** @@ -84,8 +96,12 @@ const absoluteTargets = execFileSync( .map((line) => line.trim()) .filter(Boolean); -const testDirPrefix = `${testDir}/`; +/** Matrix keys are posix-style, so normalise the separators jest emits on Windows. */ +const toPosix = (p) => p.replace(/\\/g, "/"); + +const testDirPrefix = `${toPosix(testDir)}/`; const targets = absoluteTargets + .map(toPosix) .map((p) => (p.startsWith(testDirPrefix) ? p.slice(testDirPrefix.length) : p)) .sort(); From ac5cd1ee23daa34ac25aac39bebf965a7deff182 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 8 Oct 2026 08:52:59 -0500 Subject: [PATCH 02/15] fix(cli): write cdktf.json provider sources against the project's registry `CdktfConfigManager` stripped the literal `registry.terraform.io/` prefix and compared existing entries via `ProviderConstraint.fromConfigEntry()` with no registry, which defaults to Terraform. Both only hold for a Terraform project, so #445 left this file behind when it made provider resolution registry-aware. In an OpenTofu project `cdktn provider upgrade random@=3.2.0` produced: ["hashicorp/random@=3.1.3", "registry.opentofu.org/hashicorp/random@=3.2.0"] The prefix did not match so the source stayed qualified, and the comparison normalised the existing entry under the wrong registry, so it was not recognised as the same provider and was left in place next to the new one. The project was left with two conflicting constraints for one provider. The manager now derives its registry from the `targetVersions` of the very file it manages, which is the same source of truth #445 used, and needs no plumbing from callers. The host is dropped only when it matches that registry, so a source explicitly qualified with another registry stays qualified - OpenTofu users have been told to fully qualify providers and that must keep working. Terraform behaviour is unchanged: of the five new test cases, the four covering OpenTofu fail against the previous implementation and the Terraform one passes. Co-Authored-By: Claude Opus 5 --- .../lib/dependencies/cdktf-config-manager.ts | 30 +++-- .../dependencies/cdktf-config-manager.test.ts | 110 ++++++++++++++++++ 2 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts b/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts index b49c6b72b..54df06de5 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts @@ -1,37 +1,51 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 +import { Registry, registryForTargetVersions } from "@cdktn/commons"; import { CdktfConfig } from "../cdktf-config"; import { ProviderConstraint } from "./dependency-manager"; +/** + * Drops the host when it is the project's own registry, so cdktf.json keeps the + * short form. A source qualified with any other host stays qualified. + */ +function simplifySource(source: string, registry: Registry): string { + const prefix = `${registry.hostname}/`; + return source.startsWith(prefix) ? source.slice(prefix.length) : source; +} + /** * facilitates adding provider dependencies to cdktf.json */ export class CdktfConfigManager { private config: CdktfConfig = CdktfConfig.read(); + /** Registry an unqualified entry in this cdktf.json resolves against. */ + private get registry(): Registry { + return registryForTargetVersions(this.config.targetVersions); + } + public async hasProvider(constraint: ProviderConstraint): Promise { + const registry = this.registry; return this.config.terraformProviders.some( (provider) => - ProviderConstraint.fromConfigEntry(provider).source === + ProviderConstraint.fromConfigEntry(provider, registry).source === constraint.source, ); } private async setProvider(constraint: ProviderConstraint): Promise { + const registry = this.registry; + // excluding the constraint to be added (if it already existed) const currentProviders = this.config.terraformProviders.filter( (provider) => - ProviderConstraint.fromConfigEntry(provider).source !== + ProviderConstraint.fromConfigEntry(provider, registry).source !== constraint.source, ); - const simplifiedSource = constraint.source.replace( - "registry.terraform.io/", - "", - ); - const provider = - simplifiedSource + (constraint.version ? `@${constraint.version}` : ""); + simplifySource(constraint.source, registry) + + (constraint.version ? `@${constraint.version}` : ""); currentProviders.push(provider); this.config.writeTerraformProviders(currentProviders); diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts new file mode 100644 index 000000000..153fb37bf --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + OPENTOFU_REGISTRY, + TERRAFORM_REGISTRY, + TerraformTargetVersions, +} from "@cdktn/commons"; +import { CdktfConfigManager } from "../../../lib/dependencies/cdktf-config-manager"; +import { ProviderConstraint } from "../../../lib/dependencies/dependency-manager"; + +describe("CdktfConfigManager", () => { + let projectDir: string; + let originalCwd: string; + + const writeConfig = ( + providers: string[], + targetVersions?: TerraformTargetVersions, + ) => + fs.writeFileSync( + path.join(projectDir, "cdktf.json"), + JSON.stringify({ + language: "typescript", + app: "npx tsx main.ts", + terraformProviders: providers, + ...(targetVersions ? { targetVersions } : {}), + }), + ); + + const readProviders = (): string[] => + JSON.parse(fs.readFileSync(path.join(projectDir, "cdktf.json"), "utf8")) + .terraformProviders; + + beforeEach(() => { + originalCwd = process.cwd(); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktf-config-mgr-")); + process.chdir(projectDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + describe("a Terraform project", () => { + it("writes the short form and replaces the existing entry", async () => { + writeConfig(["hashicorp/random@=3.1.3"]); + + await new CdktfConfigManager().updateProvider( + new ProviderConstraint("random", "=3.2.0", TERRAFORM_REGISTRY), + ); + + expect(readProviders()).toEqual(["hashicorp/random@=3.2.0"]); + }); + }); + + describe("an OpenTofu project", () => { + const opentofu: TerraformTargetVersions = { opentofu: ">=1.6.0" }; + + it("writes the short form rather than qualifying with the tofu registry", async () => { + writeConfig(["hashicorp/random@=3.1.3"], opentofu); + + await new CdktfConfigManager().updateProvider( + new ProviderConstraint("random", "=3.2.0", OPENTOFU_REGISTRY), + ); + + expect(readProviders()).toEqual(["hashicorp/random@=3.2.0"]); + }); + + it("recognises an unqualified entry as the same provider", async () => { + writeConfig(["hashicorp/random@=3.1.3"], opentofu); + + await expect( + new CdktfConfigManager().hasProvider( + new ProviderConstraint("random", "=3.1.3", OPENTOFU_REGISTRY), + ), + ).resolves.toBe(true); + }); + + it("keeps a source explicitly qualified with another registry", async () => { + writeConfig([], opentofu); + + await new CdktfConfigManager().addProvider( + new ProviderConstraint( + "registry.terraform.io/hashicorp/random", + "=3.2.0", + OPENTOFU_REGISTRY, + ), + ); + + expect(readProviders()).toEqual([ + "registry.terraform.io/hashicorp/random@=3.2.0", + ]); + }); + + it("treats an explicitly qualified entry as distinct from the project's own", async () => { + writeConfig(["registry.terraform.io/hashicorp/random@=3.1.3"], opentofu); + + await new CdktfConfigManager().addProvider( + new ProviderConstraint("random", "=3.2.0", OPENTOFU_REGISTRY), + ); + + expect(readProviders()).toEqual([ + "registry.terraform.io/hashicorp/random@=3.1.3", + "hashicorp/random@=3.2.0", + ]); + }); + }); +}); From 121241b1898106838e0b5c9647fda80e66f49cdd Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 8 Oct 2026 17:25:26 -0500 Subject: [PATCH 03/15] test(tests): drop the OpenTofu pin that trips on the versions.json defect Pinning typescript/provider-upgrade-command to OpenTofu exposed a real defect, not a test problem: `cdktn provider upgrade` leaves `.gen/versions.json` with two entries for one provider, keyed by different registry hosts, and the stale one is read back. Filed as #483 with the reproduction. The target goes back to the `tested` Terraform cross-product until that is fixed; provider-add-command and provider-features keep their OpenTofu pins and pass. Also makes `providerVersion()` throw when a versions.json holds more than one entry for the same provider. It previously returned the first match, which was only accidentally the stale one - had insertion order gone the other way the duplicate would have passed silently. A loud failure naming both entries is what this helper owes its callers. Co-Authored-By: Claude Opus 5 --- test/test-helper.ts | 14 ++++++++++++-- tools/build-test-matrix.mjs | 10 +++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/test/test-helper.ts b/test/test-helper.ts index 1d8538e50..6f6fd751f 100644 --- a/test/test-helper.ts +++ b/test/test-helper.ts @@ -59,9 +59,19 @@ export function providerVersion( versionsFile: Record, fqn: string, ): string | undefined { - return Object.entries(versionsFile).find( + const matches = Object.entries(versionsFile).filter( ([key]) => key.split("/").slice(1).join("/") === fqn, - )?.[1]; + ); + // More than one means one entry per registry host for the same provider (#483). + // Returning either would let a stale version pass silently. + if (matches.length > 1) { + throw new Error( + `versions.json has ${matches.length} entries for ${fqn}: ${matches + .map(([key, version]) => `${key}=${version}`) + .join(", ")}`, + ); + } + return matches[0]?.[1]; } export class QueryableStack { diff --git a/tools/build-test-matrix.mjs b/tools/build-test-matrix.mjs index 03d9b1507..d3963fd94 100644 --- a/tools/build-test-matrix.mjs +++ b/tools/build-test-matrix.mjs @@ -46,18 +46,14 @@ const pinnedRuntimes = { { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], - // These resolve providers through the CLI's own registry, so they are the ones that catch a regression in which - // registry we pick. The Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. + // Resolves providers through the CLI's own registry, so it catches a regression in which registry we pick. The + // Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. Its sibling + // provider-upgrade-command is deliberately not pinned here: it fails under OpenTofu on #483. "typescript/provider-add-command/test.ts": [ { product: "terraform", version: "1.5.7" }, { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], - "typescript/provider-upgrade-command/test.ts": [ - { product: "terraform", version: "1.5.7" }, - { product: "terraform", version: "1.16.1" }, - { product: "opentofu", version: "1.12.6" }, - ], }; /** From 8e30cd525fb1d5d56ab1a282f2c15047227e6331 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 8 Oct 2026 21:00:45 -0500 Subject: [PATCH 04/15] fix(cli): resolve upgraded providers from the CLI's own registry Fixes #483. `cdktn provider upgrade` in an OpenTofu project fetched the provider from registry.terraform.io, and left `.gen/versions.json` holding two entries for one provider with the stale version winning any lookup by name. `readProviderSchema` writes a throwaway `main.tf.json` whose `required_providers` source comes straight from the target, and a source that names a registry pins the CLI to it. The upgrade handler built `constraintsToGenerate` from `ProviderConstraint.fromConfigEntry(provider)` with no registry, which normalizes to `registry.terraform.io/...`, so that host reached the schema fetch. The add path passes no `constraintsToGenerate`, so `get` derived bare constraints from cdktf.json and OpenTofu resolved them from its own registry - which is why the two commands disagreed and the file ended up with one key per host. It now passes `namespace/name`, leaving resolution to the CLI. Of the ten `new TerraformProviderConstraint(...)` sites, this was the only one handing on a normalized FQPN. `updateVersionsFile` no longer carries a previous entry forward for a provider regenerated this run. It matched the old entry by provider part but re-inserted it under its old fully qualified key, so a host change left both - defence in depth now that the host above is stable. `LocalProviderVersions` stripped the literal `registry.terraform.io/` prefix, so under OpenTofu `versionForProvider("random")` found nothing and version detection and `provider list` came up empty. It now strips any public registry host. Restores the OpenTofu pin on typescript/provider-upgrade-command that #479 had to drop, bringing the matrix back to three tofu rows. Verified against both CLIs locally, each leaving a single correctly-keyed entry: terraform 1.14.3 gives registry.terraform.io/hashicorp/random 3.2.0, opentofu 1.12.6 gives registry.opentofu.org/hashicorp/random 3.2.0. Of the four new LocalProviderVersions tests, the two OpenTofu cases fail against the previous implementation. Co-Authored-By: Claude Opus 5 --- .../src/lib/local-provider-versions.ts | 19 ++--- .../test/lib/local-provider-versions.test.ts | 71 +++++++++++++++++++ .../src/get/constructs-maker.ts | 14 ++++ packages/cdktn-cli/src/bin/cmds/handlers.ts | 10 ++- tools/build-test-matrix.mjs | 10 ++- 5 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 packages/@cdktn/cli-core/src/test/lib/local-provider-versions.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/local-provider-versions.ts b/packages/@cdktn/cli-core/src/lib/local-provider-versions.ts index 792f21248..d7048f0ec 100644 --- a/packages/@cdktn/cli-core/src/lib/local-provider-versions.ts +++ b/packages/@cdktn/cli-core/src/lib/local-provider-versions.ts @@ -1,13 +1,10 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { Errors, logger } from "@cdktn/commons"; +import { Errors, logger, registryForHostname } from "@cdktn/commons"; import fs from "fs-extra"; import path from "path"; import { CdktfConfig } from "./cdktf-config"; -import { - DEFAULT_HOSTNAME, - DEFAULT_NAMESPACE, -} from "./dependencies/dependency-manager"; +import { DEFAULT_NAMESPACE } from "./dependencies/dependency-manager"; // TODO: move this to @cdktn/commons // tracked here https://github.com/hashicorp/terraform-cdk/issues/1814 @@ -46,11 +43,15 @@ export class LocalProviderVersions { this.versions = Object.fromEntries( Object.entries(providerVersions).map(([providerFqn, versions]) => { + // Any public registry host, not just Terraform's: the host recorded here + // is whichever registry the fetching CLI used. + const [maybeHost, ...rest] = providerFqn.split("/"); + const withoutHost = registryForHostname(maybeHost) + ? rest.join("/") + : providerFqn; + return [ - providerFqn - .replace(`${DEFAULT_HOSTNAME}/`, "") - .replace(`${DEFAULT_NAMESPACE}/`, "") - .toLowerCase(), + withoutHost.replace(`${DEFAULT_NAMESPACE}/`, "").toLowerCase(), versions, ]; }), diff --git a/packages/@cdktn/cli-core/src/test/lib/local-provider-versions.test.ts b/packages/@cdktn/cli-core/src/test/lib/local-provider-versions.test.ts new file mode 100644 index 000000000..27eb1be9f --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/local-provider-versions.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { LocalProviderVersions } from "../../lib/local-provider-versions"; + +describe("LocalProviderVersions", () => { + let projectDir: string; + let originalCwd: string; + + const writeProject = (versions: Record) => { + fs.writeFileSync( + path.join(projectDir, "cdktf.json"), + JSON.stringify({ language: "typescript", app: "npx tsx main.ts" }), + ); + fs.mkdirSync(path.join(projectDir, ".gen"), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, ".gen", "versions.json"), + JSON.stringify(versions), + ); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "local-versions-")); + process.chdir(projectDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("reads a provider fetched by Terraform", async () => { + writeProject({ "registry.terraform.io/hashicorp/random": "3.2.0" }); + + await expect( + new LocalProviderVersions().versionForProvider("random"), + ).resolves.toBe("3.2.0"); + }); + + it("reads a provider fetched by OpenTofu", async () => { + writeProject({ "registry.opentofu.org/hashicorp/random": "3.2.0" }); + + await expect( + new LocalProviderVersions().versionForProvider("random"), + ).resolves.toBe("3.2.0"); + }); + + it("keeps a private registry host in the key", async () => { + writeProject({ "my.registry.example.com/hashicorp/random": "3.2.0" }); + + const versions = new LocalProviderVersions(); + await expect( + versions.versionForProvider("random"), + ).resolves.toBeUndefined(); + // The default namespace is still dropped wherever it appears, which is + // pre-existing behaviour this change does not alter. + await expect( + versions.versionForProvider("my.registry.example.com/random"), + ).resolves.toBe("3.2.0"); + }); + + it("keeps a non-default namespace", async () => { + writeProject({ "registry.opentofu.org/kreuzwerker/docker": "3.9.0" }); + + await expect( + new LocalProviderVersions().versionForProvider("kreuzwerker/docker"), + ).resolves.toBe("3.9.0"); + }); +}); diff --git a/packages/@cdktn/provider-generator/src/get/constructs-maker.ts b/packages/@cdktn/provider-generator/src/get/constructs-maker.ts index 28b295732..ac9b8766e 100644 --- a/packages/@cdktn/provider-generator/src/get/constructs-maker.ts +++ b/packages/@cdktn/provider-generator/src/get/constructs-maker.ts @@ -529,7 +529,21 @@ export class ConstructsMaker { ); } + // Keys are fully qualified and the host is whichever registry the fetching CLI + // resolved from, so a provider regenerated this run can arrive under a different + // host than the file already holds. Carrying the old key forward as well would + // leave two entries for one provider, and the stale one sorts first. + const regenerated = new Set( + Object.keys(this.versions).map((fqpn) => + fqpn.split("/").slice(1).join("/"), + ), + ); + const versions = allowedConstraints.reduce((acc, constraint) => { + if (regenerated.has(constraint.fqn)) { + return acc; + } + const provider = Object.entries(previousVersions).find(([name]) => // This could be more refined, but it's good enough for now name.endsWith(constraint.fqn), diff --git a/packages/cdktn-cli/src/bin/cmds/handlers.ts b/packages/cdktn-cli/src/bin/cmds/handlers.ts index 293e59d39..fdfce2a3a 100644 --- a/packages/cdktn-cli/src/bin/cmds/handlers.ts +++ b/packages/cdktn-cli/src/bin/cmds/handlers.ts @@ -759,8 +759,16 @@ export async function providerUpgrade(argv: any) { constructsOptions, constraints, cleanDirectory: false, + // Host-stripped on purpose: a source that names a registry pins the CLI to + // it, so passing the normalized FQPN here made OpenTofu fetch the provider + // from registry.terraform.io. Bare, the CLI uses its own registry. constraintsToGenerate: constraintsToUpdate.map( - (c) => new TerraformProviderConstraint(c), + (c) => + new TerraformProviderConstraint( + c.version + ? `${c.namespace}/${c.name}@${c.version}` + : `${c.namespace}/${c.name}`, + ), ), }); } diff --git a/tools/build-test-matrix.mjs b/tools/build-test-matrix.mjs index d3963fd94..03d9b1507 100644 --- a/tools/build-test-matrix.mjs +++ b/tools/build-test-matrix.mjs @@ -46,14 +46,18 @@ const pinnedRuntimes = { { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], - // Resolves providers through the CLI's own registry, so it catches a regression in which registry we pick. The - // Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. Its sibling - // provider-upgrade-command is deliberately not pinned here: it fails under OpenTofu on #483. + // These resolve providers through the CLI's own registry, so they are the ones that catch a regression in which + // registry we pick. The Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. "typescript/provider-add-command/test.ts": [ { product: "terraform", version: "1.5.7" }, { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], + "typescript/provider-upgrade-command/test.ts": [ + { product: "terraform", version: "1.5.7" }, + { product: "terraform", version: "1.16.1" }, + { product: "opentofu", version: "1.12.6" }, + ], }; /** From 8c5c54d22341265f18d1f65dff844cf8845d34ed Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 06:23:42 -0500 Subject: [PATCH 05/15] fix(cli): upgrade providers against the project's own registry `cdktn provider upgrade` failed outright in an OpenTofu-only project: Usage Error: Trying to upgrade random but it is not installed, please use "cdktn provider add random" to add it. even with `hashicorp/random@=3.1.3` declared in cdktf.json. The handler built its constraint with `ProviderConstraint.fromConfigEntry()` and no registry, which defaults to Terraform, so it normalized to `registry.terraform.io/hashicorp/random`. `CdktfConfigManager` resolves the project's entries against the registry the project declares, giving `registry.opentofu.org/hashicorp/random`, so `hasProvider` compared two different sources and reported an installed provider as missing. It now resolves the argument the way the project's own entries resolve. Without this the earlier commit on this branch only fixed the schema fetch, so the command still aborted before reaching it in exactly the projects it was meant to help. No integration fixture declares `targetVersions`, which is why this was not caught: the OpenTofu matrix rows cover the CLI being OpenTofu, not the project targeting it. Verified by pointing the fixture at `targetVersions.opentofu` and running under tofu 1.12.6 - failing before, and afterwards leaving cdktf.json at =3.2.0 with a single `registry.opentofu.org/hashicorp/random` entry. Co-Authored-By: Claude Opus 5 --- packages/cdktn-cli/src/bin/cmds/handlers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cdktn-cli/src/bin/cmds/handlers.ts b/packages/cdktn-cli/src/bin/cmds/handlers.ts index fdfce2a3a..25d7e3fac 100644 --- a/packages/cdktn-cli/src/bin/cmds/handlers.ts +++ b/packages/cdktn-cli/src/bin/cmds/handlers.ts @@ -21,6 +21,7 @@ import { getPackageVersion, TerraformDependencyConstraint, ConstructsMakerProviderTarget, + registryForTargetVersions, } from "@cdktn/commons"; import { checkForEmptyDirectory, runInit } from "./helper/init"; @@ -723,8 +724,13 @@ export async function providerUpgrade(argv: any) { const constraintsToUpdate: ProviderConstraint[] = []; + // Resolve an unqualified argument the way this project's cdktf.json entries + // resolve. Defaulting to Terraform made `upgrade` normalize to a different + // host than the config, so an installed provider looked missing. + const registry = registryForTargetVersions(config.targetVersions); + for (const provider of argv.provider) { - const constraint = ProviderConstraint.fromConfigEntry(provider); + const constraint = ProviderConstraint.fromConfigEntry(provider, registry); const { addedLocalProvider } = await manager.upgradeProvider(constraint); if (addedLocalProvider) { constraintsToUpdate.push(constraint); From 62809f996072e0546c187569a43a6a4a9c153efb Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 07:18:21 -0500 Subject: [PATCH 06/15] fix(cli): match a bare provider source against whichever registry locked it `cdktn deploy` failed for an OpenTofu project after a provider version bump: Could not resolve provider hashicorp/random: locked provider registry.opentofu.org/hashicorp/random 3.6.0 does not match configured version constraint 3.6.1; must use tofu init -upgrade Before diff and deploy, CdktfStack compares the synthesized `required_providers` against `.terraform.lock.hcl` to decide whether to run `init` and whether an `init -upgrade` is needed. Both lookups were exact on the fully qualified address, and `requiredProviders()` normalized each source with `new ProviderConstraint(source, version)` - no registry, so always `registry.terraform.io/...`. Synthesized sources are bare in practice: generated bindings copy the source as written in cdktf.json, and published prebuilt packages ship bare sources too (checked @cdktn/provider-aws 25.7.2 and @cdktn/provider-random 15.0.1). OpenTofu locks a bare source under its own registry, so the comparison missed for every OpenTofu project in both directions: - `checkNeedsLockfileUpdate` never found the provider, so every diff and deploy ran `init` - `checkNeedsUpgrade` looked the lock entry up by its own address, missed, and treated the provider as no longer used, so a version bump never triggered `init -upgrade` - and the plain `init` that did run rejects the bump, as above `lockAddressesFor` now returns the addresses that can satisfy a source: exactly its own for a source that names a host, and one per public registry for a bare source, since which one the CLI locked depends on whether it is Terraform or OpenTofu. Both checks go through it. An explicit host still has to match exactly; treating every public registry as equivalent would report an explicit `registry.terraform.io` source as locked when only the OpenTofu entry exists, skip `init`, and leave the CLI to fail. Projects that fully qualify their providers were never affected, since their sources match exactly. Verified end to end with the OpenTofu-target integration fixture: with this change reverted, its provider-bump test fails with the error above; with it, the bump deploys. Terraform projects are unchanged - Terraform locks a bare source under registry.terraform.io, which was the only address checked before. Co-Authored-By: Claude Opus 5.5 --- .../@cdktn/cli-core/src/lib/cdktf-stack.ts | 36 +++++----- .../src/lib/terraform-provider-lock.ts | 20 +++++- .../test/lib/terraform-provider-lock.test.ts | 68 ++++++++++++++++++- 3 files changed, 106 insertions(+), 18 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts index ce6c19511..5048eac9a 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts @@ -17,9 +17,11 @@ import { tryReadGeneratedConfigurationFile, tryRemoveGeneratedConfigurationFile, } from "./models/terraform-cli"; -import { ProviderConstraint } from "./dependencies/dependency-manager"; import { terraformJsonSchema, TerraformStack } from "./terraform-json"; -import { TerraformProviderLock } from "./terraform-provider-lock"; +import { + TerraformProviderLock, + lockAddressesFor, +} from "./terraform-provider-lock"; import { convertConfigurationFile } from "./convert"; export type StackUpdate = @@ -300,14 +302,10 @@ export class CdktfStack { // Read required providers from the stack output const requiredProviders = this.parsedContent.terraform?.required_providers; - return Object.values(requiredProviders || {}).reduce( - (acc, obj) => { - const constraint = new ProviderConstraint(obj.source, obj.version); - acc[constraint.source] = constraint; - return acc; - }, - {} as Record, - ); + return Object.values(requiredProviders || {}).map((obj) => ({ + version: obj.version, + addresses: lockAddressesFor(obj.source, obj.version), + })); } private async checkNeedsLockfileUpdate(): Promise { @@ -323,11 +321,15 @@ export class CdktfStack { return true; } - const requiredProviders = this.requiredProviders(); - - for (const provider of Object.values(requiredProviders)) { - const hasProvider = await lock.hasMatchingProvider(provider); - if (!hasProvider) { + for (const { addresses } of this.requiredProviders()) { + let locked = false; + for (const address of addresses) { + if (await lock.hasMatchingProvider(address)) { + locked = true; + break; + } + } + if (!locked) { // If we don't have a provider or version doesn't match, we need to init return true; } @@ -355,7 +357,9 @@ export class CdktfStack { return false; } - const provider = allProviders[lockedConstraint.source]; + const provider = allProviders.find(({ addresses }) => + addresses.some(({ source }) => source === lockedConstraint.source), + ); if (!provider) { // else no longer using this provider, so won't cause problems return; diff --git a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts index 079d59245..48acdf6c6 100644 --- a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts +++ b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts @@ -6,7 +6,7 @@ import path from "path"; import fs from "fs/promises"; import { ProviderConstraint } from "./dependencies/dependency-manager"; -import { logger } from "@cdktn/commons"; +import { PUBLIC_REGISTRIES, logger } from "@cdktn/commons"; const TerraformLockFileName = ".terraform.lock.hcl"; @@ -22,6 +22,24 @@ type TerraformProviderLockFileData = { }; }; +/** + * Lock-file addresses that can satisfy a `required_providers` source. The CLI + * locks a source that names a host under exactly that host, and a bare source + * under whichever public registry it resolves bare sources from - which differs + * between Terraform and OpenTofu, so each public registry is a candidate. + */ +export function lockAddressesFor( + source: string, + version?: string, +): ProviderConstraint[] { + if (source.split("/").length === 3) { + return [new ProviderConstraint(source, version)]; + } + return PUBLIC_REGISTRIES.map( + (registry) => new ProviderConstraint(source, version, registry), + ); +} + export class TerraformProviderLock { private _providerLockData: TerraformProviderLockFileData | null; constructor(private stackWorkingDirectory: string) { diff --git a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts index 78a38efe5..c9ccf2a1f 100644 --- a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts @@ -3,7 +3,10 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { TerraformProviderLock } from "../../lib/terraform-provider-lock"; +import { + TerraformProviderLock, + lockAddressesFor, +} from "../../lib/terraform-provider-lock"; import { readFile, stat } from "fs/promises"; import * as path from "path"; import { ProviderConstraint } from "../../lib/dependencies/dependency-manager"; @@ -203,3 +206,66 @@ describe("TerraformProviderLock", () => { expect(stat).toHaveBeenCalledWith(path.join("test", ".terraform.lock.hcl")); }); }); + +describe("lockAddressesFor", () => { + const lockedUnder = (name: string) => + (readFile as jest.Mock).mockResolvedValueOnce( + generateProviderLockFileContents([ + { name, version: "1.2.3", constraints: "1.2" }, + ]), + ); + + /** Whether any address the CLI could have locked `source` under is in the lock. */ + const isLocked = async (source: string) => { + const lock = new TerraformProviderLock("test"); + for (const address of lockAddressesFor(source, "1.2")) { + if (await lock.hasMatchingProvider(address)) return true; + } + return false; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps a source that names a host to exactly that host", () => { + expect( + lockAddressesFor("registry.terraform.io/hashicorp/test").map( + (a) => a.source, + ), + ).toEqual(["registry.terraform.io/hashicorp/test"]); + }); + + it("offers every public registry for a bare source", () => { + expect(lockAddressesFor("hashicorp/test").map((a) => a.source)).toEqual([ + "registry.terraform.io/hashicorp/test", + "registry.opentofu.org/hashicorp/test", + ]); + }); + + it("finds a bare source that Terraform locked", async () => { + lockedUnder("registry.terraform.io/hashicorp/test"); + await expect(isLocked("hashicorp/test")).resolves.toBe(true); + }); + + it("finds a bare source that OpenTofu locked", async () => { + lockedUnder("registry.opentofu.org/hashicorp/test"); + await expect(isLocked("hashicorp/test")).resolves.toBe(true); + }); + + // Matching on provider alone would report this as locked, skip init, and + // leave the CLI to fail on an address that is not in the lock file. + it("does not let a different public registry satisfy an explicit host", async () => { + lockedUnder("registry.opentofu.org/hashicorp/test"); + await expect( + isLocked("registry.terraform.io/hashicorp/test"), + ).resolves.toBe(false); + }); + + it("does not let a public registry satisfy a private host", async () => { + lockedUnder("registry.terraform.io/hashicorp/test"); + await expect( + isLocked("my.registry.example.com/hashicorp/test"), + ).resolves.toBe(false); + }); +}); From 33a08a7f83cc73c71107d72bcd3b3b2cbbc48487 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 07:04:32 -0500 Subject: [PATCH 07/15] fix(tests): install the CLI under test from its tarball, not by name Fixes #488. `run-against-dist.mjs` published dist/js to Verdaccio and then ran `pnpm add cdktn-cli`. Every local build carries the same version, and nothing invalidates pnpm's view of it - only Verdaccio's storage is cleaned - so pnpm could install an earlier build it already held. Observed directly: a freshly built tarball containing a fix, and an installed `bundle/bin/cmds/handlers.js` from that morning whose shipped source map did not contain it. The run reported normally, so a stale CLI looked like real evidence. The install now names the tarball path. The glob is `cdktn-cli-[0-9]*.tgz` rather than a fixed `0.0.0` because release builds stamp real versions through align-version, and the digit keeps `cdktn-cli-core-*.tgz` - which a plain `cdktn-cli-*` would also match - out of it. Anything other than exactly one match fails the run with the files found. `--registry` stays so the CLI's @cdktn/* dependencies still resolve from Verdaccio. CI was not affected, since jobs start from a clean container; this only bit local verification, which is where a fix is iterated and a stale result is most likely to be misread. Co-Authored-By: Claude Opus 5.5 --- test/run-against-dist.mjs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/run-against-dist.mjs b/test/run-against-dist.mjs index fbacf162f..4305d867e 100755 --- a/test/run-against-dist.mjs +++ b/test/run-against-dist.mjs @@ -190,14 +190,30 @@ try { stdio: ["ignore", "inherit", "inherit"], }); - // There's only one version in our local registry, so we don't need to specify - // a version here. Retry to ride out transient ECONNRESETs from Verdaccio's - // upstream fetch. + // Install the tarball itself rather than `cdktn-cli` by name: local builds all + // carry the same version, so pnpm can resolve an earlier build it already + // holds for it and silently test stale code (#488). The digit keeps + // cdktn-cli-core-*.tgz out of the match. + const cliTarballs = []; + const cliGlob = join(cdktnDist, "js", "cdktn-cli-[0-9]*.tgz").replaceAll( + "\\", + "/", + ); + for await (const tgz of glob(cliGlob)) { + cliTarballs.push(tgz); + } + if (cliTarballs.length !== 1) { + await fail( + `Expected exactly one cdktn-cli tarball in dist/js, found ${cliTarballs.length}: ${cliTarballs.join(", ")}`, + ); + } + + // Retry to ride out transient ECONNRESETs from Verdaccio's upstream fetch. for (let attempt = 1; attempt <= 3; attempt++) { try { await execa( packageManagerName, - ["add", "cdktn-cli", `--registry=${REGISTRY_URL}`], + ["add", cliTarballs[0], `--registry=${REGISTRY_URL}`], { cwd: staging, stdio: ["ignore", "inherit", "inherit"] }, ); break; From a20569938c6d5d6087aae5758168d59359fd3f74 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 07:18:54 -0500 Subject: [PATCH 08/15] test(tests): cover a project that declares an OpenTofu target Closes #494. Every fixture left `targetVersions` unset, and `cdktn init` writes none, so `registryForTargetVersions()` returned the Terraform registry in every integration run. The matrix's OpenTofu rows only changed which CLI ran, and nothing exercised a project whose registry resolution follows its declared target - which is where the recent run of OpenTofu defects lived. `typescript/opentofu-target` declares `targetVersions: { opentofu: ">=1.6.0" }` and is pinned to the OpenTofu runtime alone, since driving an OpenTofu-targeting project with the Terraform CLI is not a pairing worth paying a job for. One shared project walks the lifecycle a user does: - bindings come from the OpenTofu registry: one `registry.opentofu.org` entry in versions.json, and docs links to search.opentofu.org - synth keeps the provider source bare, since a host would pin the CLI - deploy installs from registry.opentofu.org - `provider list` reports the generated version, through the LocalProviderVersions reader - a provider bump with `provider upgrade` redeploys at the new version Each assertion was checked to fail for the defect it guards rather than pass incidentally. Reverting the lock-file matching fix makes the bump fail with "locked provider registry.opentofu.org/hashicorp/random 3.6.0 does not match configured version constraint 3.6.1". The fixture also ran against a CLI shown by extracting its tarball to contain the fix, after three separate stale-build layers had produced misleading results. Adds one matrix job. Co-Authored-By: Claude Opus 5.5 --- test/typescript/opentofu-target/cdktf.json | 12 ++++ test/typescript/opentofu-target/main.ts | 19 +++++ test/typescript/opentofu-target/test.ts | 84 ++++++++++++++++++++++ tools/build-test-matrix.mjs | 4 ++ 4 files changed, 119 insertions(+) create mode 100644 test/typescript/opentofu-target/cdktf.json create mode 100644 test/typescript/opentofu-target/main.ts create mode 100644 test/typescript/opentofu-target/test.ts diff --git a/test/typescript/opentofu-target/cdktf.json b/test/typescript/opentofu-target/cdktf.json new file mode 100644 index 000000000..dac27d013 --- /dev/null +++ b/test/typescript/opentofu-target/cdktf.json @@ -0,0 +1,12 @@ +{ + "language": "typescript", + "app": "npx tsx main.ts", + "terraformProviders": [ + "hashicorp/random@=3.6.0" + ], + "terraformModules": [], + "targetVersions": { + "opentofu": ">=1.6.0" + }, + "context": {} +} diff --git a/test/typescript/opentofu-target/main.ts b/test/typescript/opentofu-target/main.ts new file mode 100644 index 000000000..78d658aa5 --- /dev/null +++ b/test/typescript/opentofu-target/main.ts @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Construct } from "constructs"; +import { App, TerraformOutput, TerraformStack, Testing } from "cdktn"; +import { RandomProvider } from "./.gen/providers/random/provider"; +import { Id } from "./.gen/providers/random/id"; + +export class OpenTofuTarget extends TerraformStack { + constructor(scope: Construct, name: string) { + super(scope, name); + new RandomProvider(this, "random", {}); + const randomId = new Id(this, "id", { byteLength: 4 }); + new TerraformOutput(this, "id_hex", { value: randomId.hex }); + } +} + +const app = Testing.stubVersion(new App({ stackTraces: false })); +new OpenTofuTarget(app, "opentofu-target"); +app.synth(); diff --git a/test/typescript/opentofu-target/test.ts b/test/typescript/opentofu-target/test.ts new file mode 100644 index 000000000..dc9852eb3 --- /dev/null +++ b/test/typescript/opentofu-target/test.ts @@ -0,0 +1,84 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as path from "path"; +import { TestDriver, providerVersion } from "../../test-helper"; + +// The project declares an OpenTofu-only target. The matrix's other OpenTofu rows +// change only the CLI, so code that resolves registries from `targetVersions` is +// covered nowhere else (#494). +describe("project targeting OpenTofu", () => { + const stack = "opentofu-target"; + let driver: TestDriver; + + const lockFile = () => + fs.readFileSync( + path.join(driver.stackDirectory(stack), ".terraform.lock.hcl"), + "utf8", + ); + + beforeAll(async () => { + driver = new TestDriver(__dirname); + await driver.setupTypescriptProject(); + }, 500_000); + + test("generates bindings from the OpenTofu registry", () => { + const versions = JSON.parse(driver.readLocalFile(".gen/versions.json")); + expect(versions).toEqual({ + "registry.opentofu.org/hashicorp/random": "3.6.0", + }); + }); + + test("links generated docs to the OpenTofu registry", () => { + const provider = driver.readLocalFile( + ".gen/providers/random/provider/index.ts", + ); + expect(provider).toContain( + "https://search.opentofu.org/provider/hashicorp/random/v3.6.0/docs", + ); + expect(provider).not.toContain("registry.terraform.io/providers"); + }); + + // A host here would pin the CLI to that registry. + test("synthesizes the provider source without a registry host", async () => { + await driver.synth(); + const synthesized = JSON.parse(driver.synthesizedStackContentsRaw(stack)); + expect(synthesized.terraform.required_providers.random.source).toBe( + "hashicorp/random", + ); + }); + + test("deploys with the provider from the OpenTofu registry", async () => { + const output = await driver.deploy([stack]); + expect(output).not.toContain("registry.terraform.io"); + expect(lockFile()).toContain( + 'provider "registry.opentofu.org/hashicorp/random"', + ); + }, 300_000); + + test("reports the version the provider was generated at", async () => { + const { stdout } = await driver.exec("cdktn", [ + "provider", + "list", + "--json", + ]); + const list = JSON.parse(stdout.slice(stdout.indexOf("{"))); + expect(list.local).toEqual([ + expect.objectContaining({ + providerName: "random", + providerVersion: "3.6.0", + }), + ]); + }); + + // Exercises the lock-file comparison: the bump has to be detected as needing + // `init -upgrade`, which plain `init` would reject. + test("upgrades the provider and redeploys at the new version", async () => { + await driver.exec("cdktn", ["provider", "upgrade", "random@=3.6.1"]); + const versions = JSON.parse(driver.readLocalFile(".gen/versions.json")); + expect(providerVersion(versions, "hashicorp/random")).toBe("3.6.1"); + + await driver.deploy([stack]); + expect(lockFile()).toMatch(/version\s+=\s+"3\.6\.1"/); + }, 500_000); +}); diff --git a/tools/build-test-matrix.mjs b/tools/build-test-matrix.mjs index 03d9b1507..a4012ef8a 100644 --- a/tools/build-test-matrix.mjs +++ b/tools/build-test-matrix.mjs @@ -42,6 +42,10 @@ const availableVersions = { * @type {Record>} */ const pinnedRuntimes = { + // Declares an OpenTofu-only target, so only the OpenTofu CLI is a meaningful pairing. + "typescript/opentofu-target/test.ts": [ + { product: "opentofu", version: "1.12.6" }, + ], "typescript/provider-features/test.ts": [ { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, From 0dd5735be5910304c690b3fe4d8c2aa4e6b7da81 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 07:19:04 -0500 Subject: [PATCH 09/15] chore(tools): build before the per-language package targets `package` depends on `build`, but `package:js`, `package:python`, `package:java`, `package:dotnet` and `package:go` had no target defaults, so they repacked whatever compiled output was already in `lib/` and `bundle/`. The tarball gets a fresh timestamp while its contents are stale, which is the hardest kind of stale artifact to notice: run-against-dist.mjs's own error message points people at `pnpm run package:js`, and a fix made since the last full build silently goes untested. Observed directly: after source edits, `package:js` produced a 07:06 tarball whose `lib/features.js` was from 06:21 and lacked the change. With `dependsOn: ["build"]` the same command runs the dependency build chain first. Only `dependsOn` is added, not caching: a cached `package` target is what served a weeks-old CLI tarball in the first place. `build` is cached, so the added dependency costs a cache hit when nothing changed. No CI workflow invokes these targets. Co-Authored-By: Claude Opus 5.5 --- nx.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nx.json b/nx.json index 62a03429e..ca98aa141 100644 --- a/nx.json +++ b/nx.json @@ -47,6 +47,11 @@ "outputs": ["{projectRoot}/dist"], "cache": true }, + "package:js": { "dependsOn": ["build"] }, + "package:python": { "dependsOn": ["build"] }, + "package:java": { "dependsOn": ["build"] }, + "package:dotnet": { "dependsOn": ["build"] }, + "package:go": { "dependsOn": ["build"] }, "test": { "dependsOn": [ "build", From 2d96734f55250a43e913ba0164f6f10dfb1859b2 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 11:48:21 -0500 Subject: [PATCH 10/15] chore(docs): describe how OpenTofu runs in the test matrix test/Readme.md still said "OpenTofu is currently not part of the CI image; OpenTofu runs are local only for now". The image has shipped OpenTofu as `tofu` since the jsii-terraform image update, unit tests tagged `unit-test:terraform` run once under it, and four integration targets are now pinned to it. Documents the `opentofu` block in .terraform.versions.json, how a test opts into OpenTofu through `pinnedRuntimes` and is kept out of it with `terraformOnly`, and the distinction that #494 turned on: running under the OpenTofu CLI does not exercise a project that targets OpenTofu, because registry resolution follows the project's declared `targetVersions`. Co-Authored-By: Claude Opus 5.5 --- test/Readme.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/Readme.md b/test/Readme.md index 66f077269..7683af6ad 100644 --- a/test/Readme.md +++ b/test/Readme.md @@ -77,14 +77,25 @@ Which tests execute the binary: `.terraform.versions.json` at the repository root drives the matrix: -- `available` — versions baked into the CI Docker image (see `Dockerfile`); - each is installed as `/usr/local/bin/terraform` with the `default` - version symlinked to `terraform`. +- `available` — Terraform versions baked into the CI Docker image (see + `Dockerfile`); each is installed as `/usr/local/bin/terraform` with + the `default` version symlinked to `terraform`. - `tested` — the subset run in CI workflows, exported as `TERRAFORM_BINARY_NAME=terraform` per matrix job. - -OpenTofu is currently **not** part of the CI image; OpenTofu runs are local -only for now. +- `opentofu.available` / `opentofu.default` — the same for OpenTofu, installed + as `tofu` with the default symlinked to `tofu`. + +OpenTofu does not run across the whole integration suite. A test opts in +through `pinnedRuntimes` in `tools/build-test-matrix.mjs`, which replaces the +`tested` cross-product for that test with an explicit list of +`{ product, version }` runtimes; `terraformOnly` marks tests that can never run +under OpenTofu. Unit tests in packages tagged `unit-test:terraform` also run +once under OpenTofu (`pr-unit.yml`). + +Running under OpenTofu exercises the CLI, not a project that _targets_ +OpenTofu. Code that resolves registries from `targetVersions` only takes its +OpenTofu path when the project declares it, so cover that with a fixture that +sets `targetVersions` — see `typescript/opentofu-target`. ### Local runs From 5813aaffb71fb9fa0e0ca58c4890f046babb7ac7 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 20:41:07 -0500 Subject: [PATCH 11/15] fix(cli): check the lock file against the CLI that is actually running Review found that `lockAddressesFor` accepted a bare source's lock entry from either public registry. A bare source resolves according to the CLI that runs, so Terraform and OpenTofu entries are not interchangeable: - a project deployed with Terraform and then run with OpenTofu has only a registry.terraform.io entry; that counted as locked, `init` was skipped, and OpenTofu then failed on the lock file - with both entries present, a stale entry for the inactive registry could force an unnecessary `init -upgrade` CdktfStack now identifies the running CLI from `version` output and resolves a bare source against that CLI's registry only. When it cannot be identified, both public registries are candidates and the decisions stay conservative: refresh unless every candidate is locked, and upgrade if any conflicts. A source that names a host still matches exactly that host. The two decisions move out of CdktfStack's private methods into `needsLockfileUpdate` and `needsUpgrade`, so the tests exercise the code production calls. They cover a lock holding both public-registry entries with conflicting constraints, a switch from Terraform to OpenTofu, an unidentified CLI, and an explicit host against another registry's entry. Co-Authored-By: Claude Opus 5.5 --- .../@cdktn/cli-core/src/lib/cdktf-stack.ts | 87 ++++------ .../src/lib/terraform-provider-lock.ts | 69 +++++++- .../test/lib/terraform-provider-lock.test.ts | 161 ++++++++++++------ 3 files changed, 210 insertions(+), 107 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts index 5048eac9a..5dbcf1c5f 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts @@ -5,7 +5,11 @@ import { Terraform } from "./models/terraform"; import { getConstructIdsForOutputs, NestedTerraformOutputs } from "./output"; import { Errors, + OPENTOFU_REGISTRY, + Registry, + TERRAFORM_REGISTRY, logger, + parseTerraformCliVersion, readConfigSync, terraformBinaryName, } from "@cdktn/commons"; @@ -20,7 +24,8 @@ import { import { terraformJsonSchema, TerraformStack } from "./terraform-json"; import { TerraformProviderLock, - lockAddressesFor, + needsLockfileUpdate, + needsUpgrade, } from "./terraform-provider-lock"; import { convertConfigurationFile } from "./convert"; @@ -285,10 +290,11 @@ export class CdktfStack { ) { const terraform = await this.terraformClient(); await this.validateInstalledBinaryIfConfigured(terraform); + const cliRegistry = await this.runningCliRegistry(terraform); const needsLockfileUpdate = skipProviderLock ? false - : await this.checkNeedsLockfileUpdate(); - const needsUpgrade = await this.checkNeedsUpgrade(); + : await this.checkNeedsLockfileUpdate(cliRegistry); + const needsUpgrade = await this.checkNeedsUpgrade(cliRegistry); await terraform.init({ needsUpgrade, noColor: noColor ?? false, @@ -298,17 +304,32 @@ export class CdktfStack { return terraform; } + /** + * The registry the running CLI resolves bare provider sources from, or + * undefined when it cannot be identified. + */ + private async runningCliRegistry( + terraform: Terraform, + ): Promise { + try { + const { name } = parseTerraformCliVersion(await terraform.version()); + if (name === "opentofu") return OPENTOFU_REGISTRY; + if (name === "terraform") return TERRAFORM_REGISTRY; + } catch (e) { + logger.debug(`Could not identify the Terraform-compatible CLI: ${e}`); + } + return undefined; + } + private requiredProviders() { // Read required providers from the stack output const requiredProviders = this.parsedContent.terraform?.required_providers; - - return Object.values(requiredProviders || {}).map((obj) => ({ - version: obj.version, - addresses: lockAddressesFor(obj.source, obj.version), - })); + return Object.values(requiredProviders || {}); } - private async checkNeedsLockfileUpdate(): Promise { + private async checkNeedsLockfileUpdate( + cliRegistry: Registry | undefined, + ): Promise { if (this.options.migrateState) { // If we're migrating state, we need to init return true; @@ -321,52 +342,14 @@ export class CdktfStack { return true; } - for (const { addresses } of this.requiredProviders()) { - let locked = false; - for (const address of addresses) { - if (await lock.hasMatchingProvider(address)) { - locked = true; - break; - } - } - if (!locked) { - // If we don't have a provider or version doesn't match, we need to init - return true; - } - } - - return false; + return needsLockfileUpdate(this.requiredProviders(), lock, cliRegistry); } - private async checkNeedsUpgrade(): Promise { + private async checkNeedsUpgrade( + cliRegistry: Registry | undefined, + ): Promise { const lock = new TerraformProviderLock(this.stack.workingDirectory); - const allProviders = this.requiredProviders(); - const lockedProviders = Object.values(await lock.providers()); - - // Check if any provider contained in `providers` violates constraints in `lockedProviders` - // Upgrade if some provider constraint not met - // If a provider wasn't preset in lockedProviders, that's fine; it will just get added - return lockedProviders.some((lockedProvider) => { - const lockedConstraint = lockedProvider.constraints; - if (!lockedConstraint) { - // Provider lock doesn't have a constraint specified, so we can't check. - // This shouldn't happen - logger.debug( - `Provider lock doesn't have a constraint for ${lockedProvider.name}`, - ); - return false; - } - - const provider = allProviders.find(({ addresses }) => - addresses.some(({ source }) => source === lockedConstraint.source), - ); - if (!provider) { - // else no longer using this provider, so won't cause problems - return; - } - - return !lockedConstraint.matchesVersion(provider.version ?? ">0"); - }); + return needsUpgrade(this.requiredProviders(), lock, cliRegistry); } private async run(cb: () => Promise) { diff --git a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts index 48acdf6c6..e49cbc0ee 100644 --- a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts +++ b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts @@ -6,7 +6,7 @@ import path from "path"; import fs from "fs/promises"; import { ProviderConstraint } from "./dependencies/dependency-manager"; -import { PUBLIC_REGISTRIES, logger } from "@cdktn/commons"; +import { PUBLIC_REGISTRIES, Registry, logger } from "@cdktn/commons"; const TerraformLockFileName = ".terraform.lock.hcl"; @@ -22,24 +22,81 @@ type TerraformProviderLockFileData = { }; }; +type RequiredProvider = { source: string; version?: string }; + /** - * Lock-file addresses that can satisfy a `required_providers` source. The CLI - * locks a source that names a host under exactly that host, and a bare source - * under whichever public registry it resolves bare sources from - which differs - * between Terraform and OpenTofu, so each public registry is a candidate. + * Lock-file addresses that can satisfy a `required_providers` source. A source + * that names a host is locked under exactly that host; a bare one under the + * registry the running CLI resolves bare sources from. If that CLI could not be + * identified, every public registry is returned and callers stay conservative. */ export function lockAddressesFor( source: string, - version?: string, + version: string | undefined, + cliRegistry: Registry | undefined, ): ProviderConstraint[] { if (source.split("/").length === 3) { return [new ProviderConstraint(source, version)]; } + if (cliRegistry) { + return [new ProviderConstraint(source, version, cliRegistry)]; + } return PUBLIC_REGISTRIES.map( (registry) => new ProviderConstraint(source, version, registry), ); } +/** + * Whether `init` must refresh the lock file. Every candidate address has to be + * locked, so an unidentified CLI refreshes rather than trusting an entry another + * CLI wrote. + */ +export async function needsLockfileUpdate( + required: RequiredProvider[], + lock: TerraformProviderLock, + cliRegistry: Registry | undefined, +): Promise { + for (const { source, version } of required) { + for (const address of lockAddressesFor(source, version, cliRegistry)) { + if (!(await lock.hasMatchingProvider(address))) { + return true; + } + } + } + return false; +} + +/** Whether a locked version no longer satisfies a requirement, needing `init -upgrade`. */ +export async function needsUpgrade( + required: RequiredProvider[], + lock: TerraformProviderLock, + cliRegistry: Registry | undefined, +): Promise { + const lockedProviders = Object.values(await lock.providers()); + return lockedProviders.some((lockedProvider) => { + const lockedConstraint = lockedProvider.constraints; + if (!lockedConstraint) { + // Provider lock doesn't have a constraint specified, so we can't check. + logger.debug( + `Provider lock doesn't have a constraint for ${lockedProvider.name}`, + ); + return false; + } + + const provider = required.find(({ source, version }) => + lockAddressesFor(source, version, cliRegistry).some( + (address) => address.source === lockedConstraint.source, + ), + ); + // Not required by this stack, or locked for another CLI: leave it alone. + if (!provider) { + return false; + } + + return !lockedConstraint.matchesVersion(provider.version ?? ">0"); + }); +} + export class TerraformProviderLock { private _providerLockData: TerraformProviderLockFileData | null; constructor(private stackWorkingDirectory: string) { diff --git a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts index c9ccf2a1f..123198488 100644 --- a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts @@ -6,7 +6,10 @@ import { TerraformProviderLock, lockAddressesFor, + needsLockfileUpdate, + needsUpgrade, } from "../../lib/terraform-provider-lock"; +import { OPENTOFU_REGISTRY, TERRAFORM_REGISTRY } from "@cdktn/commons"; import { readFile, stat } from "fs/promises"; import * as path from "path"; import { ProviderConstraint } from "../../lib/dependencies/dependency-manager"; @@ -207,65 +210,125 @@ describe("TerraformProviderLock", () => { }); }); -describe("lockAddressesFor", () => { - const lockedUnder = (name: string) => - (readFile as jest.Mock).mockResolvedValueOnce( - generateProviderLockFileContents([ - { name, version: "1.2.3", constraints: "1.2" }, - ]), - ); +describe("lock decisions for the running CLI", () => { + const terraformEntry = "registry.terraform.io/hashicorp/test"; + const opentofuEntry = "registry.opentofu.org/hashicorp/test"; - /** Whether any address the CLI could have locked `source` under is in the lock. */ - const isLocked = async (source: string) => { - const lock = new TerraformProviderLock("test"); - for (const address of lockAddressesFor(source, "1.2")) { - if (await lock.hasMatchingProvider(address)) return true; - } - return false; + const lockWith = ( + entries: { name: string; version: string; constraints: string }[], + ) => { + (readFile as jest.Mock).mockResolvedValue( + generateProviderLockFileContents(entries), + ); + return new TerraformProviderLock("test"); }; beforeEach(() => { jest.clearAllMocks(); }); - it("keeps a source that names a host to exactly that host", () => { - expect( - lockAddressesFor("registry.terraform.io/hashicorp/test").map( - (a) => a.source, - ), - ).toEqual(["registry.terraform.io/hashicorp/test"]); - }); - - it("offers every public registry for a bare source", () => { - expect(lockAddressesFor("hashicorp/test").map((a) => a.source)).toEqual([ - "registry.terraform.io/hashicorp/test", - "registry.opentofu.org/hashicorp/test", - ]); - }); - - it("finds a bare source that Terraform locked", async () => { - lockedUnder("registry.terraform.io/hashicorp/test"); - await expect(isLocked("hashicorp/test")).resolves.toBe(true); - }); - - it("finds a bare source that OpenTofu locked", async () => { - lockedUnder("registry.opentofu.org/hashicorp/test"); - await expect(isLocked("hashicorp/test")).resolves.toBe(true); + describe("lockAddressesFor", () => { + it("keeps a source that names a host to exactly that host", () => { + expect( + lockAddressesFor(terraformEntry, "1.2", OPENTOFU_REGISTRY).map( + (a) => a.source, + ), + ).toEqual([terraformEntry]); + }); + + it("resolves a bare source against the running CLI's registry", () => { + expect( + lockAddressesFor("hashicorp/test", "1.2", OPENTOFU_REGISTRY).map( + (a) => a.source, + ), + ).toEqual([opentofuEntry]); + expect( + lockAddressesFor("hashicorp/test", "1.2", TERRAFORM_REGISTRY).map( + (a) => a.source, + ), + ).toEqual([terraformEntry]); + }); + + it("offers every public registry when the CLI is unknown", () => { + expect( + lockAddressesFor("hashicorp/test", "1.2", undefined).map( + (a) => a.source, + ), + ).toEqual([terraformEntry, opentofuEntry]); + }); }); - // Matching on provider alone would report this as locked, skip init, and - // leave the CLI to fail on an address that is not in the lock file. - it("does not let a different public registry satisfy an explicit host", async () => { - lockedUnder("registry.opentofu.org/hashicorp/test"); - await expect( - isLocked("registry.terraform.io/hashicorp/test"), - ).resolves.toBe(false); + describe("needsLockfileUpdate", () => { + const required = [{ source: "hashicorp/test", version: "1.2" }]; + + it("is satisfied by the running CLI's own entry", async () => { + const lock = lockWith([ + { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, + ]); + await expect( + needsLockfileUpdate(required, lock, OPENTOFU_REGISTRY), + ).resolves.toBe(false); + }); + + // A project deployed with Terraform and now run with OpenTofu: skipping init + // here would leave OpenTofu to fail on the lock file. + it("refreshes when only another CLI's entry exists", async () => { + const lock = lockWith([ + { name: terraformEntry, version: "1.2.3", constraints: "1.2" }, + ]); + await expect( + needsLockfileUpdate(required, lock, OPENTOFU_REGISTRY), + ).resolves.toBe(true); + }); + + it("refreshes when the CLI is unknown and not every registry is locked", async () => { + const lock = lockWith([ + { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, + ]); + await expect( + needsLockfileUpdate(required, lock, undefined), + ).resolves.toBe(true); + }); + + it("does not let a different public registry satisfy an explicit host", async () => { + const lock = lockWith([ + { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, + ]); + await expect( + needsLockfileUpdate( + [{ source: terraformEntry, version: "1.2" }], + lock, + OPENTOFU_REGISTRY, + ), + ).resolves.toBe(true); + }); }); - it("does not let a public registry satisfy a private host", async () => { - lockedUnder("registry.terraform.io/hashicorp/test"); - await expect( - isLocked("my.registry.example.com/hashicorp/test"), - ).resolves.toBe(false); + describe("needsUpgrade with both public registries locked", () => { + // The OpenTofu entry satisfies the requirement; the stale Terraform one does not. + const lock = () => + lockWith([ + { name: terraformEntry, version: "1.0.0", constraints: "1.0.0" }, + { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, + ]); + const required = [{ source: "hashicorp/test", version: "1.2" }]; + + it("ignores the other CLI's conflicting entry", async () => { + await expect( + needsUpgrade(required, lock(), OPENTOFU_REGISTRY), + ).resolves.toBe(false); + }); + + it("upgrades when the running CLI's own entry conflicts", async () => { + await expect( + needsUpgrade(required, lock(), TERRAFORM_REGISTRY), + ).resolves.toBe(true); + }); + + it("upgrades conservatively when the CLI is unknown", async () => { + await expect(needsUpgrade(required, lock(), undefined)).resolves.toBe( + true, + ); + }); }); }); From 08f880c57af28b00ed78feb5b05add946535187b Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 20:41:09 -0500 Subject: [PATCH 12/15] fix(cli): keep an explicitly named host when upgrading a provider Review found that `provider upgrade` rebuilt every updated constraint for schema generation as `namespace/name`, dropping any host. In an OpenTofu-targeted project, upgrading `registry.terraform.io/hashicorp/random` kept the Terraform source in cdktf.json while generation fetched `hashicorp/random`, which OpenTofu resolves from its own registry - bindings from a different registry than the configured provider. Private hosts were dropped the same way. ProviderConstraint now records whether its source named a host as given, and `requiredProvidersSource` keeps such a host while still dropping one added by normalization. The handler uses it, and the rule is unit tested where the handler cannot be. Co-Authored-By: Claude Opus 5.5 --- .../lib/dependencies/dependency-manager.ts | 12 ++++++ .../dependencies/dependency-manager.test.ts | 38 ++++++++++++++++++- packages/cdktn-cli/src/bin/cmds/handlers.ts | 7 +--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts index 05b7da078..433dde8b5 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts @@ -58,12 +58,24 @@ export class ProviderConstraint { // TODO: add examples to cli command description (i.e. =,~>.> etc.) // if no version constraint is specified, we assume the latest version // if specific version is specified without e.g. =, we allow patch level increments (e.g. ~>2.12 for "2.12") + /** Whether the source as given named a host, rather than gaining one here. */ + private readonly namedHost: boolean; + constructor( source: string, public readonly version: string | undefined, registry: Registry = TERRAFORM_REGISTRY, ) { this.source = normalizeProviderSource(source, registry); + this.namedHost = source.split("/").length === 3; + } + + /** + * Source for a `required_providers` block. Keeps a host the user wrote; drops + * one added by normalization, since a host pins the CLI to that registry. + */ + public get requiredProvidersSource(): string { + return this.namedHost ? this.source : `${this.namespace}/${this.name}`; } static fromConfigEntry( diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts index 40b6c60bf..c44e31824 100644 --- a/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts @@ -1,6 +1,6 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { Language } from "@cdktn/commons"; +import { Language, OPENTOFU_REGISTRY } from "@cdktn/commons"; import { ProviderConstraint, DependencyManager, @@ -8,6 +8,42 @@ import { describe("dependency manager", () => { describe("ProviderConstraint", () => { + describe("requiredProvidersSource", () => { + it("drops a host that normalization added", () => { + expect( + new ProviderConstraint("random", "=3.6.1", OPENTOFU_REGISTRY) + .requiredProvidersSource, + ).toBe("hashicorp/random"); + expect( + new ProviderConstraint( + "hashicorp/random", + "=3.6.1", + OPENTOFU_REGISTRY, + ).requiredProvidersSource, + ).toBe("hashicorp/random"); + }); + + it("keeps a public host the user wrote", () => { + expect( + new ProviderConstraint( + "registry.terraform.io/hashicorp/random", + "=3.6.1", + OPENTOFU_REGISTRY, + ).requiredProvidersSource, + ).toBe("registry.terraform.io/hashicorp/random"); + }); + + it("keeps a private host", () => { + expect( + new ProviderConstraint( + "registry.example.com/acme/thing", + "1.0.0", + OPENTOFU_REGISTRY, + ).requiredProvidersSource, + ).toBe("registry.example.com/acme/thing"); + }); + }); + it.each(["aws", "hashicorp/aws", "registry.terraform.io/hashicorp/aws"])( "should parse a simple constraint from '%s'", (configEntry) => { diff --git a/packages/cdktn-cli/src/bin/cmds/handlers.ts b/packages/cdktn-cli/src/bin/cmds/handlers.ts index 25d7e3fac..1d9cae6ce 100644 --- a/packages/cdktn-cli/src/bin/cmds/handlers.ts +++ b/packages/cdktn-cli/src/bin/cmds/handlers.ts @@ -765,15 +765,12 @@ export async function providerUpgrade(argv: any) { constructsOptions, constraints, cleanDirectory: false, - // Host-stripped on purpose: a source that names a registry pins the CLI to - // it, so passing the normalized FQPN here made OpenTofu fetch the provider - // from registry.terraform.io. Bare, the CLI uses its own registry. constraintsToGenerate: constraintsToUpdate.map( (c) => new TerraformProviderConstraint( c.version - ? `${c.namespace}/${c.name}@${c.version}` - : `${c.namespace}/${c.name}`, + ? `${c.requiredProvidersSource}@${c.version}` + : c.requiredProvidersSource, ), ), }); From 6d435379fbe1ad0bf34870055226b4fb9a94a718 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Fri, 9 Oct 2026 20:41:13 -0500 Subject: [PATCH 13/15] chore(tests): say what the provider-command OpenTofu pins cover Review of #479 noted that the comment on the provider-add/upgrade OpenTofu pins claimed they catch regressions in registry selection. Their fixtures declare no targetVersions, so they exercise the OpenTofu CLI, not a project targeting OpenTofu; typescript/opentofu-target covers that. Co-Authored-By: Claude Opus 5.5 --- tools/build-test-matrix.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/build-test-matrix.mjs b/tools/build-test-matrix.mjs index a4012ef8a..4a3f73a94 100644 --- a/tools/build-test-matrix.mjs +++ b/tools/build-test-matrix.mjs @@ -50,8 +50,8 @@ const pinnedRuntimes = { { product: "terraform", version: "1.16.1" }, { product: "opentofu", version: "1.12.6" }, ], - // These resolve providers through the CLI's own registry, so they are the ones that catch a regression in which - // registry we pick. The Terraform entries restate the `tested` default so adding OpenTofu does not drop a version. + // Provider commands driven by the OpenTofu CLI. Their projects declare no targetVersions, so registry selection + // stays Terraform-based; opentofu-target covers that. The Terraform entries restate the `tested` default. "typescript/provider-add-command/test.ts": [ { product: "terraform", version: "1.5.7" }, { product: "terraform", version: "1.16.1" }, From d1886f4b790ae49b628575f6f1ba3a3af5671fc0 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Mon, 26 Oct 2026 15:36:20 -0500 Subject: [PATCH 14/15] fix(cli): accept a bare source's lock entry from either public registry again Reverts the running-CLI part of a79880be7, from review. That commit made the lock checks match a bare provider source only against the registry of the CLI that was running, identified by calling `terraform version` before every init. It was justified by two switching cases, and neither held up: - "counted as locked, `init` was skipped, and OpenTofu then failed on the lock file" - `init` always runs; these checks only add `-upgrade` and a follow-up `providers lock`. OpenTofu rewrites a registry.terraform.io entry to its own registry on init and keeps the selected version. - a stale entry for the other registry forcing an unneeded `-upgrade` - real, but only after a switch, and it costs one extra `-upgrade`. And the filter broke the realistic case. Reproduced with Terraform 1.14.3 and OpenTofu 1.12.6 on a stack deployed by Terraform and then bumped from 3.6.0 to 3.6.1 under OpenTofu: the filter ignored the Terraform entry, added no `-upgrade`, and `tofu init` failed with "locked provider ... must use tofu init -upgrade". Matching either public registry adds `-upgrade` and the bump applies. It also put `terraform version` output - including "Your version of Terraform is out of date!" - into every diff, deploy and destroy log, once per stack, and spawned that process for every project; on main it only ran behind the opt-in validateInstalledBinary. A bare source again matches its lock entry under any public registry; a source that names a host still matches only that host. `needsLockfileUpdate` and `needsUpgrade` stay the exported functions CdktfStack calls, and their tests now cover the Terraform-to-OpenTofu bump. `lockAddressesFor` is module-private. Filtering bare sources to one registry fails three of the cases. Co-Authored-By: Claude Opus 5.5 --- .../@cdktn/cli-core/src/lib/cdktf-stack.ts | 38 +----- .../src/lib/terraform-provider-lock.ts | 42 +++---- .../test/lib/terraform-provider-lock.test.ts | 117 ++++++------------ 3 files changed, 60 insertions(+), 137 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts index 5dbcf1c5f..2d85a8a0d 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-stack.ts @@ -5,11 +5,7 @@ import { Terraform } from "./models/terraform"; import { getConstructIdsForOutputs, NestedTerraformOutputs } from "./output"; import { Errors, - OPENTOFU_REGISTRY, - Registry, - TERRAFORM_REGISTRY, logger, - parseTerraformCliVersion, readConfigSync, terraformBinaryName, } from "@cdktn/commons"; @@ -290,11 +286,10 @@ export class CdktfStack { ) { const terraform = await this.terraformClient(); await this.validateInstalledBinaryIfConfigured(terraform); - const cliRegistry = await this.runningCliRegistry(terraform); const needsLockfileUpdate = skipProviderLock ? false - : await this.checkNeedsLockfileUpdate(cliRegistry); - const needsUpgrade = await this.checkNeedsUpgrade(cliRegistry); + : await this.checkNeedsLockfileUpdate(); + const needsUpgrade = await this.checkNeedsUpgrade(); await terraform.init({ needsUpgrade, noColor: noColor ?? false, @@ -304,32 +299,13 @@ export class CdktfStack { return terraform; } - /** - * The registry the running CLI resolves bare provider sources from, or - * undefined when it cannot be identified. - */ - private async runningCliRegistry( - terraform: Terraform, - ): Promise { - try { - const { name } = parseTerraformCliVersion(await terraform.version()); - if (name === "opentofu") return OPENTOFU_REGISTRY; - if (name === "terraform") return TERRAFORM_REGISTRY; - } catch (e) { - logger.debug(`Could not identify the Terraform-compatible CLI: ${e}`); - } - return undefined; - } - private requiredProviders() { // Read required providers from the stack output const requiredProviders = this.parsedContent.terraform?.required_providers; return Object.values(requiredProviders || {}); } - private async checkNeedsLockfileUpdate( - cliRegistry: Registry | undefined, - ): Promise { + private async checkNeedsLockfileUpdate(): Promise { if (this.options.migrateState) { // If we're migrating state, we need to init return true; @@ -342,14 +318,12 @@ export class CdktfStack { return true; } - return needsLockfileUpdate(this.requiredProviders(), lock, cliRegistry); + return needsLockfileUpdate(this.requiredProviders(), lock); } - private async checkNeedsUpgrade( - cliRegistry: Registry | undefined, - ): Promise { + private async checkNeedsUpgrade(): Promise { const lock = new TerraformProviderLock(this.stack.workingDirectory); - return needsUpgrade(this.requiredProviders(), lock, cliRegistry); + return needsUpgrade(this.requiredProviders(), lock); } private async run(cb: () => Promise) { diff --git a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts index e49cbc0ee..b56dd1fe2 100644 --- a/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts +++ b/packages/@cdktn/cli-core/src/lib/terraform-provider-lock.ts @@ -6,7 +6,7 @@ import path from "path"; import fs from "fs/promises"; import { ProviderConstraint } from "./dependencies/dependency-manager"; -import { PUBLIC_REGISTRIES, Registry, logger } from "@cdktn/commons"; +import { PUBLIC_REGISTRIES, logger } from "@cdktn/commons"; const TerraformLockFileName = ".terraform.lock.hcl"; @@ -24,44 +24,37 @@ type TerraformProviderLockFileData = { type RequiredProvider = { source: string; version?: string }; -/** - * Lock-file addresses that can satisfy a `required_providers` source. A source - * that names a host is locked under exactly that host; a bare one under the - * registry the running CLI resolves bare sources from. If that CLI could not be - * identified, every public registry is returned and callers stay conservative. - */ -export function lockAddressesFor( +// A source that names a host is locked under exactly that host. A bare one is +// locked under whichever public registry the CLI resolved it from, so either +// counts: OpenTofu migrates a Terraform entry itself on init. +function lockAddressesFor( source: string, - version: string | undefined, - cliRegistry: Registry | undefined, + version?: string, ): ProviderConstraint[] { if (source.split("/").length === 3) { return [new ProviderConstraint(source, version)]; } - if (cliRegistry) { - return [new ProviderConstraint(source, version, cliRegistry)]; - } return PUBLIC_REGISTRIES.map( (registry) => new ProviderConstraint(source, version, registry), ); } -/** - * Whether `init` must refresh the lock file. Every candidate address has to be - * locked, so an unidentified CLI refreshes rather than trusting an entry another - * CLI wrote. - */ +/** Whether `init` should be followed by a `providers lock` refresh. */ export async function needsLockfileUpdate( required: RequiredProvider[], lock: TerraformProviderLock, - cliRegistry: Registry | undefined, ): Promise { for (const { source, version } of required) { - for (const address of lockAddressesFor(source, version, cliRegistry)) { - if (!(await lock.hasMatchingProvider(address))) { - return true; + let locked = false; + for (const address of lockAddressesFor(source, version)) { + if (await lock.hasMatchingProvider(address)) { + locked = true; + break; } } + if (!locked) { + return true; + } } return false; } @@ -70,7 +63,6 @@ export async function needsLockfileUpdate( export async function needsUpgrade( required: RequiredProvider[], lock: TerraformProviderLock, - cliRegistry: Registry | undefined, ): Promise { const lockedProviders = Object.values(await lock.providers()); return lockedProviders.some((lockedProvider) => { @@ -84,11 +76,11 @@ export async function needsUpgrade( } const provider = required.find(({ source, version }) => - lockAddressesFor(source, version, cliRegistry).some( + lockAddressesFor(source, version).some( (address) => address.source === lockedConstraint.source, ), ); - // Not required by this stack, or locked for another CLI: leave it alone. + // No longer required by this stack, so it can't block the upgrade. if (!provider) { return false; } diff --git a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts index 123198488..40f38d489 100644 --- a/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/terraform-provider-lock.test.ts @@ -5,11 +5,9 @@ import { TerraformProviderLock, - lockAddressesFor, needsLockfileUpdate, needsUpgrade, } from "../../lib/terraform-provider-lock"; -import { OPENTOFU_REGISTRY, TERRAFORM_REGISTRY } from "@cdktn/commons"; import { readFile, stat } from "fs/promises"; import * as path from "path"; import { ProviderConstraint } from "../../lib/dependencies/dependency-manager"; @@ -210,9 +208,10 @@ describe("TerraformProviderLock", () => { }); }); -describe("lock decisions for the running CLI", () => { +describe("lock decisions", () => { const terraformEntry = "registry.terraform.io/hashicorp/test"; const opentofuEntry = "registry.opentofu.org/hashicorp/test"; + const bare = [{ source: "hashicorp/test", version: "1.2" }]; const lockWith = ( entries: { name: string; version: string; constraints: string }[], @@ -227,108 +226,66 @@ describe("lock decisions for the running CLI", () => { jest.clearAllMocks(); }); - describe("lockAddressesFor", () => { - it("keeps a source that names a host to exactly that host", () => { - expect( - lockAddressesFor(terraformEntry, "1.2", OPENTOFU_REGISTRY).map( - (a) => a.source, - ), - ).toEqual([terraformEntry]); - }); - - it("resolves a bare source against the running CLI's registry", () => { - expect( - lockAddressesFor("hashicorp/test", "1.2", OPENTOFU_REGISTRY).map( - (a) => a.source, - ), - ).toEqual([opentofuEntry]); - expect( - lockAddressesFor("hashicorp/test", "1.2", TERRAFORM_REGISTRY).map( - (a) => a.source, - ), - ).toEqual([terraformEntry]); + describe("needsLockfileUpdate", () => { + it.each([ + ["Terraform", terraformEntry], + ["OpenTofu", opentofuEntry], + ])("accepts a bare source locked by %s", async (_cli, name) => { + const lock = lockWith([{ name, version: "1.2.3", constraints: "1.2" }]); + await expect(needsLockfileUpdate(bare, lock)).resolves.toBe(false); }); - it("offers every public registry when the CLI is unknown", () => { - expect( - lockAddressesFor("hashicorp/test", "1.2", undefined).map( - (a) => a.source, - ), - ).toEqual([terraformEntry, opentofuEntry]); + it("refreshes when the provider is not locked at all", async () => { + const lock = lockWith([]); + await expect(needsLockfileUpdate(bare, lock)).resolves.toBe(true); }); - }); - describe("needsLockfileUpdate", () => { - const required = [{ source: "hashicorp/test", version: "1.2" }]; - - it("is satisfied by the running CLI's own entry", async () => { + it("does not let a different public registry satisfy an explicit host", async () => { const lock = lockWith([ { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, ]); await expect( - needsLockfileUpdate(required, lock, OPENTOFU_REGISTRY), - ).resolves.toBe(false); - }); - - // A project deployed with Terraform and now run with OpenTofu: skipping init - // here would leave OpenTofu to fail on the lock file. - it("refreshes when only another CLI's entry exists", async () => { - const lock = lockWith([ - { name: terraformEntry, version: "1.2.3", constraints: "1.2" }, - ]); - await expect( - needsLockfileUpdate(required, lock, OPENTOFU_REGISTRY), + needsLockfileUpdate([{ source: terraformEntry, version: "1.2" }], lock), ).resolves.toBe(true); }); + }); - it("refreshes when the CLI is unknown and not every registry is locked", async () => { + describe("needsUpgrade", () => { + // A project deployed with Terraform and then bumped under OpenTofu: without + // `-upgrade`, OpenTofu rejects the plain init. + it("upgrades a bump locked under the other CLI's registry", async () => { const lock = lockWith([ - { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, + { name: terraformEntry, version: "1.0.0", constraints: "1.0.0" }, ]); - await expect( - needsLockfileUpdate(required, lock, undefined), - ).resolves.toBe(true); + await expect(needsUpgrade(bare, lock)).resolves.toBe(true); }); - it("does not let a different public registry satisfy an explicit host", async () => { + it("does not upgrade when the locked version still satisfies the requirement", async () => { const lock = lockWith([ { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, ]); - await expect( - needsLockfileUpdate( - [{ source: terraformEntry, version: "1.2" }], - lock, - OPENTOFU_REGISTRY, - ), - ).resolves.toBe(true); + await expect(needsUpgrade(bare, lock)).resolves.toBe(false); }); - }); - describe("needsUpgrade with both public registries locked", () => { - // The OpenTofu entry satisfies the requirement; the stale Terraform one does not. - const lock = () => - lockWith([ + // After a switch the lock can hold both; a stale entry costs one extra + // `-upgrade`, which is safe. + it("upgrades when either registry's entry conflicts", async () => { + const lock = lockWith([ { name: terraformEntry, version: "1.0.0", constraints: "1.0.0" }, { name: opentofuEntry, version: "1.2.3", constraints: "1.2" }, ]); - const required = [{ source: "hashicorp/test", version: "1.2" }]; - - it("ignores the other CLI's conflicting entry", async () => { - await expect( - needsUpgrade(required, lock(), OPENTOFU_REGISTRY), - ).resolves.toBe(false); + await expect(needsUpgrade(bare, lock)).resolves.toBe(true); }); - it("upgrades when the running CLI's own entry conflicts", async () => { - await expect( - needsUpgrade(required, lock(), TERRAFORM_REGISTRY), - ).resolves.toBe(true); - }); - - it("upgrades conservatively when the CLI is unknown", async () => { - await expect(needsUpgrade(required, lock(), undefined)).resolves.toBe( - true, - ); + it("ignores a locked provider the stack no longer requires", async () => { + const lock = lockWith([ + { + name: "registry.terraform.io/hashicorp/other", + version: "1.0.0", + constraints: "1.0.0", + }, + ]); + await expect(needsUpgrade(bare, lock)).resolves.toBe(false); }); }); }); From a5660300b6dcfe82e6185cff409b19582a8d055c Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Mon, 26 Oct 2026 22:54:28 -0500 Subject: [PATCH 15/15] fix(cli): keep a provider entry's own host when upgrading it sakul-learning's scenario runner showed `provider upgrade random@=3.6.1` turning `registry.opentofu.org/hashicorp/random@=3.6.0` in an OpenTofu-targeted project's cdktf.json into `hashicorp/random@=3.6.1`. Matching the entry was the fix in this PR, but the update was then written back through the step that shortens the project registry's own host, so the host the user wrote was dropped. The fully qualified upgrade argument dropped it the same way. An update now keeps the existing entry's spelling of its source and changes only the version. New entries are still written in the short form. Co-Authored-By: Claude Opus 5.5 --- .../lib/dependencies/cdktf-config-manager.ts | 23 ++++++++++++++----- .../dependencies/cdktf-config-manager.test.ts | 21 +++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts b/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts index 54df06de5..f2554cbf6 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/cdktf-config-manager.ts @@ -1,6 +1,10 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { Registry, registryForTargetVersions } from "@cdktn/commons"; +import { + Registry, + TerraformDependencyConstraint, + registryForTargetVersions, +} from "@cdktn/commons"; import { CdktfConfig } from "../cdktf-config"; import { ProviderConstraint } from "./dependency-manager"; @@ -35,17 +39,24 @@ export class CdktfConfigManager { private async setProvider(constraint: ProviderConstraint): Promise { const registry = this.registry; + const isSame = (provider: string | TerraformDependencyConstraint) => + ProviderConstraint.fromConfigEntry(provider, registry).source === + constraint.source; + + // An update keeps the entry's own spelling, so a host the user wrote stays. + const existing = this.config.terraformProviders.find(isSame); + const source = + typeof existing === "string" + ? existing.split("@")[0].trim() + : simplifySource(constraint.source, registry); // excluding the constraint to be added (if it already existed) const currentProviders = this.config.terraformProviders.filter( - (provider) => - ProviderConstraint.fromConfigEntry(provider, registry).source !== - constraint.source, + (provider) => !isSame(provider), ); const provider = - simplifySource(constraint.source, registry) + - (constraint.version ? `@${constraint.version}` : ""); + source + (constraint.version ? `@${constraint.version}` : ""); currentProviders.push(provider); this.config.writeTerraformProviders(currentProviders); diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts index 153fb37bf..e67f69568 100644 --- a/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/cdktf-config-manager.test.ts @@ -68,6 +68,27 @@ describe("CdktfConfigManager", () => { expect(readProviders()).toEqual(["hashicorp/random@=3.2.0"]); }); + it.each([ + ["a shorthand", "random"], + ["a qualified", "registry.opentofu.org/hashicorp/random"], + ])( + "keeps the host of an explicitly qualified entry on %s upgrade", + async (_name, upgradeSource) => { + writeConfig( + ["registry.opentofu.org/hashicorp/random@=3.6.0"], + opentofu, + ); + + await new CdktfConfigManager().updateProvider( + new ProviderConstraint(upgradeSource, "=3.6.1", OPENTOFU_REGISTRY), + ); + + expect(readProviders()).toEqual([ + "registry.opentofu.org/hashicorp/random@=3.6.1", + ]); + }, + ); + it("recognises an unqualified entry as the same provider", async () => { writeConfig(["hashicorp/random@=3.1.3"], opentofu);