Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 <HOSTNAME>/<NAMESPACE>/<TYPE>

/**
* Expands a source to <HOSTNAME>/<NAMESPACE>/<TYPE>. 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;
}
Expand All @@ -52,30 +61,37 @@ 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,
);
}

const src =
(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;
}

/**
Expand Down Expand Up @@ -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("/");
}

Expand Down Expand Up @@ -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(
Expand All @@ -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.`,
);
}
}
Expand Down
29 changes: 26 additions & 3 deletions packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<VersionsReturnType["versions"] | null> {
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,
Expand All @@ -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/<ns>/<name>/versions shape,
* and their version lists differ, so the project's target decides which to ask.
*/
export async function getLatestVersion(
constraint: ProviderConstraint,
): Promise<string | null> {
const versions = await fetchVersions(constraint);
const versions = await fetchVersions(
constraint,
registryForConstraint(constraint),
);
if (!versions) {
return null;
}
Expand Down
14 changes: 12 additions & 2 deletions packages/@cdktn/cli-core/src/lib/provider-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand All @@ -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");
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading
Loading