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 e7dc9f79a..a76554e0d 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts @@ -6,6 +6,9 @@ import { IsErrorType, logger, TerraformDependencyConstraint, + Registry, + TERRAFORM_REGISTRY, + registryForHostname, } from "@cdktn/commons"; import { toPascalCase, toSnakeCase } from "codemaker"; import { CdktfConfig } from "../cdktf-config"; @@ -17,23 +20,29 @@ import { getPrebuiltProviderVersionInformation, getPrebuiltProviderVersions, } from "./prebuilt-providers"; -import { getLatestVersion } from "./registry-api"; +import { getLatestVersion, registryForConstraint } from "./registry-api"; import { versionMatchesConstraint } from "./version-constraints"; import * as semver from "semver"; import { LocalProviderVersions } from "../local-provider-versions"; import { LocalProviderConstraints } from "../local-provider-constraints"; // ref: https://www.terraform.io/language/providers/requirements#source-addresses -export const DEFAULT_HOSTNAME = "registry.terraform.io"; +export const DEFAULT_HOSTNAME = TERRAFORM_REGISTRY.hostname; export const DEFAULT_NAMESPACE = "hashicorp"; -function normalizeProviderSource(source: string) { - // returns // + +/** + * Expands a source to //. A source that already + * names a hostname is left alone, so an explicitly qualified provider - which + * is what OpenTofu users have been told to write - always wins over the + * project's target. + */ +function normalizeProviderSource(source: string, registry: Registry) { const slashes = source.split("/").length - 1; switch (slashes) { case 0: - return `${DEFAULT_HOSTNAME}/${DEFAULT_NAMESPACE}/${source}`; + return `${registry.hostname}/${DEFAULT_NAMESPACE}/${source}`; case 1: - return `${DEFAULT_HOSTNAME}/${source}`; + return `${registry.hostname}/${source}`; default: return source; } @@ -52,18 +61,21 @@ export class ProviderConstraint { constructor( source: string, public readonly version: string | undefined, + registry: Registry = TERRAFORM_REGISTRY, ) { - this.source = normalizeProviderSource(source); + this.source = normalizeProviderSource(source, registry); } static fromConfigEntry( provider: string | TerraformDependencyConstraint, + registry: Registry = TERRAFORM_REGISTRY, ): ProviderConstraint { if (typeof provider === "string") { const [src, version] = provider.split("@"); return new ProviderConstraint( src.trim(), version ? version.trim() : undefined, + registry, ); } @@ -71,11 +83,15 @@ export class ProviderConstraint { (provider.namespace ? `${provider.namespace}/` : "") + (provider.source || provider.name); - return new ProviderConstraint(src, provider.version); + return new ProviderConstraint(src, provider.version, registry); } - public isFromTerraformRegistry(): boolean { - return this.hostname === DEFAULT_HOSTNAME; + /** + * Whether this provider lives on a registry cdktn can query for versions. + * Private and self-hosted registries expose no such API. + */ + public isFromPublicRegistry(): boolean { + return registryForHostname(this.hostname) !== undefined; } /** @@ -109,7 +125,10 @@ export class ProviderConstraint { public get simplifiedName(): string { return this.source .split("/") - .filter((part) => part !== DEFAULT_HOSTNAME && part !== DEFAULT_NAMESPACE) + .filter( + (part) => + registryForHostname(part) === undefined && part !== DEFAULT_NAMESPACE, + ) .join("/"); } @@ -362,7 +381,7 @@ export class DependencyManager { `Adding local provider ${constraint.source} with version constraint ${constraint.version} to cdktf.json`, ); - if (!constraint.version && constraint.isFromTerraformRegistry()) { + if (!constraint.version && constraint.isFromPublicRegistry()) { const v = await getLatestVersion(constraint); if (v) { constraint = new ProviderConstraint( @@ -372,7 +391,7 @@ export class DependencyManager { ); } else { throw Errors.Usage( - `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at https://registry.terraform.io/browse/providers to find all supported providers.`, + `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at ${registryForConstraint(constraint).browseUrl} to find all supported providers.`, ); } } diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts index a5164d118..5b020b927 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts @@ -3,7 +3,12 @@ import { fetch, ProxyAgent } from "undici"; import { ProviderConstraint } from "./dependency-manager"; import * as semver from "semver"; -import { Errors } from "@cdktn/commons"; +import { + Errors, + Registry, + TERRAFORM_REGISTRY, + registryForHostname, +} from "@cdktn/commons"; type VersionsReturnType = { id: string; // e.g. hashicorp/aws @@ -14,12 +19,24 @@ type VersionsReturnType = { }[]; }; +/** + * The registry a constraint resolves against: the one its own hostname names. + * A constraint is normalized before it gets here, so this is either the host + * the author wrote explicitly or the project's target registry. + */ +export function registryForConstraint( + constraint: ProviderConstraint, +): Registry { + return registryForHostname(constraint.hostname) ?? TERRAFORM_REGISTRY; +} + async function fetchVersions( constraint: ProviderConstraint, + registry: Registry, ): Promise { const proxy = process.env.http_proxy || process.env.HTTP_PROXY; const dispatcher = proxy ? new ProxyAgent(proxy) : undefined; - const url = `https://registry.terraform.io/v1/providers/${constraint.namespace}/${constraint.name}/versions`; + const url = `https://${registry.hostname}/v1/providers/${constraint.namespace}/${constraint.name}/versions`; const result = await fetch(url, { dispatcher, @@ -41,11 +58,17 @@ async function fetchVersions( * returns the latest available version for the provider in the constraint * the version of the constraint is ignored * returns null, if the provider does not exist + * + * Both registries expose the same /v1/providers///versions shape, + * and their version lists differ, so the project's target decides which to ask. */ export async function getLatestVersion( constraint: ProviderConstraint, ): Promise { - const versions = await fetchVersions(constraint); + const versions = await fetchVersions( + constraint, + registryForConstraint(constraint), + ); if (!versions) { return null; } diff --git a/packages/@cdktn/cli-core/src/lib/provider-add.ts b/packages/@cdktn/cli-core/src/lib/provider-add.ts index 065d2a4bb..9fbe6c8a0 100644 --- a/packages/@cdktn/cli-core/src/lib/provider-add.ts +++ b/packages/@cdktn/cli-core/src/lib/provider-add.ts @@ -3,7 +3,12 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { Language } from "@cdktn/commons"; +import * as path from "path"; +import { + Language, + readConfigSync, + registryForTargetVersions, +} from "@cdktn/commons"; import { DependencyManager, ProviderConstraint, @@ -30,12 +35,17 @@ export async function providerAdd({ const version = cdktfVersion || (await determineDeps(cdktfVersion, dist)).cdktf_version; + // Read the target project's config, not the caller's cwd - init() scaffolds + // into `destination` and calls this without changing directory. + const registry = registryForTargetVersions( + readConfigSync(path.join(projectDirectory, "cdktf.json")).targetVersions, + ); const manager = new DependencyManager(language, version, projectDirectory); let needsGet = false; for (const provider of providers) { - const constraint = ProviderConstraint.fromConfigEntry(provider); + const constraint = ProviderConstraint.fromConfigEntry(provider, registry); if (forceLocal) { needsGet = true; await manager.addLocalProvider(constraint); 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 466ef7c63..40b6c60bf 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 @@ -16,7 +16,7 @@ describe("dependency manager", () => { "registry.terraform.io/hashicorp/aws", ); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("hashicorp"); expect(constraint.name).toEqual("aws"); expect(constraint.simplifiedName).toEqual("aws"); @@ -40,7 +40,7 @@ describe("dependency manager", () => { "registry.terraform.io/hashicorp/aws", ); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("hashicorp"); expect(constraint.name).toEqual("aws"); expect(constraint.version).toBeDefined(); @@ -52,7 +52,7 @@ describe("dependency manager", () => { const constraint = ProviderConstraint.fromConfigEntry("kreuzwerker/docker"); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("kreuzwerker"); expect(constraint.name).toEqual("docker"); expect(constraint.simplifiedName).toEqual("kreuzwerker/docker"); @@ -63,7 +63,7 @@ describe("dependency manager", () => { "registry.example.com/acme/customprovider", ); expect(constraint.hostname).toEqual("registry.example.com"); - expect(constraint.isFromTerraformRegistry()).toBe(false); + expect(constraint.isFromPublicRegistry()).toBe(false); expect(constraint.namespace).toEqual("acme"); expect(constraint.name).toEqual("customprovider"); expect(constraint.simplifiedName).toEqual( diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts new file mode 100644 index 000000000..14c4375d9 --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { + MockAgent, + setGlobalDispatcher, + getGlobalDispatcher, + Dispatcher, +} from "undici"; +import { OPENTOFU_REGISTRY, TERRAFORM_REGISTRY } from "@cdktn/commons"; +import { ProviderConstraint } from "../../../lib/dependencies/dependency-manager"; +import { getLatestVersion } from "../../../lib/dependencies/registry-api"; + +describe("getLatestVersion", () => { + let mockAgent: MockAgent; + let originalDispatcher: Dispatcher; + + beforeEach(() => { + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + }); + + afterEach(async () => { + setGlobalDispatcher(originalDispatcher); + await mockAgent.close(); + }); + + const versionsPath = "/v1/providers/hashicorp/random/versions"; + const body = { id: "hashicorp/random", versions: [{ version: "3.7.2" }] }; + + it("asks the Terraform registry by default", async () => { + mockAgent + .get("https://registry.terraform.io") + .intercept({ path: versionsPath }) + .reply(200, body); + + const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); + expect(await getLatestVersion(constraint)).toBe("3.7.2"); + }); + + it("asks the OpenTofu registry when the project targets it", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(200, { ...body, versions: [{ version: "3.9.1" }] }); + + // A bare source expands against the project's registry. + const constraint = ProviderConstraint.fromConfigEntry( + "hashicorp/random", + OPENTOFU_REGISTRY, + ); + expect(constraint.source).toBe("registry.opentofu.org/hashicorp/random"); + expect(await getLatestVersion(constraint)).toBe("3.9.1"); + }); + + it("honours an explicitly qualified source over the project's target", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(200, { ...body, versions: [{ version: "3.9.1" }] }); + + // OpenTofu users have been told to fully qualify; that must keep working + // even when the project declares no targetVersions. + const constraint = ProviderConstraint.fromConfigEntry( + "registry.opentofu.org/hashicorp/random", + ); + expect(constraint.isFromPublicRegistry()).toBe(true); + expect(await getLatestVersion(constraint)).toBe("3.9.1"); + }); + + it("treats a private registry as unqueryable", () => { + const constraint = ProviderConstraint.fromConfigEntry( + "registry.example.com/acme/thing", + ); + expect(constraint.isFromPublicRegistry()).toBe(false); + }); + + it("returns null for a provider the registry does not have", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(404, ""); + + const constraint = ProviderConstraint.fromConfigEntry( + "hashicorp/random", + OPENTOFU_REGISTRY, + ); + expect(await getLatestVersion(constraint)).toBeNull(); + }); +}); + +describe("registry browse URLs", () => { + it("point at each registry's own provider listing", () => { + expect(TERRAFORM_REGISTRY.browseUrl).toBe( + "https://registry.terraform.io/browse/providers", + ); + // registry.opentofu.org/browse/providers is a 404; the listing lives here. + expect(OPENTOFU_REGISTRY.browseUrl).toBe( + "https://search.opentofu.org/providers", + ); + }); +}); diff --git a/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts new file mode 100644 index 000000000..c2f7c04d5 --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import { Language } from "@cdktn/commons"; +import { DependencyManager } from "../../lib/dependencies/dependency-manager"; +import { providerAdd } from "../../lib/provider-add"; + +// init() scaffolds into a destination and calls providerAdd with that +// directory without changing cwd, so the registry has to come from the project +// being written to rather than from wherever the CLI runs. +// +// Spying on addLocalProvider rather than mocking the module keeps the real +// ProviderConstraint doing the normalizing, so the source it receives is what +// proves which registry providerAdd selected. +describe("providerAdd registry selection", () => { + let tmpRoot: string; + let previousCwd: string; + let addLocalProvider: jest.SpyInstance; + + function project(name: string, targetVersions?: Record) { + const dir = path.join(tmpRoot, name); + fs.mkdirpSync(dir); + fs.writeFileSync( + path.join(dir, "cdktf.json"), + JSON.stringify({ + language: "typescript", + app: "npx tsx main.ts", + ...(targetVersions ? { targetVersions } : {}), + }), + ); + return dir; + } + + async function sourceAddedTo(projectDirectory: string) { + addLocalProvider.mockClear(); + await providerAdd({ + providers: ["hashicorp/random"], + language: Language.TYPESCRIPT, + projectDirectory, + cdktfVersion: "0.0.0", // non-empty, so determineDeps is never called + forceLocal: true, + }); + expect(addLocalProvider).toHaveBeenCalledTimes(1); + return addLocalProvider.mock.calls[0][0].source as string; + } + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-add-registry")); + previousCwd = process.cwd(); + addLocalProvider = jest + .spyOn(DependencyManager.prototype, "addLocalProvider") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + addLocalProvider.mockRestore(); + process.chdir(previousCwd); + fs.removeSync(tmpRoot); + }); + + it("follows the project directory, not the cwd", async () => { + process.chdir(project("cwd-terraform")); + const destination = project("destination-opentofu", { + opentofu: ">=1.6.0", + }); + + expect(await sourceAddedTo(destination)).toBe( + "registry.opentofu.org/hashicorp/random", + ); + }); + + it("does not let an OpenTofu cwd leak into a Terraform project", async () => { + process.chdir(project("cwd-opentofu", { opentofu: ">=1.6.0" })); + const destination = project("destination-terraform"); + + expect(await sourceAddedTo(destination)).toBe( + "registry.terraform.io/hashicorp/random", + ); + }); +}); diff --git a/packages/@cdktn/commons/src/registry.ts b/packages/@cdktn/commons/src/registry.ts index e5052bb07..d5bde5d87 100644 --- a/packages/@cdktn/commons/src/registry.ts +++ b/packages/@cdktn/commons/src/registry.ts @@ -76,3 +76,17 @@ export function registryForTargetVersions( targetVersions?.terraform === undefined; return opentofuOnly ? OPENTOFU_REGISTRY : TERRAFORM_REGISTRY; } + +/** The registries cdktn knows how to query for available versions. */ +export const PUBLIC_REGISTRIES: readonly Registry[] = [ + TERRAFORM_REGISTRY, + OPENTOFU_REGISTRY, +]; + +/** + * The public registry serving `hostname`, or undefined for a private or + * self-hosted one - which cdktn cannot query for versions. + */ +export function registryForHostname(hostname: string): Registry | undefined { + return PUBLIC_REGISTRIES.find((r) => r.hostname === hostname); +}