// Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 import { convert } from "../../src/index"; import * as fs from "fs-extra"; import * as path from "path"; import execa from "execa"; import { LANGUAGES, TerraformModuleConstraint, TerraformProviderConstraint, } from "@cdktn/commons"; import { readSchema } from "@cdktn/provider-schema"; import type { FixturesManifest } from "../globalSetup"; import { createTmpHelper } from "./tmp"; const tmp = createTmpHelper(); // Tests `process.chdir(projectDir)` for jsii-rosetta. If a test leaves cwd inside a projectDir that tmp()'s afterAll later removes, the worker's next test load sees uv_cwd ENOENT. const _originalCwd = process.cwd(); if (typeof afterEach === "function") { afterEach(() => process.chdir(_originalCwd)); } const includeSynthTests = Boolean(process.env.CI); // Load pre-generated fixtures manifest from globalSetup let _manifest: FixturesManifest | undefined; function getManifest(): FixturesManifest { if (_manifest) return _manifest; const manifestPath = process.env.HCL2CDK_FIXTURES_MANIFEST; if (!manifestPath) { throw new Error( "HCL2CDK_FIXTURES_MANIFEST env var not set. Ensure globalSetup ran successfully.", ); } _manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); return _manifest!; } export enum Synth { yes_all_languages, // Synth and snapshot all languages yes, yes_but_only_typescript_right_now_because_it_breaks, no_cant_resolve_construct, no_missing_map_access, // See https://github.com/hashicorp/terraform-cdk/issues/2670 no_missing_type_coercion, // We don't type coerce numbers yet never, // Some examples are built so that they will never synth but test a specific generation edge case } export enum Snapshot { yes_all_languages, // Synth and snapshot all languages yes, } type PathToCopy = string; type ProviderFqn = string; enum ProviderType { provider, module, } type ProviderDefinition = { fqn: ProviderFqn; type: ProviderType; path: PathToCopy; }; type SchemaFilter = { resources?: string[]; dataSources?: string[]; }; const cdktnBin = path.join(__dirname, "../../../../cdktn-cli/bin/cdktn"); const cdktnDist = path.join(__dirname, "../../../../../dist"); const tsxPkgJsonPath = require.resolve("tsx/package.json"); const tsxBin = path.join( path.dirname(tsxPkgJsonPath), fs.readJsonSync(tsxPkgJsonPath).bin, ); export const binding = { aws: { fqn: "hashicorp/aws@=5.11.0", type: ProviderType.provider, path: "providers/aws", }, docker: { fqn: "kreuzwerker/docker@=3.0.1", type: ProviderType.provider, path: "providers/docker", }, null: { fqn: "hashicorp/null@=3.2.1", type: ProviderType.provider, path: "providers/null", }, google: { fqn: "hashicorp/google@=4.55.0", type: ProviderType.provider, path: "providers/google", }, azuread: { fqn: "hashicorp/azuread@=2.36.0", type: ProviderType.provider, path: "providers/azuread", }, local: { fqn: "hashicorp/local@=2.3.0", type: ProviderType.provider, path: "providers/local", }, auth0: { fqn: "alexkappa/auth0@=0.26.2", type: ProviderType.provider, path: "providers/auth0", }, datadog: { fqn: "DataDog/datadog@=3.21.0", type: ProviderType.provider, path: "providers/datadog", }, kubernetes: { fqn: "hashicorp/kubernetes@=2.18.0", type: ProviderType.provider, path: "providers/kubernetes", }, scaleway: { fqn: "scaleway/scaleway@ ~>2.10.0", type: ProviderType.provider, path: "providers/scaleway", }, external: { fqn: "hashicorp/external@=2.3.1", type: ProviderType.provider, path: "providers/external", }, awsVpc: { fqn: "terraform-aws-modules/vpc/aws@=3.19.0", type: ProviderType.module, path: "modules/terraform-aws-modules/aws", }, }; type AbsolutePath = string; async function copyBindingsForProvider( binding: ProviderDefinition, targetDirectory: AbsolutePath, ) { const manifest = getManifest(); const absoluteBindingPath = manifest.providerBindings[binding.fqn]; if (!absoluteBindingPath) { throw new Error( `No pre-generated binding found for ${binding.fqn}. Ensure globalSetup generates it.`, ); } const target = path.resolve(targetDirectory, ".gen", binding.path); await fs.mkdirp(target); await fs.copy(absoluteBindingPath, target); } /** * Provisions `projectDir` from `baseDir`. * * Copies everything except `node_modules`, then drops a directory symlink in its place. `node_modules` is the heavy * bit of the base project (hundreds of MB) and the test runner only reads from it — sharing it across tests avoids * per-test copy cost. The base project must therefore be treated as read-only for the lifetime of the test suite. * * @param baseDir Source project (output of `cdktn init`) created once in globalSetup. * @param projectDir Destination for this test's project. */ async function copyBaseProject(baseDir: string, projectDir: string) { await fs.copy(baseDir, projectDir, { filter: (src) => path.basename(src) !== "node_modules", }); await fs.symlink( path.join(baseDir, "node_modules"), path.join(projectDir, "node_modules"), "dir", ); } const fileEndings: Record = { typescript: ".ts", python: ".py", csharp: ".cs", }; const getFileContent: Record< string, ( code: { all: string; code: string; imports: string }, stackName: string, ) => string > = { typescript: ({ all }, stackName) => ` ${all} import { App } from "cdktn"; const app = new App(); new MyConvertedCode(app, "${stackName}"); app.synth();`, python: ({ all }, stackName) => ` ${all} app = App() MyConvertedCode(app, "${stackName}") app.synth() `, csharp: ({ all }, stackName) => { const endOfStack = all.lastIndexOf("}"); const stack = all.substring(0, endOfStack); return ` ${stack} public static void Main(string[] args) { App app = new App(); new MyConvertedCode(app, "${stackName}"); app.Synth(); } } `; }, }; const getAppCommand: Record string> = { typescript: (stackName) => `${tsxBin} ${stackName}.ts`, python: (stackName) => `pipenv run python ${stackName}.py`, csharp: (stackName) => `dotnet run --project ${stackName}.csproj`, }; const preSynth: Record< string, ( stackName: string, projectDir: string, providers: ProviderDefinition[], ) => Promise | undefined > = { csharp: async (stackName, projectDir, providers) => { await fs.writeFile( path.join(projectDir, `${stackName}.csproj`), ` Exe net6.0 ${providers .map((provider) => { const [, name] = provider.fqn.split("@")[0].split("/"); return ``; }) .join("\n")} `, "utf8", ); await fs.writeFile( path.join(projectDir, "NuGet.Config"), ` `, "utf8", ); }, }; /** * Writes the converted code to a project directory and spawns `cdktn synth` * against it, asserting that synth reports success on stdout. * * @param language Target language for the synth (typescript, python, csharp). * @param name Human-readable test-case name. * @param code Output of `convert()` for the HCL input * @param providers Provider bindings the converted code imports. * @param projectDir An existing project dir to reuse. */ async function synthForLanguage( language: string, name: string, code: { all: string; code: string; imports: string }, providers: ProviderDefinition[] = [], projectDir?: string, ) { const stackName = name.replace(/\s/g, "-"); if (!projectDir) { projectDir = await getProjectDirectory(language, providers); } // Have a before all somewhere above bootstrap a TS project // __dirname should be replaceed by the bootstrapped directory const pathToThisProjectsFile = path.join( projectDir, stackName + fileEndings[language], ); const fileContent = getFileContent[language](code, stackName); fs.writeFileSync(pathToThisProjectsFile, fileContent, "utf8"); const runBeforeSynth = preSynth[language]; if (runBeforeSynth) { await runBeforeSynth(stackName, projectDir, providers); } const { all } = await execa( cdktnBin, [ "synth", "-a", `'${getAppCommand[language](stackName)}'`, "-o", `./${stackName}-output`, ], { cwd: projectDir, all: true, shell: true }, ); expect(all!).toEqual( expect.stringContaining(`Generated Terraform code for the stacks`), ); } // getProviderSchema(Object.values(binding)); async function getProjectDirectory( language: string, providers: ProviderDefinition[], ) { const manifest = getManifest(); const baseDir = manifest.baseProjects[language]; if (!baseDir) { throw new Error( `Unsupported language used to synthesize code: ${language}`, ); } const projectDir = tmp("cdktf-convert-test-"); await Promise.all([ copyBaseProject(baseDir, projectDir), ...providers.map((provider) => copyBindingsForProvider(provider, projectDir), ), ]); // We only copy the TS bindings, but we need to run cdktn get for the language specific ones if (language !== "typescript") { await fs.writeFile( path.resolve(projectDir, "cdktf.json"), JSON.stringify( { language, app: "echo 'app command should be overwritten'", terraformProviders: providers .filter((binding) => binding.type === ProviderType.provider) .map((binding) => binding.fqn), terraformModules: providers .filter((binding) => binding.type === ProviderType.module) .map((binding) => binding.fqn), }, null, 2, ), ); await execa(cdktnBin, ["get", "--force"], { cwd: projectDir }); } return projectDir; } async function getProviderSchema(providers: ProviderDefinition[]) { const constraints = providers.map( (provider) => ProviderType.provider === provider.type ? new TerraformProviderConstraint(provider.fqn) : new TerraformModuleConstraint(provider.fqn), LANGUAGES[0], ); const schemaCacheDir = process.env.CDKTF_EXPERIMENTAL_PROVIDER_SCHEMA_CACHE_PATH || getManifest().schemaCacheDir; return await readSchema(constraints, schemaCacheDir); } function filterSchema( providerSchema: any, schemaFilter: SchemaFilter | undefined, ) { if (!schemaFilter) return providerSchema; const { resources, dataSources } = schemaFilter; const providerSchemaKey = Object.keys(providerSchema.provider_schemas)[0]; const actualSchema = providerSchema.provider_schemas[providerSchemaKey]; let filteredDataSourceSchemas = {}; let filteredResourceSchemas = {}; if (resources && resources.length > 0) { filteredResourceSchemas = Object.fromEntries( Object.entries(actualSchema.resource_schemas).filter(([resourceName]) => resources?.includes(resourceName), ), ); } if (dataSources && dataSources.length > 0) { filteredDataSourceSchemas = Object.fromEntries( Object.entries(actualSchema.data_source_schemas).filter( ([dataSourceName]) => dataSources?.includes(dataSourceName), ), ); } return { provider_schemas: { [providerSchemaKey]: { provider: providerSchema.provider_schemas[providerSchemaKey].provider, resource_schemas: filteredResourceSchemas, data_source_schemas: filteredDataSourceSchemas, }, }, }; } const createTestCase = (opts: { skip?: true; only?: true }) => ( name: string, hcl: string, providers: ProviderDefinition[], shouldSnapshot: Snapshot, shouldSynth: Synth, schemaFilter?: SchemaFilter, ) => { if (opts.skip) { describe.skip(name, () => {}); return; } async function runConvert(language: string) { let { providerSchema } = await getProviderSchema(providers); if (schemaFilter) { // TODO: Re-enable once we can trick Terraform CLI Checksums providerSchema = filterSchema(providerSchema, undefined); } return await convert(hcl, { language: language as any, providerSchema: providerSchema || { format_version: "0.1", }, codeContainer: "cdktn.TerraformStack", }); } const testBody = () => { describe("snapshot", () => { it.each( shouldSnapshot === Snapshot.yes_all_languages ? ["typescript", "python", "csharp", "java", "go"] : ["typescript"], )( "%s", async (language) => { const projectDir = await getProjectDirectory( // We need the typescript project directory to start the convert so JSII has the right types "typescript", providers, ); process.chdir(projectDir); // JSII rosetta needs to be run in the project directory with bindings included const convertResult = await runConvert(language); expect(convertResult.all).toMatchSnapshot(); }, 500_000, ); }); if ( includeSynthTests && [Synth.yes_all_languages, Synth.yes].includes(shouldSynth) ) { describe("synth", () => { it.each( shouldSynth === Synth.yes_all_languages ? ["typescript", "python", "csharp"] : ["typescript"], )( "%s", async (language) => { const projectDir = await getProjectDirectory( "typescript", providers, ); process.chdir(projectDir); // JSII rosetta needs to be run in the project directory with bindings included const convertResult = await runConvert(language); await synthForLanguage( language, name, convertResult, providers, projectDir, ); }, 500_000, ); }); } else { describe.skip("synth", () => {}); } }; if (opts.only) { describe.only(name, testBody); return; } describe(name, testBody); }; export const testCase = { test: createTestCase({}), skip: createTestCase({ skip: true }), only: createTestCase({ only: true }), };