diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts index e75aa78a2..7ee97eef3 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts @@ -5,8 +5,10 @@ import { Language, Errors, CONFIG_DEFAULTS, + validateTargetVersions, type LanguageOptions, type TerraformDependencyConstraint, + type TerraformTargetVersions, } from "@cdktn/commons"; import path from "path"; import { logger } from "@cdktn/commons"; @@ -87,6 +89,24 @@ export class CdktfConfig { return options; } + public get targetVersions(): TerraformTargetVersions | undefined { + const targetVersions = this.getProperty("targetVersions") as + | TerraformTargetVersions + | undefined; + + const problems = validateTargetVersions(targetVersions); + if (problems.length > 0) { + logger.warn( + `cdktf.json \`targetVersions\` is invalid and will be ignored:\n ${problems.join( + "\n ", + )}`, + ); + return undefined; + } + + return targetVersions; + } + public get terraformProviders(): (TerraformDependencyConstraint | string)[] { const providers = this.getProperty("terraformProviders"); if (!Array.isArray(providers)) return []; diff --git a/packages/@cdktn/cli-core/src/lib/get.ts b/packages/@cdktn/cli-core/src/lib/get.ts index 88c8e9699..1f85ad764 100644 --- a/packages/@cdktn/cli-core/src/lib/get.ts +++ b/packages/@cdktn/cli-core/src/lib/get.ts @@ -102,6 +102,7 @@ export async function runGetInDir(dir: string, clean = true) { targetLanguage: config.language, jsiiParallelism: 1, languageOptions: config.languageOptions, + targetVersions: config.targetVersions, }, cleanDirectory: clean, }); diff --git a/packages/@cdktn/cli-core/src/test/lib/cdktf-config.test.ts b/packages/@cdktn/cli-core/src/test/lib/cdktf-config.test.ts new file mode 100644 index 000000000..764e5d2ad --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/cdktf-config.test.ts @@ -0,0 +1,55 @@ +// 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 { logger } from "@cdktn/commons"; +import { CdktfConfig } from "../../lib/cdktf-config"; + +describe("CdktfConfig.targetVersions", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cdktf-config-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + function writeConfig(targetVersions: unknown): string { + const cdktfConfigPath = path.join(tmpDir, "cdktf.json"); + fs.writeFileSync( + cdktfConfigPath, + JSON.stringify({ language: "typescript", app: "", targetVersions }), + ); + return cdktfConfigPath; + } + + it("returns a well-formed targetVersions unchanged", () => { + const cdktfConfigPath = writeConfig({ terraform: ">=1.5.7" }); + const config = new CdktfConfig(cdktfConfigPath); + + expect(config.targetVersions).toEqual({ terraform: ">=1.5.7" }); + }); + + it("warns and returns undefined for a malformed targetVersions instead of throwing", () => { + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + const cdktfConfigPath = writeConfig({ terraform: "not-a-range" }); + const config = new CdktfConfig(cdktfConfigPath); + + expect(() => config.targetVersions).not.toThrow(); + expect(config.targetVersions).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not-a-range"), + ); + }); + + it("returns undefined when targetVersions is not declared", () => { + const cdktfConfigPath = writeConfig(undefined); + const config = new CdktfConfig(cdktfConfigPath); + + expect(config.targetVersions).toBeUndefined(); + }); +}); diff --git a/packages/@cdktn/commons/src/provider-schema.ts b/packages/@cdktn/commons/src/provider-schema.ts index 0b172c26b..393ad5066 100644 --- a/packages/@cdktn/commons/src/provider-schema.ts +++ b/packages/@cdktn/commons/src/provider-schema.ts @@ -13,12 +13,72 @@ export interface ProviderSchema { format_version?: "0.1" | "0.2"; provider_schemas?: { [fqpn: string]: Provider }; provider_versions?: { [fqpn: string]: string }; + /** + * Name of the CLI (terraform/opentofu) that fetched this schema, stamped + * by readProviderSchema. Used to reason about which newer-protocol + * sections (functions, ephemeral resources, ...) this particular fetch + * could possibly have emitted. + */ + cli_name?: string; + /** + * Version of the CLI that fetched this schema, e.g. "1.7.5". + */ + cli_version?: string; } export interface Provider { provider: Schema; resource_schemas: { [type: string]: Schema }; data_source_schemas: { [type: string]: Schema }; + // list_resource_schemas, action_schemas and state_store_schemas are real + // sections of `terraform providers schema -json` (Terraform-only today; + // OpenTofu emits none). They are intentionally left untyped and + // unconsumed: they pass through parse/sanitize/cache verbatim so cached + // schemas stay complete, but no bindings are generated for them yet + // (deferred - RFC-04 Phase 4; re-evaluate when opentofu/opentofu#3787 + // lands). + ephemeral_resource_schemas?: { [type: string]: Schema }; + functions?: { [name: string]: FunctionSignature }; + resource_identity_schemas?: { [type: string]: ResourceIdentitySchema }; +} + +/** + * A single parameter (or the variadic parameter) of a provider function. + * The providers-schema JSON fields are exactly: name, description, + * is_nullable, type. `is_nullable` derives from the plugin-protocol + * `AllowNullValue` field and means the parameter accepts an explicit null + * argument. Other protocol-only fields (e.g. `allow_unknown_values`) never + * reach the providers-schema JSON. + */ +export interface FunctionParameter { + name?: string; + type: AttributeType; + description?: string; + is_nullable?: boolean; +} + +export interface FunctionSignature { + description?: string; + summary?: string; + // Emitted by Terraform (>=1.8) for provider-defined functions; OpenTofu + // does not emit this field at all - a real divergence, do not "fix" by + // expecting parity. + deprecation_message?: string; + return_type: AttributeType; + parameters?: FunctionParameter[]; + variadic_parameter?: FunctionParameter; +} + +export interface IdentityAttribute { + type?: AttributeType; + description?: string; + required_for_import?: boolean; + optional_for_import?: boolean; +} + +export interface ResourceIdentitySchema { + version: number; + attributes: { [name: string]: IdentityAttribute }; } export interface Schema { @@ -58,6 +118,7 @@ interface BaseAttribute { optional?: boolean; computed?: boolean; sensitive?: boolean; + write_only?: boolean; } interface NestedTypeAttribute extends BaseAttribute { diff --git a/packages/@cdktn/provider-generator/src/__tests__/__snapshots__/edge-provider-schema.test.ts.snap b/packages/@cdktn/provider-generator/src/__tests__/__snapshots__/edge-provider-schema.test.ts.snap index 2cacfb7e2..06c866f94 100644 --- a/packages/@cdktn/provider-generator/src/__tests__/__snapshots__/edge-provider-schema.test.ts.snap +++ b/packages/@cdktn/provider-generator/src/__tests__/__snapshots__/edge-provider-schema.test.ts.snap @@ -9,7 +9,9 @@ export * as listBlockResource from './list-block-resource/index'; export * as mapResource from './map-resource/index'; export * as setBlockResource from './set-block-resource/index'; export * as mapListResource from './map-list-resource/index'; +export * as ephemeralCachedSecret from './ephemeral-cached-secret/index'; export * as provider from './provider/index'; +export * as providerFunctions from './provider-functions/index'; " `; @@ -23,7 +25,9 @@ Object.defineProperty(exports, 'listBlockResource', { get: function () { return Object.defineProperty(exports, 'mapResource', { get: function () { return require('./map-resource'); } }); Object.defineProperty(exports, 'setBlockResource', { get: function () { return require('./set-block-resource'); } }); Object.defineProperty(exports, 'mapListResource', { get: function () { return require('./map-list-resource'); } }); +Object.defineProperty(exports, 'ephemeralCachedSecret', { get: function () { return require('./ephemeral-cached-secret'); } }); Object.defineProperty(exports, 'provider', { get: function () { return require('./provider'); } }); +Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } }); " `; diff --git a/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/builder.ts b/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/builder.ts index 4c9d9b745..03e1f48c0 100644 --- a/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/builder.ts +++ b/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/builder.ts @@ -1,18 +1,29 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { ProviderSchema, Schema, AttributeType, Block } from "@cdktn/commons"; +import { + ProviderSchema, + Schema, + AttributeType, + Block, + FunctionSignature, +} from "@cdktn/commons"; type ResourceSchema = { [type: string]: Schema }; +type FunctionSchema = { [name: string]: FunctionSignature }; export function schema({ name, provider, resources = {}, dataSources = {}, + ephemeralResources = {}, + functions = {}, }: { name: string; provider: Schema; resources: ResourceSchema; dataSources: ResourceSchema; + ephemeralResources?: ResourceSchema; + functions?: FunctionSchema; }): ProviderSchema { return { format_version: "0.2", @@ -21,6 +32,8 @@ export function schema({ provider: provider, resource_schemas: resources, data_source_schemas: dataSources, + ephemeral_resource_schemas: ephemeralResources, + functions: functions, }, }, }; @@ -45,18 +58,21 @@ export class SchemaBuilder { required = false, computed = false, optional = !required, + writeOnly = false, }: { name: string; type: AttributeType; required?: boolean; computed?: boolean; optional?: boolean; + writeOnly?: boolean; }): SchemaBuilder { this.schema.block.attributes[name] = { type, optional, computed, required, + write_only: writeOnly, }; return this; } diff --git a/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/index.ts b/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/index.ts index 83c9ff53d..36445cd95 100644 --- a/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/index.ts +++ b/packages/@cdktn/provider-generator/src/__tests__/edge-provider-schema/index.ts @@ -1,6 +1,6 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { ProviderSchema } from "@cdktn/commons"; +import { ProviderSchema, FunctionSignature } from "@cdktn/commons"; import { schema, SchemaBuilder as S } from "./builder"; const required_attribute_resource = new S() @@ -11,6 +11,19 @@ const required_attribute_resource = new S() const optional_attribute_resource = new S() .addAllPrimitiveTypes({ required: false, computed: false }) .addAllPrimitiveListTypes({ required: false, computed: false }) + // Write-only attribute (RFC-04 / protocol v6): exercises the deprecated + // getter + the resolve-time `markWriteOnlyAttribute` wrapping in + // synthesizeAttributes()/synthesizeHclAttributes() on an existing managed + // resource. Left optional so no existing usage of + // OptionalAttributeResource across the edge test suite needs updating + // (and so it also generates a reset method). + .attribute({ + name: "secretWo", + type: "string", + required: false, + computed: false, + writeOnly: true, + }) .build(); const optional_computed_attribute_resource = new S() @@ -145,6 +158,112 @@ const set_block_resource = new S() }) .build(); +// A modest ephemeral resource (plugin protocol v6 / RFC-04): a required +// string, an optional number, a computed attribute, and a nested single +// block - consistent with the primitive/nested-block conventions used by +// the resources above. +const cached_secret = new S() + .attribute({ name: "str", type: "string", required: true }) + .attribute({ name: "num", type: "number", required: false }) + .attribute({ + name: "computedStr", + type: "string", + computed: true, + optional: false, + }) + .singleBlock({ + name: "nested", + block: new S() + .attribute({ name: "str", type: "string", required: false }) + .attribute({ name: "num", type: "number", required: false }) + .asBlock(), + }) + .build(); + +// Provider-defined functions (Terraform >=1.8 / protocol v6), each +// exercising a distinct generator branch of `provider-function-model.ts`. +// Reuses shapes from `provider-functions-synthetic.test.fixture.json` where +// sensible, renamed to fit the edge fixture's naming. +const functions: { [name: string]: FunctionSignature } = { + // Simple string -> string function. + greet: { + description: "Returns a friendly greeting for the given name.", + summary: "Greet a name", + return_type: "string", + parameters: [{ name: "name", type: "string" }], + }, + // Nullable TRAILING fixed param: becomes jsii-optional. + greet_with_title: { + description: "Greets the given name, optionally with a title.", + summary: "Greet with an optional title", + return_type: "string", + parameters: [ + { name: "name", type: "string" }, + { name: "title", type: "string", is_nullable: true }, + ], + }, + // Nullable MID-POSITION fixed param followed by a required param: stays + // positional, widens to `any` so a caller can still pass an explicit null. + join_prefixed: { + description: "Joins a possibly-null prefix with a required suffix.", + summary: "Join with a nullable prefix", + return_type: "string", + parameters: [ + { name: "prefix", type: "string", is_nullable: true }, + { name: "suffix", type: "string" }, + ], + }, + // Nullable VARIADIC param: rest parameter widens to `any[]`. + describe_items: { + description: "Joins a label with any number of possibly-null items.", + summary: "Describe items with a label", + return_type: "string", + parameters: [{ name: "label", type: "string" }], + variadic_parameter: { name: "items", type: "string", is_nullable: true }, + }, + // Variadic of bool: union element type -> Array + // rest param. + all_true: { + description: "Returns true if every given flag is true.", + summary: "All flags true", + return_type: "bool", + variadic_parameter: { name: "flags", type: "bool" }, + }, + // deprecation_message present (Terraform >=1.8 only, see FunctionSignature + // docstring) -> surfaced as an `@deprecated` JSDoc tag. + legacy_greet: { + description: + "A deprecated greeting helper kept for backwards compatibility.", + summary: "Legacy greet", + deprecation_message: "Use greet instead.", + return_type: "string", + parameters: [{ name: "name", type: "string" }], + }, + // Returns ["object", {...}]. + build_info: { + description: + "Returns an object describing the build that produced the given seed.", + summary: "Build info", + return_type: ["object", { version: "string", revision: "string" }], + parameters: [{ name: "seed", type: "string" }], + }, + // Returns ["list", "number"]. + list_squares: { + description: + "Returns the squares of the numbers from 1 to the given count.", + summary: "List squares", + return_type: ["list", "number"], + parameters: [{ name: "count", type: "number" }], + }, + // A ["map", "string"] param. + render_tags: { + description: "Renders the given map of string tags as a single string.", + summary: "Render tags", + return_type: "string", + parameters: [{ name: "tags", type: ["map", "string"] }], + }, +}; + export const edgeSchema: ProviderSchema = schema({ name: "edge", provider: new S().addAllPrimitivePermutations().build(), @@ -158,4 +277,8 @@ export const edgeSchema: ProviderSchema = schema({ map_list_resource, }, dataSources: {}, + ephemeralResources: { + cached_secret, + }, + functions, }); diff --git a/packages/@cdktn/provider-generator/src/__tests__/provider.test.ts b/packages/@cdktn/provider-generator/src/__tests__/provider.test.ts index 99a26eb88..ec0bfc84e 100644 --- a/packages/@cdktn/provider-generator/src/__tests__/provider.test.ts +++ b/packages/@cdktn/provider-generator/src/__tests__/provider.test.ts @@ -31,6 +31,9 @@ function directorySnapshot(root: string) { if (path.basename(filePath) === "constraints.json") { delete content.cdktf; + // cli identity depends on the terraform/opentofu binary installed + // on the test runner - not deterministic, so excluded like cdktf. + delete content.cli; } } else { content = fs.readFileSync(filePath, "utf-8"); diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/ephemeral-resources.test.ts.snap b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/ephemeral-resources.test.ts.snap new file mode 100644 index 000000000..4ab035f93 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/ephemeral-resources.test.ts.snap @@ -0,0 +1,524 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generate an ephemeral random_password resource alongside a regular random_uuid resource: ephemeral-random-password 1`] = ` +"// https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password +// generated from terraform resource schema + +import { Construct } from 'constructs'; +import * as cdktn from 'cdktn'; + +// Configuration + +export interface EphemeralRandomPasswordConfig extends cdktn.TerraformEphemeralMetaArguments { + /** + * The length of the string desired. The minimum value for length is 1 and, length must also be >= (\`min_upper\` + \`min_lower\` + \`min_numeric\` + \`min_special\`). + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#length EphemeralRandomPassword#length} + */ + readonly length: number; + /** + * Include lowercase alphabet characters in the result. Default value is \`true\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#lower EphemeralRandomPassword#lower} + */ + readonly lower?: boolean | cdktn.IResolvable; + /** + * Minimum number of lowercase alphabet characters in the result. Default value is \`0\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#min_lower EphemeralRandomPassword#min_lower} + */ + readonly minLower?: number; + /** + * Minimum number of numeric characters in the result. Default value is \`0\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#min_numeric EphemeralRandomPassword#min_numeric} + */ + readonly minNumeric?: number; + /** + * Minimum number of special characters in the result. Default value is \`0\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#min_special EphemeralRandomPassword#min_special} + */ + readonly minSpecial?: number; + /** + * Minimum number of uppercase alphabet characters in the result. Default value is \`0\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#min_upper EphemeralRandomPassword#min_upper} + */ + readonly minUpper?: number; + /** + * Include numeric characters in the result. Default value is \`true\`. If \`numeric\`, \`upper\`, \`lower\`, and \`special\` are all configured, at least one of them must be set to \`true\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#numeric EphemeralRandomPassword#numeric} + */ + readonly numeric?: boolean | cdktn.IResolvable; + /** + * Supply your own list of special characters to use for string generation. This overrides the default character list in the special argument. The \`special\` argument must still be set to true for any overwritten characters to be used in generation. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#override_special EphemeralRandomPassword#override_special} + */ + readonly overrideSpecial?: string; + /** + * Include special characters in the result. These are \`!@#$%&*()-_=+[]{}<>:?\`. Default value is \`true\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#special EphemeralRandomPassword#special} + */ + readonly special?: boolean | cdktn.IResolvable; + /** + * Include uppercase alphabet characters in the result. Default value is \`true\`. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password#upper EphemeralRandomPassword#upper} + */ + readonly upper?: boolean | cdktn.IResolvable; +} + +/** +* Represents a {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password random_password} +*/ +export class EphemeralRandomPassword extends cdktn.TerraformEphemeralResource { + + // ================= + // STATIC PROPERTIES + // ================= + public static readonly tfResourceType = "random_password"; + + // =========== + // INITIALIZER + // =========== + + /** + * Create a new {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/ephemeral-resources/password random_password} Ephemeral Resource + * + * @param scope The scope in which to define this construct + * @param id The scoped construct ID. Must be unique amongst siblings in the same scope + * @param options EphemeralRandomPasswordConfig + */ + public constructor(scope: Construct, id: string, config: EphemeralRandomPasswordConfig) { + super(scope, id, { + terraformResourceType: 'random_password', + terraformGeneratorMetadata: { + providerName: 'random' + }, + provider: config.provider, + dependsOn: config.dependsOn, + count: config.count, + lifecycle: config.lifecycle, + forEach: config.forEach + }); + this._length = config.length; + this._lower = config.lower; + this._minLower = config.minLower; + this._minNumeric = config.minNumeric; + this._minSpecial = config.minSpecial; + this._minUpper = config.minUpper; + this._numeric = config.numeric; + this._overrideSpecial = config.overrideSpecial; + this._special = config.special; + this._upper = config.upper; + } + + // ========== + // ATTRIBUTES + // ========== + + // bcrypt_hash - computed: true, optional: false, required: false + public get bcryptHash() { + return this.getStringAttribute('bcrypt_hash'); + } + + // length - computed: false, optional: false, required: true + private _length?: number; + public get length() { + return this.getNumberAttribute('length'); + } + public set length(value: number) { + this._length = value; + } + // Temporarily expose input value. Use with caution. + public get lengthInput() { + return this._length; + } + + // lower - computed: true, optional: true, required: false + private _lower?: boolean | cdktn.IResolvable; + public get lower() { + return this.getBooleanAttribute('lower'); + } + public set lower(value: boolean | cdktn.IResolvable) { + this._lower = value; + } + public resetLower() { + this._lower = undefined; + } + // Temporarily expose input value. Use with caution. + public get lowerInput() { + return this._lower; + } + + // min_lower - computed: true, optional: true, required: false + private _minLower?: number; + public get minLower() { + return this.getNumberAttribute('min_lower'); + } + public set minLower(value: number) { + this._minLower = value; + } + public resetMinLower() { + this._minLower = undefined; + } + // Temporarily expose input value. Use with caution. + public get minLowerInput() { + return this._minLower; + } + + // min_numeric - computed: true, optional: true, required: false + private _minNumeric?: number; + public get minNumeric() { + return this.getNumberAttribute('min_numeric'); + } + public set minNumeric(value: number) { + this._minNumeric = value; + } + public resetMinNumeric() { + this._minNumeric = undefined; + } + // Temporarily expose input value. Use with caution. + public get minNumericInput() { + return this._minNumeric; + } + + // min_special - computed: true, optional: true, required: false + private _minSpecial?: number; + public get minSpecial() { + return this.getNumberAttribute('min_special'); + } + public set minSpecial(value: number) { + this._minSpecial = value; + } + public resetMinSpecial() { + this._minSpecial = undefined; + } + // Temporarily expose input value. Use with caution. + public get minSpecialInput() { + return this._minSpecial; + } + + // min_upper - computed: true, optional: true, required: false + private _minUpper?: number; + public get minUpper() { + return this.getNumberAttribute('min_upper'); + } + public set minUpper(value: number) { + this._minUpper = value; + } + public resetMinUpper() { + this._minUpper = undefined; + } + // Temporarily expose input value. Use with caution. + public get minUpperInput() { + return this._minUpper; + } + + // numeric - computed: true, optional: true, required: false + private _numeric?: boolean | cdktn.IResolvable; + public get numeric() { + return this.getBooleanAttribute('numeric'); + } + public set numeric(value: boolean | cdktn.IResolvable) { + this._numeric = value; + } + public resetNumeric() { + this._numeric = undefined; + } + // Temporarily expose input value. Use with caution. + public get numericInput() { + return this._numeric; + } + + // override_special - computed: false, optional: true, required: false + private _overrideSpecial?: string; + public get overrideSpecial() { + return this.getStringAttribute('override_special'); + } + public set overrideSpecial(value: string) { + this._overrideSpecial = value; + } + public resetOverrideSpecial() { + this._overrideSpecial = undefined; + } + // Temporarily expose input value. Use with caution. + public get overrideSpecialInput() { + return this._overrideSpecial; + } + + // result - computed: true, optional: false, required: false + public get result() { + return this.getStringAttribute('result'); + } + + // special - computed: true, optional: true, required: false + private _special?: boolean | cdktn.IResolvable; + public get special() { + return this.getBooleanAttribute('special'); + } + public set special(value: boolean | cdktn.IResolvable) { + this._special = value; + } + public resetSpecial() { + this._special = undefined; + } + // Temporarily expose input value. Use with caution. + public get specialInput() { + return this._special; + } + + // upper - computed: true, optional: true, required: false + private _upper?: boolean | cdktn.IResolvable; + public get upper() { + return this.getBooleanAttribute('upper'); + } + public set upper(value: boolean | cdktn.IResolvable) { + this._upper = value; + } + public resetUpper() { + this._upper = undefined; + } + // Temporarily expose input value. Use with caution. + public get upperInput() { + return this._upper; + } + + // ========= + // SYNTHESIS + // ========= + + protected synthesizeAttributes(): { [name: string]: any } { + return { + length: cdktn.numberToTerraform(this._length), + lower: cdktn.booleanToTerraform(this._lower), + min_lower: cdktn.numberToTerraform(this._minLower), + min_numeric: cdktn.numberToTerraform(this._minNumeric), + min_special: cdktn.numberToTerraform(this._minSpecial), + min_upper: cdktn.numberToTerraform(this._minUpper), + numeric: cdktn.booleanToTerraform(this._numeric), + override_special: cdktn.stringToTerraform(this._overrideSpecial), + special: cdktn.booleanToTerraform(this._special), + upper: cdktn.booleanToTerraform(this._upper), + }; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + const attrs = { + length: { + value: cdktn.numberToHclTerraform(this._length), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + lower: { + value: cdktn.booleanToHclTerraform(this._lower), + isBlock: false, + type: "simple", + storageClassType: "boolean", + }, + min_lower: { + value: cdktn.numberToHclTerraform(this._minLower), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + min_numeric: { + value: cdktn.numberToHclTerraform(this._minNumeric), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + min_special: { + value: cdktn.numberToHclTerraform(this._minSpecial), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + min_upper: { + value: cdktn.numberToHclTerraform(this._minUpper), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + numeric: { + value: cdktn.booleanToHclTerraform(this._numeric), + isBlock: false, + type: "simple", + storageClassType: "boolean", + }, + override_special: { + value: cdktn.stringToHclTerraform(this._overrideSpecial), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + special: { + value: cdktn.booleanToHclTerraform(this._special), + isBlock: false, + type: "simple", + storageClassType: "boolean", + }, + upper: { + value: cdktn.booleanToHclTerraform(this._upper), + isBlock: false, + type: "simple", + storageClassType: "boolean", + }, + }; + + // remove undefined attributes + return Object.fromEntries(Object.entries(attrs).filter(([_, value]) => value !== undefined && value.value !== undefined )) + } +} +" +`; + +exports[`generate an ephemeral random_password resource alongside a regular random_uuid resource: provider-index 1`] = ` +"// generated by cdktn get +export * as uuid from './uuid/index'; +export * as ephemeralRandomBytes from './ephemeral-random-bytes/index'; +export * as ephemeralRandomPassword from './ephemeral-random-password/index'; + +" +`; + +exports[`generate an ephemeral random_password resource alongside a regular random_uuid resource: provider-lazy-index 1`] = ` +"// generated by cdktn get +Object.defineProperty(exports, 'uuid', { get: function () { return require('./uuid'); } }); +Object.defineProperty(exports, 'ephemeralRandomBytes', { get: function () { return require('./ephemeral-random-bytes'); } }); +Object.defineProperty(exports, 'ephemeralRandomPassword', { get: function () { return require('./ephemeral-random-password'); } }); + +" +`; + +exports[`generate an ephemeral random_password resource alongside a regular random_uuid resource: random-uuid 1`] = ` +"// https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid +// generated from terraform resource schema + +import { Construct } from 'constructs'; +import * as cdktn from 'cdktn'; + +// Configuration + +export interface UuidConfig extends cdktn.TerraformMetaArguments { + /** + * Arbitrary map of values that, when changed, will trigger recreation of resource. See [the main provider documentation](../index.html) for more information. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid#keepers Uuid#keepers} + */ + readonly keepers?: { [key: string]: string }; +} + +/** +* Represents a {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid random_uuid} +*/ +export class Uuid extends cdktn.TerraformResource { + + // ================= + // STATIC PROPERTIES + // ================= + public static readonly tfResourceType = "random_uuid"; + + // ============== + // STATIC Methods + // ============== + /** + * Generates CDKTN code for importing a Uuid resource upon running "cdktn plan " + * @param scope The scope in which to define this construct + * @param importToId The construct id used in the generated config for the Uuid to import + * @param importFromId The id of the existing Uuid that should be imported. Refer to the {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid#import import section} in the documentation of this resource for the id to use + * @param provider? Optional instance of the provider where the Uuid to import is found + */ + public static generateConfigForImport(scope: Construct, importToId: string, importFromId: string, provider?: cdktn.TerraformProvider) { + return new cdktn.ImportableResource(scope, importToId, { terraformResourceType: "random_uuid", importId: importFromId, provider }); + } + + // =========== + // INITIALIZER + // =========== + + /** + * Create a new {@link https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid random_uuid} Resource + * + * @param scope The scope in which to define this construct + * @param id The scoped construct ID. Must be unique amongst siblings in the same scope + * @param options UuidConfig = {} + */ + public constructor(scope: Construct, id: string, config: UuidConfig = {}) { + super(scope, id, { + terraformResourceType: 'random_uuid', + terraformGeneratorMetadata: { + providerName: 'random' + }, + provider: config.provider, + dependsOn: config.dependsOn, + count: config.count, + lifecycle: config.lifecycle, + provisioners: config.provisioners, + connection: config.connection, + forEach: config.forEach + }); + this._keepers = config.keepers; + } + + // ========== + // ATTRIBUTES + // ========== + + // id - computed: true, optional: false, required: false + public get id() { + return this.getStringAttribute('id'); + } + + // keepers - computed: false, optional: true, required: false + private _keepers?: { [key: string]: string }; + public get keepers() { + return this.getStringMapAttribute('keepers'); + } + public set keepers(value: { [key: string]: string }) { + this._keepers = value; + } + public resetKeepers() { + this._keepers = undefined; + } + // Temporarily expose input value. Use with caution. + public get keepersInput() { + return this._keepers; + } + + // result - computed: true, optional: false, required: false + public get result() { + return this.getStringAttribute('result'); + } + + // ========= + // SYNTHESIS + // ========= + + protected synthesizeAttributes(): { [name: string]: any } { + return { + keepers: cdktn.hashMapper(cdktn.stringToTerraform)(this._keepers), + }; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + const attrs = { + keepers: { + value: cdktn.hashMapperHcl(cdktn.stringToHclTerraform)(this._keepers), + isBlock: false, + type: "map", + storageClassType: "stringMap", + }, + }; + + // remove undefined attributes + return Object.fromEntries(Object.entries(attrs).filter(([_, value]) => value !== undefined && value.value !== undefined )) + } +} +" +`; diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/provider-functions.test.ts.snap b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/provider-functions.test.ts.snap new file mode 100644 index 000000000..be158604a --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/provider-functions.test.ts.snap @@ -0,0 +1,346 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name: example-provider-functions 1`] = ` +"// generated from provider function schema + +import * as cdktn from 'cdktn'; + +/** +* Provider-defined functions of the example provider. +*/ +export class ExampleProviderFunctions { + private readonly providerLocalName: string; + + /** + * @param providerLocalName The local name of the provider in required_providers; defaults to the registry short name. Override when the provider is declared under a different local name — aliases do not change the namespace, local names do. + */ + constructor(providerLocalName: string) { + this.providerLocalName = providerLocalName; + } + + /** + * Returns an object describing the build that produced the given seed. + * @param {string} seed + */ + public buildInfo(seed: string): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "build_info", [seed]); + } + + /** + * Returns true_value if condition is true, otherwise returns false_value. The dynamic return type allows either branch to be a value of any type. + * @param {boolean | IResolvable} condition + * @param {any} trueValue + * @param {any} falseValue + */ + public conditionIf(condition: boolean | cdktn.IResolvable, trueValue: any, falseValue: any): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "condition_if", [condition, trueValue, falseValue]); + } + + /** + * Renders the given map of config objects as a single string. + * @param {any} configs + */ + public configMap(configs: any): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "config_map", [configs])); + } + + /** + * Returns true if any flag in the given set of flags is true. + * @param {Array} flags + */ + public flagSet(flags: Array): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "flag_set", [flags]); + } + + /** + * Joins the given strings together using the provided separator. + * @param {string} separator + * @param {Array} values + */ + public joinStrings(separator: string, values: Array): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "join_strings", [separator, ...values])); + } + + /** + * A deprecated helper kept around for backwards compatibility. + * @deprecated Use new_helper instead. + * @param {string} input + */ + public legacyHelper(input: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "legacy_helper", [input])); + } + + /** + * Returns a list of numbers counting up from 1 to the given count. + * @param {number} count + */ + public listNumbers(count: number): number[] { + return cdktn.Token.asNumberList(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "list_numbers", [count])); + } + + /** + * Returns a list of names prefixed with the given prefix. + * @param {string} prefix + */ + public listPrefixedNames(prefix: string): string[] { + return cdktn.Token.asList(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "list_prefixed_names", [prefix])); + } + + /** + * Joins a possibly-null prefix with a required suffix. + * @param {string | null} prefix + * @param {string} suffix + */ + public midNullable(prefix: any, suffix: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "mid_nullable", [prefix, suffix])); + } + + /** + * Flattens a matrix (a list of lists of strings) into a single string. + * @param {Array>} matrix + */ + public nestedList(matrix: string[][]): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "nested_list", [matrix])); + } + + /** + * Adds up a default starting value and any number of addends. + * @param {number} defaultValue + * @param {Array} addends + */ + public sum(defaultValue: number, addends: Array): number { + return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "sum", [defaultValue, ...addends])); + } + + /** + * Renders the given map of string tags as a single string. + * @param {{ [key: string]: string }} tags + */ + public tagMap(tags: { [key: string]: string }): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "tag_map", [tags])); + } + + /** + * Greets the given name, optionally with a custom greeting. + * @param {string} name + * @param {string | null} greeting + */ + public trailingNullable(name: string, greeting?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "trailing_nullable", [name, greeting])); + } + + /** + * Joins a label with any number of possibly-null items. + * @param {string} label + * @param {Array} items + */ + public variadicNullable(label: string, items: Array): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "variadic_nullable", [label, ...items])); + } +} +" +`; + +exports[`generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name: provider-index 1`] = ` +"// generated by cdktn get +export * as providerFunctions from './provider-functions/index'; + +" +`; + +exports[`generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name: provider-lazy-index 1`] = ` +"// generated by cdktn get +Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } }); + +" +`; + +exports[`generate provider functions for the time provider (real terraform 1.15.6 schema fragment): provider-index 1`] = ` +"// generated by cdktn get +export * as staticResource from './static-resource/index'; +export * as provider from './provider/index'; +export * as providerFunctions from './provider-functions/index'; + +" +`; + +exports[`generate provider functions for the time provider (real terraform 1.15.6 schema fragment): provider-lazy-index 1`] = ` +"// generated by cdktn get +Object.defineProperty(exports, 'staticResource', { get: function () { return require('./static-resource'); } }); +Object.defineProperty(exports, 'provider', { get: function () { return require('./provider'); } }); +Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } }); + +" +`; + +exports[`generate provider functions for the time provider (real terraform 1.15.6 schema fragment): time-provider 1`] = ` +"// https://registry.terraform.io/providers/hashicorp/time/latest/docs +// generated from terraform resource schema + +import { Construct } from 'constructs'; +import * as cdktn from 'cdktn'; + +// Configuration + +export interface TimeProviderConfig { + /** + * Alias name + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs#alias TimeProvider#alias} + */ + readonly alias?: string; +} + +import { TimeProviderFunctions } from '../provider-functions/index'; +/** +* Represents a {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs time} +*/ +export class TimeProvider extends cdktn.TerraformProvider { + + // ================= + // STATIC PROPERTIES + // ================= + public static readonly tfResourceType = "time"; + + // ============== + // STATIC Methods + // ============== + /** + * Generates CDKTN code for importing a TimeProvider resource upon running "cdktn plan " + * @param scope The scope in which to define this construct + * @param importToId The construct id used in the generated config for the TimeProvider to import + * @param importFromId The id of the existing TimeProvider that should be imported. Refer to the {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs#import import section} in the documentation of this resource for the id to use + * @param provider? Optional instance of the provider where the TimeProvider to import is found + */ + public static generateConfigForImport(scope: Construct, importToId: string, importFromId: string, provider?: cdktn.TerraformProvider) { + return new cdktn.ImportableResource(scope, importToId, { terraformResourceType: "time", importId: importFromId, provider }); + } + + // =========== + // INITIALIZER + // =========== + + /** + * Create a new {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs time} Resource + * + * @param scope The scope in which to define this construct + * @param id The scoped construct ID. Must be unique amongst siblings in the same scope + * @param options TimeProviderConfig = {} + */ + public constructor(scope: Construct, id: string, config: TimeProviderConfig = {}) { + super(scope, id, { + terraformResourceType: 'time', + terraformGeneratorMetadata: { + providerName: 'time' + }, + terraformProviderSource: 'registry.terraform.io/hashicorp/time' + }); + this._alias = config.alias; + } + + // ========== + // ATTRIBUTES + // ========== + + // alias - computed: false, optional: true, required: false + private _alias?: string; + public get alias() { + return this._alias; + } + public set alias(value: string | undefined) { + this._alias = value; + } + public resetAlias() { + this._alias = undefined; + } + // Temporarily expose input value. Use with caution. + public get aliasInput() { + return this._alias; + } + + // ========================== + // PROVIDER-DEFINED FUNCTIONS + // ========================== + private _functions?: TimeProviderFunctions; + + /** + * Provider-defined functions of the time provider. + */ + public get functions(): TimeProviderFunctions { + if (!this._functions) { + this._functions = new TimeProviderFunctions(this.terraformResourceType); + } + return this._functions; + } + + // ========= + // SYNTHESIS + // ========= + + protected synthesizeAttributes(): { [name: string]: any } { + return { + alias: cdktn.stringToTerraform(this._alias), + }; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + const attrs = { + alias: { + value: cdktn.stringToHclTerraform(this._alias), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + }; + + // remove undefined attributes + return Object.fromEntries(Object.entries(attrs).filter(([_, value]) => value !== undefined && value.value !== undefined )) + } +} +" +`; + +exports[`generate provider functions for the time provider (real terraform 1.15.6 schema fragment): time-provider-functions 1`] = ` +"// generated from provider function schema + +import * as cdktn from 'cdktn'; + +/** +* Provider-defined functions of the time provider. +*/ +export class TimeProviderFunctions { + private readonly providerLocalName: string; + + /** + * @param providerLocalName The local name of the provider in required_providers; defaults to the registry short name. Override when the provider is declared under a different local name — aliases do not change the namespace, local names do. + */ + constructor(providerLocalName: string) { + this.providerLocalName = providerLocalName; + } + + /** + * Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. + * @param {string} duration Go time package duration string to parse + */ + public durationParse(duration: string): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "duration_parse", [duration]); + } + + /** + * Given an RFC3339 timestamp string, will parse and return an object representation of that date and time. + * @param {string} timestamp RFC3339 timestamp string to parse + */ + public rfc3339Parse(timestamp: string): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "rfc3339_parse", [timestamp]); + } + + /** + * Given a unix timestamp integer, will parse and return an object representation of that date and time. A unix timestamp is the number of seconds elapsed since January 1, 1970 UTC. + * @param {number} unixTimestamp Unix Timestamp integer to parse + */ + public unixTimestampParse(unixTimestamp: number): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "unix_timestamp_parse", [unixTimestamp]); + } +} +" +`; diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/write-only.test.ts.snap b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/write-only.test.ts.snap new file mode 100644 index 000000000..b746a62f8 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/write-only.test.ts.snap @@ -0,0 +1,262 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`generate a vault_alicloud_secret_backend resource with a write-only attribute 1`] = ` +"// https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend +// generated from terraform resource schema + +import { Construct } from 'constructs'; +import * as cdktn from 'cdktn'; + +// Configuration + +export interface AlicloudSecretBackendConfig extends cdktn.TerraformMetaArguments { + /** + * The AliCloud Access Key ID to use when generating new credentials. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#access_key AlicloudSecretBackend#access_key} + */ + readonly accessKey: string; + /** + * Path of the AliCloud secrets engine mount. Must match the \`path\` of a \`vault_mount\` resource with \`type = "alicloud"\`. Use \`vault_mount.alicloud.path\` here. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#mount AlicloudSecretBackend#mount} + */ + readonly mount: string; + /** + * Target namespace. (requires Enterprise) + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#namespace AlicloudSecretBackend#namespace} + */ + readonly namespace?: string; + /** + * Write-only AliCloud Secret Access Key. This value will never be read back from Vault. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#secret_key_wo AlicloudSecretBackend#secret_key_wo} + */ + readonly secretKeyWo: string; + /** + * Optional write-only secondary AliCloud Secret Access Key. This value will never be read back from Vault. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#secondary_secret_key_wo AlicloudSecretBackend#secondary_secret_key_wo} + */ + readonly secondarySecretKeyWo?: string; + /** + * A version counter for the write-only \`secret_key_wo\` field. Incrementing this value will trigger an update to the secret key in Vault. + * + * Docs at Terraform Registry: {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#secret_key_wo_version AlicloudSecretBackend#secret_key_wo_version} + */ + readonly secretKeyWoVersion: number; +} + +/** +* Represents a {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend vault_alicloud_secret_backend} +*/ +export class AlicloudSecretBackend extends cdktn.TerraformResource { + + // ================= + // STATIC PROPERTIES + // ================= + public static readonly tfResourceType = "vault_alicloud_secret_backend"; + + // ============== + // STATIC Methods + // ============== + /** + * Generates CDKTN code for importing a AlicloudSecretBackend resource upon running "cdktn plan " + * @param scope The scope in which to define this construct + * @param importToId The construct id used in the generated config for the AlicloudSecretBackend to import + * @param importFromId The id of the existing AlicloudSecretBackend that should be imported. Refer to the {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend#import import section} in the documentation of this resource for the id to use + * @param provider? Optional instance of the provider where the AlicloudSecretBackend to import is found + */ + public static generateConfigForImport(scope: Construct, importToId: string, importFromId: string, provider?: cdktn.TerraformProvider) { + return new cdktn.ImportableResource(scope, importToId, { terraformResourceType: "vault_alicloud_secret_backend", importId: importFromId, provider }); + } + + // =========== + // INITIALIZER + // =========== + + /** + * Create a new {@link https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/alicloud_secret_backend vault_alicloud_secret_backend} Resource + * + * @param scope The scope in which to define this construct + * @param id The scoped construct ID. Must be unique amongst siblings in the same scope + * @param options AlicloudSecretBackendConfig + */ + public constructor(scope: Construct, id: string, config: AlicloudSecretBackendConfig) { + super(scope, id, { + terraformResourceType: 'vault_alicloud_secret_backend', + terraformGeneratorMetadata: { + providerName: 'vault' + }, + provider: config.provider, + dependsOn: config.dependsOn, + count: config.count, + lifecycle: config.lifecycle, + provisioners: config.provisioners, + connection: config.connection, + forEach: config.forEach + }); + this._accessKey = config.accessKey; + this._mount = config.mount; + this._namespace = config.namespace; + this._secretKeyWo = config.secretKeyWo; + this._secondarySecretKeyWo = config.secondarySecretKeyWo; + this._secretKeyWoVersion = config.secretKeyWoVersion; + } + + // ========== + // ATTRIBUTES + // ========== + + // access_key - computed: false, optional: false, required: true + private _accessKey?: string; + public get accessKey() { + return this.getStringAttribute('access_key'); + } + public set accessKey(value: string) { + this._accessKey = value; + } + // Temporarily expose input value. Use with caution. + public get accessKeyInput() { + return this._accessKey; + } + + // mount - computed: false, optional: false, required: true + private _mount?: string; + public get mount() { + return this.getStringAttribute('mount'); + } + public set mount(value: string) { + this._mount = value; + } + // Temporarily expose input value. Use with caution. + public get mountInput() { + return this._mount; + } + + // namespace - computed: false, optional: true, required: false + private _namespace?: string; + public get namespace() { + return this.getStringAttribute('namespace'); + } + public set namespace(value: string) { + this._namespace = value; + } + public resetNamespace() { + this._namespace = undefined; + } + // Temporarily expose input value. Use with caution. + public get namespaceInput() { + return this._namespace; + } + + // secret_key_wo - computed: false, optional: false, required: true + private _secretKeyWo?: string; + /** + * @deprecated Write-only: the provider never returns this value; reading it always yields null by protocol contract. The getter remains for compatibility and will be removed in a future prebuilt-provider major. + */ + public get secretKeyWo() { + return this.getStringAttribute('secret_key_wo'); + } + public set secretKeyWo(value: string) { + this._secretKeyWo = value; + } + // Temporarily expose input value. Use with caution. + public get secretKeyWoInput() { + return this._secretKeyWo; + } + + // secondary_secret_key_wo - computed: false, optional: true, required: false + private _secondarySecretKeyWo?: string; + /** + * @deprecated Write-only: the provider never returns this value; reading it always yields null by protocol contract. The getter remains for compatibility and will be removed in a future prebuilt-provider major. + */ + public get secondarySecretKeyWo() { + return this.getStringAttribute('secondary_secret_key_wo'); + } + public set secondarySecretKeyWo(value: string) { + this._secondarySecretKeyWo = value; + } + public resetSecondarySecretKeyWo() { + this._secondarySecretKeyWo = undefined; + } + // Temporarily expose input value. Use with caution. + public get secondarySecretKeyWoInput() { + return this._secondarySecretKeyWo; + } + + // secret_key_wo_version - computed: false, optional: false, required: true + private _secretKeyWoVersion?: number; + public get secretKeyWoVersion() { + return this.getNumberAttribute('secret_key_wo_version'); + } + public set secretKeyWoVersion(value: number) { + this._secretKeyWoVersion = value; + } + // Temporarily expose input value. Use with caution. + public get secretKeyWoVersionInput() { + return this._secretKeyWoVersion; + } + + // ========= + // SYNTHESIS + // ========= + + protected synthesizeAttributes(): { [name: string]: any } { + return { + access_key: cdktn.stringToTerraform(this._accessKey), + mount: cdktn.stringToTerraform(this._mount), + namespace: cdktn.stringToTerraform(this._namespace), + secret_key_wo: this.markWriteOnlyAttribute(cdktn.stringToTerraform(this._secretKeyWo)), + secondary_secret_key_wo: this.markWriteOnlyAttribute(cdktn.stringToTerraform(this._secondarySecretKeyWo)), + secret_key_wo_version: cdktn.numberToTerraform(this._secretKeyWoVersion), + }; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + const attrs = { + access_key: { + value: cdktn.stringToHclTerraform(this._accessKey), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + mount: { + value: cdktn.stringToHclTerraform(this._mount), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + namespace: { + value: cdktn.stringToHclTerraform(this._namespace), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + secret_key_wo: { + value: this.markWriteOnlyAttribute(cdktn.stringToHclTerraform(this._secretKeyWo)), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + secondary_secret_key_wo: { + value: this.markWriteOnlyAttribute(cdktn.stringToHclTerraform(this._secondarySecretKeyWo)), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + secret_key_wo_version: { + value: cdktn.numberToHclTerraform(this._secretKeyWoVersion), + isBlock: false, + type: "simple", + storageClassType: "number", + }, + }; + + // remove undefined attributes + return Object.fromEntries(Object.entries(attrs).filter(([_, value]) => value !== undefined && value.value !== undefined )) + } +} +" +`; diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/ephemeral-resources.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/ephemeral-resources.test.ts new file mode 100644 index 000000000..1aff9bc34 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/ephemeral-resources.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as path from "path"; +import { TerraformProviderGenerator } from "../../generator/provider-generator"; +import { CodeMaker } from "codemaker"; +import { createTmpHelper } from "../util"; + +const tmp = createTmpHelper(); + +test("generate an ephemeral random_password resource alongside a regular random_uuid resource", async () => { + const code = new CodeMaker(); + const workdir = tmp("ephemeral.test"); + const spec = JSON.parse( + fs.readFileSync( + path.join(__dirname, "fixtures", "ephemeral-resources.test.fixture.json"), + "utf-8", + ), + ); + new TerraformProviderGenerator(code, spec).generateAll(); + await code.save(workdir); + + const ephemeralOutput = fs.readFileSync( + path.join(workdir, "providers/random/ephemeral-random-password/index.ts"), + "utf-8", + ); + expect(ephemeralOutput).toMatchSnapshot("ephemeral-random-password"); + + const resourceOutput = fs.readFileSync( + path.join(workdir, "providers/random/uuid/index.ts"), + "utf-8", + ); + expect(resourceOutput).toMatchSnapshot("random-uuid"); + + const providerIndex = fs.readFileSync( + path.join(workdir, "providers/random/index.ts"), + "utf-8", + ); + expect(providerIndex).toMatchSnapshot("provider-index"); + + const providerLazyIndex = fs.readFileSync( + path.join(workdir, "providers/random/lazy-index.ts"), + "utf-8", + ); + expect(providerLazyIndex).toMatchSnapshot("provider-lazy-index"); +}); diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/ephemeral-resources.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/ephemeral-resources.test.fixture.json new file mode 100644 index 000000000..c99926eb6 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/ephemeral-resources.test.fixture.json @@ -0,0 +1,160 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/random": { + "resource_schemas": { + "random_uuid": { + "version": 0, + "block": { + "attributes": { + "id": { + "type": "string", + "description": "The generated uuid presented in string format.", + "description_kind": "plain", + "computed": true + }, + "keepers": { + "type": [ + "map", + "string" + ], + "description": "Arbitrary map of values that, when changed, will trigger recreation of resource. See [the main provider documentation](../index.html) for more information.", + "description_kind": "plain", + "optional": true + }, + "result": { + "type": "string", + "description": "The generated uuid presented in string format.", + "description_kind": "plain", + "computed": true + } + }, + "description": "The resource `random_uuid` generates a random uuid string that is intended to be used as a unique identifier for other resources.\n\nThis resource uses [hashicorp/go-uuid](https://github.com/hashicorp/go-uuid) to generate a UUID-formatted string for use with services needing a unique string identifier.", + "description_kind": "plain" + } + } + }, + "ephemeral_resource_schemas": { + "random_bytes": { + "version": 0, + "block": { + "attributes": { + "base64": { + "type": "string", + "description": "The generated bytes presented in base64 string format.", + "description_kind": "plain", + "computed": true, + "sensitive": true + }, + "hex": { + "type": "string", + "description": "The generated bytes presented in lowercase hexadecimal string format. The length of the encoded string is exactly twice the `length` parameter.", + "description_kind": "plain", + "computed": true, + "sensitive": true + }, + "length": { + "type": "number", + "description": "The number of bytes requested. The minimum value for length is 1.", + "description_kind": "plain", + "required": true + } + }, + "description": "-> If the managed resource doesn't have a write-only argument available for the secret (first introduced in Terraform 1.11), then the secret can only be created with the managed resource variant of [`random_bytes`](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/bytes).\n\nGenerates ephemeral random bytes using a cryptographic random number generator.\n\nThe primary use-case for generating ephemeral random bytes is to be used in combination with a write-only argument in a managed resource, which will avoid Terraform storing the secret value in the plan or state file.", + "description_kind": "plain" + } + }, + "random_password": { + "version": 0, + "block": { + "attributes": { + "bcrypt_hash": { + "type": "string", + "description": "A bcrypt hash of the generated random string. **NOTE**: If the generated random string is greater than 72 bytes in length, `bcrypt_hash` will contain a hash of the first 72 bytes.", + "description_kind": "plain", + "computed": true, + "sensitive": true + }, + "length": { + "type": "number", + "description": "The length of the string desired. The minimum value for length is 1 and, length must also be >= (`min_upper` + `min_lower` + `min_numeric` + `min_special`).", + "description_kind": "plain", + "required": true + }, + "lower": { + "type": "bool", + "description": "Include lowercase alphabet characters in the result. Default value is `true`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "min_lower": { + "type": "number", + "description": "Minimum number of lowercase alphabet characters in the result. Default value is `0`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "min_numeric": { + "type": "number", + "description": "Minimum number of numeric characters in the result. Default value is `0`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "min_special": { + "type": "number", + "description": "Minimum number of special characters in the result. Default value is `0`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "min_upper": { + "type": "number", + "description": "Minimum number of uppercase alphabet characters in the result. Default value is `0`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "numeric": { + "type": "bool", + "description": "Include numeric characters in the result. Default value is `true`. If `numeric`, `upper`, `lower`, and `special` are all configured, at least one of them must be set to `true`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "override_special": { + "type": "string", + "description": "Supply your own list of special characters to use for string generation. This overrides the default character list in the special argument. The `special` argument must still be set to true for any overwritten characters to be used in generation.", + "description_kind": "plain", + "optional": true + }, + "result": { + "type": "string", + "description": "The generated random string.", + "description_kind": "plain", + "computed": true, + "sensitive": true + }, + "special": { + "type": "bool", + "description": "Include special characters in the result. These are `!@#$%&*()-_=+[]{}<>:?`. Default value is `true`.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "upper": { + "type": "bool", + "description": "Include uppercase alphabet characters in the result. Default value is `true`.", + "description_kind": "plain", + "optional": true, + "computed": true + } + }, + "description": "-> If the managed resource doesn't have a write-only argument available for the password (first introduced in Terraform 1.11), then the password can only be created with the managed resource variant of [`random_password`](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password).\n\nGenerates an ephemeral password string using a cryptographic random number generator.\n\nThe primary use-case for generating an ephemeral random password is to be used in combination with a write-only argument in a managed resource, which will avoid Terraform storing the password string in the plan or state file.", + "description_kind": "plain" + } + } + } + } + } +} diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-collision.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-collision.test.fixture.json new file mode 100644 index 000000000..a22d7bcd8 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-collision.test.fixture.json @@ -0,0 +1,31 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/example": { + "provider": { + "version": 0, + "block": { + "attributes": { + "functions": { + "type": "string", + "description": "A config attribute that happens to be named the same as the generated functions getter.", + "description_kind": "plain", + "optional": true + } + } + } + }, + "functions": { + "greet": { + "description": "Greets the given name.", + "return_type": "string", + "parameters": [ + { + "name": "name", + "type": "string" + } + ] + } + } + } + } +} diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-synthetic.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-synthetic.test.fixture.json new file mode 100644 index 000000000..d70a4c17b --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-synthetic.test.fixture.json @@ -0,0 +1,129 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/example": { + "resource_schemas": {}, + "functions": { + "join_strings": { + "description": "Joins the given strings together using the provided separator.", + "summary": "Join strings with a separator", + "return_type": "string", + "parameters": [{ "name": "separator", "type": "string" }], + "variadic_parameter": { "name": "values", "type": "string" } + }, + "sum": { + "description": "Adds up a default starting value and any number of addends.", + "summary": "Sum numbers", + "return_type": "number", + "parameters": [{ "name": "default", "type": "number" }], + "variadic_parameter": { "name": "addends", "type": "number" } + }, + "list_prefixed_names": { + "description": "Returns a list of names prefixed with the given prefix.", + "summary": "List prefixed names", + "return_type": ["list", "string"], + "parameters": [{ "name": "prefix", "type": "string" }] + }, + "condition_if": { + "description": "Returns true_value if condition is true, otherwise returns false_value. The dynamic return type allows either branch to be a value of any type.", + "summary": "Dynamic conditional", + "return_type": "dynamic", + "parameters": [ + { "name": "condition", "type": "bool" }, + { "name": "true_value", "type": "dynamic" }, + { "name": "false_value", "type": "dynamic" } + ] + }, + "list_numbers": { + "description": "Returns a list of numbers counting up from 1 to the given count.", + "summary": "List numbers", + "return_type": ["list", "number"], + "parameters": [{ "name": "count", "type": "number" }] + }, + "flag_set": { + "description": "Returns true if any flag in the given set of flags is true.", + "summary": "Any flag set", + "return_type": "bool", + "parameters": [{ "name": "flags", "type": ["set", "bool"] }] + }, + "nested_list": { + "description": "Flattens a matrix (a list of lists of strings) into a single string.", + "summary": "Flatten a matrix", + "return_type": "string", + "parameters": [ + { "name": "matrix", "type": ["list", ["list", "string"]] } + ] + }, + "build_info": { + "description": "Returns an object describing the build that produced the given seed.", + "summary": "Build info", + "return_type": [ + "object", + { "version": "string", "revision": "string" } + ], + "parameters": [{ "name": "seed", "type": "string" }] + }, + "tag_map": { + "description": "Renders the given map of string tags as a single string.", + "summary": "Render tags", + "return_type": "string", + "parameters": [{ "name": "tags", "type": ["map", "string"] }] + }, + "config_map": { + "description": "Renders the given map of config objects as a single string.", + "summary": "Render configs", + "return_type": "string", + "parameters": [ + { + "name": "configs", + "type": ["map", ["object", { "enabled": "bool" }]] + } + ] + }, + "legacy_helper": { + "description": "A deprecated helper kept around for backwards compatibility.", + "summary": "Legacy helper", + "deprecation_message": "Use new_helper instead.", + "return_type": "string", + "parameters": [{ "name": "input", "type": "string" }] + }, + "trailing_nullable": { + "description": "Greets the given name, optionally with a custom greeting.", + "summary": "Greet with an optional greeting", + "return_type": "string", + "parameters": [ + { "name": "name", "type": "string" }, + { + "name": "greeting", + "type": "string", + "is_nullable": true + } + ] + }, + "mid_nullable": { + "description": "Joins a possibly-null prefix with a required suffix.", + "summary": "Join with a nullable prefix", + "return_type": "string", + "parameters": [ + { + "name": "prefix", + "type": "string", + "is_nullable": true + }, + { "name": "suffix", "type": "string" } + ] + }, + "variadic_nullable": { + "description": "Joins a label with any number of possibly-null items.", + "summary": "Join with nullable items", + "return_type": "string", + "parameters": [{ "name": "label", "type": "string" }], + "variadic_parameter": { + "name": "items", + "type": "string", + "is_nullable": true + } + } + } + } + } +} diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json new file mode 100644 index 000000000..294f28a84 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json @@ -0,0 +1,170 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/time": { + "provider": { + "version": 0, + "block": { + "description": "Use Terraform to trigger actions and generate time based data. Currently supports outputting time or ranges of time from a supplied timestamp, as well as basic date manipulation, encoding into RFC3339, and describing durations.\n\nThis provider is intended to be used in Terraform configurations that require basic date/time functionality where existing time-related data does not exist. Since this provider does not use any external APIs, it does not require any configuration and is used to fill in gaps where the Terraform configuration language itself does not yet support certain functionality.", + "description_kind": "markdown" + } + }, + "resource_schemas": { + "time_static": { + "version": 0, + "block": { + "attributes": { + "day": { + "type": "number", + "description": "Number day of timestamp.", + "description_kind": "plain", + "computed": true + }, + "hour": { + "type": "number", + "description": "Number hour of timestamp.", + "description_kind": "plain", + "computed": true + }, + "id": { + "type": "string", + "description": "RFC3339 format of the offset timestamp, e.g. `2020-02-12T06:36:13Z`.", + "description_kind": "plain", + "computed": true + }, + "minute": { + "type": "number", + "description": "Number minute of timestamp.", + "description_kind": "plain", + "computed": true + }, + "month": { + "type": "number", + "description": "Number month of timestamp.", + "description_kind": "plain", + "computed": true + }, + "rfc3339": { + "type": "string", + "description": "Base timestamp in [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.8) format (see [RFC3339 time string](https://tools.ietf.org/html/rfc3339#section-5.8) e.g., `YYYY-MM-DDTHH:MM:SSZ`). Defaults to the current time.", + "description_kind": "plain", + "optional": true, + "computed": true + }, + "second": { + "type": "number", + "description": "Number second of timestamp.", + "description_kind": "plain", + "computed": true + }, + "triggers": { + "type": [ + "map", + "string" + ], + "description": "Arbitrary map of values that, when changed, will trigger a new base timestamp value to be saved. See [the main provider documentation](../index.md) for more information.", + "description_kind": "plain", + "optional": true + }, + "unix": { + "type": "number", + "description": "Number of seconds since epoch time, e.g. `1581489373`.", + "description_kind": "plain", + "computed": true + }, + "year": { + "type": "number", + "description": "Number year of timestamp.", + "description_kind": "plain", + "computed": true + } + }, + "description": "Manages a static time resource, which keeps a locally sourced UTC timestamp stored in the Terraform state. This prevents perpetual differences caused by using the [`timestamp()` function](https://www.terraform.io/docs/configuration/functions/timestamp.html).", + "description_kind": "plain" + } + } + }, + "functions": { + "duration_parse": { + "description": "Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration.", + "summary": "Parse a [Go duration string](https://pkg.go.dev/time#ParseDuration) into an object", + "return_type": [ + "object", + { + "hours": "number", + "microseconds": "number", + "milliseconds": "number", + "minutes": "number", + "nanoseconds": "number", + "seconds": "number" + } + ], + "parameters": [ + { + "name": "duration", + "description": "Go time package duration string to parse", + "type": "string" + } + ] + }, + "rfc3339_parse": { + "description": "Given an RFC3339 timestamp string, will parse and return an object representation of that date and time.", + "summary": "Parse an RFC3339 timestamp string into an object", + "return_type": [ + "object", + { + "day": "number", + "hour": "number", + "iso_week": "number", + "iso_year": "number", + "minute": "number", + "month": "number", + "month_name": "string", + "second": "number", + "unix": "number", + "weekday": "number", + "weekday_name": "string", + "year": "number", + "year_day": "number" + } + ], + "parameters": [ + { + "name": "timestamp", + "description": "RFC3339 timestamp string to parse", + "type": "string" + } + ] + }, + "unix_timestamp_parse": { + "description": "Given a unix timestamp integer, will parse and return an object representation of that date and time. A unix timestamp is the number of seconds elapsed since January 1, 1970 UTC.", + "summary": "Parse a unix timestamp integer into an object", + "return_type": [ + "object", + { + "day": "number", + "hour": "number", + "iso_week": "number", + "iso_year": "number", + "minute": "number", + "month": "number", + "month_name": "string", + "rfc3339": "string", + "second": "number", + "weekday": "number", + "weekday_name": "string", + "year": "number", + "year_day": "number" + } + ], + "parameters": [ + { + "name": "unix_timestamp", + "description": "Unix Timestamp integer to parse", + "type": "number" + } + ] + } + } + } + } +} diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/write-only.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/write-only.test.fixture.json new file mode 100644 index 000000000..af6b0adaa --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/write-only.test.fixture.json @@ -0,0 +1,58 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/vault": { + "resource_schemas": { + "vault_alicloud_secret_backend": { + "version": 0, + "block": { + "attributes": { + "access_key": { + "type": "string", + "description": "The AliCloud Access Key ID to use when generating new credentials.", + "description_kind": "markdown", + "required": true, + "sensitive": true + }, + "mount": { + "type": "string", + "description": "Path of the AliCloud secrets engine mount. Must match the `path` of a `vault_mount` resource with `type = \"alicloud\"`. Use `vault_mount.alicloud.path` here.", + "description_kind": "markdown", + "required": true + }, + "namespace": { + "type": "string", + "description": "Target namespace. (requires Enterprise)", + "description_kind": "markdown", + "optional": true + }, + "secret_key_wo": { + "type": "string", + "description": "Write-only AliCloud Secret Access Key. This value will never be read back from Vault.", + "description_kind": "markdown", + "required": true, + "sensitive": true, + "write_only": true + }, + "secondary_secret_key_wo": { + "type": "string", + "description": "Optional write-only secondary AliCloud Secret Access Key. This value will never be read back from Vault.", + "description_kind": "markdown", + "optional": true, + "sensitive": true, + "write_only": true + }, + "secret_key_wo_version": { + "type": "number", + "description": "A version counter for the write-only `secret_key_wo` field. Incrementing this value will trigger an update to the secret key in Vault.", + "description_kind": "markdown", + "required": true + } + }, + "description": "Configures the AliCloud secrets engine credentials. The mount itself must be created first using a `vault_mount` resource with `type = \"alicloud\"`. Use `vault_mount.alicloud.id` as `mount_id` on ephemeral resources to guarantee deferral.", + "description_kind": "markdown" + } + } + } + } + } +} diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts new file mode 100644 index 000000000..a04759b47 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as path from "path"; +import { TerraformProviderGenerator } from "../../generator/provider-generator"; +import { + assertNoFunctionsGetterCollision, + buildProviderFunctionsModel, +} from "../../generator/models/provider-function-model"; +import { CodeMaker } from "codemaker"; +import { FunctionSignature } from "@cdktn/commons"; +import { createTmpHelper } from "../util"; + +const tmp = createTmpHelper(); + +test("generate provider functions for the time provider (real terraform 1.15.6 schema fragment)", async () => { + const code = new CodeMaker(); + const workdir = tmp("provider-functions.test"); + const spec = JSON.parse( + fs.readFileSync( + path.join(__dirname, "fixtures", "provider-functions.test.fixture.json"), + "utf-8", + ), + ); + new TerraformProviderGenerator(code, spec).generateAll(); + await code.save(workdir); + + const providerFunctionsOutput = fs.readFileSync( + path.join(workdir, "providers/time/provider-functions/index.ts"), + "utf-8", + ); + expect(providerFunctionsOutput).toMatchSnapshot("time-provider-functions"); + + const providerOutput = fs.readFileSync( + path.join(workdir, "providers/time/provider/index.ts"), + "utf-8", + ); + expect(providerOutput).toMatchSnapshot("time-provider"); + + const providerIndex = fs.readFileSync( + path.join(workdir, "providers/time/index.ts"), + "utf-8", + ); + expect(providerIndex).toMatchSnapshot("provider-index"); + + const providerLazyIndex = fs.readFileSync( + path.join(workdir, "providers/time/lazy-index.ts"), + "utf-8", + ); + expect(providerLazyIndex).toMatchSnapshot("provider-lazy-index"); +}); + +test("generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name", async () => { + const code = new CodeMaker(); + const workdir = tmp("provider-functions-synthetic.test"); + const spec = JSON.parse( + fs.readFileSync( + path.join( + __dirname, + "fixtures", + "provider-functions-synthetic.test.fixture.json", + ), + "utf-8", + ), + ); + new TerraformProviderGenerator(code, spec).generateAll(); + await code.save(workdir); + + const providerFunctionsOutput = fs.readFileSync( + path.join(workdir, "providers/example/provider-functions/index.ts"), + "utf-8", + ); + expect(providerFunctionsOutput).toMatchSnapshot("example-provider-functions"); + + const providerIndex = fs.readFileSync( + path.join(workdir, "providers/example/index.ts"), + "utf-8", + ); + expect(providerIndex).toMatchSnapshot("provider-index"); + + const providerLazyIndex = fs.readFileSync( + path.join(workdir, "providers/example/lazy-index.ts"), + "utf-8", + ); + expect(providerLazyIndex).toMatchSnapshot("provider-lazy-index"); +}); + +test("buildProviderFunctionsModel throws when two function names collapse to the same generated method name", () => { + expect(() => + buildProviderFunctionsModel("example", { + foo_bar: { return_type: "string", parameters: [] }, + foo__bar: { return_type: "string", parameters: [] }, + }), + ).toThrow(/foo_bar/); + expect(() => + buildProviderFunctionsModel("example", { + foo_bar: { return_type: "string", parameters: [] }, + foo__bar: { return_type: "string", parameters: [] }, + }), + ).toThrow(/foo__bar/); +}); + +test("buildProviderFunctionsModel throws when two parameter names collapse within one function", () => { + expect(() => + buildProviderFunctionsModel("example", { + my_function: { + return_type: "string", + parameters: [ + { name: "some_value", type: "string" }, + { name: "some__value", type: "string" }, + ], + }, + }), + ).toThrow(/some_value/); + expect(() => + buildProviderFunctionsModel("example", { + my_function: { + return_type: "string", + parameters: [ + { name: "some_value", type: "string" }, + { name: "some__value", type: "string" }, + ], + }, + }), + ).toThrow(/some__value/); +}); + +// Both of these assert the model-level throw happens before any file is +// generated: buildProviderFunctionsModel is called directly, with no +// CodeMaker/generator involved, so there is no emission step to reach. +test("buildProviderFunctionsModel throws when a function name sanitizes to 'constructor'", () => { + // The nested signature literal is cast to `FunctionSignature` (rather than + // relying on contextual typing from the surrounding index signature) + // because TypeScript special-cases a property literally named + // "constructor" - contextually typing it against `Function` (from + // `Object.prototype`) instead of the index signature's `FunctionSignature`, + // and spuriously flagging `return_type: "string"` as an error. See + // https://github.com/microsoft/TypeScript/issues/40776. + const signature: FunctionSignature = { + return_type: "string", + parameters: [], + }; + expect(() => + buildProviderFunctionsModel("example", { + constructor: signature, + }), + ).toThrow(/constructor/); + expect(() => + buildProviderFunctionsModel("example", { + constructor: signature, + }), + ).toThrow(/example/); +}); + +test("buildProviderFunctionsModel throws when a function name sanitizes to 'providerLocalName'", () => { + expect(() => + buildProviderFunctionsModel("example", { + provider_local_name: { return_type: "string", parameters: [] }, + }), + ).toThrow(/provider_local_name/); + expect(() => + buildProviderFunctionsModel("example", { + provider_local_name: { return_type: "string", parameters: [] }, + }), + ).toThrow(/providerLocalName/); +}); + +test("assertNoFunctionsGetterCollision throws when the provider's own config schema would generate a 'functions' property", () => { + expect(() => + assertNoFunctionsGetterCollision("example", ["alias", "functions"]), + ).toThrow(/"functions"/); +}); + +test("assertNoFunctionsGetterCollision does not throw when there is no colliding attribute", () => { + expect(() => + assertNoFunctionsGetterCollision("example", ["alias"]), + ).not.toThrow(); +}); + +test("generation throws when a provider's config attribute collides with the generated 'functions' getter", async () => { + const code = new CodeMaker(); + const spec = JSON.parse( + fs.readFileSync( + path.join( + __dirname, + "fixtures", + "provider-functions-collision.test.fixture.json", + ), + "utf-8", + ), + ); + expect(() => + new TerraformProviderGenerator(code, spec).generateAll(), + ).toThrow(/"functions"/); +}); diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider.test.ts index efd9427aa..fc40679bf 100644 --- a/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider.test.ts +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider.test.ts @@ -25,6 +25,10 @@ test("generate provider", async () => { "utf-8", ); expect(output).toMatchSnapshot(); + // aws has no provider-defined functions in this fixture - no getter, no + // cross-directory import should be emitted. + expect(output).not.toContain("public get functions()"); + expect(output).not.toContain("provider-functions"); }); test("generate provider with only block_types", async () => { @@ -48,4 +52,8 @@ test("generate provider with only block_types", async () => { "utf-8", ); expect(output).toMatchSnapshot(); + // elasticstack has no provider-defined functions in this fixture - no + // getter, no cross-directory import should be emitted. + expect(output).not.toContain("public get functions()"); + expect(output).not.toContain("provider-functions"); }); diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts new file mode 100644 index 000000000..d96a2d152 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs"; +import * as path from "path"; +import { TerraformProviderGenerator } from "../../generator/provider-generator"; +import { CodeMaker } from "codemaker"; +import { createTmpHelper } from "../util"; + +const tmp = createTmpHelper(); + +test("generate a vault_alicloud_secret_backend resource with a write-only attribute", async () => { + const code = new CodeMaker(); + const workdir = tmp("write-only.test"); + const spec = JSON.parse( + fs.readFileSync( + path.join(__dirname, "fixtures", "write-only.test.fixture.json"), + "utf-8", + ), + ); + new TerraformProviderGenerator(code, spec).generateAll(); + await code.save(workdir); + + const output = fs.readFileSync( + path.join(workdir, "providers/vault/alicloud-secret-backend/index.ts"), + "utf-8", + ); + expect(output).toMatchSnapshot(); + + // The write-only attribute's getter is deprecated ... + expect(output).toMatch( + /@deprecated Write-only: the provider never returns this value[\s\S]*?public get secretKeyWo\(\)/, + ); + // ... its setter is a plain assignment - no registration happens at + // mutation time anymore ... + expect(output).toMatch( + /public set secretKeyWo\(value: string\) \{\n\s*this\._secretKeyWo = value;\n\s*\}/, + ); + // ... nor does the constructor-assigned config value register anything. + expect(output).not.toMatch(/registerProviderFeatureUsage/); + // Registration instead happens at resolve time, from inside + // synthesizeAttributes()/synthesizeHclAttributes(), by wrapping the + // mapped value in markWriteOnlyAttribute() ... + expect(output).toMatch( + /secret_key_wo: this\.markWriteOnlyAttribute\(cdktn\.stringToTerraform\(this\._secretKeyWo\)\),/, + ); + expect(output).toMatch( + /value: this\.markWriteOnlyAttribute\(cdktn\.stringToHclTerraform\(this\._secretKeyWo\)\),/, + ); + + // The required write-only attribute gets no reset method (required + // attributes never do) ... + expect(output).not.toMatch(/resetSecretKeyWo\b/); + // ... but the OPTIONAL write-only sibling does, exercising the reset -> + // clear -> synth-passes path end to end at the generator level. + expect(output).toMatch( + /public resetSecondarySecretKeyWo\(\) \{\n\s*this\._secondarySecretKeyWo = undefined;\n\s*\}/, + ); + expect(output).toMatch( + /secondary_secret_key_wo: this\.markWriteOnlyAttribute\(cdktn\.stringToTerraform\(this\._secondarySecretKeyWo\)\),/, + ); + + // The non-write-only sibling attribute is untouched: its getter is + // emitted directly (no @deprecated JSDoc immediately above), its setter + // has no registration call, and its synthesis isn't wrapped. + expect(output).toMatch( + /private _secretKeyWoVersion\?: number; \n\s*public get secretKeyWoVersion\(\)/, + ); + expect(output).toMatch( + /public set secretKeyWoVersion\(value: number\) \{\n\s*this\._secretKeyWoVersion = value;\n\s*\}/, + ); + expect(output).toMatch( + /secret_key_wo_version: cdktn\.numberToTerraform\(this\._secretKeyWoVersion\),/, + ); +}); diff --git a/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts new file mode 100644 index 000000000..9ad3b4623 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs-extra"; +import * as path from "path"; +import { + Language, + TerraformDependencyConstraint, + TerraformTargetVersions, +} from "@cdktn/commons"; +import { createTmpHelper } from "./util"; + +const mockReadSchema = jest.fn(); +jest.mock("@cdktn/provider-schema", () => ({ + readSchema: (...args: unknown[]) => mockReadSchema(...args), +})); + +// Imported after the mock so ConstructsMaker picks up the mocked readSchema. +import { ConstructsMaker, GetOptions } from "../constructs-maker"; + +const tmp = createTmpHelper(); + +const constraint: TerraformDependencyConstraint = { + fqn: "hashicorp/random", + name: "random", + source: "hashicorp/random", + version: "3.1.3", +}; + +// Minimal schema - no resources/data sources - so generation stays within +// pure TypeScript emission (no jsii-pacmak invocation), mirroring the +// "empty-provider-resources" fixture used elsewhere in this package. +const fakeSchema = { + providerSchema: { + format_version: "0.2" as const, + provider_schemas: { + "registry.terraform.io/hashicorp/random": { + resource_schemas: null, + data_source_schemas: null, + }, + }, + provider_versions: { + "registry.terraform.io/hashicorp/random": "3.1.3", + }, + cli_name: "terraform", + cli_version: "1.7.5", + }, +}; + +describe("targetVersions threading through 'cdktn get'", () => { + beforeEach(() => { + mockReadSchema.mockReset(); + mockReadSchema.mockResolvedValue(fakeSchema); + }); + + it("passes GetOptions.targetVersions through to readSchema", async () => { + const workdir = tmp("target-versions-thread.test"); + const targetVersions: TerraformTargetVersions = { terraform: ">=1.5.7" }; + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + targetVersions, + }; + + const maker = new ConstructsMaker(options, "/some/cache/path"); + await maker.generate([constraint]); + + expect(mockReadSchema).toHaveBeenCalledWith( + [constraint], + "/some/cache/path", + targetVersions, + ); + }); + + it("omits targetVersions from the readSchema call when not configured", async () => { + const workdir = tmp("target-versions-absent.test"); + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + }; + + const maker = new ConstructsMaker(options); + await maker.generate([constraint]); + + expect(mockReadSchema).toHaveBeenCalledWith( + [constraint], + undefined, + undefined, + ); + }); + + it("stamps constraints.json with targetVersions + cli identity, and older constraints.json files without those fields are still treated as fresh (no forced regeneration)", async () => { + const workdir = tmp("target-versions-stamp.test"); + const targetVersions: TerraformTargetVersions = { terraform: ">=1.5.7" }; + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + targetVersions, + }; + + const maker = new ConstructsMaker(options); + await maker.generate([constraint]); + + const constraintsFile = JSON.parse( + await fs.readFile(path.join(workdir, "constraints.json"), "utf8"), + ); + expect(constraintsFile.targetVersions).toEqual(targetVersions); + expect(constraintsFile.cli).toEqual({ + name: "terraform", + version: "1.7.5", + }); + + // Rewrite constraints.json as it would have looked before this feature + // landed (no targetVersions/cli fields), keeping the rest identical - + // this simulates re-running `cdktn get` against an output directory + // generated by an older cdktn version. + const { + targetVersions: _tv, + cli: _cli, + ...legacyConstraints + } = constraintsFile; + await fs.writeFile( + path.join(workdir, "constraints.json"), + JSON.stringify(legacyConstraints, null, 2), + ); + + const maker2 = new ConstructsMaker(options); + const toGenerate = await maker2.filterAlreadyGenerated([constraint]); + expect(toGenerate).toEqual([]); + }); + + it("threads a partial targetVersions (single product) through unchanged and stamps it verbatim", async () => { + const workdir = tmp("target-versions-partial.test"); + const targetVersions: TerraformTargetVersions = { opentofu: ">=1.11.0" }; + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + targetVersions, + }; + + const maker = new ConstructsMaker(options); + await maker.generate([constraint]); + + expect(mockReadSchema).toHaveBeenCalledWith( + [constraint], + undefined, + targetVersions, + ); + + // The stamp records exactly what the project declared - no terraform + // default is injected at get-time (defaulting for the undeclared + // product happens only at synth-time, in resolveTargetVersions). + const constraintsFile = JSON.parse( + await fs.readFile(path.join(workdir, "constraints.json"), "utf8"), + ); + expect(constraintsFile.targetVersions).toEqual({ opentofu: ">=1.11.0" }); + }); + + it("omits the cli stamp when the fetched schema carries no CLI version metadata", async () => { + const workdir = tmp("target-versions-no-cli.test"); + const { + cli_name: _cliName, + cli_version: _cliVersion, + ...schemaWithoutCli + } = fakeSchema.providerSchema; + mockReadSchema.mockResolvedValue({ providerSchema: schemaWithoutCli }); + + const targetVersions: TerraformTargetVersions = { terraform: ">=1.5.7" }; + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + targetVersions, + }; + + const maker = new ConstructsMaker(options); + await maker.generate([constraint]); + + const constraintsFile = JSON.parse( + await fs.readFile(path.join(workdir, "constraints.json"), "utf8"), + ); + expect(constraintsFile.cli).toBeUndefined(); + expect(constraintsFile.targetVersions).toEqual(targetVersions); + }); + + it("does not force regeneration when targetVersions change between runs", async () => { + // The stamp is diagnostic-only: per the GetOptions.targetVersions doc, + // targetVersions never filters codegen (the full schema surface is + // always generated), so freshness only compares the providers map and + // the cdktf version. + const workdir = tmp("target-versions-change.test"); + const options: GetOptions = { + codeMakerOutput: workdir, + targetLanguage: Language.TYPESCRIPT, + targetVersions: { terraform: ">=1.5.7" }, + }; + + const maker = new ConstructsMaker(options); + await maker.generate([constraint]); + + const maker2 = new ConstructsMaker({ + ...options, + targetVersions: { terraform: ">=1.11.0" }, + }); + const toGenerate = await maker2.filterAlreadyGenerated([constraint]); + expect(toGenerate).toEqual([]); + }); +}); diff --git a/packages/@cdktn/provider-generator/src/get/constructs-maker.ts b/packages/@cdktn/provider-generator/src/get/constructs-maker.ts index dcc21273d..510099183 100644 --- a/packages/@cdktn/provider-generator/src/get/constructs-maker.ts +++ b/packages/@cdktn/provider-generator/src/get/constructs-maker.ts @@ -15,6 +15,7 @@ import { ModuleSchema, Errors, type LanguageOptions, + type TerraformTargetVersions, } from "@cdktn/commons"; import { DISPLAY_VERSION, Language } from "@cdktn/commons"; import { TerraformProviderGenerator } from "./generator/provider-generator"; @@ -265,7 +266,23 @@ export async function generateJsiiLanguage( }); } -type ConstraintFile = { providers: Record; cdktf: string }; +type ConstraintFile = { + providers: Record; + cdktf: string; + /** + * The project's declared targetVersions (cdktf.json), stamped for + * diagnostics/cache debugging. Purely informational - never read back to + * decide whether cached output is stale, since it doesn't affect the + * generated surface (the full surface is always generated; narrowing + * happens at synth). + */ + targetVersions?: TerraformTargetVersions; + /** + * Identity of the CLI (terraform/opentofu) that fetched the schemas used + * for this generation, stamped for diagnostics/cache debugging. + */ + cli?: { name?: string; version?: string }; +}; export interface GetOptions { readonly targetLanguage: Language; @@ -280,6 +297,13 @@ export interface GetOptions { * Language-specific code generation options. */ readonly languageOptions?: LanguageOptions; + /** + * The project's declared targetVersions (cdktf.json). Used to drive the + * fetch-time emission-gap warning in @cdktn/provider-schema and to stamp + * the generated constraints.json for diagnostics. Does NOT filter codegen: + * the full surface is always generated, narrowing happens at synth. + */ + readonly targetVersions?: TerraformTargetVersions; } export class ConstructsMaker { @@ -570,6 +594,7 @@ export class ConstructsMaker { // this is used for caching purposes private emitConstraintsFile( allowedConstraints: TerraformDependencyConstraint[], + providerSchema?: ProviderSchema, ) { const filePath = "constraints.json"; @@ -586,6 +611,17 @@ export class ConstructsMaker { ), }; + if (this.options.targetVersions) { + content.targetVersions = this.options.targetVersions; + } + + if (providerSchema?.cli_name || providerSchema?.cli_version) { + content.cli = { + name: providerSchema.cli_name, + version: providerSchema.cli_version, + }; + } + this.code.openFile(filePath); this.code.line(JSON.stringify(content, null, 2)); this.code.closeFile(filePath); @@ -686,7 +722,11 @@ a NODE_OPTIONS variable, we won't override it. Hence, the provider generation mi } public async getSchemas(targets: TerraformDependencyConstraint[]) { - return await readSchema(targets, this.schemaCachePath); + return await readSchema( + targets, + this.schemaCachePath, + this.options.targetVersions, + ); } public async generate( @@ -708,7 +748,7 @@ a NODE_OPTIONS variable, we won't override it. Hence, the provider generation mi endGenerateTimer(); this.updateVersionsFile(allConstraints); - this.emitConstraintsFile(allConstraints); + this.emitConstraintsFile(allConstraints, schemas.providerSchema); if (this.isJavascriptTarget) { await this.save(); diff --git a/packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts index 7ba6b345a..c1fd98eda 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts @@ -43,12 +43,14 @@ export class AttributesEmitter { switch (getterType._type) { case "plain": + this.emitWriteOnlyGetterDeprecationNotice(att); this.code.openBlock(`public get ${att.name}()`); this.code.line(`return ${this.determineGetAttCall(att)};`); this.code.closeBlock(); break; case "args": + this.emitWriteOnlyGetterDeprecationNotice(att); this.code.openBlock( `public ${att.name}(${getterType.args})${ getterType.returnType ? ": " + getterType.returnType : "" @@ -59,6 +61,7 @@ export class AttributesEmitter { break; case "stored_class": + this.emitWriteOnlyGetterDeprecationNotice(att); this.code.openBlock(`public get ${att.name}()`); this.code.line(`return this.${att.storageName};`); this.code.closeBlock(); @@ -67,6 +70,10 @@ export class AttributesEmitter { const setterType = att.setterType; + // Write-only usage is registered at resolve time instead (see + // #emitToTerraform/#emitToHclTerraform and + // TerraformResource#markWriteOnlyAttribute), so setters intentionally + // emit no registration call here: they only store the value. switch (setterType._type) { case "set": this.code.openBlock( @@ -123,6 +130,21 @@ export class AttributesEmitter { } } + // emits a @deprecated JSDoc block above a write-only attribute's getter: + // providers never persist/return write-only values, so the state-backed + // getter always reads back null by protocol contract + private emitWriteOnlyGetterDeprecationNotice(att: AttributeModel) { + if (!att.isWriteOnly) { + return; + } + + const comment = sanitizedComment(this.code); + comment.line( + "@deprecated Write-only: the provider never returns this value; reading it always yields null by protocol contract. The getter remains for compatibility and will be removed in a future prebuilt-provider major.", + ); + comment.end(); + } + // returns an invocation of a stored class, e.g. 'new DeplotmentMetadataOutputReference(this, "metadata")' private storedClassInit(att: AttributeModel) { return att.type.getStoredClassInitializer(att.terraformName); @@ -204,7 +226,20 @@ export class AttributesEmitter { } } - public emitToHclTerraform(att: AttributeModel, isStruct: boolean) { + /** + * @param canRegisterProviderFeatureUsage Whether this attribute's mapped + * value should be wrapped in `this.markWriteOnlyAttribute(...)` when it is + * write-only. Only true for attributes emitted on classes that extend + * TerraformResource (managed resources) - struct-level `xToTerraform`/ + * `xToHclTerraform` functions are standalone functions with no `this` of + * that type, so they never pass this (see ResourceEmitter's + * `supportsWriteOnlyRegistration`). + */ + public emitToHclTerraform( + att: AttributeModel, + isStruct: boolean, + canRegisterProviderFeatureUsage = false, + ) { const type = att.type; const context = isStruct ? "struct!" : "this"; const name = isStruct ? att.name : att.storageName; @@ -223,7 +258,15 @@ export class AttributesEmitter { ? `${varReference} === undefined ? ${customDefault} : ` : ""; - const value = `${defaultCheck}${type.toHclTerraformFunction}(${varReference})`; + const mappedValue = `${defaultCheck}${type.toHclTerraformFunction}(${varReference})`; + // markWriteOnlyAttribute() passes `undefined`/`null` straight through + // unwrapped, so this keeps the "remove undefined attributes" filter in + // the caller (which checks `value.value !== undefined`) working exactly + // as before for unset/cleared write-only attributes. + const value = + att.isWriteOnly && canRegisterProviderFeatureUsage + ? `this.markWriteOnlyAttribute(${mappedValue})` + : mappedValue; const isBlock = att.type.isComplex; const tt = att.type.typeModelType; @@ -235,7 +278,11 @@ export class AttributesEmitter { this.code.close("},"); } - public emitToTerraform(att: AttributeModel, isStruct: boolean) { + public emitToTerraform( + att: AttributeModel, + isStruct: boolean, + canRegisterProviderFeatureUsage = false, + ) { const type = att.type; const context = isStruct ? "struct!" : "this"; const name = isStruct ? att.name : att.storageName; @@ -254,8 +301,16 @@ export class AttributesEmitter { ? `${varReference} === undefined ? ${customDefault} : ` : ""; - this.code.line( - `${att.terraformName}: ${defaultCheck}${type.toTerraformFunction}(${varReference}),`, - ); + const mappedValue = `${defaultCheck}${type.toTerraformFunction}(${varReference})`; + // markWriteOnlyAttribute() passes `undefined`/`null` straight through + // unwrapped, so an unset/cleared write-only attribute still vanishes + // from the returned object exactly as before (see the "skip undefined" + // step in tokens/private/resolve.ts). + const value = + att.isWriteOnly && canRegisterProviderFeatureUsage + ? `this.markWriteOnlyAttribute(${mappedValue})` + : mappedValue; + + this.code.line(`${att.terraformName}: ${value},`); } } diff --git a/packages/@cdktn/provider-generator/src/get/generator/emitter/index.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/index.ts index a88091416..651d94624 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/emitter/index.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/emitter/index.ts @@ -1,5 +1,6 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 export * from "./attributes-emitter"; +export * from "./provider-functions-emitter"; export * from "./resource-emitter"; export * from "./struct-emitter"; diff --git a/packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts new file mode 100644 index 000000000..2957e1208 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts @@ -0,0 +1,106 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { CodeMaker } from "codemaker"; +import { + ProviderFunctionModel, + ProviderFunctionsModel, +} from "../models/provider-function-model"; +import { sanitizedComment } from "../sanitized-comments"; + +const PROVIDER_LOCAL_NAME_JSDOC = + "The local name of the provider in required_providers; defaults to the registry short name. Override when the provider is declared under a different local name — aliases do not change the namespace, local names do."; + +export class ProviderFunctionsEmitter { + constructor(private readonly code: CodeMaker) {} + + public emit(model: ProviderFunctionsModel) { + this.code.line(`import * as cdktn from 'cdktn';`); + this.code.line(); + this.emitClass(model); + } + + private emitClass(model: ProviderFunctionsModel) { + const comment = sanitizedComment(this.code); + comment.line( + `Provider-defined functions of the ${model.providerName} provider.`, + ); + comment.end(); + this.code.openBlock(`export class ${model.className}`); + + this.emitConstructor(); + + for (const fn of model.functions) { + this.emitMethod(fn); + } + + this.code.closeBlock(); + } + + private emitConstructor() { + // A TypeScript parameter property (`constructor(private readonly x: T)`) + // requires the compiler to synthesize a `this.x = x;` assignment - that's + // a real transform, not a type-only erasure, so it isn't recognized by + // Node's native type-stripping (`node file.ts` with no bundler/transpiler, + // e.g. `--experimental-strip-types`), which throws + // `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` on sight of one. Declare the field + // and assign it in the constructor body instead, which strips cleanly. + this.code.line(`private readonly providerLocalName: string;`); + this.code.line(); + const comment = sanitizedComment(this.code); + comment.line(`@param providerLocalName ${PROVIDER_LOCAL_NAME_JSDOC}`); + comment.end(); + this.code.openBlock(`constructor(providerLocalName: string)`); + this.code.line(`this.providerLocalName = providerLocalName;`); + this.code.closeBlock(); + } + + private emitMethod(fn: ProviderFunctionModel) { + this.code.line(); + + const comment = sanitizedComment(this.code); + const description = fn.description ?? fn.summary; + if (description) comment.line(description); + if (fn.deprecationMessage) { + comment.line(`@deprecated ${fn.deprecationMessage}`); + } + for (const param of fn.parameters) { + comment.line( + `@param {${param.docstringType}} ${param.name} ${param.description ?? ""}`.trimEnd(), + ); + } + if (fn.variadicParameter) { + const param = fn.variadicParameter; + comment.line( + `@param {Array<${param.docstringType}>} ${param.name} ${param.description ?? ""}`.trimEnd(), + ); + } + comment.end(); + + const methodParams = [ + ...fn.parameters.map( + (p) => `${p.name}${p.optional ? "?" : ""}: ${p.tsType}`, + ), + ...(fn.variadicParameter + ? // `Array` rather than `T[]`: for a union element type (e.g. + // `boolean | cdktn.IResolvable`), bare `T[]` binds as `boolean | + // (cdktn.IResolvable[])` instead of `(boolean | cdktn.IResolvable)[]`. + [ + `${fn.variadicParameter.name}: Array<${fn.variadicParameter.tsType}>`, + ] + : []), + ]; + + const invokeArgs = [ + ...fn.parameters.map((p) => p.name), + ...(fn.variadicParameter ? [`...${fn.variadicParameter.name}`] : []), + ]; + + const invokeExpression = `cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "${fn.terraformName}", [${invokeArgs.join(", ")}])`; + + this.code.openBlock( + `public ${fn.methodName}(${methodParams.join(", ")}): ${fn.returnTsType}`, + ); + this.code.line(`return ${fn.wrapReturn(invokeExpression)};`); + this.code.closeBlock(); + } +} diff --git a/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts index bf298e35a..72ce901ed 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts @@ -8,12 +8,26 @@ import { sanitizedComment } from "../sanitized-comments"; export class ResourceEmitter { attributesEmitter: AttributesEmitter; - constructor(private readonly code: CodeMaker) { + constructor( + private readonly code: CodeMaker, + private readonly importExtension: string, + ) { this.attributesEmitter = new AttributesEmitter(this.code); } public emit(resource: ResourceModel) { this.code.line(); + + if (resource.isProvider && resource.providerFunctionsModel) { + // Sibling-directory import: providers//provider/index.ts + // (the emitted file) and providers//provider-functions/ + // index.ts are siblings under providers//, so unlike the + // child-folder struct imports this one has to step up a level. + this.code.line( + `import { ${resource.providerFunctionsModel.className} } from '../provider-functions/index${this.importExtension}';`, + ); + } + const comment = sanitizedComment(this.code); comment.line( `Represents a {@link ${resource.linkToDocs} ${resource.terraformResourceType}}`, @@ -26,8 +40,10 @@ export class ResourceEmitter { this.emitHeader("STATIC PROPERTIES"); this.emitStaticProperties(resource); - this.emitHeader("STATIC Methods"); - this.emitStaticMethods(resource); + if (!resource.isEphemeralResource) { + this.emitHeader("STATIC Methods"); + this.emitStaticMethods(resource); + } this.emitHeader("INITIALIZER"); this.emitInitializer(resource); @@ -35,6 +51,11 @@ export class ResourceEmitter { this.emitHeader("ATTRIBUTES"); this.emitResourceAttributes(resource); + if (resource.isProvider && resource.providerFunctionsModel) { + this.emitHeader("PROVIDER-DEFINED FUNCTIONS"); + this.emitFunctionsGetter(resource.providerFunctionsModel); + } + // synthesis this.emitHeader("SYNTHESIS"); this.emitResourceSynthesis(resource); @@ -43,6 +64,38 @@ export class ResourceEmitter { this.code.closeBlock(); // construct } + // Emits a memoized `functions` getter on a provider class that declares + // provider-defined functions. The local name to invoke functions under is + // derived from `this.terraformResourceType`, not baked in as the schema + // name constant: terraform-provider.ts keys both `required_providers` and + // the `provider` block directly off `terraformResourceType`, so whatever + // this instance's `terraformResourceType` is IS its correct + // required_providers local name (it already accounts for a `name` + // override in the cdktf.json provider constraint, see + // ConstructsMakerProviderTarget/TerraformProviderConstraint) - the + // instance always knows its own correct local name, so there is no need + // for a providerLocalName override parameter here. + private emitFunctionsGetter( + model: NonNullable, + ) { + this.code.line(`private _functions?: ${model.className};`); + this.code.line(); + + const comment = sanitizedComment(this.code); + comment.line( + `Provider-defined functions of the ${model.providerName} provider.`, + ); + comment.end(); + this.code.openBlock(`public get functions(): ${model.className}`); + this.code.openBlock(`if (!this._functions)`); + this.code.line( + `this._functions = new ${model.className}(this.terraformResourceType);`, + ); + this.code.closeBlock(); + this.code.line(`return this._functions;`); + this.code.closeBlock(); + } + private emitHeader(title: string) { this.code.line(); this.code.line("// " + "=".repeat(title.length)); @@ -86,8 +139,13 @@ export class ResourceEmitter { ); this.code.open(`const attrs = {`); + const registerWriteOnlyUsage = this.supportsWriteOnlyRegistration(resource); for (const att of resource.synthesizableAttributes) { - this.attributesEmitter.emitToHclTerraform(att, false); + this.attributesEmitter.emitToHclTerraform( + att, + false, + registerWriteOnlyUsage, + ); } this.code.close(`};`); @@ -112,14 +170,38 @@ export class ResourceEmitter { ); this.code.open(`return {`); + const registerWriteOnlyUsage = this.supportsWriteOnlyRegistration(resource); for (const att of resource.synthesizableAttributes) { - this.attributesEmitter.emitToTerraform(att, false); + this.attributesEmitter.emitToTerraform( + att, + false, + registerWriteOnlyUsage, + ); } this.code.close(`};`); this.code.closeBlock(); } + // `markWriteOnlyAttribute` (called from generated `synthesizeAttributes()`/ + // `synthesizeHclAttributes()` for write-only attributes, see + // AttributesEmitter#emitToTerraform/#emitToHclTerraform) lives on + // TerraformResource - so only managed resources can register write-only + // usage; data sources, providers, and ephemeral resources only get the + // deprecated getter. + // + // write-only is fundamentally a state concept - the value is passed to + // the provider but never persisted to state - so gating this on + // TerraformResource is a semantic choice, not a capability one: ephemeral + // resources have no state at all, and, consistent with that, no provider + // schema in the RFC-04 sweep (including vault's 16 ephemeral resources) + // marks an ephemeral attribute write_only. So ephemeral classes + // deliberately land in the same "getter only" bucket as data sources and + // providers. + private supportsWriteOnlyRegistration(resource: ResourceModel): boolean { + return resource.parentClassName === "TerraformResource"; + } + private emitResourceAttributes(resource: ResourceModel) { for (const att of resource.attributes) { this.attributesEmitter.emit( @@ -136,7 +218,13 @@ export class ResourceEmitter { comment.line( `Create a new {@link ${resource.linkToDocs} ${ resource.terraformResourceType - }} ${resource.isDataSource ? "Data Source" : "Resource"}`, + }} ${ + resource.isDataSource + ? "Data Source" + : resource.isEphemeralResource + ? "Ephemeral Resource" + : "Resource" + }`, ); comment.line(``); comment.line(`@param scope The scope in which to define this construct`); @@ -151,11 +239,20 @@ export class ResourceEmitter { if (resource.isProvider) { this.emitProviderSuper(resource); + } else if (resource.isEphemeralResource) { + this.emitEphemeralResourceSuper(resource); } else { this.emitResourceSuper(resource); } // initialize config properties + // + // Write-only usage is not registered here: registration now happens at + // resolve time from synthesizeAttributes()/synthesizeHclAttributes() + // (see AttributesEmitter#emitToTerraform/#emitToHclTerraform and + // TerraformResource#markWriteOnlyAttribute), not at mutation time, so + // there is no mutation-time guard to emit for constructor assignment + // either. for (const att of resource.configStruct.assignableAttributes) { if (att.setterType._type === "stored_class") { this.code.line( @@ -185,6 +282,20 @@ export class ResourceEmitter { this.code.close(`});`); } + private emitEphemeralResourceSuper(resource: ResourceModel) { + this.code.open(`super(scope, id, {`); + this.code.line( + `terraformResourceType: '${resource.terraformResourceType}',`, + ); + this.emitTerraformGeneratorMetadata(resource); + this.code.line(`provider: config.provider,`); + this.code.line(`dependsOn: config.dependsOn,`); + this.code.line(`count: config.count,`); + this.code.line(`lifecycle: config.lifecycle,`); + this.code.line(`forEach: config.forEach`); + this.code.close(`});`); + } + private emitProviderSuper(resource: ResourceModel) { this.code.open(`super(scope, id, {`); this.code.line( diff --git a/packages/@cdktn/provider-generator/src/get/generator/models/attribute-model.ts b/packages/@cdktn/provider-generator/src/get/generator/models/attribute-model.ts index d7192c05b..87c0d5ed9 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/models/attribute-model.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/models/attribute-model.ts @@ -37,6 +37,7 @@ export interface AttributeModelOptions { provider: boolean; required: boolean; forcePlainGetterType?: boolean; // used for skipping attribute type attributes that use the SkippedAttributeTypeModel which returns an interpolation and has no stored type + isWriteOnly?: boolean; // provider protocol write-only attribute: providers never persist/return its value, see RFC-04 } export function escapeAttributeName(name: string) { @@ -74,6 +75,7 @@ export class AttributeModel { public required: boolean; public forcePlainGetterType?: boolean; private loggedStoredClassUnavailable = false; + public isWriteOnly: boolean; constructor(options: AttributeModelOptions) { this.storageName = options.storageName; @@ -87,6 +89,7 @@ export class AttributeModel { this.provider = options.provider; this.required = options.required; this.forcePlainGetterType = options.forcePlainGetterType; + this.isWriteOnly = !!options.isWriteOnly; } public get typeDefinition() { diff --git a/packages/@cdktn/provider-generator/src/get/generator/models/index.ts b/packages/@cdktn/provider-generator/src/get/generator/models/index.ts index 5d59d2406..9addc479a 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/models/index.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/models/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 export * from "./attribute-model"; export * from "./attribute-type-model"; +export * from "./provider-function-model"; export * from "./resource-model"; export * from "./scope"; export * from "./struct"; diff --git a/packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts b/packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts new file mode 100644 index 000000000..caff37b6b --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts @@ -0,0 +1,556 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { toCamelCase, toPascalCase } from "codemaker"; +import { + AttributeType, + FunctionParameter, + FunctionSignature, +} from "@cdktn/commons"; + +/** + * A provider-defined function parameter (positional or the trailing + * variadic one), mapped to the jsii-safe TypeScript type used in the + * generated method signature. + */ +export interface ProviderFunctionParameterModel { + readonly terraformName: string; + readonly name: string; + readonly tsType: string; + readonly docstringType: string; + readonly description?: string; + /** + * True when this (fixed, non-variadic) parameter is emitted as a + * jsii-optional TypeScript parameter (`name?: T`) - see the + * is_nullable/trailing-compatibility handling in `applyNullability`. + * Never set on the variadic parameter: rest parameters can't be `?`. + */ + readonly optional?: boolean; +} + +/** + * A single provider-defined function, mapped to a static method on the + * generated `ProviderFunctions` class. + */ +export interface ProviderFunctionModel { + readonly terraformName: string; + readonly methodName: string; + readonly description?: string; + readonly summary?: string; + /** + * Terraform (>=1.8) deprecation message for the function, surfaced as an + * `@deprecated` JSDoc tag. OpenTofu never emits this field - see + * `FunctionSignature.deprecation_message`. + */ + readonly deprecationMessage?: string; + readonly returnTsType: string; + /** + * Wraps the `cdktn.TerraformProviderFunction.invoke(...)` call expression + * into the method's `return` statement (e.g. wrapping it in + * `cdktn.Token.asString(...)`, or returning it unwrapped for `bool`). + */ + readonly wrapReturn: (invokeExpression: string) => string; + readonly parameters: ProviderFunctionParameterModel[]; + readonly variadicParameter?: ProviderFunctionParameterModel; +} + +/** + * All provider-defined functions of a single provider, mapped to a single + * generated `providers//provider-functions/index.ts` file. + */ +export interface ProviderFunctionsModel { + readonly providerName: string; + readonly className: string; + readonly functions: ProviderFunctionModel[]; +} + +// Parameter names that collide with a reserved word/identifier in one of the +// jsii target languages. This is the sibling table for provider-defined +// functions: tools/generate-function-bindings/scripts/generate.ts holds the +// canonical (and separately maintained) table for built-in `Fn.*` functions. +// The two are intentionally not shared - that script lives outside the +// packages/ build and generates a different artifact - but any reserved name +// added there should be considered here too. +const RESERVED_PARAMETER_NAMES: { [name: string]: string } = { + default: "defaultValue", // reserved word in TypeScript + string: "str", // causes issues in Go +}; + +function sanitizeParameterName(name: string): string { + const camelCased = toCamelCase(name); + return RESERVED_PARAMETER_NAMES[camelCased] ?? camelCased; +} + +function sanitizeMethodName(name: string): string { + if (name === "length") return "lengthOf"; // reserved on jsii-generated classes + return toCamelCase(name); +} + +// Members that `ProviderFunctionsEmitter` (see `emitter/provider-functions-emitter.ts`) +// itself puts on every generated `ProviderFunctions` wrapper class, +// regardless of which functions a provider declares: +// - "constructor": the class's own constructor, which takes the provider's +// `providerLocalName`. A provider-defined function named "constructor" +// would sanitize to a *second* `constructor` member on the class, which +// is invalid TypeScript. +// - "providerLocalName": the `private readonly providerLocalName: string` +// field the constructor assigns (see the emitter's `emitClassHeader`/ +// constructor emission). A provider-defined function sanitizing to this +// name would collide with that field. +// If `ProviderFunctionsEmitter` ever grows more members on the wrapper class, +// add them here too. +const RESERVED_WRAPPER_MEMBER_NAMES = new Set([ + "constructor", + "providerLocalName", +]); + +/** + * Maps a provider function parameter's declared Terraform type to a + * jsii-safe TypeScript parameter type, recursively for collection types. + * + * Unlike return types, jsii allows unions in *parameter* position, so + * `bool` is typed `boolean | cdktn.IResolvable` (mirroring + * `SimpleAttributeTypeModel.inputTypeDefinition`'s input-side convention) - + * this both accepts a real boolean and still accepts a token in place of + * one. `dynamic`, `object`, and non-primitive `map` values have no + * structural typing here (structural input helpers for `object` are + * deferred to a follow-up issue) and stay `any`. + * + * `list`/`set` recurse on their element type: primitive elements (string, + * number, bool) compose into a proper array type; a nested list/set of a + * representable element type composes naturally too (e.g. + * `list(list(string))` -> `string[][]`). Anything that isn't representable + * as an array element in jsii (map, object, dynamic, or a nested collection + * that itself bottoms out at `any`) widens the *whole* parameter to + * `any[]`, but the docstring keeps describing the real element shape (e.g. + * `Array`) rather than lying about it. + */ +function mapParameterType(type: AttributeType): { + tsType: string; + docstringType: string; +} { + if (type === "string") return { tsType: "string", docstringType: "string" }; + if (type === "number") return { tsType: "number", docstringType: "number" }; + if (type === "bool") { + return { + tsType: "boolean | cdktn.IResolvable", + docstringType: "boolean | IResolvable", + }; + } + if (type === "dynamic") return { tsType: "any", docstringType: "any" }; + + if (Array.isArray(type) && (type[0] === "list" || type[0] === "set")) { + const element = type[1]; + + if (element === "string") { + return { tsType: "string[]", docstringType: "Array" }; + } + if (element === "number") { + return { tsType: "number[]", docstringType: "Array" }; + } + if (element === "bool") { + return { + tsType: "Array", + docstringType: "Array", + }; + } + if ( + Array.isArray(element) && + (element[0] === "list" || element[0] === "set") + ) { + // Nested list/set: recurse and compose. If the inner element type + // is itself a union (bool's `boolean | cdktn.IResolvable`), a plain + // trailing `[]` would bind to the wrong operand + // (`boolean | cdktn.IResolvable[]` reads as `boolean | (IResolvable[])`), + // so use the generic `Array` form in that case instead. + const child = mapParameterType(element); + return { + tsType: child.tsType.includes("|") + ? `Array<${child.tsType}>` + : `${child.tsType}[]`, + docstringType: `Array<${child.docstringType}>`, + }; + } + // map/object/dynamic element: no jsii-safe array-of-X type exists - + // widen the whole parameter to `any[]`, but keep the docstring honest + // about the real element shape. + const child = mapParameterType(element); + return { + tsType: "any[]", + docstringType: `Array<${child.docstringType}>`, + }; + } + + if (Array.isArray(type) && type[0] === "map") { + const element = type[1]; + if (element === "string") { + return { + tsType: "{ [key: string]: string }", + docstringType: "{ [key: string]: string }", + }; + } + if (element === "number") { + return { + tsType: "{ [key: string]: number }", + docstringType: "{ [key: string]: number }", + }; + } + if (element === "bool") { + return { + tsType: "{ [key: string]: (boolean | cdktn.IResolvable) }", + docstringType: "{ [key: string]: (boolean | IResolvable) }", + }; + } + return { tsType: "any", docstringType: "any" }; + } + + // object: structural input helpers are deferred to a follow-up issue. + return { tsType: "any", docstringType: "object" }; +} + +function buildParameterModel( + parameter: FunctionParameter, + fallbackName: string, +): ProviderFunctionParameterModel { + const terraformName = parameter.name ?? fallbackName; + const { tsType, docstringType } = mapParameterType(parameter.type); + return { + terraformName, + name: sanitizeParameterName(terraformName), + tsType, + docstringType, + description: parameter.description, + }; +} + +/** + * Applies `is_nullable` trailing-compatibility rules across a function's + * fixed (non-variadic) parameter list. This needs the whole list in view + * (see `buildFunctionModel`), since whether a nullable parameter can become + * jsii-optional depends on every parameter *after* it too: + * + * - A nullable parameter where it and every later fixed parameter are also + * nullable becomes jsii-optional (`name?: T`): omitting it in TypeScript + * leaves `undefined` in the invoke args array, which `FunctionCall` + * already renders as the Terraform `null` keyword. + * - A nullable parameter followed by a required one keeps its position + * (jsii can't express "optional but not trailing"), but widens its type + * to `any` so a caller can still pass an explicit `null`. + * + * Both cases widen the docstring type to `T | null` to document the real + * Terraform-side nullability regardless of what jsii can express. + */ +function applyNullability( + parameters: FunctionParameter[], + models: ProviderFunctionParameterModel[], +): ProviderFunctionParameterModel[] { + const trailingCompatible = new Array(parameters.length).fill(false); + let allNullableSoFar = true; + for (let i = parameters.length - 1; i >= 0; i--) { + allNullableSoFar = allNullableSoFar && parameters[i].is_nullable === true; + trailingCompatible[i] = allNullableSoFar; + } + + return models.map((model, index) => { + const parameter = parameters[index]; + if (!parameter.is_nullable) return model; + + if (trailingCompatible[index]) { + return { + ...model, + optional: true, + docstringType: `${model.docstringType} | null`, + }; + } + + return { + ...model, + tsType: "any", + docstringType: `${model.docstringType} | null`, + }; + }); +} + +/** + * Maps a provider function's declared return type to the jsii-safe + * TypeScript return type and the expression that unwraps the + * `TerraformProviderFunction.invoke(...)` `IResolvable` into it. + * + * jsii return positions can't use the `T | cdktn.IResolvable` union that + * parameters can (see `mapParameterType`), so every shape that can't be + * losslessly unwrapped through an `asXxx` Token helper falls back to the + * plain `cdktn.IResolvable` produced by `invoke()` itself, unwrapped. + * Provider functions frequently return objects (e.g. every function in the + * `time` provider), so - unlike built-in `Fn.*` functions - the object case + * is a primary path, not an error: it is treated the same as + * `dynamic`/`map`/a `list`/`set` of anything other than `string`/`number` + * (no `asAnyList` Token helper exists), and returned as the RAW `invoke()` + * result. + * + * The declared return type is `cdktn.IResolvable` (not `any`): unlike + * `helpers.ts` `asAny`, this must NOT go through `cdktn.Token.asString(...)` + * - `Token.asString()` produces an encoded string token that + * `Tokenization.isResolvable()` does not recognize, so generated struct + * setters (e.g. an `OutputReference` `internalValue`) treat it as a plain + * object with no known keys and the attribute silently vanishes from synth + * output. The raw `IResolvable` from `invoke()` IS recognized by + * `Tokenization.isResolvable()` and resolves correctly. Declaring it as + * `cdktn.IResolvable` instead of `any` also gives callers real type safety: + * `any` let a caller write `result.hours` on a token with no compile-time + * feedback, silently producing `undefined` at runtime; `IResolvable` has no + * such property and forces the caller through `cdktn.Token`/the provider's + * struct types instead. `invoke()` already returns `IResolvable`, so no + * cast is needed to return it as `cdktn.IResolvable`. + */ +function mapReturnType(returnType: AttributeType): { + tsType: string; + wrapReturn: (invokeExpression: string) => string; +} { + if (returnType === "string") { + return { + tsType: "string", + wrapReturn: (expr) => `cdktn.Token.asString(${expr})`, + }; + } + if (returnType === "number") { + return { + tsType: "number", + wrapReturn: (expr) => `cdktn.Token.asNumber(${expr})`, + }; + } + if (returnType === "bool") { + // Booleans can't be represented as tokens (see helpers.ts asBoolean): + // return the IResolvable produced by invoke() unwrapped. + return { + tsType: "cdktn.IResolvable", + wrapReturn: (expr) => expr, + }; + } + if ( + Array.isArray(returnType) && + (returnType[0] === "list" || returnType[0] === "set") + ) { + const element = returnType[1]; + if (element === "string") { + return { + tsType: "string[]", + wrapReturn: (expr) => `cdktn.Token.asList(${expr})`, + }; + } + if (element === "number") { + return { + tsType: "number[]", + wrapReturn: (expr) => `cdktn.Token.asNumberList(${expr})`, + }; + } + // list/set of anything else: there is no `asAnyList` Token helper - + // fall back to the raw IResolvable, same reasoning as + // dynamic/map/object below. + return { + tsType: "cdktn.IResolvable", + wrapReturn: (expr) => expr, + }; + } + // dynamic, map, object: no structural typing for arbitrary provider + // function results - declared as `cdktn.IResolvable`, and returned as the + // raw `IResolvable` from invoke() (NOT wrapped in `cdktn.Token.asString(...)`, + // which would make the result unrecognizable to + // `Tokenization.isResolvable()` downstream - see the mapReturnType + // docstring above). + return { + tsType: "cdktn.IResolvable", + wrapReturn: (expr) => expr, + }; +} + +function buildFunctionModel( + terraformName: string, + signature: FunctionSignature, +): ProviderFunctionModel { + const { tsType: returnTsType, wrapReturn } = mapReturnType( + signature.return_type, + ); + + const rawParameters = signature.parameters ?? []; + const parameters = applyNullability( + rawParameters, + rawParameters.map((parameter, index) => + buildParameterModel(parameter, `arg${index}`), + ), + ); + + let variadicParameter = signature.variadic_parameter + ? buildParameterModel(signature.variadic_parameter, "values") + : undefined; + if (variadicParameter && signature.variadic_parameter?.is_nullable) { + // A nullable variadic parameter: jsii can't express "each individual + // argument may independently be null" on a rest parameter, so the + // element type widens to `any` (signature becomes `values: any[]`); + // the docstring keeps the honest per-element type. + variadicParameter = { + ...variadicParameter, + tsType: "any", + docstringType: `${variadicParameter.docstringType} | null`, + }; + } + + return { + terraformName, + methodName: sanitizeMethodName(terraformName), + description: signature.description, + summary: signature.summary, + deprecationMessage: signature.deprecation_message, + returnTsType, + wrapReturn, + parameters, + variadicParameter, + }; +} + +/** + * Throws if two functions in the same provider sanitize to the same + * generated method name. Terraform function names are unique, but + * `sanitizeMethodName` (camelCasing, `length` -> `lengthOf`) is not + * necessarily injective, so a real - if unlikely - provider schema could + * still produce a duplicate method. Generation is aborted rather than + * silently emitting one method that shadows the other. + */ +function assertNoMethodNameCollisions( + providerName: string, + functions: ProviderFunctionModel[], +): void { + const seenBy = new Map(); // methodName -> terraformName + + for (const fn of functions) { + const existingTerraformName = seenBy.get(fn.methodName); + if (existingTerraformName !== undefined) { + throw new Error( + `Provider "${providerName}" declares two provider-defined functions, ` + + `"${existingTerraformName}" and "${fn.terraformName}", that both ` + + `sanitize to the generated method name "${fn.methodName}". ` + + `Generation aborted to avoid silently overwriting one method with ` + + `the other - please report this as an issue.`, + ); + } + seenBy.set(fn.methodName, fn.terraformName); + } +} + +/** + * Throws if a function's sanitized `methodName` lands on a member name + * `ProviderFunctionsEmitter` already puts on every wrapper class (see + * `RESERVED_WRAPPER_MEMBER_NAMES`). Unlike `assertNoMethodNameCollisions`, + * which only catches two *functions* colliding with each other, this catches + * a single function colliding with the wrapper class itself - e.g. a + * Terraform function literally named "constructor", or "provider_local_name" + * camelCasing to "providerLocalName". + * + * This intentionally does NOT also check sanitized *parameter* names against + * "providerLocalName": parameters are declared in the generated method's own + * scope, not the class's, so a parameter named `providerLocalName` shadows + * the class field within that method body without colliding with it (valid, + * if confusing, TypeScript) - there is nothing to abort generation over. + */ +function assertNoReservedWrapperMemberCollisions( + providerName: string, + functions: ProviderFunctionModel[], +): void { + for (const fn of functions) { + if (RESERVED_WRAPPER_MEMBER_NAMES.has(fn.methodName)) { + throw new Error( + `Provider "${providerName}" declares a provider-defined function ` + + `"${fn.terraformName}" that sanitizes to the generated method name ` + + `"${fn.methodName}", which collides with a member that ` + + `ProviderFunctionsEmitter already puts on every ` + + `"ProviderFunctions" wrapper class (the class ` + + `constructor, or its "providerLocalName" field). Generation ` + + `aborted to avoid emitting invalid TypeScript or silently ` + + `shadowing that member - please report this as an issue.`, + ); + } + } +} + +/** + * Throws if two parameters of the same function (including the variadic + * parameter) sanitize to the same generated parameter name, for the same + * reason as `assertNoMethodNameCollisions` above but at the parameter level. + */ +function assertNoParameterNameCollisions( + providerName: string, + fn: ProviderFunctionModel, +): void { + const seenBy = new Map(); // sanitized name -> terraformName + const allParameters = fn.variadicParameter + ? [...fn.parameters, fn.variadicParameter] + : fn.parameters; + + for (const param of allParameters) { + const existingTerraformName = seenBy.get(param.name); + if (existingTerraformName !== undefined) { + throw new Error( + `Provider "${providerName}" function "${fn.terraformName}" declares ` + + `two parameters, "${existingTerraformName}" and ` + + `"${param.terraformName}", that both sanitize to the generated ` + + `parameter name "${param.name}". Generation aborted to avoid ` + + `silently dropping one parameter - please report this as an issue.`, + ); + } + seenBy.set(param.name, param.terraformName); + } +} + +/** + * Throws if the provider's own config schema declares an attribute whose + * generated property name (see `AttributeModel.name` / + * `escapeAttributeName`) is exactly "functions", while the provider also + * declares provider-defined functions. That combination would collide with + * the memoized `functions` getter `ResourceEmitter` emits on the provider + * class (see `resource-emitter.ts`'s `emitFunctionsGetter`) - one would + * silently shadow the other. Same loud-failure convention as + * `assertNoMethodNameCollisions`/`assertNoParameterNameCollisions` above: + * generation is aborted rather than silently picking a winner. + */ +export function assertNoFunctionsGetterCollision( + providerName: string, + attributeNames: string[], +): void { + if (attributeNames.includes("functions")) { + throw new Error( + `Provider "${providerName}" declares provider-defined functions and its ` + + `own config schema also has an attribute that generates the property ` + + `name "functions" on the provider class. This collides with the ` + + `generated "functions" getter used to invoke provider-defined ` + + `functions. Generation aborted to avoid silently shadowing one with ` + + `the other - please report this as an issue.`, + ); + } +} + +/** + * Builds the model for a provider's `provider-functions/index.ts` file from + * its provider schema `functions` map. Returns `undefined` when the provider + * declares no functions - callers should skip emitting the file entirely. + */ +export function buildProviderFunctionsModel( + providerName: string, + functions: { [name: string]: FunctionSignature } | undefined, +): ProviderFunctionsModel | undefined { + const entries = Object.entries(functions ?? {}); + if (entries.length === 0) return undefined; + + const mappedFunctions = entries + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, signature]) => buildFunctionModel(name, signature)); + + assertNoMethodNameCollisions(providerName, mappedFunctions); + assertNoReservedWrapperMemberCollisions(providerName, mappedFunctions); + for (const fn of mappedFunctions) { + assertNoParameterNameCollisions(providerName, fn); + } + + return { + providerName, + className: `${toPascalCase(providerName)}ProviderFunctions`, + functions: mappedFunctions, + }; +} diff --git a/packages/@cdktn/provider-generator/src/get/generator/models/resource-model.ts b/packages/@cdktn/provider-generator/src/get/generator/models/resource-model.ts index 097573135..33e8beb69 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/models/resource-model.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/models/resource-model.ts @@ -6,6 +6,7 @@ import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema"; import { AttributeModel } from "./attribute-model"; import { Struct, ConfigStruct } from "./struct"; import { Schema } from "@cdktn/commons"; +import { ProviderFunctionsModel } from "./provider-function-model"; // Limit is 1200 to prevent stack size error. // Could increase now that calculation is more accurate; @@ -42,6 +43,13 @@ export class ResourceModel { * to be able to use two providers with the same name */ public terraformProviderName: string; + /* + * Only set (by provider-generator.ts) when isProvider is true and the + * provider schema declares provider-defined functions - drives whether + * ResourceEmitter emits the memoized `functions` getter and its + * cross-directory import of the sibling provider-functions/index.ts file. + */ + public providerFunctionsModel?: ProviderFunctionsModel; public fileName: string; public attributes: AttributeModel[]; public schema: Schema; @@ -81,7 +89,11 @@ export class ResourceModel { } public get configStruct() { - return new ConfigStruct(this.configStructName, this.attributes); + return new ConfigStruct( + this.configStructName, + this.attributes, + this.isEphemeralResource ? "TerraformEphemeralMetaArguments" : undefined, + ); } public get synthesizableAttributes(): AttributeModel[] { @@ -103,6 +115,8 @@ export class ResourceModel { if (this.isProvider) return base; if (this.isDataSource) return `${base}/data-sources/${this.terraformDocName}`; + if (this.isEphemeralResource) + return `${base}/ephemeral-resources/${this.terraformDocName}`; return `${base}/resources/${this.terraformDocName}`; } @@ -114,12 +128,18 @@ export class ResourceModel { return this.terraformSchemaType === "data_source"; } + public get isEphemeralResource(): boolean { + return this.terraformSchemaType === "ephemeral_resource"; + } + public get parentClassName(): string { return this.isProvider ? "TerraformProvider" : this.isDataSource ? "TerraformDataSource" - : "TerraformResource"; + : this.isEphemeralResource + ? "TerraformEphemeralResource" + : "TerraformResource"; } public get terraformResourceType(): string { @@ -127,7 +147,9 @@ export class ResourceModel { ? this.terraformProviderName : this.isDataSource ? this.terraformType.replace(/^data_/, "") - : this.terraformType; + : this.isEphemeralResource + ? this.terraformType.replace(/^ephemeral_/, "") + : this.terraformType; } public get terraformDocName(): string { diff --git a/packages/@cdktn/provider-generator/src/get/generator/models/struct.ts b/packages/@cdktn/provider-generator/src/get/generator/models/struct.ts index 757d75b7b..b3bff2b33 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/models/struct.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/models/struct.ts @@ -110,7 +110,15 @@ export class Struct { } export class ConfigStruct extends Struct { + constructor( + name: string, + attributes: AttributeModel[], + private readonly extendsClass: string = "TerraformMetaArguments", + ) { + super(name, attributes); + } + public get extends(): string { - return ` extends cdktn.TerraformMetaArguments`; + return ` extends cdktn.${this.extendsClass}`; } } diff --git a/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts b/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts index 92446bb89..b3d62eadd 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts @@ -10,9 +10,18 @@ import { TerraformProviderConstraint, } from "@cdktn/commons"; import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema"; -import { ResourceModel } from "./models"; +import { + ProviderFunctionsModel, + ResourceModel, + assertNoFunctionsGetterCollision, + buildProviderFunctionsModel, +} from "./models"; import { ResourceParser } from "./resource-parser"; -import { ResourceEmitter, StructEmitter } from "./emitter"; +import { + ProviderFunctionsEmitter, + ResourceEmitter, + StructEmitter, +} from "./emitter"; export interface TerraformProviderGeneratorOptions { /** @@ -101,6 +110,7 @@ export class TerraformProviderGenerator { private resourceParser = new ResourceParser(); private resourceEmitter: ResourceEmitter; private structEmitter: StructEmitter; + private providerFunctionsEmitter: ProviderFunctionsEmitter; private readonly importExtension: string; public versions: { [fqpn: string]: string | undefined } = {}; @@ -111,8 +121,9 @@ export class TerraformProviderGenerator { ) { this.code.indentation = 2; this.importExtension = options.importExtension ?? ""; - this.resourceEmitter = new ResourceEmitter(this.code); + this.resourceEmitter = new ResourceEmitter(this.code, this.importExtension); this.structEmitter = new StructEmitter(this.code, this.importExtension); + this.providerFunctionsEmitter = new ProviderFunctionsEmitter(this.code); } private getProviderByConstraint( @@ -185,7 +196,26 @@ export class TerraformProviderGenerator { ), ); - return ([] as ResourceModel[]).concat(...resources, ...dataSources); + const ephemeralResources = Object.entries( + provider.ephemeral_resource_schemas || {}, + ).map(([type, resource]) => + this.resourceParser.parse( + fqpn, + `ephemeral_${type}`, + resource, + "ephemeral_resource", + constraint, + ), + ); + + // CRITICAL: ephemeral resources must be appended AFTER resources and data + // sources - uniqueClassName dedup is order-dependent and existing + // prebuilt snapshots must not churn. + return ([] as ResourceModel[]).concat( + ...resources, + ...dataSources, + ...ephemeralResources, + ); } public getClassNameForResource(terraformType: string) { @@ -225,6 +255,16 @@ export class TerraformProviderGenerator { this.emitResourceReadme(resourceModel); }); + // Built before the provider class is emitted (rather than after, as + // resources/data sources/ephemeral resources above already are) so that + // the model can be attached to providerResource and picked up by + // ResourceEmitter while it still emits the provider class file below - + // see the engineering brief for why this ordering matters. + const providerFunctionsModel = buildProviderFunctionsModel( + name, + provider.functions, + ); + if (provider.provider) { const providerResource = this.resourceParser.parse( fqpn, @@ -239,14 +279,40 @@ export class TerraformProviderGenerator { providerResource.terraformProviderName = constraint.name; } providerResource.providerVersion = providerVersion; + + if (providerFunctionsModel) { + assertNoFunctionsGetterCollision( + name, + providerResource.attributes.map((att) => att.name), + ); + providerResource.providerFunctionsModel = providerFunctionsModel; + } + files.push(this.emitResource(providerResource)); this.emitResourceReadme(providerResource); } + if (providerFunctionsModel) { + files.push(this.emitProviderFunctions(name, providerFunctionsModel)); + } + this.emitIndexFile(name, files); this.emitLazyIndexFile(name, files); } + private emitProviderFunctions( + provider: ProviderName, + model: ProviderFunctionsModel, + ): string { + const filePath = `providers/${provider}/provider-functions/index.ts`; + this.code.openFile(filePath); + this.code.line(`// generated from provider function schema`); + this.code.line(); + this.providerFunctionsEmitter.emit(model); + this.code.closeFile(filePath); + return filePath; + } + private emitResourceReadme(resource: ResourceModel): void { const filePath = `${resource.namespaceFolderPath}/README.md`; this.code.openFile(filePath); diff --git a/packages/@cdktn/provider-generator/src/get/generator/resource-parser.ts b/packages/@cdktn/provider-generator/src/get/generator/resource-parser.ts index 58f23697b..f4e723a30 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/resource-parser.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/resource-parser.ts @@ -463,6 +463,7 @@ class Parser { provider: parentType.isProvider, required: !!att.required, forcePlainGetterType, + isWriteOnly: !!att.write_only, }), ); } @@ -669,6 +670,7 @@ class Parser { type, provider: parent.isProvider, required: required, + isWriteOnly: !!att.write_only, }), ); } diff --git a/packages/@cdktn/provider-schema/package.json b/packages/@cdktn/provider-schema/package.json index 5af722325..f530f3be1 100644 --- a/packages/@cdktn/provider-schema/package.json +++ b/packages/@cdktn/provider-schema/package.json @@ -39,10 +39,12 @@ "@cdktn/commons": "workspace:*", "@cdktn/hcl2json": "workspace:*", "deepmerge": "4.3.1", - "fs-extra": "11.3.4" + "fs-extra": "11.3.4", + "semver": "7.7.4" }, "devDependencies": { "@types/fs-extra": "11.0.4", + "@types/semver": "7.7.1", "safe-stable-stringify": "2.5.0", "tsc-files": "1.1.4", "typescript": "^5.0.0" diff --git a/packages/@cdktn/provider-schema/src/__tests__/__snapshots__/provider-schema.test.ts.snap b/packages/@cdktn/provider-schema/src/__tests__/__snapshots__/provider-schema.test.ts.snap index 1089a924f..8b161b9db 100644 --- a/packages/@cdktn/provider-schema/src/__tests__/__snapshots__/provider-schema.test.ts.snap +++ b/packages/@cdktn/provider-schema/src/__tests__/__snapshots__/provider-schema.test.ts.snap @@ -675,6 +675,8 @@ exports[`readSchema generates a single module schema 1`] = ` exports[`readSchema generates a single provider schema 1`] = ` "{ + "cli_name": "STUBBED CLI", + "cli_version": "STUBBED VERSION", "format_version": "STUBBED VERSION", "provider_schemas": { "registry.terraform.io/hashicorp/null": { @@ -774,6 +776,28 @@ exports[`sanitizeProviderSchema sanitizes a provider schema 1`] = ` "provider_schemas": { "registry.terraform.io/hashicorp/null": { "data_source_schemas": {}, + "ephemeral_resource_schemas": { + "null_ephemeral": { + "block": { + "attributes": { + "correct": { + "type": [ + "list", + "string", + ], + }, + "incorrect": { + "type": [ + "list", + "string", + ], + }, + }, + "block_types": {}, + }, + "version": 0, + }, + }, "provider": { "block": { "attributes": { diff --git a/packages/@cdktn/provider-schema/src/__tests__/cache.test.ts b/packages/@cdktn/provider-schema/src/__tests__/cache.test.ts new file mode 100644 index 000000000..a301b3719 --- /dev/null +++ b/packages/@cdktn/provider-schema/src/__tests__/cache.test.ts @@ -0,0 +1,68 @@ +// 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 { cachedAccess } from "../cache"; + +describe("cachedAccess", () => { + let cacheDir: string; + + beforeEach(() => { + cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "provider-schema-cache-")); + }); + + afterEach(() => { + fs.removeSync(cacheDir); + }); + + it("writes to a suffix-qualified cache file when a keySuffix is given", async () => { + const producer = jest.fn().mockResolvedValue({ hello: "world" }); + const access = cachedAccess(producer, cacheDir, "terraform-1.7"); + + await access({ fqn: "hashicorp/null", version: "3.1.0" } as any); + + const files = fs.readdirSync(cacheDir); + expect(files).toHaveLength(1); + expect(files[0]).toContain("terraform-1.7"); + }); + + it("keeps distinct cache files for distinct suffixes", async () => { + const producerA = jest.fn().mockResolvedValue({ v: "a" }); + const producerB = jest.fn().mockResolvedValue({ v: "b" }); + + const input = { fqn: "hashicorp/null", version: "3.1.0" } as any; + + await cachedAccess(producerA, cacheDir, "terraform-1.7")(input); + await cachedAccess(producerB, cacheDir, "terraform-1.15")(input); + + const files = fs.readdirSync(cacheDir); + expect(files).toHaveLength(2); + expect(producerA).toHaveBeenCalledTimes(1); + expect(producerB).toHaveBeenCalledTimes(1); + }); + + it("hits the same cache file on repeated calls with the same suffix", async () => { + const producer = jest.fn().mockResolvedValue({ v: "a" }); + const input = { fqn: "hashicorp/null", version: "3.1.0" } as any; + const access = cachedAccess(producer, cacheDir, "terraform-1.7"); + + await access(input); + await access(input); + + expect(producer).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(cacheDir)).toHaveLength(1); + }); + + it("stays backward-compatible without a keySuffix", async () => { + const producer = jest.fn().mockResolvedValue({ hello: "world" }); + const access = cachedAccess(producer, cacheDir); + + await access({ fqn: "hashicorp/null", version: "3.1.0" } as any); + + const files = fs.readdirSync(cacheDir); + expect(files).toHaveLength(1); + expect(files[0]).not.toContain("undefined"); + }); +}); diff --git a/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts new file mode 100644 index 000000000..996a8fc9c --- /dev/null +++ b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +import { DEFAULT_TARGET_VERSIONS, TerraformCliVersion } from "@cdktn/commons"; +import { + checkSchemaEmissionGaps, + suggestedEmittingCliVersions, +} from "../emission-check"; + +const cli = (name: TerraformCliVersion["name"], version: string) => ({ + name, + version, +}); + +describe("checkSchemaEmissionGaps", () => { + it("reports every gap when fetched with an old terraform and default targets", () => { + const gaps = checkSchemaEmissionGaps( + cli("terraform", "1.7.5"), + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual( + expect.arrayContaining([ + "ephemeral resources", + "provider functions", + "write-only attributes", + "resource identity", + ]), + ); + expect(gaps).toHaveLength(4); + }); + + it("reports no gaps when fetched with a recent terraform", () => { + const gaps = checkSchemaEmissionGaps( + cli("terraform", "1.15.6"), + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual([]); + }); + + it("reports gaps for an old opentofu fetch when opentofu is targeted", () => { + const gaps = checkSchemaEmissionGaps(cli("opentofu", "1.10.0"), { + opentofu: ">=1.11.0", + }); + + expect(gaps).toEqual( + expect.arrayContaining([ + "ephemeral resources", + "write-only attributes", + "resource identity", + ]), + ); + // functions were already emitted as of opentofu 1.8.0, so the 1.10.0 + // fetch isn't missing them + expect(gaps).not.toContain("provider functions"); + }); + + it("does not warn about a feature the targets never admit", () => { + // Pinned to a version that predates every emission boundary in this + // family - the project itself never runs where the feature would show + // up, so an old fetching CLI isn't a gap. + const gaps = checkSchemaEmissionGaps(cli("terraform", "1.7.5"), { + terraform: "1.6.6", + }); + + expect(gaps).toEqual([]); + }); + + it("returns no gaps for an unrecognized fetching CLI", () => { + const gaps = checkSchemaEmissionGaps( + { name: "unknown" }, + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual([]); + }); + + it("does not throw and skips the product when targetVersions has a malformed range", () => { + const gaps = checkSchemaEmissionGaps(cli("terraform", "1.7.5"), { + terraform: "not-a-range", + }); + + expect(gaps).toEqual([]); + }); + + it("reports no gap for families whose boundary the fetching CLI exactly meets", () => { + // terraform 1.11.0 sits exactly on the write-only boundary (and above + // the functions/ephemeral ones); only the 1.12.0 identity boundary is + // still ahead of it. + const gaps = checkSchemaEmissionGaps( + cli("terraform", "1.11.0"), + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual(["resource identity"]); + }); + + it("reports no gaps when the fetching CLI is exactly at the newest boundary", () => { + const gaps = checkSchemaEmissionGaps( + cli("terraform", "1.12.0"), + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual([]); + }); + + it("returns no gaps when the fetching CLI has no version metadata", () => { + const gaps = checkSchemaEmissionGaps( + { name: "terraform" }, + DEFAULT_TARGET_VERSIONS, + ); + + expect(gaps).toEqual([]); + }); + + it("still admits a family whose boundary the target pins exactly", () => { + // A project pinned to exactly 1.11.0 intersects ">=1.11.0" (write-only) + // but not ">=1.12.0" (identity), so an old fetching CLI is a gap for the + // first three families only. + const gaps = checkSchemaEmissionGaps(cli("terraform", "1.7.5"), { + terraform: "1.11.0", + }); + + expect(gaps).toEqual( + expect.arrayContaining([ + "provider functions", + "ephemeral resources", + "write-only attributes", + ]), + ); + expect(gaps).not.toContain("resource identity"); + }); +}); + +describe("suggestedEmittingCliVersions", () => { + it("suggests the highest boundary across the gapped families per product", () => { + expect( + suggestedEmittingCliVersions(["functions", "ephemeral_resource_schemas"]), + ).toBe("terraform >=1.10.0 / opentofu >=1.11.0"); + }); + + it("suggests the identity boundary when identity is the only gap", () => { + expect(suggestedEmittingCliVersions(["resource_identity_schemas"])).toBe( + "terraform >=1.12.0 / opentofu >=1.12.0", + ); + }); +}); diff --git a/packages/@cdktn/provider-schema/src/__tests__/provider-schema.test.ts b/packages/@cdktn/provider-schema/src/__tests__/provider-schema.test.ts index d01c56979..641a2f987 100644 --- a/packages/@cdktn/provider-schema/src/__tests__/provider-schema.test.ts +++ b/packages/@cdktn/provider-schema/src/__tests__/provider-schema.test.ts @@ -19,6 +19,10 @@ import { function sanitizeJson(value: any) { value["format_version"] = "STUBBED VERSION"; + // cli_name/cli_version stamp whatever binary fetched the schema and vary + // by environment + if ("cli_name" in value) value["cli_name"] = "STUBBED CLI"; + if ("cli_version" in value) value["cli_version"] = "STUBBED VERSION"; return stableStringify(value, null, 2); } @@ -171,6 +175,22 @@ describe("sanitizeProviderSchema", () => { }, }, data_source_schemas: {}, + ephemeral_resource_schemas: { + null_ephemeral: { + version: 0, + block: { + attributes: { + correct: { + type: ["list", "string"], + }, + incorrect: { + type: ["list", "string", "list", "string"] as any, + }, + }, + block_types: {}, + }, + }, + }, }, }, }; diff --git a/packages/@cdktn/provider-schema/src/__tests__/provider-schema.unit.test.ts b/packages/@cdktn/provider-schema/src/__tests__/provider-schema.unit.test.ts index 67020cddb..caf007b26 100644 --- a/packages/@cdktn/provider-schema/src/__tests__/provider-schema.unit.test.ts +++ b/packages/@cdktn/provider-schema/src/__tests__/provider-schema.unit.test.ts @@ -2,11 +2,15 @@ // SPDX-License-Identifier: MPL-2.0 jest.mock("@cdktn/commons", () => ({ + ...jest.requireActual("@cdktn/commons"), exec: jest.fn(), })); import { exec } from "@cdktn/commons"; -import { terraformInitWithRetry } from "../provider-schema"; +import { + getFetchingCliVersion, + terraformInitWithRetry, +} from "../provider-schema"; const execMock = exec as unknown as jest.Mock; @@ -77,3 +81,17 @@ describe("terraformInitWithRetry", () => { expect(execMock).toHaveBeenCalledTimes(3); }); }); + +describe("getFetchingCliVersion", () => { + it("execs `terraform version` once and memoizes the result", async () => { + execMock.mockReset(); + execMock.mockResolvedValue("Terraform v1.7.5\non linux_amd64\n"); + + const first = await getFetchingCliVersion(); + const second = await getFetchingCliVersion(); + + expect(first).toEqual({ name: "terraform", version: "1.7.5" }); + expect(second).toBe(first); + expect(execMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@cdktn/provider-schema/src/__tests__/read-emission-gap-warning.unit.test.ts b/packages/@cdktn/provider-schema/src/__tests__/read-emission-gap-warning.unit.test.ts new file mode 100644 index 000000000..439f558e3 --- /dev/null +++ b/packages/@cdktn/provider-schema/src/__tests__/read-emission-gap-warning.unit.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +jest.mock("../provider-schema", () => ({ + ...jest.requireActual("../provider-schema"), + readProviderSchema: jest.fn(), + getFetchingCliVersion: jest.fn(), +})); + +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import { + logger, + ProviderSchema, + TerraformProviderConstraint, +} from "@cdktn/commons"; +import { readSchema } from "../read"; +import { getFetchingCliVersion, readProviderSchema } from "../provider-schema"; + +const readProviderSchemaMock = readProviderSchema as unknown as jest.Mock; +const getFetchingCliVersionMock = getFetchingCliVersion as unknown as jest.Mock; + +const stampedFixture: ProviderSchema = { + format_version: "0.1", + cli_name: "terraform", + cli_version: "1.7.5", +}; + +describe("readSchema - emission gap warning on cache hits (regression)", () => { + let cacheDir: string; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "provider-schema-cache-")); + warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined); + getFetchingCliVersionMock.mockReset(); + getFetchingCliVersionMock.mockResolvedValue({ + name: "terraform", + version: "1.7.5", + }); + readProviderSchemaMock.mockReset(); + readProviderSchemaMock.mockResolvedValue(stampedFixture); + }); + + afterEach(() => { + warnSpy.mockRestore(); + fs.removeSync(cacheDir); + }); + + it("fires the warning on both a fresh fetch and a later cache hit for the same target", async () => { + const constraint = new TerraformProviderConstraint("hashicorp/null@3.1.0"); + const targetVersions = { terraform: ">=1.12.0" }; + + await readSchema([constraint], cacheDir, targetVersions); + await readSchema([constraint], cacheDir, targetVersions); + + // second readSchema call is served from the on-disk cache written by the + // first - the underlying fetch only ever runs once. + expect(readProviderSchemaMock).toHaveBeenCalledTimes(1); + + // yet the emission-gap warning fires for both calls, because it now + // runs against the resolved (cached-or-fresh) schema in read.ts, not + // inside readProviderSchema. + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toContain( + "provider schema fetched with terraform 1.7.5", + ); + expect(warnSpy.mock.calls[1][0]).toContain( + "provider schema fetched with terraform 1.7.5", + ); + }); +}); diff --git a/packages/@cdktn/provider-schema/src/__tests__/warn-schema-emission-gaps.unit.test.ts b/packages/@cdktn/provider-schema/src/__tests__/warn-schema-emission-gaps.unit.test.ts new file mode 100644 index 000000000..7bb631c0f --- /dev/null +++ b/packages/@cdktn/provider-schema/src/__tests__/warn-schema-emission-gaps.unit.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 + +jest.mock("@cdktn/commons", () => ({ + ...jest.requireActual("@cdktn/commons"), + exec: jest.fn(), +})); + +import { + DEFAULT_TARGET_VERSIONS, + exec, + logger, + ProviderSchema, +} from "@cdktn/commons"; +import { warnIfSchemaEmissionGaps } from "../provider-schema"; + +const execMock = exec as unknown as jest.Mock; + +function stampedSchema( + cli_name?: string, + cli_version?: string, +): ProviderSchema { + const schema: ProviderSchema = { format_version: "0.1" }; + if (cli_name) schema.cli_name = cli_name; + if (cli_version) schema.cli_version = cli_version; + return schema; +} + +describe("warnIfSchemaEmissionGaps", () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns once when the schema was stamped with an old CLI and default targets are declared", async () => { + const schema = stampedSchema("terraform", "1.7.5"); + + await warnIfSchemaEmissionGaps(schema, DEFAULT_TARGET_VERSIONS); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = warnSpy.mock.calls[0][0]; + expect(message).toContain("provider schema fetched with terraform 1.7.5"); + expect(message).toContain("terraform >=1.12.0 / opentofu >=1.12.0"); + }); + + it("stays silent when the schema was stamped with a recent CLI", async () => { + const schema = stampedSchema("terraform", "1.15.6"); + + await warnIfSchemaEmissionGaps(schema, DEFAULT_TARGET_VERSIONS); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("stays silent without declared targetVersions, even for an old stamped CLI", async () => { + const schema = stampedSchema("terraform", "1.7.5"); + + await warnIfSchemaEmissionGaps(schema, undefined); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("falls back to the current process's fetching CLI version when the schema carries no stamps", async () => { + execMock.mockReset(); + execMock.mockResolvedValue("Terraform v1.7.5\non linux_amd64\n"); + + const schema = stampedSchema(); + + await warnIfSchemaEmissionGaps(schema, DEFAULT_TARGET_VERSIONS); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain( + "provider schema fetched with terraform 1.7.5", + ); + }); +}); diff --git a/packages/@cdktn/provider-schema/src/cache.ts b/packages/@cdktn/provider-schema/src/cache.ts index 08aaacc5c..707910e45 100644 --- a/packages/@cdktn/provider-schema/src/cache.ts +++ b/packages/@cdktn/provider-schema/src/cache.ts @@ -9,15 +9,22 @@ import * as path from "path"; // We keep this very simple since the caching feature is experimental // we might need to do housekeeping / include terraform / cdktf version in the future -function cacheKey(input: ConstructsMakerTarget): string { - return `${encodeURIComponent(input.fqn)}@${encodeURIComponent( +// +// keySuffix distinguishes cache entries fetched by different CLIs/versions +// (e.g. "terraform-1.7") so a schema fetched by a CLI too old to emit newer +// sections (functions, ephemeral resources, ...) isn't served up as if it +// were a complete fetch. See read.ts. +function cacheKey(input: ConstructsMakerTarget, keySuffix?: string): string { + const base = `${encodeURIComponent(input.fqn)}@${encodeURIComponent( input.version || "", )}`; + return keySuffix ? `${base}@${encodeURIComponent(keySuffix)}` : base; } export function cachedAccess( producer: (input: I) => Promise, cacheDir?: string | null, + keySuffix?: string, ): (input: I) => Promise { const cacheEnabled = typeof cacheDir === "string" && cacheDir.length > 0; @@ -36,7 +43,7 @@ export function cachedAccess( logger.debug(`Provider Schema Cache enabled, caching at ${cacheDir}`); return async (input) => { - const key = cacheKey(input); + const key = cacheKey(input, keySuffix); const cachePath = path.join(cacheDir, `${key}.json`); if (fs.existsSync(cachePath)) { logger.debug(`Cache hit for ${key}`); diff --git a/packages/@cdktn/provider-schema/src/emission-check.ts b/packages/@cdktn/provider-schema/src/emission-check.ts new file mode 100644 index 000000000..572965a50 --- /dev/null +++ b/packages/@cdktn/provider-schema/src/emission-check.ts @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as semver from "semver"; + +import type { + TerraformCliVersion, + TerraformTargetVersions, +} from "@cdktn/commons"; + +/** + * The newer-protocol schema sections that a fetching CLI only starts to + * *emit* in `terraform providers schema -json` as of a certain version. + * + * NOTE: these are emission boundaries, not language-support boundaries - + * e.g. provider functions are usable in HCL since Terraform 1.7.0, but only + * show up in the schema dump as of 1.8.0. + */ +export type SchemaEmissionFamily = + | "functions" + | "ephemeral_resource_schemas" + | "write_only" + | "resource_identity_schemas"; + +const TARGET_PRODUCTS = ["terraform", "opentofu"] as const; +type TargetProduct = (typeof TARGET_PRODUCTS)[number]; + +/** + * Hand-maintained from `tools/provider-feature-availability/features-matrix.json` + * — specifically each feature's `documented_emitted_from` / + * `observed_introduced` fields per product (see that directory's README for + * how the dataset itself is produced/regenerated, and its "In-repo + * consumers" section for how this map and `providerFeatureConstraints` in + * `packages/cdktn/src/provider-feature-constraints.ts` relate to the + * matrix and to each other). + * + * There is no automated check that this map stays in sync with the matrix; + * updates must be applied by hand when the matrix changes. An automated + * drift check is tracked in #309. + */ +export const SCHEMA_EMISSION_BOUNDARIES: Record< + SchemaEmissionFamily, + Record +> = { + functions: { terraform: "1.8.0", opentofu: "1.8.0" }, + ephemeral_resource_schemas: { terraform: "1.10.0", opentofu: "1.11.0" }, + write_only: { terraform: "1.11.0", opentofu: "1.11.0" }, + resource_identity_schemas: { terraform: "1.12.0", opentofu: "1.12.0" }, +}; + +export const SCHEMA_EMISSION_FAMILY_LABELS: Record< + SchemaEmissionFamily, + string +> = { + functions: "provider functions", + ephemeral_resource_schemas: "ephemeral resources", + write_only: "write-only attributes", + resource_identity_schemas: "resource identity", +}; + +/** + * Determines which schema-emission gaps the given fetching CLI has, in + * light of the project's declared `targetVersions`. + * + * A family is reported when both are true: + * - the fetching CLI's own version predates that family's emission + * boundary for its product (so this particular fetch could not have + * included the section, even if the provider implements it) + * - at least one of the project's targeted products declares a range that + * intersects the versions where that family is emitted (i.e. the project + * actually intends to run on versions where the feature would show up) + * + * @internal exposed for testing + */ +export function checkSchemaEmissionGaps( + cli: TerraformCliVersion, + targets: TerraformTargetVersions, +): string[] { + return checkSchemaEmissionGapFamilies(cli, targets).map( + (family) => SCHEMA_EMISSION_FAMILY_LABELS[family], + ); +} + +/** + * Same as checkSchemaEmissionGaps, but returns the raw family keys so + * callers can also compute a remedy (see suggestedEmittingCliVersions). + * + * @internal exposed for testing + */ +export function checkSchemaEmissionGapFamilies( + cli: TerraformCliVersion, + targets: TerraformTargetVersions, +): SchemaEmissionFamily[] { + if (cli.name === "unknown" || !cli.version) return []; + const cliProduct = cli.name as TargetProduct; + + const gaps: SchemaEmissionFamily[] = []; + + for (const family of Object.keys( + SCHEMA_EMISSION_BOUNDARIES, + ) as SchemaEmissionFamily[]) { + const boundaries = SCHEMA_EMISSION_BOUNDARIES[family]; + const cliBoundary = boundaries[cliProduct]; + if (!cliBoundary) continue; + + // The fetching CLI is new enough to emit this family - no gap. + if (!semver.lt(cli.version, cliBoundary)) continue; + + const wanted = TARGET_PRODUCTS.some((product) => { + const targetRange = targets[product]; + if (!targetRange) return false; + if (semver.validRange(targetRange) === null) return false; + const supportedRange = `>=${boundaries[product]}`; + try { + return semver.intersects(targetRange, supportedRange); + } catch { + return false; + } + }); + + if (wanted) gaps.push(family); + } + + return gaps; +} + +/** + * The lowest CLI version per product whose `providers schema -json` emits + * every one of the given families — the remedy to suggest alongside the + * fetch-time warning. + * + * @internal exposed for testing + */ +export function suggestedEmittingCliVersions( + families: SchemaEmissionFamily[], +): string { + return TARGET_PRODUCTS.map((product) => { + const needed = families + .map((family) => SCHEMA_EMISSION_BOUNDARIES[family][product]) + .sort(semver.compare) + .pop(); + return needed ? `${product} >=${needed}` : undefined; + }) + .filter((part) => part !== undefined) + .join(" / "); +} diff --git a/packages/@cdktn/provider-schema/src/provider-schema.ts b/packages/@cdktn/provider-schema/src/provider-schema.ts index 895ba1b02..b6e93f6b2 100644 --- a/packages/@cdktn/provider-schema/src/provider-schema.ts +++ b/packages/@cdktn/provider-schema/src/provider-schema.ts @@ -14,15 +14,45 @@ import { ModuleIndex, ModuleSchema, ProviderSchema, + TerraformCliVersion, TerraformModuleConstraint, + TerraformTargetVersions, VersionSchema, exec, isNestedTypeAttribute, + logger, + parseTerraformCliVersion, withTempDir, } from "@cdktn/commons"; +import { + SCHEMA_EMISSION_FAMILY_LABELS, + checkSchemaEmissionGapFamilies, + suggestedEmittingCliVersions, +} from "./emission-check"; const terraformBinaryName = process.env.TERRAFORM_BINARY_NAME || "terraform"; +let fetchingCliVersionPromise: Promise | undefined; + +/** + * Determines the version of the fetching CLI (`terraform`/`tofu` binary + * resolved via `TERRAFORM_BINARY_NAME`) once per process, memoized. + * + * Used both to stamp fetched provider schemas (`cli_name`/`cli_version`) + * and as the cache key suffix in read.ts, since schema emission is fixed + * per CLI minor version. + * + * @internal exposed for testing like terraformInitWithRetry + */ +export function getFetchingCliVersion(): Promise { + if (!fetchingCliVersionPromise) { + fetchingCliVersionPromise = exec(terraformBinaryName, ["version"], { + cwd: process.cwd(), + }).then(parseTerraformCliVersion); + } + return fetchingCliVersionPromise; +} + // Provider binaries are downloaded from GitHub release CDNs during // `terraform init`, which intermittently returns 5xx. Retry on those // signatures only — real config/schema errors should still fail fast. @@ -233,6 +263,11 @@ const harvestModuleSchema = async ( return result; }; +function joinWithAnd(items: string[]): string { + if (items.length <= 1) return items.join(""); + return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; +} + export interface TerraformConfig { provider?: { [name: string]: Record }; terraform: { @@ -282,9 +317,77 @@ export async function readProviderSchema( providerSchema.provider_versions = versionSchema.provider_selections; }); + // Stamp the fetching CLI's identity so downstream consumers can reason + // about which newer-protocol sections this fetch could possibly have + // emitted. Never fail the fetch over this - it's best-effort metadata. + try { + const cli = await getFetchingCliVersion(); + providerSchema.cli_name = cli.name; + providerSchema.cli_version = cli.version; + } catch (error) { + logger.debug( + `Could not determine fetching CLI version to stamp provider schema: ${error}`, + ); + } + return sanitizeProviderSchema(providerSchema); } +/** + * Warns when the CLI that produced a given provider schema predates one or + * more schema-emission boundaries the project's declared `targetVersions` + * care about (see emission-check.ts) - i.e. some newer-protocol sections + * (functions, ephemeral resources, ...) may be missing from the generated + * bindings even though the schema fetch itself succeeded. + * + * Deliberately decoupled from the fetch path (readProviderSchema) so it + * also runs for schemas served from the on-disk cache: cached JSON carries + * the same `cli_name`/`cli_version` stamps a fresh fetch would have + * written (the cache key already segments by CLI product+minor), so the gap + * check is just as meaningful on a cache hit as on a miss. + * + * No-op without `targetVersions` - there is nothing to compare the CLI + * against. Falls back to the current process's fetching CLI version when + * the schema itself isn't stamped (e.g. older cache entries written before + * stamping existed). + * + * @internal exposed for testing + */ +export async function warnIfSchemaEmissionGaps( + schema: ProviderSchema, + targetVersions?: TerraformTargetVersions, +): Promise { + if (!targetVersions) return; + + let cli: TerraformCliVersion; + if (schema.cli_name && schema.cli_version) { + if (schema.cli_name !== "terraform" && schema.cli_name !== "opentofu") { + return; + } + cli = { name: schema.cli_name, version: schema.cli_version }; + } else { + try { + cli = await getFetchingCliVersion(); + } catch (error) { + logger.debug( + `Could not determine fetching CLI version to check for schema emission gaps: ${error}`, + ); + return; + } + } + + const gapFamilies = checkSchemaEmissionGapFamilies(cli, targetVersions); + if (gapFamilies.length > 0 && cli.version) { + logger.warn( + `provider schema fetched with ${cli.name} ${cli.version} — ${joinWithAnd( + gapFamilies.map((family) => SCHEMA_EMISSION_FAMILY_LABELS[family]), + )} will not be generated; run cdktn get with ${suggestedEmittingCliVersions( + gapFamilies, + )}`, + ); + } +} + // The providers have some potential bugs that we want to pro-actively // fix here so that the rest of the code can assume a consistent schema. export function sanitizeProviderSchema(schema: ProviderSchema): ProviderSchema { @@ -317,6 +420,7 @@ export function sanitizeProviderSchema(schema: ProviderSchema): ProviderSchema { provider.provider, ...Object.values(provider.resource_schemas || {}), ...Object.values(provider.data_source_schemas || {}), + ...Object.values(provider.ephemeral_resource_schemas || {}), ]; entities.forEach((entity) => { diff --git a/packages/@cdktn/provider-schema/src/read.ts b/packages/@cdktn/provider-schema/src/read.ts index 19aea2433..bfac2b2b3 100644 --- a/packages/@cdktn/provider-schema/src/read.ts +++ b/packages/@cdktn/provider-schema/src/read.ts @@ -14,10 +14,17 @@ import { LANGUAGES, ConstructsMakerProviderTarget, ConstructsMakerModuleTarget, + TerraformTargetVersions, Errors, + logger, } from "@cdktn/commons"; import deepmerge from "deepmerge"; -import { readModuleSchema, readProviderSchema } from "./provider-schema"; +import { + getFetchingCliVersion, + readModuleSchema, + readProviderSchema, + warnIfSchemaEmissionGaps, +} from "./provider-schema"; import { cachedAccess } from "./cache"; export type Schema = { @@ -25,11 +32,43 @@ export type Schema = { moduleSchema?: Awaited>; }; +// Schema emission is fixed per CLI product+minor version, so that's enough +// granularity for the cache key - no need for the full patch version. +async function resolveCacheKeySuffix( + cacheDir?: string, +): Promise { + if (!cacheDir) return undefined; + + try { + const cli = await getFetchingCliVersion(); + if (cli.name === "unknown" || !cli.version) { + logger.debug( + "Could not determine fetching CLI name/version for the schema cache key, falling back to 'unknown-cli'", + ); + return "unknown-cli"; + } + + const [major, minor] = cli.version.split("."); + return `${cli.name}-${major}.${minor}`; + } catch (error) { + logger.debug( + `Could not determine fetching CLI version for the schema cache key: ${error}`, + ); + return "unknown-cli"; + } +} + export async function readSchema( constraints: TerraformDependencyConstraint[], cacheDir?: string, + targetVersions?: TerraformTargetVersions, ): Promise { - const cachedReadProviderSchema = cachedAccess(readProviderSchema, cacheDir); + const keySuffix = await resolveCacheKeySuffix(cacheDir); + const cachedReadProviderSchema = cachedAccess( + readProviderSchema, + cacheDir, + keySuffix, + ); const targets = constraints.map((constraint) => ConstructsMakerProviderTarget.from(constraint, LANGUAGES[0]), ); @@ -42,9 +81,14 @@ export async function readSchema( ? readModuleSchema(t as any).then( (s) => ({ moduleSchema: s }) as Schema, ) - : cachedReadProviderSchema(t as any).then( - (s) => ({ providerSchema: s }) as Schema, - ), + : cachedReadProviderSchema(t as any).then(async (s) => { + // Runs on both cache hits and misses: cached schemas carry the + // same cli_name/cli_version stamps a fresh fetch would have + // written, so the emission-gap warning is just as relevant on + // a hit as on a miss. + await warnIfSchemaEmissionGaps(s, targetVersions); + return { providerSchema: s } as Schema; + }), ), ); diff --git a/packages/cdktn-cli/src/bin/cmds/handlers.ts b/packages/cdktn-cli/src/bin/cmds/handlers.ts index a7c05702b..88e88f58c 100644 --- a/packages/cdktn-cli/src/bin/cmds/handlers.ts +++ b/packages/cdktn-cli/src/bin/cmds/handlers.ts @@ -345,6 +345,7 @@ export async function get(argv: { silent: argv.silent, providerSchemaCachePath: argv.experimentalProviderSchemaCachePath, languageOptions: config.languageOptions, + targetVersions: config.targetVersions, }); } finally { if (!argv.silent) { @@ -700,6 +701,7 @@ export async function providerUpgrade(argv: any) { targetLanguage: language, languageOptions: config.languageOptions, jsiiParallelism: 1, + targetVersions: config.targetVersions, }; const constraints: TerraformDependencyConstraint[] = [ diff --git a/packages/cdktn-cli/src/bin/cmds/ui/get.ts b/packages/cdktn-cli/src/bin/cmds/ui/get.ts index b647e1c69..22f7eef4c 100644 --- a/packages/cdktn-cli/src/bin/cmds/ui/get.ts +++ b/packages/cdktn-cli/src/bin/cmds/ui/get.ts @@ -8,6 +8,7 @@ import { LanguageOptions, sendTelemetry, TerraformDependencyConstraint, + TerraformTargetVersions, } from "@cdktn/commons"; import { get, GetStatus } from "@cdktn/cli-core"; import { StreamRenderer } from "../helper/tty-stream"; @@ -22,6 +23,7 @@ export interface GetConfig { force?: boolean; silent?: boolean; providerSchemaCachePath?: string; + targetVersions?: TerraformTargetVersions; } /** @@ -42,12 +44,14 @@ export async function runGet({ silent = false, providerSchemaCachePath, languageOptions, + targetVersions, }: GetConfig): Promise { const constructsOptions: GetOptions = { codeMakerOutput, targetLanguage: language, jsiiParallelism: parallelism, languageOptions, + targetVersions, }; const stream = silent ? undefined : new StreamRenderer(); diff --git a/packages/cdktn/src/app.ts b/packages/cdktn/src/app.ts index e399f7de1..4d8f87d0f 100644 --- a/packages/cdktn/src/app.ts +++ b/packages/cdktn/src/app.ts @@ -13,6 +13,7 @@ import { noAppFound, } from "./errors"; import { FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS } from "./features"; +import { resetUsageForRoot } from "./functions/usage-registry"; const APP_SYMBOL = Symbol.for("cdktf/App"); export const CONTEXT_ENV = "CDKTF_CONTEXT_JSON"; @@ -152,10 +153,20 @@ export class App extends Construct { * Synthesizes all resources to the output directory */ public synth(): void { + // Terraform-function usage (Fn.* / provider::...) represents the + // CURRENT synthesis pass only, not this App's whole lifetime - an App + // can be synth()'d more than once (e.g. in tests). Clear it once, here, + // before any stack's resolve rediscovers this pass's usage. This must + // NOT live in prepareStack(): stacks below are prepared one at a time, + // and a per-stack clear would erase sibling stacks' usage already + // recorded earlier in this same loop. + resetUsageForRoot(this); + const session: ISynthesisSession = { outdir: this.outdir, skipValidation: this.skipValidation, manifest: this.manifest, + stacksPrepared: true, }; const stacks = this.node diff --git a/packages/cdktn/src/errors.ts b/packages/cdktn/src/errors.ts index 2594e1fe4..13a58c7c6 100644 --- a/packages/cdktn/src/errors.ts +++ b/packages/cdktn/src/errors.ts @@ -549,6 +549,11 @@ export const assetCanNotCreateZipArchive = ( `A TerraformAsset trying to zip archive '${src}' into ${dest} failed: ${error}`, ); +export const unknownProviderFeature = (feature: string) => + new Error( + `Unknown provider-protocol feature "${feature}" passed to registerProviderFeatureUsage. This is an internal cdktn API intended to be called by generated provider bindings, not user code; if you did not call it directly, please file a bug report.`, + ); + export const terraformModuleHasChildren = (pathName: string) => { return new Error( `Trying to add children to a TerraformModule at '${pathName}'. TerraformModules cannot have children, if you want to group resources or constructs in general together please use the Constructs class instead. See https://cdktn.io/docs/concepts/constructs for more details.`, diff --git a/packages/cdktn/src/functions/helpers.ts b/packages/cdktn/src/functions/helpers.ts index 3b6239e7f..0ef6843a9 100644 --- a/packages/cdktn/src/functions/helpers.ts +++ b/packages/cdktn/src/functions/helpers.ts @@ -12,7 +12,6 @@ import { valueIsInvalidNumberOrToken, valueIsInvalidStringOrToken, } from "../errors"; -import { recordFunctionUsage } from "./usage-registry"; type TFValue = { variadic?: boolean; value: T }; type TFValueValidator = (value: T) => TFValue; @@ -162,7 +161,6 @@ export function terraformFunction( argValidators: TFValueValidator[], ): ExecutableTfFunction { return function (...args: any[]) { - recordFunctionUsage(name); if (args.length !== argValidators.length) { throw functionReceivedWrongNumberOfArgs( name, diff --git a/packages/cdktn/src/functions/provider-function.ts b/packages/cdktn/src/functions/provider-function.ts new file mode 100644 index 000000000..28fb583c2 --- /dev/null +++ b/packages/cdktn/src/functions/provider-function.ts @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { IResolvable } from "../tokens/resolvable"; +import { anyValue, terraformFunction } from "./helpers"; + +/** + * Runtime entry point invoked by generated provider function bindings + * (`providers//provider-functions/index.ts`). Generated code only + * imports the public `cdktn` package root, so this is the single chokepoint + * through which every provider-defined function call flows. + */ +export class TerraformProviderFunction { + /** + * Invokes a provider-defined function (Terraform's + * `provider::::(...)` syntax). + * + * Note: provider-defined functions are evaluated by the provider itself — + * do not call this inside the configuration of the same provider + * (Terraform reports a self-referential cycle). + * + * @param providerLocalName the local name of the provider as declared in + * `required_providers` (defaults to the registry short name; callers may + * override this when the provider is aliased under a different local + * name — aliases do not change the namespace, local names do) + * @param functionName the provider-defined function name (snake_case, as + * published in the provider schema) + * @param args the function arguments, positional (in declared order). Each + * entry in `args` maps 1:1 to a positional slot in the rendered call — a + * `null` or `undefined` entry keeps its slot and renders as the Terraform + * `null` keyword, it is not dropped. Callers that flatten a genuinely + * variadic trailing parameter (`[a, ...values]`) get one positional slot + * per flattened element, which is the desired behavior. + */ + public static invoke( + providerLocalName: string, + functionName: string, + args: any[], + ): IResolvable { + const fullName = `provider::${providerLocalName}::${functionName}`; + // Usage is recorded at token-resolve time (see FunctionCall.resolve() in + // tfExpression.ts and usage-registry.ts), not here at call time: this + // just builds the token that resolve() later records when it actually + // renders into a stack. + // + // Note: intentionally NOT `[variadic(anyValue)]` - variadic()/listOf() + // filters out null/undefined ENTRIES of the array, which is correct for + // a genuinely variadic list but wrong here: `args` is a fixed-arity, + // positional argument list where a `null`/`undefined` entry is a + // meaningful value (e.g. Terraform's `condition_if(cond, null, "b")`), + // not a hole to be squeezed out. Validating per-position with a plain + // `anyValue` per slot (one validator per `args` element, by + // construction the same length) preserves every slot, including + // null/undefined ones. + return terraformFunction( + fullName, + args.map(() => anyValue), + )(...args); + } + + private constructor() {} +} diff --git a/packages/cdktn/src/functions/usage-registry.ts b/packages/cdktn/src/functions/usage-registry.ts index 427779a54..9177c6a10 100644 --- a/packages/cdktn/src/functions/usage-registry.ts +++ b/packages/cdktn/src/functions/usage-registry.ts @@ -1,32 +1,99 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 +import { IConstruct } from "constructs"; /** - * Records which Terraform functions have been called through the `Fn` class - * so that synthesis-time validations can check them against the version of - * the selected Terraform-compatible CLI. + * Records which Terraform functions (`Fn.*` and provider-defined + * `provider::::` calls) have actually been rendered into a + * synthesized stack, so synthesis-time validations can check them against + * the version of the selected Terraform-compatible CLI. * - * The registry is intentionally process-global rather than stack-scoped: - * an `Fn.*()` result is a plain token that can be shared freely across - * stacks, so reliable per-stack attribution is impossible anyway. Functions - * used through raw escape hatches (overrides, hand-built expression strings) - * are not recorded. + * Recording happens at token-RESOLVE time (`FunctionCall.resolve()` in + * `tfExpression.ts`), not at call time: a `Fn.*()`/`invoke()` call merely + * builds a token, and that token is only meaningful once it is resolved + * while rendering a stack's `toTerraform()` output. Usage is keyed by + * `context.scope.node.root` — the App that owns the stack doing the + * resolving (or, in odd standalone test setups, whatever root construct is + * doing the resolving). This makes usage owned by the App whose synthesis + * renders the expression, not by the process: + * + * - Interleaved Apps (one App constructed, then a second App constructed + * before the first is synthesized) cannot cross-contaminate each other's + * usage, because each App is its own map key. + * - A function call whose token never lands in any rendered output (e.g. + * built but discarded, or only ever passed to `JSON.stringify` outside of + * synthesis) is deliberately not recorded and therefore not validated. + * - Functions used via raw escape hatches (overrides, hand-written + * expression strings) are never wrapped in a `FunctionCall` token and so + * are never recorded either. + * + * The set recorded per root represents usage for the CURRENT synthesis pass + * only, not the root's whole lifetime: a root can be synthesized more than + * once (repeated `app.synth()` calls against the same App, tests reusing a + * stack), and a function rendered in an earlier pass but no longer present + * in the current one must not keep failing validation forever. Every + * validation-enabled entry point therefore clears its root's sets with + * `resetUsageForRoot` before (re-)discovering usage: `App.synth()` does + * this once, at the very start, for the whole App root it owns - NOT inside + * `prepareStack()`, since `App.synth()` prepares stacks one at a time and a + * per-stack clear there would erase sibling stacks' already-recorded usage + * mid-session. `Testing.synth`/`synthHcl` (when running validations) do the + * same for the stack's root before their own discovery resolve. Either way, + * the discovery resolve that follows the reset runs before that entry + * point's validations do, so usage recorded during it is always visible to + * the validations that read it afterwards. */ -const usedFunctions = new Set(); +const usedFunctionsByRoot = new WeakMap>(); +const usedProviderFunctionsByRoot = new WeakMap>(); + +// eslint-disable-next-line jsdoc/require-jsdoc +function recordIn( + registry: WeakMap>, + root: IConstruct, + name: string, +): void { + let usedNames = registry.get(root); + if (!usedNames) { + usedNames = new Set(); + registry.set(root, usedNames); + } + usedNames.add(name); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export function recordFunctionUsage( + root: IConstruct, + functionName: string, +): void { + recordIn(usedFunctionsByRoot, root, functionName); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export function getUsedFunctions(root: IConstruct): string[] { + return Array.from(usedFunctionsByRoot.get(root) ?? []); +} // eslint-disable-next-line jsdoc/require-jsdoc -export function recordFunctionUsage(functionName: string): void { - usedFunctions.add(functionName); +export function recordProviderFunctionUsage( + root: IConstruct, + fullName: string, +): void { + recordIn(usedProviderFunctionsByRoot, root, fullName); } // eslint-disable-next-line jsdoc/require-jsdoc -export function getUsedFunctions(): string[] { - return Array.from(usedFunctions); +export function getUsedProviderFunctions(root: IConstruct): string[] { + return Array.from(usedProviderFunctionsByRoot.get(root) ?? []); } /** - * Clears the recorded function usage; intended for tests. + * Clears both function-usage sets recorded for `root`, so a fresh + * synthesis pass over the same root starts from a clean slate instead of + * accumulating usage from every pass the root has ever been through. See + * the module-level doc comment for which entry points call this, and why + * it is a per-root-at-session-start operation rather than a per-stack one. */ -export function resetFunctionUsageRegistry(): void { - usedFunctions.clear(); +export function resetUsageForRoot(root: IConstruct): void { + usedFunctionsByRoot.delete(root); + usedProviderFunctionsByRoot.delete(root); } diff --git a/packages/cdktn/src/hcl/render.ts b/packages/cdktn/src/hcl/render.ts index cf0ac1405..4df93ec03 100644 --- a/packages/cdktn/src/hcl/render.ts +++ b/packages/cdktn/src/hcl/render.ts @@ -284,6 +284,38 @@ export function renderDatasource(dataSource: any) { }; } +/** + * + */ +export function renderEphemeral(ephemeral: any) { + const ephemeralType = Object.keys(ephemeral)[0]; + const ephemeralsWithType = ephemeral[ephemeralType]; + const ephemeralName = Object.keys(ephemeralsWithType)[0]; + const ephemeralAttributes = ephemeralsWithType[ephemeralName]; + + const { dynamic, ...otherAttrs } = ephemeralAttributes; + + const hcl = [`ephemeral "${ephemeralType}" "${ephemeralName}" {`]; + + const attrs = renderAttributes(otherAttrs); + if (attrs) hcl.push(attrs); + if (dynamic) hcl.push(...renderDynamicBlocks(dynamic)); + hcl.push("}"); + + return { + hcl: hcl.join("\n"), + metadata: { + ephemeral: { + [ephemeralType]: { + [ephemeralName]: { + "//": ephemeralAttributes["//"], + }, + }, + }, + }, + }; +} + /** * */ @@ -583,6 +615,46 @@ export function renderSimpleAttributes(attributes: any): string { ) .join("\n"); } +/** + * Detects a resolved-but-emptied attribute descriptor: an object shaped like + * what `AttributesEmitter#emitToHclTerraform` always emits for HCL synthesis + * - `{ value, isBlock, type, storageClassType }` - that has lost its `value` + * key. + * + * The only way a descriptor loses `value` here is that it resolved to + * `undefined`: `resolve()` (see `tokens/private/resolve.ts`) deep-resolves + * every key of a plain object and silently drops any key whose resolved + * value is `undefined`, but only `value` can ever do that - `isBlock`, + * `type`, and `storageClassType` are emitted as literal strings/booleans by + * the generator, never as tokens. So an object carrying exactly this + * metadata combination, with `value` and `dynamic` both absent, is always + * the leftover shell of a descriptor whose value vanished, never a genuine + * user-supplied map (those don't happen to also carry `isBlock` + + * `storageClassType`). + * + * This is intentionally not specific to write-only attributes: a write-only + * attribute wrapped by `markWriteOnlyAttribute()` around a `Lazy`/token + * producer that resolves to `undefined` is simply one example (it survives + * the generated "remove undefined attributes" pre-filter in + * `synthesizeHclAttributes()` because that filter only sees the still- + * unresolved token, not what it later resolves to) - but ANY attribute + * descriptor whose token resolves to `undefined` in HCL mode hits this same + * path (see the #313 HCL-defects family). + */ +function isOrphanedAttributeDescriptor(v: unknown): v is Record { + if (typeof v !== "object" || v === null) { + return false; + } + const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(v, key); + return ( + !hasOwn("value") && + !hasOwn("dynamic") && + hasOwn("isBlock") && + hasOwn("type") && + hasOwn("storageClassType") + ); +} + /** * */ @@ -615,6 +687,14 @@ export function renderAttributes(attributes: any): string { // eslint-disable-next-line no-prototype-builtins !v.hasOwnProperty("dynamic") ) { + // An orphaned attribute descriptor (its 'value' resolved to + // `undefined`) carries only internal metadata at this point - omit + // the attribute entirely, exactly as if it had never been set, + // instead of leaking that metadata into the HCL output via + // renderFuzzyJsonExpression(). See isOrphanedAttributeDescriptor(). + if (isOrphanedAttributeDescriptor(v)) { + return undefined; + } if (metaBlocks.includes(name)) { return `${name} { ${renderSimpleAttributes(v)} diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index 65d231d6e..75ace0f6b 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -9,7 +9,10 @@ export * from "./terraform-element"; export * from "./terraform-module"; export * from "./terraform-provider"; export * from "./terraform-resource"; +// named export: only the enum is public API, the constraint/label maps stay internal +export { ProviderFeature } from "./provider-feature-constraints"; export * from "./terraform-data-source"; +export * from "./terraform-ephemeral-resource"; export * from "./terraform-output"; export * from "./complex-computed-list"; export * from "./resource"; @@ -43,3 +46,4 @@ export * from "./upgrade-id-aspect"; export * from "./terraform-data-resource"; // required for JSII because Fn extends from it export * from "./functions/terraform-functions.generated"; +export * from "./functions/provider-function"; diff --git a/packages/cdktn/src/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts new file mode 100644 index 000000000..cfe3298dc --- /dev/null +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -0,0 +1,111 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { TerraformFeatureVersionConstraints } from "./validations/validate-terraform-feature-version"; + +/** + * Provider-protocol feature families whose usage is gated on the project's + * declared `targetVersions`. Generated provider bindings pass a member of + * this enum to `TerraformResource.registerProviderFeatureUsage()` when a + * versioned feature is actually used; the per-product minimum versions live + * in the (internal) `providerFeatureConstraints` map in this module. + */ +export enum ProviderFeature { + /** + * Provider-defined functions (`provider::::()` expressions). + */ + PROVIDER_FUNCTIONS = "providerFunctions", + /** + * Ephemeral resources (`ephemeral` blocks), which are never persisted to + * state or plan files. + */ + EPHEMERAL_RESOURCES = "ephemeralResources", + /** + * Write-only resource attributes: accepted on create/update but never + * persisted to state and never returned by the provider. + */ + WRITE_ONLY_ATTRIBUTES = "writeOnlyAttributes", + /** + * Resource identity data alongside state. Reserved: not yet consumed by + * any generated binding. + */ + RESOURCE_IDENTITY = "resourceIdentity", +} + +/** + * Minimum CLI version per product required for a provider-protocol feature + * family, hand-maintained from `tools/provider-feature-availability/features-matrix.json` + * (see that directory's README for how the dataset is produced/regenerated, + * and its "In-repo consumers" section for how this map relates to the + * matrix and to the sibling `SCHEMA_EMISSION_BOUNDARIES` map in + * `packages/@cdktn/provider-schema/src/emission-check.ts`). + * + * These are *usage* (language-support) boundaries, not schema-emission + * boundaries — the two can differ per feature. In particular, + * `providerFunctions.opentofu` is deliberately pinned to `>=1.7.0`, the + * version where OpenTofu's HCL parser started accepting + * `provider::ns::fn()`, even though `tofu providers schema -json` only + * starts emitting the `functions` key from 1.8.0 (see + * `SCHEMA_EMISSION_BOUNDARIES.functions.opentofu`). Do not "fix" this + * mismatch without checking the matrix and the emission-check module first. + * + * There is no automated check that this map stays in sync with the matrix; + * updates must be applied by hand when the matrix changes. An automated + * drift check is tracked in #309. + * + * Used by synth-time `ValidateFeatureTargetSupport` checks against a + * project's declared `targetVersions`. Deliberately not exported from + * src/index.ts: it is wired up from the specific constructs/generators that + * need it rather than being part of cdktn's public API. (The `ProviderFeature` + * enum above IS exported - it appears in the public + * `registerProviderFeatureUsage` signature, which jsii requires.) + */ +export const providerFeatureConstraints = { + [ProviderFeature.PROVIDER_FUNCTIONS]: { + terraform: ">=1.8.0", + opentofu: ">=1.7.0", + }, // opentofu language support since 1.7.0 even though schema emission starts 1.8.0 + [ProviderFeature.EPHEMERAL_RESOURCES]: { + terraform: ">=1.10.0", + opentofu: ">=1.11.0", + }, + [ProviderFeature.WRITE_ONLY_ATTRIBUTES]: { + terraform: ">=1.11.0", + opentofu: ">=1.11.0", + }, + [ProviderFeature.RESOURCE_IDENTITY]: { + terraform: ">=1.12.0", + opentofu: ">=1.12.0", + }, +} as const satisfies Record< + ProviderFeature, + TerraformFeatureVersionConstraints +>; + +/** + * Human-readable labels for each `providerFeatureConstraints` key, used as + * the `featureName` passed to `ValidateFeatureTargetSupport` so synth-time + * errors read naturally (e.g. "write-only attributes requires terraform + * >=1.11.0, ..."). + */ +export const providerFeatureLabels: Record = { + [ProviderFeature.PROVIDER_FUNCTIONS]: "provider functions", + [ProviderFeature.EPHEMERAL_RESOURCES]: "ephemeral resources", + [ProviderFeature.WRITE_ONLY_ATTRIBUTES]: "write-only attributes", + [ProviderFeature.RESOURCE_IDENTITY]: "resource identity", +}; + +/** + * Sentence-cased lead-in for each `providerFeatureConstraints` key, used to + * build the "where this feature IS available" hint appended to synth-time + * errors (e.g. "Write-only attributes are available in terraform >=1.11.0 + * and opentofu >=1.11.0."). Kept as its own hand-maintained map (rather than + * derived from `providerFeatureLabels` at runtime) so each feature's display + * text is a single, deliberate source of truth, same as its siblings above - + * see `tools/provider-feature-availability/features-matrix.json`. + */ +export const providerFeatureHints: Record = { + [ProviderFeature.PROVIDER_FUNCTIONS]: "Provider functions", + [ProviderFeature.EPHEMERAL_RESOURCES]: "Ephemeral resources", + [ProviderFeature.WRITE_ONLY_ATTRIBUTES]: "Write-only attributes", + [ProviderFeature.RESOURCE_IDENTITY]: "Resource identity", +}; diff --git a/packages/cdktn/src/synthesize/synthesizer.ts b/packages/cdktn/src/synthesize/synthesizer.ts index 46726baab..655983fb5 100644 --- a/packages/cdktn/src/synthesize/synthesizer.ts +++ b/packages/cdktn/src/synthesize/synthesizer.ts @@ -11,6 +11,7 @@ import { Aspects, IAspect } from "../aspect"; import { StackAnnotation } from "../manifest"; import { ValidateTerraformVersion } from "../validations/validate-terraform-version"; import { encounteredAnnotationWithLevelError } from "../errors"; +import { resetUsageForRoot } from "../functions/usage-registry"; // eslint-disable-next-line jsdoc/require-jsdoc export class StackSynthesizer implements IStackSynthesizer { @@ -29,6 +30,26 @@ export class StackSynthesizer implements IStackSynthesizer { synthesize(session: ISynthesisSession) { invokeAspects(this.stack); + if (!session.stacksPrepared) { + // This session wasn't built by App.synth() (which already prepared + // every stack up front) - prepare this stack ourselves so + // resolve-discovered provider-feature usage and Terraform-function + // usage are rediscovered for the current pass before validations run + // below. See ISynthesisSession.stacksPrepared. + // + // Terraform-function usage is recorded per-root (see + // functions/usage-registry.ts) and, unlike provider-feature + // registrations, is NOT reset as a side effect of `prepareStack()` - + // resetting it is the caller's responsibility. Without this, a + // function rendered by an earlier pass over this same stack/root + // (e.g. a previous direct call to this same synthesize()) would keep + // failing target-version validation in a later pass even after that + // usage is gone, breaking the per-synthesis-epoch guarantee that + // App.synth() and Testing.synth()/synthHcl() already provide. + resetUsageForRoot(this.stack.node.root); + this.stack.prepareStack(); + } + if (this.stack.hasResourceMove()) { // TODO(target-versions): this probes the locally installed binary during // synth. Migrate to ValidateFeatureTargetSupport (declared cdktf.json diff --git a/packages/cdktn/src/synthesize/types.ts b/packages/cdktn/src/synthesize/types.ts index 3f75abcd2..b9c886b80 100644 --- a/packages/cdktn/src/synthesize/types.ts +++ b/packages/cdktn/src/synthesize/types.ts @@ -27,6 +27,29 @@ export interface ISynthesisSession { readonly manifest: Manifest; + /** + * Whether every stack that will be synthesized in this session already + * had `prepareStack()` run against it, for ALL of those stacks, before + * any of their synthesizers are invoked. + * + * `App.synth()` sets this to `true`: it calls `prepareStack()` on every + * stack up front, so resolve-discovered provider-feature usage (see + * `TerraformElement._registerResolveDiscoveredProviderFeatureUsage`) and + * Terraform-function usage are visible across ALL of an App's stacks + * before ANY of them run validations - a cross-stack reference in stack B + * that only surfaces usage while resolving stack A must not make stack + * A's validation miss it. + * + * Left unset (or `false`) by sessions built outside `App.synth()` (a + * `StackSynthesizer` driven directly). `StackSynthesizer.synthesize()` + * then runs `prepareStack()` itself before validating, so those entry + * points still discover current-pass usage instead of silently skipping + * the validations that depend on it - at the cost of only ever seeing + * that one stack's own usage, since no sibling-stack preparation happened + * first. + */ + readonly stacksPrepared?: boolean; + /** * Additional context passed to synthesizeNode through `sessionContext`. * @internal diff --git a/packages/cdktn/src/terraform-element.ts b/packages/cdktn/src/terraform-element.ts index c4512c3d5..fd2e5d0f9 100644 --- a/packages/cdktn/src/terraform-element.ts +++ b/packages/cdktn/src/terraform-element.ts @@ -1,14 +1,62 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 import { ok } from "assert"; -import { Construct } from "constructs"; +import { Construct, IValidation } from "constructs"; import { Token } from "./tokens"; import { TerraformStack } from "./terraform-stack"; import { ref } from "./tfExpression"; -import { unresolvedTokenInConstructId } from "./errors"; +import { unresolvedTokenInConstructId, unknownProviderFeature } from "./errors"; +import { ValidateFeatureTargetSupport } from "./validations/target-versions"; +import { + ProviderFeature, + providerFeatureConstraints, + providerFeatureHints, + providerFeatureLabels, +} from "./provider-feature-constraints"; const TERRAFORM_ELEMENT_SYMBOL = Symbol.for("cdktf/TerraformElement"); +/** + * How a `ProviderFeature` registration on an element was armed: + * + * - `structural`: the element's mere existence is the usage (e.g. a + * `TerraformEphemeralResource`'s constructor). There is no notion of the + * feature going unused later in the same synthesis pass, so it is armed + * once and never deactivated. + * - `resolve-discovered`: usage is only known once a value is resolved + * during synthesis (e.g. a write-only attribute wrapped by + * `markWriteOnlyAttribute`). Because the same element can be resolved + * across many synthesis passes over its lifetime (tests reusing a + * construct tree, repeated `app.synth()` calls against the same App), + * this kind of registration must be reset at the start of each pass and + * only re-armed if resolution actually rediscovers the usage during that + * pass - see `_resetResolveDiscoveredProviderFeatureUsage`. + */ +type ProviderFeatureRegistrationMode = "structural" | "resolve-discovered"; + +/** + * `constructs` has no `removeValidation`, so a validation installed via + * `node.addValidation` stays installed for the lifetime of the node. To let + * a resolve-discovered registration go quiet again (see + * `ProviderFeatureRegistrationMode`), the installed `IValidation` is this + * thin, mutable gate: while `active` is false it reports no errors at all, + * regardless of what the wrapped `ValidateFeatureTargetSupport` would say. + */ +class GatedFeatureValidation implements IValidation { + public active = true; + + constructor(private readonly delegate: ValidateFeatureTargetSupport) {} + + public validate(): string[] { + return this.active ? this.delegate.validate() : []; + } +} + +interface ProviderFeatureRegistration { + readonly mode: ProviderFeatureRegistrationMode; + readonly validation: GatedFeatureValidation; +} + export interface TerraformElementMetadata { readonly path: string; readonly uniqueId: string; @@ -32,6 +80,21 @@ export class TerraformElement extends Construct { */ private readonly _elementType?: string; + /** + * Provider-protocol feature families (see `ProviderFeature`) already + * registered on this element via `registerProviderFeatureUsage` or + * `_registerResolveDiscoveredProviderFeatureUsage`, so repeated usage + * (e.g. a setter called multiple times, or the same value resolved more + * than once in a synthesis pass) doesn't stack duplicate synth-time + * validations - each feature installs at most one node validation for + * the lifetime of the element, gated on/off via the registration's + * `active` flag instead of being re-added or removed. + */ + private readonly _registeredProviderFeatures = new Map< + ProviderFeature, + ProviderFeatureRegistration + >(); + constructor(scope: Construct, id: string, elementType?: string) { super(scope, id); Object.defineProperty(this, TERRAFORM_ELEMENT_SYMBOL, { value: true }); @@ -52,6 +115,104 @@ export class TerraformElement extends Construct { return x !== null && typeof x === "object" && TERRAFORM_ELEMENT_SYMBOL in x; } + /** + * Registers a synth-time validation that the project's declared + * targetVersions admit the given provider-protocol feature family. + * Called by generated provider bindings when a versioned feature is + * structurally in use - the element's existence in the construct tree + * already implies the feature is used, e.g. constructing a + * `TerraformEphemeralResource` at all - so, unlike + * `_registerResolveDiscoveredProviderFeatureUsage`, this registration is + * never deactivated by `_resetResolveDiscoveredProviderFeatureUsage`. Not + * intended to be called directly by user code. Lives on `TerraformElement` + * (rather than `TerraformResource`) so it covers any element subclass + * that needs it. + */ + protected registerProviderFeatureUsage(feature: ProviderFeature): void { + this._registerProviderFeature(feature, "structural"); + } + + /** + * Registers (or re-arms) a synth-time validation for a provider-protocol + * feature family whose usage is only known once a value is resolved + * during synthesis - e.g. `markWriteOnlyAttribute`'s wrapper, which calls + * this from its `resolve()` closure only when the resolved value turns + * out to be a real, renderable value. + * + * Unlike `registerProviderFeatureUsage`, a registration made through this + * method represents usage discovered during the CURRENT synthesis pass + * only: `_resetResolveDiscoveredProviderFeatureUsage` deactivates it at + * the start of each pass, and calling this method again re-activates it + * without installing a second, duplicate node validation. Every + * validation-enabled entry point (`App.synth`, `Testing.synth`/ + * `synthHcl` with validations, `StackSynthesizer.synthesize`) runs a + * prepare step that performs this reset-then-rediscover cycle before + * validations run, so what a validation sees always reflects only the + * pass that is about to be validated. + * + * @internal + */ + public _registerResolveDiscoveredProviderFeatureUsage( + feature: ProviderFeature, + ): void { + this._registerProviderFeature(feature, "resolve-discovered"); + } + + /** + * Deactivates every `resolve-discovered` provider-feature registration on + * this element (structural registrations are left untouched - see + * `ProviderFeatureRegistrationMode`). Called once per synthesis pass, + * before that pass's preparing resolve re-discovers whatever usage + * actually renders during it - see `TerraformStack._runPreparingResolve`. + * + * @internal + */ + public _resetResolveDiscoveredProviderFeatureUsage(): void { + for (const registration of this._registeredProviderFeatures.values()) { + if (registration.mode === "resolve-discovered") { + registration.validation.active = false; + } + } + } + + private _registerProviderFeature( + feature: ProviderFeature, + mode: ProviderFeatureRegistrationMode, + ): void { + const existing = this._registeredProviderFeatures.get(feature); + if (existing) { + // Already installed on this element: (re-)activate it rather than + // stacking a second node validation for the same feature. + existing.validation.active = true; + return; + } + + if ( + !Object.prototype.hasOwnProperty.call(providerFeatureConstraints, feature) + ) { + throw unknownProviderFeature(feature); + } + + const constraints = providerFeatureConstraints[feature]; + const hint = `${providerFeatureHints[feature]} are available in ${Object.entries( + constraints, + ) + .map(([product, range]) => `${product} ${range}`) + .join(" and ")}.`; + + const validation = new GatedFeatureValidation( + new ValidateFeatureTargetSupport( + this, + providerFeatureLabels[feature], + constraints, + hint, + ), + ); + + this._registeredProviderFeatures.set(feature, { mode, validation }); + this.node.addValidation(validation); + } + public toTerraform(): any { return {}; } diff --git a/packages/cdktn/src/terraform-ephemeral-resource.ts b/packages/cdktn/src/terraform-ephemeral-resource.ts new file mode 100644 index 000000000..6e2aedcc1 --- /dev/null +++ b/packages/cdktn/src/terraform-ephemeral-resource.ts @@ -0,0 +1,267 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Construct } from "constructs"; +import { Token } from "./tokens"; +import { TerraformElement } from "./terraform-element"; +import { TerraformProvider } from "./terraform-provider"; +import { + TerraformProviderGeneratorMetadata, + lifecycleToTerraform, +} from "./terraform-resource"; +import { Precondition, Postcondition } from "./terraform-conditions"; +import { + keysToSnakeCase, + deepMerge, + processDynamicAttributes, + processDynamicAttributesForHcl, +} from "./util"; +import { ITerraformDependable } from "./terraform-dependable"; +import { ref, dependable } from "./tfExpression"; +import { IResolvable } from "./tokens/resolvable"; +import { IInterpolatingParent } from "./terraform-addressable"; +import { ITerraformIterator } from "./terraform-iterator"; +import { TerraformCount } from "./terraform-count"; +import { ProviderFeature } from "./provider-feature-constraints"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import assert = require("assert"); + +const TERRAFORM_EPHEMERAL_RESOURCE_SYMBOL = Symbol.for( + "cdktf/TerraformEphemeralResource", +); + +/** + * Lifecycle options supported by Terraform ephemeral blocks. Unlike managed + * resources, ephemeral resources have no state, so Terraform only supports + * `precondition`/`postcondition` here - `createBeforeDestroy`, + * `preventDestroy`, `ignoreChanges`, and `replaceTriggeredBy` are all + * state-oriented concepts that do not apply. + */ +export interface TerraformEphemeralResourceLifecycle { + readonly precondition?: Precondition[]; + readonly postcondition?: Postcondition[]; +} + +export interface ITerraformEphemeralResource { + readonly terraformResourceType: string; + readonly fqn: string; + readonly friendlyUniqueId: string; + + dependsOn?: string[]; + count?: number | TerraformCount; + provider?: TerraformProvider; + lifecycle?: TerraformEphemeralResourceLifecycle; + forEach?: ITerraformIterator; + + interpolationForAttribute(terraformAttribute: string): IResolvable; +} + +/** + * Meta-arguments accepted by ephemeral resources. Terraform ephemeral blocks + * do not support provisioners or connection blocks, unlike managed resources. + */ +export interface TerraformEphemeralMetaArguments { + readonly dependsOn?: ITerraformDependable[]; + readonly count?: number | TerraformCount; + readonly provider?: TerraformProvider; + readonly lifecycle?: TerraformEphemeralResourceLifecycle; + readonly forEach?: ITerraformIterator; +} + +export interface TerraformEphemeralResourceConfig extends TerraformEphemeralMetaArguments { + readonly terraformResourceType: string; + readonly terraformGeneratorMetadata?: TerraformProviderGeneratorMetadata; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export class TerraformEphemeralResource + extends TerraformElement + implements + ITerraformEphemeralResource, + ITerraformDependable, + IInterpolatingParent +{ + public readonly terraformResourceType: string; + public readonly terraformGeneratorMetadata?: TerraformProviderGeneratorMetadata; + + // TerraformEphemeralMetaArguments + + public dependsOn?: string[]; + public count?: number | TerraformCount; + public provider?: TerraformProvider; + public lifecycle?: TerraformEphemeralResourceLifecycle; + public forEach?: ITerraformIterator; + + constructor( + scope: Construct, + id: string, + config: TerraformEphemeralResourceConfig, + ) { + super(scope, id, `ephemeral.${config.terraformResourceType}`); + Object.defineProperty(this, TERRAFORM_EPHEMERAL_RESOURCE_SYMBOL, { + value: true, + }); + + this.terraformResourceType = config.terraformResourceType; + this.terraformGeneratorMetadata = config.terraformGeneratorMetadata; + if (Array.isArray(config.dependsOn)) { + this.dependsOn = config.dependsOn.map((dependency) => + dependable(dependency), + ); + } + this.count = config.count; + this.provider = config.provider; + this.lifecycle = config.lifecycle; + this.forEach = config.forEach; + + // Every ephemeral resource unconditionally uses the ephemeral-resources + // provider-protocol feature family (unlike e.g. write-only attributes, + // which are opt-in per attribute), so this is registered once here + // rather than by generated bindings. Goes through the same + // `registerProviderFeatureUsage` hook (inherited from `TerraformElement`) + // that generated bindings use, for identical message/dedup behavior. + this.registerProviderFeatureUsage(ProviderFeature.EPHEMERAL_RESOURCES); + } + + public static isTerraformEphemeralResource( + x: any, + ): x is TerraformEphemeralResource { + return ( + x !== null && + typeof x === "object" && + TERRAFORM_EPHEMERAL_RESOURCE_SYMBOL in x + ); + } + + public getStringAttribute(terraformAttribute: string) { + return Token.asString(this.interpolationForAttribute(terraformAttribute)); + } + + public getNumberAttribute(terraformAttribute: string) { + return Token.asNumber(this.interpolationForAttribute(terraformAttribute)); + } + + public getListAttribute(terraformAttribute: string) { + return Token.asList(this.interpolationForAttribute(terraformAttribute)); + } + + public getBooleanAttribute(terraformAttribute: string) { + return this.interpolationForAttribute(terraformAttribute); + } + + public getNumberListAttribute(terraformAttribute: string) { + return Token.asNumberList( + this.interpolationForAttribute(terraformAttribute), + ); + } + + public getStringMapAttribute(terraformAttribute: string) { + return Token.asStringMap( + this.interpolationForAttribute(terraformAttribute), + ); + } + + public getNumberMapAttribute(terraformAttribute: string) { + return Token.asNumberMap( + this.interpolationForAttribute(terraformAttribute), + ); + } + + public getBooleanMapAttribute(terraformAttribute: string) { + return Token.asBooleanMap( + this.interpolationForAttribute(terraformAttribute), + ); + } + + public getAnyMapAttribute(terraformAttribute: string) { + return Token.asAnyMap(this.interpolationForAttribute(terraformAttribute)); + } + + public get terraformMetaArguments(): { [name: string]: any } { + assert( + !this.forEach || typeof this.count === "undefined", + `forEach and count are both set, but they are mutually exclusive. You can only use either of them. Check the ephemeral resource at path: ${this.node.path}`, + ); + + return { + dependsOn: this.dependsOn, + count: TerraformCount.isTerraformCount(this.count) + ? this.count.toTerraform() + : this.count, + provider: this.provider?.fqn, + lifecycle: lifecycleToTerraform(this.lifecycle), + forEach: this.forEach?._getForEachExpression(), + }; + } + + // jsii can't handle abstract classes? + protected synthesizeAttributes(): { [name: string]: any } { + return {}; + } + protected synthesizeHclAttributes(): { [name: string]: any } { + return {}; + } + + /** + * Adds this ephemeral resource to the terraform JSON output. + */ + public toTerraform(): any { + const attributes = deepMerge( + processDynamicAttributes(this.synthesizeAttributes()), + keysToSnakeCase(this.terraformMetaArguments), + this.rawOverrides, + ); + + attributes["//"] = { + ...(attributes["//"] ?? {}), + ...this.constructNodeMetadata, + }; + + return { + ephemeral: { + [this.terraformResourceType]: { + [this.friendlyUniqueId]: attributes, + }, + }, + }; + } + + public toHclTerraform(): any { + const attributes = deepMerge( + processDynamicAttributesForHcl(this.synthesizeHclAttributes()), + keysToSnakeCase(this.terraformMetaArguments), + this.rawOverrides, + ); + + attributes["//"] = { + ...(attributes["//"] ?? {}), + ...this.constructNodeMetadata, + }; + + return { + ephemeral: { + [this.terraformResourceType]: { + [this.friendlyUniqueId]: attributes, + }, + }, + }; + } + + public toMetadata(): any { + return { + overrides: Object.keys(this.rawOverrides).length + ? { + [this.terraformResourceType]: Object.keys(this.rawOverrides), + } + : undefined, + }; + } + + public interpolationForAttribute(terraformAttribute: string) { + return ref( + `ephemeral.${this.terraformResourceType}.${this.friendlyUniqueId}${ + this.forEach ? ".*" : "" + }.${terraformAttribute}`, + this.cdktfStack, + ); + } +} diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 675ea7ba2..60b0efeab 100644 --- a/packages/cdktn/src/terraform-resource.ts +++ b/packages/cdktn/src/terraform-resource.ts @@ -12,7 +12,9 @@ import { } from "./util"; import { ITerraformDependable } from "./terraform-dependable"; import { ref, dependable } from "./tfExpression"; -import { IResolvable } from "./tokens/resolvable"; +import { IResolvable, IResolveContext } from "./tokens/resolvable"; +import { captureCreationStack } from "./tokens/private/stack-trace"; +import { ProviderFeature } from "./provider-feature-constraints"; import { IInterpolatingParent } from "./terraform-addressable"; import { ITerraformIterator } from "./terraform-iterator"; import { Precondition, Postcondition } from "./terraform-conditions"; @@ -251,6 +253,71 @@ export class TerraformResource return {}; } + /** + * Wraps a write-only attribute's already-mapped value so that + * `ProviderFeature.WRITE_ONLY_ATTRIBUTES` usage is registered at + * *resolve* time instead of at mutation time (setter/constructor). + * Called by generated bindings from `synthesizeAttributes()` and + * `synthesizeHclAttributes()`, e.g. + * `secret_key_wo: this.markWriteOnlyAttribute(cdktn.stringToTerraform(this._secretKeyWo))`; + * not intended to be called directly. + * + * `undefined` passes through completely unchanged, so the existing + * undefined-filtering that omits unset attributes from synthesized + * output (see `resolve()` in `tokens/private/resolve.ts`, and the + * `value.value !== undefined` filter in generated + * `synthesizeHclAttributes()`) keeps working untouched. `null` is also + * passed through unchanged: it already renders as an explicit + * null-out and must not arm the validation either. + * + * Any other value - including one that will itself resolve to nothing + * (e.g. a `Lazy`/`IResolvable` producer with no value to contribute) - + * is wrapped in a token whose `resolve()` defers to the real resolver + * first and registers usage only if what comes back is not + * `null`/`undefined`; the resolved value is then returned unchanged, + * so what actually renders is untouched by this wrapper. A producer + * that resolves to `undefined` therefore neither registers usage nor + * leaves anything behind in the synthesized attribute - the omission + * behaves exactly as if the attribute had never been set. + * + * Registration goes through `_registerResolveDiscoveredProviderFeatureUsage` + * rather than `registerProviderFeatureUsage`: usage here is only known at + * resolve time, and a given element can be resolved across many + * synthesis passes over its lifetime (repeated `app.synth()` calls, + * tests reusing a construct tree), so it must represent only the CURRENT + * pass rather than accumulate forever. Every validation-enabled entry + * point (`App.synth`; `Testing.synth`/`synthHcl` with validations; + * `StackSynthesizer.synthesize`) runs a prepare step that deactivates any + * stale registration and then resolves every element's `toTerraform()` + * before that same entry point's validations run - see + * `TerraformStack._runPreparingResolve` - so whatever this closure + * (re-)registers during that prepare step is always visible to the + * validation that reads it afterwards, and nothing left over from an + * earlier pass leaks into the current one. + */ + protected markWriteOnlyAttribute(value: any): any { + if (value === undefined || value === null) { + return value; + } + + const resource = this; + return { + creationStack: captureCreationStack(), + resolve(context: IResolveContext): any { + const resolved = context.resolve(value); + if (resolved != null) { + resource._registerResolveDiscoveredProviderFeatureUsage( + ProviderFeature.WRITE_ONLY_ATTRIBUTES, + ); + } + return resolved; + }, + toString(): string { + return Token.asString(this); + }, + } as IResolvable; + } + /** * Adds this resource to the terraform JSON output. */ diff --git a/packages/cdktn/src/terraform-stack.ts b/packages/cdktn/src/terraform-stack.ts index 73649811e..cef7a48eb 100644 --- a/packages/cdktn/src/terraform-stack.ts +++ b/packages/cdktn/src/terraform-stack.ts @@ -17,6 +17,7 @@ import { StackSynthesizer } from "./synthesize/synthesizer"; const STACK_SYMBOL = Symbol.for("cdktf/TerraformStack"); import { ValidateFunctionVersionSupport, + ValidateProviderFunctionTargetSupport, ValidateProviderPresence, } from "./validations"; import { VALIDATE_FUNCTION_VERSIONS } from "./features"; @@ -26,6 +27,7 @@ import { TerraformResourceTargets } from "./terraform-resource-targets"; import { TerraformResource } from "./terraform-resource"; import { renderDatasource, + renderEphemeral, renderModule, renderMoved, renderOutput, @@ -101,6 +103,7 @@ export class TerraformStack extends Construct { if (this.node.tryGetContext(VALIDATE_FUNCTION_VERSIONS)) { this.node.addValidation(new ValidateFunctionVersionSupport(this)); } + this.node.addValidation(new ValidateProviderFunctionTargetSupport(this)); } public static isStack(x: any): x is TerraformStack { @@ -157,7 +160,34 @@ export class TerraformStack extends Construct { public prepareStack() { // Ensure we have a backend configured this.ensureBackendExists(); - // A preparing resolve run might add new resources to the stack, e.g. for cross stack references. + this._runPreparingResolve(); + } + + /** + * The resolve-discovery half of `prepareStack()`, split out so entry + * points that must NOT inject a backend (`Testing.synth`/`synthHcl` - + * doing so would add an implicit local backend to snapshots that never + * asked for one) can still run the part that discovers current-pass + * provider-feature and Terraform-function usage. + * + * First deactivates every element's stale `resolve-discovered` + * provider-feature registrations (see + * `TerraformElement._resetResolveDiscoveredProviderFeatureUsage`) - so + * usage from an earlier synthesis pass over this same stack can't survive + * into this one - then resolves every element's `toTerraform()`, which is + * what re-discovers this pass's actual usage (both provider-feature usage + * via `markWriteOnlyAttribute`, and Terraform-function usage via + * `FunctionCall.resolve()`; the latter's own per-root reset is the + * caller's responsibility - see `functions/usage-registry.ts`). A + * preparing resolve run might also add new resources to the stack, e.g. + * for cross stack references. + * + * @internal + */ + public _runPreparingResolve() { + terraformElements(this).forEach((e) => + e._resetResolveDiscoveredProviderFeatureUsage(), + ); terraformElements(this).forEach((e) => resolve(this, e.toTerraform(), true), ); @@ -283,6 +313,12 @@ export class TerraformStack extends Construct { res = [res, hcl].join("\n"); } + if (frag.ephemeral) { + const { hcl, metadata } = renderEphemeral(frag.ephemeral); + deepMerge(tfMeta, metadata); + res = [res, hcl].join("\n"); + } + if (frag.provider) { deepMerge(tf, frag); res = [res, renderProvider(frag.provider)].join("\n\n"); diff --git a/packages/cdktn/src/testing/index.ts b/packages/cdktn/src/testing/index.ts index 334a2001a..02b24848d 100644 --- a/packages/cdktn/src/testing/index.ts +++ b/packages/cdktn/src/testing/index.ts @@ -12,6 +12,7 @@ import { FUTURE_FLAGS } from "../features"; import { IConstruct, Construct } from "constructs"; import { setupJest } from "./adapters/jest"; import { invokeAspects } from "../synthesize/synthesizer"; +import { resetUsageForRoot } from "../functions/usage-registry"; import { getToHaveResourceWithProperties, getToHaveDataSourceWithProperties, @@ -106,6 +107,16 @@ export class Testing { public static synth(stack: TerraformStack, runValidations = false) { invokeAspects(stack); if (runValidations) { + // Deliberately not the full `prepareStack()` - that also injects a + // local backend via `ensureBackendExists()`, which would add a + // backend block to every existing snapshot that never asked for one. + // Only run the resolve/reset half that (re-)discovers current-pass + // provider-feature and Terraform-function usage, so the validations + // below see what this pass actually renders instead of stale usage + // (or none at all) left over from a previous call against the same + // stack. See TerraformStack._runPreparingResolve. + resetUsageForRoot(stack.node.root); + stack._runPreparingResolve(); stack.runAllValidations(); } @@ -148,6 +159,10 @@ export class Testing { ) { invokeAspects(stack); if (runValidations) { + // See the matching comment in Testing.synth(): only the resolve/reset + // discovery half of prepareStack(), never the backend-injecting half. + resetUsageForRoot(stack.node.root); + stack._runPreparingResolve(); stack.runAllValidations(); } diff --git a/packages/cdktn/src/tfExpression.ts b/packages/cdktn/src/tfExpression.ts index 53a62d2e5..7b11721f1 100644 --- a/packages/cdktn/src/tfExpression.ts +++ b/packages/cdktn/src/tfExpression.ts @@ -7,6 +7,10 @@ import { App } from "./app"; import { TerraformStack } from "./terraform-stack"; import { ITerraformDependable } from "./terraform-dependable"; import { Construct } from "constructs"; +import { + recordFunctionUsage, + recordProviderFunctionUsage, +} from "./functions/usage-registry"; const TERRAFORM_IDENTIFIER_REGEX = /^[_a-zA-Z][_a-zA-Z0-9]*$/; @@ -29,6 +33,16 @@ class TFExpression extends Intrinsic implements IResolvable { .join(", ")}}`; } + // A literal `null`/`undefined` argument (e.g. a fixed-arity provider + // function positional slot the caller intentionally left empty) must be + // rendered as the Terraform `null` keyword. Without this, `resolvedArg` + // stays `null`/`undefined` and Array.prototype.join() (used by callers + // like FunctionCall.resolve()) silently collapses it to an empty string, + // shifting/dropping the argument instead of rendering `null`. + if (resolvedArg === null || resolvedArg === undefined) { + return "null"; + } + return resolvedArg; } @@ -362,6 +376,20 @@ class FunctionCall extends TFExpression { } public resolve(context: IResolveContext): string { + // Recording here, at resolve time, is deliberate: a `Fn.*()`/ + // `TerraformProviderFunction.invoke()` call only produces a token: this + // is the point where that token actually lands in a rendered stack, so + // usage is attributed to the App whose synthesis is doing the + // rendering (`context.scope` is always the enclosing `TerraformStack`; + // its root is the owning `App`). See usage-registry.ts for the full + // rationale. + const root = context.scope.node.root; + if (this.name.startsWith("provider::")) { + recordProviderFunctionUsage(root, this.name); + } else { + recordFunctionUsage(root, this.name); + } + const suppressBraces = context.suppressBraces; const originalIgnoreEscapes = context.ignoreEscapes; const originalWarnEscapes = context.warnEscapes; diff --git a/packages/cdktn/src/validations/index.ts b/packages/cdktn/src/validations/index.ts index 995a8abba..76396c39a 100644 --- a/packages/cdktn/src/validations/index.ts +++ b/packages/cdktn/src/validations/index.ts @@ -3,5 +3,6 @@ export * from "./target-versions"; export * from "./validate-binary-version"; export * from "./validate-function-version-support"; +export * from "./validate-provider-function-target-support"; export * from "./validate-provider-presence"; export * from "./validate-terraform-feature-version"; diff --git a/packages/cdktn/src/validations/validate-function-version-support.ts b/packages/cdktn/src/validations/validate-function-version-support.ts index 4ae1d10f8..62c00712c 100644 --- a/packages/cdktn/src/validations/validate-function-version-support.ts +++ b/packages/cdktn/src/validations/validate-function-version-support.ts @@ -30,7 +30,7 @@ export class ValidateFunctionVersionSupport implements IValidation { constructor(protected scope: IConstruct) {} public validate() { - const constrainedFunctions = getUsedFunctions() + const constrainedFunctions = getUsedFunctions(this.scope.node.root) .filter((name) => name in functionVersionConstraints) .sort(); diff --git a/packages/cdktn/src/validations/validate-provider-function-target-support.ts b/packages/cdktn/src/validations/validate-provider-function-target-support.ts new file mode 100644 index 000000000..8c1eb373a --- /dev/null +++ b/packages/cdktn/src/validations/validate-provider-function-target-support.ts @@ -0,0 +1,66 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { IConstruct, IValidation } from "constructs"; +import { + checkFeatureSupportedByTargets, + resolveTargetVersions, +} from "./target-versions"; +import { providerFeatureConstraints } from "../provider-feature-constraints"; +import { getUsedProviderFunctions } from "../functions/usage-registry"; + +/** + * Validates that any provider-defined functions + * (`provider::::()`) invoked through + * `TerraformProviderFunction.invoke` are supported by the project's declared + * Terraform/OpenTofu targets (cdktf.json `targetVersions`, defaulting to the + * dual product baseline). + * + * Unlike `ValidateFunctionVersionSupport`, there is a single version + * constraint for the whole feature family (provider-defined function call + * syntax), not one per function — so this check is skipped entirely when no + * provider function has been used, and otherwise checked once, naming every + * used function so the error is actionable. + * + * Registered unconditionally (no feature flag): this is new API surface, and + * the check only ever fires when a provider function is actually used — + * i.e. its token was resolved while rendering a stack owned by the same + * App as this validation's scope (usage is recorded per-App-root at + * token-resolve time; see `../functions/usage-registry.ts`). + */ +export class ValidateProviderFunctionTargetSupport implements IValidation { + constructor(protected scope: IConstruct) {} + + public validate() { + const usedProviderFunctions = getUsedProviderFunctions( + this.scope.node.root, + ).sort(); + + // no provider-defined functions in use: nothing to validate + if (usedProviderFunctions.length === 0) { + return []; + } + + const { targets, errors } = resolveTargetVersions(this.scope); + if (!targets) { + return errors; + } + + return checkFeatureSupportedByTargets( + `provider-defined functions (${usedProviderFunctions.join(", ")})`, + providerFeatureConstraints.providerFunctions, + targets, + availabilityHint(), + ); + } +} + +/** + * Tells the user where provider-defined functions ARE available, so they + * can adjust their declared targetVersions. + */ +function availabilityHint(): string { + const parts = Object.entries( + providerFeatureConstraints.providerFunctions, + ).map(([product, range]) => `${product} ${range}`); + return `Provider-defined functions are available in ${parts.join(" and ")}.`; +} diff --git a/packages/cdktn/test/provider-functions.test.ts b/packages/cdktn/test/provider-functions.test.ts new file mode 100644 index 000000000..40eed929e --- /dev/null +++ b/packages/cdktn/test/provider-functions.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { + App, + Testing, + Tokenization, + TerraformOutput, + TerraformStack, +} from "../src"; +import { TerraformProviderFunction } from "../src/functions/provider-function"; +import { createTmpHelper } from "./helper/tmp"; +import { TestProvider, TestResource } from "./helper"; + +const tmp = createTmpHelper(); + +test("invoke() renders provider::::(...) in synthesized output", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + + expect(Testing.synth(stack)).toMatchInlineSnapshot(` + "{ + "output": { + "test-output": { + "value": "\${provider::time::rfc3339_parse(\\"2023-01-01T00:00:00Z\\")}" + } + } + }" + `); +}); + +// Bug 1 regression: dynamic/object/map-typed generated wrappers used to +// force-coerce the invoke() result through `cdktn.Token.asString(...)`, +// which produces a value `Tokenization.isResolvable()` does not recognize - +// silently dropping the attribute wherever a generated struct setter +// (OutputReference internalValue) inspects the value's keys. The raw +// invoke() result must stay a recognizable `IResolvable`. +test("invoke() result is recognized as an IResolvable (not force-coerced through Token.asString)", () => { + const result = TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]); + + expect(Tokenization.isResolvable(result)).toBe(true); +}); + +test("invoke() result passed as a resource attribute survives synth as a provider::... expression", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TestProvider(stack, "provider", {}); + new TestResource(stack, "test", { + name: "foo", + anyMap: { + parsed: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }, + }); + + const { any_map: anyMap } = JSON.parse(Testing.synth(stack)).resource + .test_resource.test; + expect(anyMap.parsed).toBe( + '${provider::time::rfc3339_parse("2023-01-01T00:00:00Z")}', + ); +}); + +// Bug 2 regression: invoke() used to flatten/validate args through +// variadic(anyValue), whose underlying listOf() filters out null/undefined +// ENTRIES - correct for a genuinely variadic trailing list, wrong for a +// fixed-arity positional call, where a literal `null` in the middle is a +// meaningful argument (Terraform errors "Missing value for value_if_false" +// when it silently disappears). +describe("invoke() preserves null/undefined positional arguments", () => { + test("a literal null in the second position renders as three comma-separated arguments with a literal null", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("cfncompat", "condition_if", [ + true, + null, + { a: 1 }, + ]), + }); + + const { value } = JSON.parse(Testing.synth(stack)).output["test-output"]; + expect(value).toMatch( + /provider::cfncompat::condition_if\(true, null, \{"a" = 1\}\)/, + ); + }); + + test("an undefined value in the second position also renders as a literal null", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("cfncompat", "condition_if", [ + true, + undefined, + { a: 1 }, + ]), + }); + + const { value } = JSON.parse(Testing.synth(stack)).output["test-output"]; + expect(value).toMatch( + /provider::cfncompat::condition_if\(true, null, \{"a" = 1\}\)/, + ); + }); +}); + +describe("ValidateProviderFunctionTargetSupport", () => { + function appWithStack(context?: Record) { + const outdir = tmp("cdktf.outdir."); + const app = Testing.stubVersion( + new App({ stackTraces: false, outdir, context }), + ); + const stack = new TerraformStack(app, "MyStack"); + return { app, stack }; + } + + test("fails against the default baseline targets when a provider function is used", () => { + const { app, stack } = appWithStack(); + new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + + expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack] provider-defined functions (provider::time::rfc3339_parse) requires terraform >=1.8.0, but the project targets terraform >=1.5.7. Provider-defined functions are available in terraform >=1.8.0 and opentofu >=1.7.0. + [MyStack] provider-defined functions (provider::time::rfc3339_parse) requires opentofu >=1.7.0, but the project targets opentofu >=1.6.0. Provider-defined functions are available in terraform >=1.8.0 and opentofu >=1.7.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); + + test("passes when the declared targets satisfy provider function support", () => { + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.8.0", opentofu: ">=1.7.0" }, + }); + new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + + expect(() => app.synth()).not.toThrow(); + }); + + test("passes on default targets when no provider function is used", () => { + const { app } = appWithStack(); + expect(() => app.synth()).not.toThrow(); + }); + + test("does not leak provider function usage from one App into a later, unrelated App in the same process", () => { + // Usage is recorded per-App-root (the resolving stack's `node.root`) + // rather than in a single process-global registry, so isolation between + // App trees is structural, not something that depends on a reset + // running between tests. + const { app: app1, stack: stack1 } = appWithStack({ + targetVersions: { terraform: ">=1.8.0", opentofu: ">=1.7.0" }, + }); + new TerraformOutput(stack1, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + expect(() => app1.synth()).not.toThrow(); + + // app2 targets the default baseline (which does NOT support + // provider-defined functions) but never invokes one itself. + const { app: app2 } = appWithStack(); + expect(() => app2.synth()).not.toThrow(); + }); + + test("app2 does not report provider-defined function errors carried over from app1's usage", () => { + const { app: app1, stack: stack1 } = appWithStack({ + targetVersions: { terraform: ">=1.8.0", opentofu: ">=1.7.0" }, + }); + new TerraformOutput(stack1, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + expect(() => app1.synth()).not.toThrow(); + + const { app: app2 } = appWithStack(); + try { + app2.synth(); + } catch (e: any) { + expect(e.message).not.toContain("provider-defined functions"); + } + }); + + test("still fires when a second, unrelated App is constructed between usage and synth of the first (interleaved Apps)", () => { + // Regression test: with call-time recording into a single process-global + // registry, App B's constructor used to reset that registry, silently + // wiping out the usage App A had already recorded for + // TerraformProviderFunction.invoke() before App A ever got to synth() - + // a false negative that skipped target-version validation entirely. + // Recording at token-RESOLVE time, keyed by the resolving root, fixes + // this: App A's usage is only ever recorded (during App A's own + // prepareStack pass) and read back from App A's own root. + const { app: app1, stack: stack1 } = appWithStack(); + new TerraformOutput(stack1, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + + // App B is constructed here, strictly between App A's usage and App + // A's synth() call - the interleaving that used to trigger the bug. + appWithStack(); + + expect(() => app1.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack] provider-defined functions (provider::time::rfc3339_parse) requires terraform >=1.8.0, but the project targets terraform >=1.5.7. Provider-defined functions are available in terraform >=1.8.0 and opentofu >=1.7.0. + [MyStack] provider-defined functions (provider::time::rfc3339_parse) requires opentofu >=1.7.0, but the project targets opentofu >=1.6.0. Provider-defined functions are available in terraform >=1.8.0 and opentofu >=1.7.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); +}); + +// Round 6, Findings 2 and 3 for the function-usage registry: the identical +// entry-point gap that existed for write-only attributes existed here too - +// usage is only ever discovered by a prepare step that resolves every +// element's toTerraform(), and only App.synth() ran that step. And because +// usage represents only the CURRENT synthesis pass, repeat synthesis of the +// same App must not let a function rendered in an earlier pass keep failing +// a later pass where it no longer appears. +describe("resolve-discovered provider-function usage: entry points and synthesis epochs", () => { + function appWithStack(context?: Record) { + const outdir = tmp("cdktf.outdir."); + const app = Testing.stubVersion( + new App({ stackTraces: false, outdir, context }), + ); + const stack = new TerraformStack(app, "MyStack"); + return { app, stack }; + } + + function addGatedProviderFunctionOutput(stack: TerraformStack) { + return new TerraformOutput(stack, "test-output", { + value: TerraformProviderFunction.invoke("time", "rfc3339_parse", [ + "2023-01-01T00:00:00Z", + ]), + }); + } + + test("Testing.synth(stack, true) surfaces the target failure for a gated provider function", () => { + const { stack } = appWithStack(); + addGatedProviderFunctionOutput(stack); + + expect(() => Testing.synth(stack, true)).toThrow( + /provider-defined functions/, + ); + }); + + test("Testing.synthHcl(stack, true) surfaces the target failure for a gated provider function", () => { + const { stack } = appWithStack(); + addGatedProviderFunctionOutput(stack); + + expect(() => Testing.synthHcl(stack, true)).toThrow( + /provider-defined functions/, + ); + }); + + test("direct StackSynthesizer/stack synthesis without App.synth() surfaces the target failure for a gated provider function", () => { + const { stack } = appWithStack(); + addGatedProviderFunctionOutput(stack); + + // Testing.fullSynth() drives stack.synthesizer.synthesize() directly + // with a bare session that never sets `stacksPrepared`. + expect(() => Testing.fullSynth(stack)).toThrow( + /provider-defined functions/, + ); + }); + + test("removing the gated output and re-synthesizing the same App passes (function-usage epoch)", () => { + const { app, stack } = appWithStack(); + const output = addGatedProviderFunctionOutput(stack); + + expect(() => app.synth()).toThrow(/provider-defined functions/); + + stack.node.tryRemoveChild(output.node.id); + + expect(() => app.synth()).not.toThrow(); + }); + + test("multi-stack App.synth(): preparing stack2 does not erase provider-function usage stack1 already recorded", () => { + // The function-usage registry reset (resetUsageForRoot) runs exactly + // once, at the very start of App.synth(), keyed by the App root shared + // by every stack - deliberately NOT inside prepareStack(), because + // App.synth() prepares stacks one at a time and a per-stack reset there + // would wipe out a sibling stack's usage already recorded earlier in + // that same loop. stack1 is prepared (and its usage recorded) before + // stack2 is prepared here. + const outdir = tmp("cdktf.outdir."); + const app = Testing.stubVersion(new App({ stackTraces: false, outdir })); + const stack1 = new TerraformStack(app, "Stack1"); + const stack2 = new TerraformStack(app, "Stack2"); + + addGatedProviderFunctionOutput(stack1); + new TerraformOutput(stack2, "unrelated-output", { value: "just a stack" }); + + expect(() => app.synth()).toThrow(/provider-defined functions/); + }); +}); diff --git a/packages/cdktn/test/terraform-ephemeral-resource.test.ts b/packages/cdktn/test/terraform-ephemeral-resource.test.ts new file mode 100644 index 000000000..47245e0cb --- /dev/null +++ b/packages/cdktn/test/terraform-ephemeral-resource.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { Construct } from "constructs"; +import { App, Testing, TerraformStack, TerraformOutput } from "../src"; +import { + TerraformEphemeralResource, + TerraformEphemeralResourceConfig, +} from "../src/terraform-ephemeral-resource"; +import { TestProvider } from "./helper/provider"; +import { createTmpHelper } from "./helper/tmp"; + +const tmp = createTmpHelper(); + +interface TestEphemeralResourceConfig extends TerraformEphemeralResourceConfig { + name?: string; +} + +class TestEphemeralResource extends TerraformEphemeralResource { + public name?: string; + + constructor( + scope: Construct, + id: string, + config: Omit = {}, + ) { + super(scope, id, { + terraformResourceType: "test_ephemeral", + ...config, + }); + this.name = config.name; + } + + protected synthesizeAttributes(): { [name: string]: any } { + return { name: this.name }; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + return { + name: { + value: this.name, + type: "simple", + storageClassType: "string", + }, + }; + } + + public get token() { + return this.getStringAttribute("token"); + } +} + +test("synthesizes under the top-level ephemeral key", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + new TestEphemeralResource(stack, "test", { name: "foo" }); + + expect(Testing.synth(stack)).toMatchInlineSnapshot(` + "{ + "ephemeral": { + "test_ephemeral": { + "test": { + "name": "foo" + } + } + }, + "provider": { + "test": [ + {} + ] + }, + "terraform": { + "required_providers": { + "test": { + "version": "~> 2.0" + } + } + } + }" + `); +}); + +test("renders an ephemeral HCL block", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + new TestEphemeralResource(stack, "test", { name: "foo" }); + + expect(Testing.synthHcl(stack)).toMatchInlineSnapshot(` + " + ephemeral "test_ephemeral" "test" { + name = "foo" + }" + `); +}); + +test("renders a precondition lifecycle block", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + new TestEphemeralResource(stack, "test", { + name: "foo", + lifecycle: { + precondition: [ + { + condition: '${var.foo != ""}', + errorMessage: "foo must not be empty", + }, + ], + }, + }); + + expect(Testing.synth(stack)).toMatchInlineSnapshot(` + "{ + "ephemeral": { + "test_ephemeral": { + "test": { + "lifecycle": { + "precondition": [ + { + "condition": "\${var.foo != \\"\\"}", + "error_message": "foo must not be empty" + } + ] + }, + "name": "foo" + } + } + }, + "provider": { + "test": [ + {} + ] + }, + "terraform": { + "required_providers": { + "test": { + "version": "~> 2.0" + } + } + } + }" + `); +}); + +test("interpolationForAttribute produces ephemeral.-prefixed references", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + + const resource = new TestEphemeralResource(stack, "test", { name: "foo" }); + new TerraformOutput(stack, "token-output", { value: resource.token }); + + expect(JSON.parse(Testing.synth(stack)).output["token-output"].value).toEqual( + "${ephemeral.test_ephemeral.test.token}", + ); +}); + +describe("ephemeral resource target version validation", () => { + function appWithStack(context?: Record) { + const outdir = tmp("cdktf.outdir."); + const app = Testing.stubVersion( + new App({ stackTraces: false, outdir, context }), + ); + const stack = new TerraformStack(app, "MyStack"); + return { app, stack }; + } + + test("fails against the default baseline targets", () => { + const { app, stack } = appWithStack(); + new TestEphemeralResource(stack, "testResource", { name: "foo" }); + + expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack/testResource] ephemeral resources requires terraform >=1.10.0, but the project targets terraform >=1.5.7. Ephemeral resources are available in terraform >=1.10.0 and opentofu >=1.11.0. + [MyStack/testResource] ephemeral resources requires opentofu >=1.11.0, but the project targets opentofu >=1.6.0. Ephemeral resources are available in terraform >=1.10.0 and opentofu >=1.11.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); + + test("passes when the declared terraform target meets the minimum", () => { + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.10.0" }, + }); + new TestEphemeralResource(stack, "testResource", { name: "foo" }); + + expect(() => app.synth()).not.toThrow(); + }); + + test("fails when the declared opentofu target is below the minimum", () => { + const { app, stack } = appWithStack({ + targetVersions: { opentofu: ">=1.6.0" }, + }); + new TestEphemeralResource(stack, "testResource", { name: "foo" }); + + expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack/testResource] ephemeral resources requires opentofu >=1.11.0, but the project targets opentofu >=1.6.0. Ephemeral resources are available in terraform >=1.10.0 and opentofu >=1.11.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); + + test("passes when both declared targets meet the minimums", () => { + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.10.0", opentofu: ">=1.11.0" }, + }); + new TestEphemeralResource(stack, "testResource", { name: "foo" }); + + expect(() => app.synth()).not.toThrow(); + }); + + // Round 6, Finding 3: registrations made via the plain (structural) + // `registerProviderFeatureUsage` - the element's mere existence is the + // usage, as here in TestEphemeralResource's constructor - are never + // deactivated by the per-synthesis-pass reset that resolve-discovered + // registrations (e.g. markWriteOnlyAttribute) go through. So, unlike a + // write-only attribute that can be cleared between passes, an ephemeral + // resource must keep failing old targets on every single synth of the + // same App for as long as it exists in the construct tree. + test("structural registration survives synthesis epochs: still fails old targets on a second synth of the same App", () => { + const { app, stack } = appWithStack(); + new TestEphemeralResource(stack, "testResource", { name: "foo" }); + + expect(() => app.synth()).toThrow(/ephemeral resources requires/); + expect(() => app.synth()).toThrow(/ephemeral resources requires/); + }); +}); diff --git a/packages/cdktn/test/validations.test.ts b/packages/cdktn/test/validations.test.ts index 896185da8..abb807a74 100644 --- a/packages/cdktn/test/validations.test.ts +++ b/packages/cdktn/test/validations.test.ts @@ -16,7 +16,6 @@ import { import { TestProvider } from "./helper/provider"; import { createTmpHelper } from "./helper/tmp"; import { terraformBinaryName } from "../src/util"; -import { resetFunctionUsageRegistry } from "../src/functions/usage-registry"; import { VALIDATE_FUNCTION_VERSIONS } from "../src/features"; const tmp = createTmpHelper(); @@ -240,6 +239,16 @@ describe("resolveTargetVersions", () => { "must declare at least one product", ); }); + + test("accepts a pinned single version as a valid range", () => { + const stack = stackWithContext({ + targetVersions: { terraform: "1.11.0" }, + }); + expect(resolveTargetVersions(stack)).toEqual({ + targets: { terraform: "1.11.0" }, + errors: [], + }); + }); }); describe("checkFeatureSupportedByTargets", () => { @@ -309,6 +318,78 @@ describe("checkFeatureSupportedByTargets", () => { ), ).toEqual([]); }); + + test("passes when the declared floor is exactly the feature's minimum", () => { + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.8.0" }, + { terraform: ">=1.8.0" }, + ), + ).toEqual([]); + }); + + test("passes when the project pins exactly the feature's minimum version", () => { + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.11.0" }, + { terraform: "1.11.0" }, + ), + ).toEqual([]); + }); + + test("fails when the project pins a version below the minimum", () => { + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.11.0" }, + { terraform: "1.10.5" }, + ), + ).toEqual([ + "some feature requires terraform >=1.11.0, but the project targets terraform 1.10.5.", + ]); + }); + + test("fails when the declared floor is one patch below the minimum", () => { + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.10.0" }, + { terraform: ">=1.9.9" }, + ), + ).toEqual([ + "some feature requires terraform >=1.10.0, but the project targets terraform >=1.9.9.", + ]); + }); + + test("returns no errors for an empty targets object", () => { + // Callers always resolve targets via resolveTargetVersions first, which + // rejects {} with a "must declare at least one product" error - this + // documents that the comparator itself just has nothing to check. + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.8.0" }, + {}, + ), + ).toEqual([]); + }); + + test("treats a prerelease floor as below the minimum", () => { + // Inherent npm-semver subset semantics: ">=1.11.0-beta1" admits + // prerelease versions that ">=1.11.0" does not, so it is not a subset. + // Documented here as current (intentional-if-surprising) behavior. + expect( + checkFeatureSupportedByTargets( + "some feature", + { terraform: ">=1.11.0" }, + { terraform: ">=1.11.0-beta1" }, + ), + ).toEqual([ + "some feature requires terraform >=1.11.0, but the project targets terraform >=1.11.0-beta1.", + ]); + }); }); describe("ValidateFeatureTargetSupport", () => { @@ -397,10 +478,6 @@ describe("ValidateFeatureTargetSupport", () => { }); describe("ValidateFunctionVersionSupport", () => { - beforeEach(() => { - resetFunctionUsageRegistry(); - }); - function appWithStack(context?: Record) { const outdir = tmp("cdktf.outdir."); const app = Testing.stubVersion( @@ -553,4 +630,62 @@ describe("ValidateFunctionVersionSupport", () => { // does not fail synth expect(() => app.synth()).not.toThrow(); }); + + test("does not leak Fn usage from one App into a later, unrelated App in the same process", () => { + // Usage is now recorded per-App-root (the resolving stack's + // `node.root`) rather than in a single process-global registry, so + // isolation between App trees is structural, not something that + // depends on a reset running between tests. + const { app: app1, testResource: testResource1 } = appWithStack({ + [VALIDATE_FUNCTION_VERSIONS]: "true", + targetVersions: { terraform: ">=1.9.0", opentofu: ">=1.7.0" }, + }); + testResource1.node.addValidation( + new ValidateFunctionVersionSupport(testResource1), + ); + new TestResource(testResource1, "usesTemplatestring", { + name: Fn.templatestring("$${greeting}", { greeting: "hello" }), + }); + expect(() => app1.synth()).not.toThrow(); + + // app2 targets the default baseline (which does NOT support + // templatestring) but never calls Fn.templatestring itself. + const { app: app2, testResource: testResource2 } = appWithStack({ + [VALIDATE_FUNCTION_VERSIONS]: "true", + }); + testResource2.node.addValidation( + new ValidateFunctionVersionSupport(testResource2), + ); + expect(() => app2.synth()).not.toThrow(); + }); + + test("still fires when a second, unrelated App is constructed between usage and synth of the first (interleaved Apps)", () => { + // Regression test: with call-time recording into a single process-global + // registry, App B's constructor used to reset that registry, silently + // wiping out the usage App A had already recorded for + // Fn.templatestring() before App A ever got to synth() - a false + // negative that skipped target-version validation entirely. Recording + // at token-RESOLVE time, keyed by the resolving root, fixes this: App + // A's usage is only ever recorded (during App A's own prepareStack + // pass) and read back from App A's own root. + const { app: app1, testResource: testResource1 } = appWithStack({ + [VALIDATE_FUNCTION_VERSIONS]: "true", + }); + new TestResource(testResource1, "usesTemplatestring", { + name: Fn.templatestring("$${greeting}", { greeting: "hello" }), + }); + + // App B is constructed here, strictly between App A's usage and App + // A's synth() call - the interleaving that used to trigger the bug. + appWithStack({ [VALIDATE_FUNCTION_VERSIONS]: "true" }); + + expect(() => app1.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack] Terraform function "templatestring" requires terraform >=1.9.0, but the project targets terraform >=1.5.7. It is available in terraform >=1.9.0 and opentofu >=1.7.0. + [MyStack] Terraform function "templatestring" requires opentofu >=1.7.0, but the project targets opentofu >=1.6.0. It is available in terraform >=1.9.0 and opentofu >=1.7.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); }); diff --git a/packages/cdktn/test/write-only.test.ts b/packages/cdktn/test/write-only.test.ts new file mode 100644 index 000000000..bbee1e87f --- /dev/null +++ b/packages/cdktn/test/write-only.test.ts @@ -0,0 +1,488 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { App, Lazy, Testing, TerraformResource, TerraformStack } from "../src"; +import { Construct } from "constructs"; +import { TestProvider } from "./helper/provider"; +import { createTmpHelper } from "./helper/tmp"; + +const tmp = createTmpHelper(); + +/** + * Stand-in for a generated resource binding that has a write-only attribute. + * Mirrors what the provider-generator now emits: mutation (setter/ + * constructor/reset) is a plain, unguarded assignment - no + * `registerProviderFeatureUsage` call happens at mutation time at all. + * Registration instead happens at *resolve* time, from inside + * `synthesizeAttributes()`, by wrapping the mapped value in + * `this.markWriteOnlyAttribute(...)` (see AttributesEmitter#emitToTerraform + * and TerraformResource#markWriteOnlyAttribute). Validation therefore + * derives from what actually renders, not from whether a setter was ever + * called with a non-null value. + */ +class TestWriteOnlyResource extends TerraformResource { + private _secretKeyWo?: string; + + constructor( + scope: Construct, + id: string, + config: { secretKeyWo?: string | null }, + ) { + super(scope, id, { + terraformResourceType: "test_write_only_resource", + }); + this._secretKeyWo = config.secretKeyWo ?? undefined; + } + + public set secretKeyWo(value: string) { + this._secretKeyWo = value; + } + + public get secretKeyWo(): string { + return this._secretKeyWo as string; + } + + public resetSecretKeyWo() { + this._secretKeyWo = undefined; + } + + /** + * Exposes the protected hook directly, for the "unknown feature" test. + * Models what a plain-JS or non-TypeScript jsii caller could pass despite + * the ProviderFeature enum in the signature. + */ + public callWithUnknownFeature() { + (this as any).registerProviderFeatureUsage("notARealFeature"); + } + + protected synthesizeAttributes(): { [name: string]: any } { + return { + secret_key_wo: this.markWriteOnlyAttribute(this._secretKeyWo), + }; + } +} + +function appWithStack(context?: Record) { + const outdir = tmp("cdktf.outdir."); + const app = Testing.stubVersion( + new App({ stackTraces: false, outdir, context }), + ); + const stack = new TerraformStack(app, "MyStack"); + new TestProvider(stack, "foo", {}); + return { app, stack }; +} + +/** + * Reads back the write-only attribute of the single `testResource` from the + * fully resolved (but not validated) synthesized JSON - i.e. what actually + * renders, independent of whether target-version validation would pass or + * fail. + */ +function synthesizedSecretKeyWo(stack: TerraformStack): unknown { + const tfConfig = JSON.parse(Testing.synth(stack)); + return tfConfig.resource?.test_write_only_resource?.testResource + ?.secret_key_wo; +} + +describe("registerProviderFeatureUsage (write-only attributes)", () => { + test("feature not used: synth passes", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", {}); + + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); + expect(() => app.synth()).not.toThrow(); + }); + + test("default targets: synth fails naming terraform and opentofu floors with a hint", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack/testResource] write-only attributes requires terraform >=1.11.0, but the project targets terraform >=1.5.7. Write-only attributes are available in terraform >=1.11.0 and opentofu >=1.11.0. + [MyStack/testResource] write-only attributes requires opentofu >=1.11.0, but the project targets opentofu >=1.6.0. Write-only attributes are available in terraform >=1.11.0 and opentofu >=1.11.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); + + test("targets covering the feature floor: synth passes", () => { + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.11.0", opentofu: ">=1.11.0" }, + }); + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(() => app.synth()).not.toThrow(); + }); + + test("a single app.synth() call (prepareStack's preparing resolve + the synthesizer's final resolve) does not stack duplicate validations", () => { + // App.synth() itself performs two resolve passes over every element's + // toTerraform() - prepareStack's preparing pass, then the synthesizer's + // final render - so even a single synth() call resolves (and so + // registers) markWriteOnlyAttribute's wrapper twice. The dedup Set on + // TerraformElement must collapse that into one validation, one error + // line. + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.5.7" }, + }); + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` + "Validation failed with the following errors: + [MyStack/testResource] write-only attributes requires terraform >=1.11.0, but the project targets terraform >=1.5.7. Write-only attributes are available in terraform >=1.11.0 and opentofu >=1.11.0. + + If you wish to ignore these validations, pass 'skipValidation: true' to your App configuration. + " + `); + }); + + // (a) non-null setter value then explicit null before synth. Assigning a + // literal `null` through the (raw-assignment) setter renders as an + // explicit JSON `null` - Terraform's own "omit this attribute" spelling, + // distinct from the key being absent entirely - but either way the + // resolved value is not-a-real-value, so it must not register/arm the + // validation. + test("setter: value then cleared with null before synth: attribute nulled out, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = "shh"; + resource.secretKeyWo = null as unknown as string; + + expect(synthesizedSecretKeyWo(stack)).toBeNull(); + expect(() => app.synth()).not.toThrow(); + }); + + // (b) non-null setter value then reset before synth + test("setter: value then reset before synth: attribute omitted, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = "shh"; + resource.resetSecretKeyWo(); + + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); + expect(() => app.synth()).not.toThrow(); + }); + + // (c) non-null constructor value then reset before synth + test("constructor: value then reset before synth: attribute omitted, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + resource.resetSecretKeyWo(); + + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); + expect(() => app.synth()).not.toThrow(); + }); + + // (d) value present at synth + test("setter: a real value present at synth: attribute renders, synth fails", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = "shh"; + + expect(synthesizedSecretKeyWo(stack)).toBe("shh"); + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + + test("constructor: a real value present at synth: attribute renders, synth fails", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(synthesizedSecretKeyWo(stack)).toBe("shh"); + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + + // (e) re-set after clearing re-arms the validation + test("re-setting a value after clearing it fails again (re-arm)", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = "shh"; + resource.secretKeyWo = null as unknown as string; + // If registration were still sticky/event-based, clearing above would + // have permanently armed the validation regardless of what happens + // next. It must not: only the FINAL, rendered value matters. + resource.secretKeyWo = "shh again"; + + expect(synthesizedSecretKeyWo(stack)).toBe("shh again"); + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + + // (f) a Lazy/IResolvable producer - the token case that motivated this + // design: at setter-call time, the value is always a non-null token + // string, so an event-based "value != null" guard cannot tell whether the + // producer will actually contribute anything until resolve time. + test("Lazy producer resolving to undefined: attribute omitted, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = Lazy.stringValue({ produce: () => undefined }); + + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); + expect(() => app.synth()).not.toThrow(); + }); + + test("Lazy producer resolving to a string: attribute renders, synth fails", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = Lazy.stringValue({ produce: () => "lazy-shh" }); + + expect(synthesizedSecretKeyWo(stack)).toBe("lazy-shh"); + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + + // (g) explicit null from the start (previous round's semantics, preserved) + test("constructor: explicit null from the start is treated as omission, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: null, + }); + + expect(resource.secretKeyWo).toBeUndefined(); + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); + expect(() => app.synth()).not.toThrow(); + }); + + test("setter: assigning null (never having set a real value) is treated as omission, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + + resource.secretKeyWo = null as unknown as string; + + expect(synthesizedSecretKeyWo(stack)).toBeNull(); + expect(() => app.synth()).not.toThrow(); + }); + + test("unknown feature key throws", () => { + const { stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + + expect(() => + resource.callWithUnknownFeature(), + ).toThrowErrorMatchingInlineSnapshot( + `"Unknown provider-protocol feature "notARealFeature" passed to registerProviderFeatureUsage. This is an internal cdktn API intended to be called by generated provider bindings, not user code; if you did not call it directly, please file a bug report."`, + ); + }); +}); + +// Round 6, Finding 2: markWriteOnlyAttribute's registration is only ever +// discovered by a prepare step (TerraformStack._runPreparingResolve). Every +// validation-enabled entry point must run that prepare step itself before +// validating, not just App.synth() - otherwise a write-only value that +// violates the declared targets silently renders without ever being +// flagged. Round 6, Finding 3: because the registration this discovers +// represents only the CURRENT synthesis pass, repeat synthesis of the same +// App/stack must neither leak a stale registration into a pass where the +// value is no longer present, nor stack up duplicate validations across +// passes. +describe("resolve-discovered write-only usage: entry points and synthesis epochs", () => { + test("Testing.synth(stack, true) surfaces an old-target write-only failure", () => { + const { stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { secretKeyWo: "shh" }); + + expect(() => Testing.synth(stack, true)).toThrow( + /write-only attributes requires/, + ); + }); + + test("Testing.synthHcl(stack, true) surfaces an old-target write-only failure", () => { + const { stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { secretKeyWo: "shh" }); + + expect(() => Testing.synthHcl(stack, true)).toThrow( + /write-only attributes requires/, + ); + }); + + test("direct StackSynthesizer/stack synthesis without App.synth() surfaces an old-target write-only failure", () => { + const { stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { secretKeyWo: "shh" }); + + // Testing.fullSynth() drives stack.synthesizer.synthesize() directly + // with a bare session that never sets `stacksPrepared` - the same shape + // any hand-rolled IStackSynthesizer caller (not going through + // App.synth()) would build. + expect(() => Testing.fullSynth(stack)).toThrow( + /write-only attributes requires/, + ); + }); + + test("repeat synthesis of the same App: clearing the write-only value between synths lets the second pass succeed", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(() => app.synth()).toThrow(/write-only attributes requires/); + + resource.resetSecretKeyWo(); + + expect(() => app.synth()).not.toThrow(); + }); + + test("repeat synthesis of the same App: a Lazy producer returning a value on the first synth and undefined on the second - second synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + let produceValue = true; + resource.secretKeyWo = Lazy.stringValue({ + produce: () => (produceValue ? "lazy-shh" : undefined), + }); + + expect(() => app.synth()).toThrow(/write-only attributes requires/); + + produceValue = false; + + expect(() => app.synth()).not.toThrow(); + }); + + test("re-activating the same registration across repeated synthesis passes still yields exactly one error line per targeted product, not a growing stack of duplicates", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { secretKeyWo: "shh" }); + + for (let i = 0; i < 3; i++) { + let message = ""; + try { + app.synth(); + } catch (e: any) { + message = e.message as string; + } + const errorLines = message + .split("\n") + .filter((line) => line.includes("write-only attributes requires")); + // one line per targeted product (terraform + opentofu) - never more, + // no matter how many prior passes re-activated this same element's + // registration. + expect(errorLines).toHaveLength(2); + } + }); +}); + +/** + * Mirrors what generated `synthesizeHclAttributes()` emits for HCL + * synthesis: a per-attribute descriptor `{ value, isBlock, type, + * storageClassType }` (see `AttributesEmitter#emitToHclTerraform`), gated + * by the generator's own "remove undefined attributes" pre-filter - + * `value !== undefined && value.value !== undefined` - which only inspects + * the *unresolved* value. A token (write-only-wrapped or not) is never + * itself `undefined`, so it survives that pre-filter even when it will go + * on to *resolve* to `undefined`. + * + * When that happens, generic `resolve()` (tokens/private/resolve.ts) deep- + * resolves the descriptor object and silently drops just the `value` key + * (the one key whose resolution can legitimately come back `undefined`), + * leaving a bare `{ isBlock, type, storageClassType }` shell for + * `renderAttributes()` (hcl/render.ts) to deal with. Previously it fell + * through to `renderFuzzyJsonExpression()` and rendered that leftover + * metadata as a bogus assignment; it must now omit the attribute entirely + * instead, exactly as if it had never been set. + */ +class TestHclDescriptorResource extends TerraformResource { + public secretKeyWo?: any; + public plainAttr?: any; + + constructor( + scope: Construct, + id: string, + config: { secretKeyWo?: any; plainAttr?: any }, + ) { + super(scope, id, { + terraformResourceType: "test_hcl_descriptor_resource", + }); + this.secretKeyWo = config.secretKeyWo; + this.plainAttr = config.plainAttr; + } + + protected synthesizeHclAttributes(): { [name: string]: any } { + return Object.fromEntries( + Object.entries({ + // write-only: value wrapped in markWriteOnlyAttribute(), exactly + // as generated bindings do. + secret_key_wo: { + value: this.markWriteOnlyAttribute(this.secretKeyWo), + isBlock: false, + type: "simple", + storageClassType: "string", + }, + // not write-only at all - pins that the fix is general, per the + // #313 HCL-defects family, not specific to the write-only marker. + plain_attr: { + value: this.plainAttr, + isBlock: false, + type: "simple", + storageClassType: "string", + }, + }).filter( + ([_, value]) => value !== undefined && value.value !== undefined, + ), + ); + } +} + +function hclForDescriptorResource(config: { + secretKeyWo?: any; + plainAttr?: any; +}): string { + const { stack } = appWithStack(); + new TestHclDescriptorResource(stack, "testResource", config); + return Testing.synthHcl(stack); +} + +describe("renderAttributes (HCL synthesis): a descriptor whose value resolves to undefined", () => { + test("write-only attribute wrapped by markWriteOnlyAttribute: Lazy resolving to undefined - attribute entirely absent, no leaked metadata", () => { + const hcl = hclForDescriptorResource({ + secretKeyWo: Lazy.stringValue({ produce: () => undefined }), + }); + + expect(hcl).not.toMatch(/secret_key_wo/); + expect(hcl).not.toMatch(/isBlock/); + expect(hcl).not.toMatch(/storageClassType/); + // the storage type name itself must not leak either (pre-fix, this + // rendered as `storageClassType = "string"` via renderFuzzyJsonObject) + expect(hcl).not.toMatch(/storageClassType\s*=\s*"string"/); + }); + + test("write-only attribute: Lazy resolving to a real string still renders normally", () => { + const hcl = hclForDescriptorResource({ + secretKeyWo: Lazy.stringValue({ produce: () => "shh" }), + }); + + expect(hcl).toMatch(/secret_key_wo\s*=\s*"shh"/); + }); + + // Requirement 2: only the *absent*-value case is omitted - an explicit + // `null` must keep rendering as an explicit null assignment. + test("write-only attribute: explicit null still renders as an explicit null, not omitted", () => { + const hcl = hclForDescriptorResource({ secretKeyWo: null }); + + expect(hcl).toMatch(/secret_key_wo\s*=\s*null/); + }); + + // General case (not write-only specific): any attribute descriptor whose + // value is a plain (unwrapped) token resolving to undefined must also be + // omitted, with no leaked descriptor metadata. + test("plain (non-write-only) attribute: token resolving to undefined - attribute entirely absent, no leaked metadata", () => { + const hcl = hclForDescriptorResource({ + plainAttr: Lazy.anyValue({ produce: () => undefined }), + }); + + expect(hcl).not.toMatch(/plain_attr/); + expect(hcl).not.toMatch(/isBlock/); + expect(hcl).not.toMatch(/storageClassType/); + expect(hcl).not.toMatch(/storageClassType\s*=\s*"string"/); + }); + + test("plain (non-write-only) attribute: token resolving to a real value still renders normally", () => { + const hcl = hclForDescriptorResource({ + plainAttr: Lazy.anyValue({ produce: () => "value" }), + }); + + expect(hcl).toMatch(/plain_attr\s*=\s*"value"/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14c087d38..0ce75f6c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1323,10 +1323,16 @@ importers: fs-extra: specifier: 11.3.4 version: 11.3.4 + semver: + specifier: 7.7.4 + version: 7.7.4 devDependencies: '@types/fs-extra': specifier: 11.0.4 version: 11.0.4 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 safe-stable-stringify: specifier: 2.5.0 version: 2.5.0 diff --git a/tools/provider-feature-availability/README.md b/tools/provider-feature-availability/README.md new file mode 100644 index 000000000..5bb6f3dee --- /dev/null +++ b/tools/provider-feature-availability/README.md @@ -0,0 +1,53 @@ +# Provider feature availability matrix + +`features-matrix.json` records, for every provider plugin-protocol capability +family surfaced by `providers schema -json` (provider-defined functions, +ephemeral resources, write-only attributes, resource identity, list resources, +actions, state stores), the Terraform and OpenTofu releases whose CLI emits it +— merged from sweep observations and source-verified CLI serializer history. + +It is the source dataset for the hand-maintained +`packages/cdktn/src/provider-feature-constraints.ts` map used by the +synth-time `ValidateFeatureTargetSupport` check against a project's declared +`targetVersions`. + +Note the provider-functions asymmetry: OpenTofu supports the +`provider::ns::fn()` language syntax from 1.7.0, but its +`providers schema -json` only emits the `functions` key from 1.8.0 — usage +validation uses the language boundary, schema acquisition the emission +boundary. + +## In-repo consumers + +Two hand-maintained maps are derived from this matrix and must be kept in +sync with it by hand: + +- `packages/cdktn/src/provider-feature-constraints.ts` — + `providerFeatureConstraints`, the _usage_ boundaries (language support) per + feature family, keyed off each feature's `documented_ga`/language-support + version here. This is where the deliberate OpenTofu `providerFunctions` + exception lives: 1.7.0 (language support) rather than 1.8.0 (schema + emission). +- `packages/@cdktn/provider-schema/src/emission-check.ts` — + `SCHEMA_EMISSION_BOUNDARIES`, the _schema-emission_ boundaries (when + `providers schema -json` starts including the family's key) per feature + family, keyed off each feature's `documented_emitted_from` / + `observed_introduced` fields here. + +There is no automated check today that these two maps agree with +`features-matrix.json` — updates must be applied by hand when the matrix +changes. An automated drift check is tracked in #309. + +## Regenerating + +Only the matrix JSON is vendored in this repo. The baseline data sweep that +produces it — the per-release `providers schema -json` digests plus the +`build-matrix.py` / `build-report.py` / `sweep.sh` scripts, the pinned +fixtures, the interactive HTML report, and the design proposal — lives in the +planning repo: + +> **open-constructs/cdktn-planning** — `RFCS/04-provider-feature-availability/` + +New CLI minors are appended by re-running `scripts/sweep.sh` there (idempotent; +existing digests are skipped) followed by `build-matrix.py`, which fails loudly +if a new observation contradicts the documented serializer history. diff --git a/tools/provider-feature-availability/features-matrix.json b/tools/provider-feature-availability/features-matrix.json new file mode 100644 index 000000000..bfdd4ca7e --- /dev/null +++ b/tools/provider-feature-availability/features-matrix.json @@ -0,0 +1,856 @@ +{ + "generated_note": "provider-protocol feature availability from `providers schema -json` sweeps (observed) merged with source-verified CLI serializer history (documented); see PROPOSAL.md for citations", + "versions": { + "terraform": [ + "1.5.7", + "1.6.6", + "1.7.0", + "1.8.0", + "1.9.0", + "1.10.0", + "1.11.0", + "1.12.0", + "1.13.0", + "1.14.0", + "1.15.0", + "1.15.6" + ], + "opentofu": [ + "1.6.0", + "1.7.0", + "1.8.0", + "1.9.0", + "1.10.0", + "1.11.0", + "1.12.0", + "1.12.1" + ] + }, + "fixture_provider_selections": { + "terraform": { + "hashicorp/aws": "6.14.1", + "hashicorp/local": "2.8.0", + "hashicorp/random": "3.9.0", + "hashicorp/time": "0.13.0", + "hashicorp/tls": "4.2.0", + "hashicorp/vault": "5.9.0" + }, + "opentofu": { + "hashicorp/aws": "6.14.1", + "hashicorp/local": "2.8.0", + "hashicorp/random": "3.9.0", + "hashicorp/time": "0.13.0", + "hashicorp/tls": "4.2.0", + "hashicorp/vault": "5.9.0" + } + }, + "features": { + "provider_functions": { + "title": "Provider-defined functions", + "schema_key": "functions", + "protocol": "5.5 / 6.5", + "plugin_go": "v0.20.0 (2023-12)", + "notes": "OpenTofu shipped provider::ns::fn() language support in 1.7.0 (before Terraform 1.8), but `tofu providers schema -json` only emits the `functions` key from 1.8.0.", + "products": { + "terraform": { + "documented_emitted_from": "1.8.0", + "documented_ga": "1.8.0", + "observed_introduced": "1.8.0", + "observed_versions": [ + "1.8.0", + "1.9.0", + "1.10.0", + "1.11.0", + "1.12.0", + "1.13.0", + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": "1.8.0", + "documented_ga": "1.7.0", + "observed_introduced": "1.8.0", + "observed_versions": [ + "1.8.0", + "1.9.0", + "1.10.0", + "1.11.0", + "1.12.0", + "1.12.1" + ] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": [ + "arn_build", + "arn_parse", + "trim_iam_role_path" + ], + "hashicorp/local": [ + "direxists" + ], + "hashicorp/time": [ + "duration_parse", + "rfc3339_parse", + "unix_timestamp_parse" + ] + } + }, + "opentofu": { + "version": "1.12.1", + "providers": { + "hashicorp/aws": [ + "arn_build", + "arn_parse", + "trim_iam_role_path" + ], + "hashicorp/local": [ + "direxists" + ], + "hashicorp/time": [ + "duration_parse", + "rfc3339_parse", + "unix_timestamp_parse" + ] + } + } + } + }, + "ephemeral_resources": { + "title": "Ephemeral resources", + "schema_key": "ephemeral_resource_schemas", + "protocol": "5.7 / 6.7", + "plugin_go": "v0.25.0 (2024-10)", + "notes": "Same Schema shape as resource_schemas. OpenTofu 1.12 fixed ephemeral resources leaking into the plan file.", + "products": { + "terraform": { + "documented_emitted_from": "1.10.0", + "documented_ga": "1.10.0", + "observed_introduced": "1.10.0", + "observed_versions": [ + "1.10.0", + "1.11.0", + "1.12.0", + "1.13.0", + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": "1.11.0", + "documented_ga": "1.11.0", + "observed_introduced": "1.11.0", + "observed_versions": [ + "1.11.0", + "1.12.0", + "1.12.1" + ] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": [ + "aws_cognito_identity_openid_token_for_developer_identity", + "aws_eks_cluster_auth", + "aws_kms_secrets", + "aws_lambda_invocation", + "aws_secretsmanager_random_password", + "aws_secretsmanager_secret_version", + "aws_ssm_parameter" + ], + "hashicorp/local": [ + "local_command" + ], + "hashicorp/random": [ + "random_bytes", + "random_password" + ], + "hashicorp/tls": [ + "tls_private_key", + "tls_public_key" + ], + "hashicorp/vault": [ + "vault_alicloud_access_credentials", + "vault_approle_auth_backend_role_secret_id", + "vault_aws_access_credentials", + "vault_aws_static_access_credentials", + "vault_azure_access_credentials", + "vault_azure_static_credentials", + "vault_cf_auth_login", + "vault_database_secret", + "vault_gcp_oauth2_access_token", + "vault_gcp_service_account_key", + "vault_generic_endpoint", + "vault_generic_secret", + "vault_kubernetes_service_account_token", + "vault_kv_secret_v2", + "vault_spiffe_secret_backend_mintjwt", + "vault_terraform_token" + ] + } + }, + "opentofu": { + "version": "1.12.1", + "providers": { + "hashicorp/aws": [ + "aws_cognito_identity_openid_token_for_developer_identity", + "aws_eks_cluster_auth", + "aws_kms_secrets", + "aws_lambda_invocation", + "aws_secretsmanager_random_password", + "aws_secretsmanager_secret_version", + "aws_ssm_parameter" + ], + "hashicorp/local": [ + "local_command" + ], + "hashicorp/random": [ + "random_bytes", + "random_password" + ], + "hashicorp/tls": [ + "tls_private_key", + "tls_public_key" + ], + "hashicorp/vault": [ + "vault_alicloud_access_credentials", + "vault_approle_auth_backend_role_secret_id", + "vault_aws_access_credentials", + "vault_aws_static_access_credentials", + "vault_azure_access_credentials", + "vault_azure_static_credentials", + "vault_cf_auth_login", + "vault_database_secret", + "vault_gcp_oauth2_access_token", + "vault_gcp_service_account_key", + "vault_generic_endpoint", + "vault_generic_secret", + "vault_kubernetes_service_account_token", + "vault_kv_secret_v2", + "vault_spiffe_secret_backend_mintjwt", + "vault_terraform_token" + ] + } + } + } + }, + "write_only_attributes": { + "title": "Write-only attributes", + "schema_key": "attribute flag \"write_only\"", + "protocol": "5.8 / 6.8", + "plugin_go": "v0.26.0 (2025-01)", + "notes": "Attribute-level boolean (omitempty) on managed resource schemas only; must be paired with optional or required. Conventionally named *_wo with a *_wo_version companion.", + "products": { + "terraform": { + "documented_emitted_from": "1.11.0", + "documented_ga": "1.11.0", + "observed_introduced": "1.11.0", + "observed_versions": [ + "1.11.0", + "1.12.0", + "1.13.0", + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": "1.11.0", + "documented_ga": "1.11.0", + "observed_introduced": "1.11.0", + "observed_versions": [ + "1.11.0", + "1.12.0", + "1.12.1" + ] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": { + "aws_db_instance": [ + "password_wo" + ], + "aws_docdb_cluster": [ + "master_password_wo" + ], + "aws_rds_cluster": [ + "master_password_wo" + ], + "aws_redshift_cluster": [ + "master_password_wo" + ], + "aws_redshiftserverless_namespace": [ + "admin_user_password_wo" + ], + "aws_secretsmanager_secret_version": [ + "secret_string_wo" + ], + "aws_ssm_parameter": [ + "value_wo" + ] + }, + "hashicorp/vault": { + "vault_alicloud_secret_backend": [ + "secret_key_wo" + ], + "vault_approle_auth_backend_login": [ + "secret_id_wo" + ], + "vault_aws_auth_backend_client": [ + "secret_key_wo" + ], + "vault_aws_secret_backend": [ + "secret_key_wo" + ], + "vault_azure_auth_backend_config": [ + "client_secret_wo" + ], + "vault_azure_secret_backend": [ + "client_secret_wo" + ], + "vault_azure_secret_backend_static_role": [ + "client_secret" + ], + "vault_cf_auth_backend_config": [ + "cf_password_wo" + ], + "vault_consul_secret_backend": [ + "client_key_wo", + "token_wo" + ], + "vault_database_secret_backend_connection": [ + "hana.password_wo", + "mongodb.password_wo", + "mssql.password_wo", + "mysql.password_wo", + "mysql_aurora.password_wo", + "mysql_legacy.password_wo", + "mysql_rds.password_wo", + "oracle.password_wo", + "postgresql.password_wo", + "redshift.password_wo", + "snowflake.password_wo", + "snowflake.private_key_wo" + ], + "vault_database_secret_backend_static_role": [ + "password_wo" + ], + "vault_database_secrets_mount": [ + "hana.password_wo", + "mongodb.password_wo", + "mssql.password_wo", + "mysql.password_wo", + "mysql_aurora.password_wo", + "mysql_legacy.password_wo", + "mysql_rds.password_wo", + "oracle.password_wo", + "postgresql.password_wo", + "redshift.password_wo", + "snowflake.password_wo", + "snowflake.private_key_wo" + ], + "vault_gcp_auth_backend": [ + "credentials_wo" + ], + "vault_gcp_secret_backend": [ + "credentials_wo" + ], + "vault_jwt_auth_backend": [ + "oidc_client_secret_wo" + ], + "vault_keymgmt_aws_kms": [ + "credentials_wo" + ], + "vault_keymgmt_azure_kms": [ + "credentials_wo" + ], + "vault_keymgmt_gcp_kms": [ + "credentials_wo" + ], + "vault_kubernetes_auth_backend_config": [ + "token_reviewer_jwt_wo" + ], + "vault_kubernetes_secret_backend": [ + "service_account_jwt_wo" + ], + "vault_kv_secret_v2": [ + "data_json_wo" + ], + "vault_ldap_auth_backend": [ + "bindpass_wo" + ], + "vault_ldap_secret_backend": [ + "bindpass_wo" + ], + "vault_ldap_secret_backend_static_role": [ + "password_wo" + ], + "vault_mongodbatlas_secret_backend": [ + "private_key_wo" + ], + "vault_nomad_secret_backend": [ + "client_key_wo", + "token_wo" + ], + "vault_okta_auth_backend": [ + "api_token_wo" + ], + "vault_os_secret_backend_account": [ + "password_wo" + ], + "vault_pki_external_ca_secret_backend_acme_account": [ + "eab_key", + "eab_kid" + ], + "vault_rabbitmq_secret_backend": [ + "password_wo" + ], + "vault_secrets_sync_aws_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_secrets_sync_azure_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_secrets_sync_gcp_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_spiffe_auth_backend_config": [ + "defer_bundle_fetch" + ], + "vault_terraform_cloud_secret_backend": [ + "token_wo" + ] + } + } + }, + "opentofu": { + "version": "1.12.1", + "providers": { + "hashicorp/aws": { + "aws_db_instance": [ + "password_wo" + ], + "aws_docdb_cluster": [ + "master_password_wo" + ], + "aws_rds_cluster": [ + "master_password_wo" + ], + "aws_redshift_cluster": [ + "master_password_wo" + ], + "aws_redshiftserverless_namespace": [ + "admin_user_password_wo" + ], + "aws_secretsmanager_secret_version": [ + "secret_string_wo" + ], + "aws_ssm_parameter": [ + "value_wo" + ] + }, + "hashicorp/vault": { + "vault_alicloud_secret_backend": [ + "secret_key_wo" + ], + "vault_approle_auth_backend_login": [ + "secret_id_wo" + ], + "vault_aws_auth_backend_client": [ + "secret_key_wo" + ], + "vault_aws_secret_backend": [ + "secret_key_wo" + ], + "vault_azure_auth_backend_config": [ + "client_secret_wo" + ], + "vault_azure_secret_backend": [ + "client_secret_wo" + ], + "vault_azure_secret_backend_static_role": [ + "client_secret" + ], + "vault_cf_auth_backend_config": [ + "cf_password_wo" + ], + "vault_consul_secret_backend": [ + "client_key_wo", + "token_wo" + ], + "vault_database_secret_backend_connection": [ + "hana.password_wo", + "mongodb.password_wo", + "mssql.password_wo", + "mysql.password_wo", + "mysql_aurora.password_wo", + "mysql_legacy.password_wo", + "mysql_rds.password_wo", + "oracle.password_wo", + "postgresql.password_wo", + "redshift.password_wo", + "snowflake.password_wo", + "snowflake.private_key_wo" + ], + "vault_database_secret_backend_static_role": [ + "password_wo" + ], + "vault_database_secrets_mount": [ + "hana.password_wo", + "mongodb.password_wo", + "mssql.password_wo", + "mysql.password_wo", + "mysql_aurora.password_wo", + "mysql_legacy.password_wo", + "mysql_rds.password_wo", + "oracle.password_wo", + "postgresql.password_wo", + "redshift.password_wo", + "snowflake.password_wo", + "snowflake.private_key_wo" + ], + "vault_gcp_auth_backend": [ + "credentials_wo" + ], + "vault_gcp_secret_backend": [ + "credentials_wo" + ], + "vault_jwt_auth_backend": [ + "oidc_client_secret_wo" + ], + "vault_keymgmt_aws_kms": [ + "credentials_wo" + ], + "vault_keymgmt_azure_kms": [ + "credentials_wo" + ], + "vault_keymgmt_gcp_kms": [ + "credentials_wo" + ], + "vault_kubernetes_auth_backend_config": [ + "token_reviewer_jwt_wo" + ], + "vault_kubernetes_secret_backend": [ + "service_account_jwt_wo" + ], + "vault_kv_secret_v2": [ + "data_json_wo" + ], + "vault_ldap_auth_backend": [ + "bindpass_wo" + ], + "vault_ldap_secret_backend": [ + "bindpass_wo" + ], + "vault_ldap_secret_backend_static_role": [ + "password_wo" + ], + "vault_mongodbatlas_secret_backend": [ + "private_key_wo" + ], + "vault_nomad_secret_backend": [ + "client_key_wo", + "token_wo" + ], + "vault_okta_auth_backend": [ + "api_token_wo" + ], + "vault_os_secret_backend_account": [ + "password_wo" + ], + "vault_pki_external_ca_secret_backend_acme_account": [ + "eab_key", + "eab_kid" + ], + "vault_rabbitmq_secret_backend": [ + "password_wo" + ], + "vault_secrets_sync_aws_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_secrets_sync_azure_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_secrets_sync_gcp_destination": [ + "identity_token_audience_wo", + "identity_token_key_wo" + ], + "vault_spiffe_auth_backend_config": [ + "defer_bundle_fetch" + ], + "vault_terraform_cloud_secret_backend": [ + "token_wo" + ] + } + } + } + } + }, + "resource_identity": { + "title": "Resource identity", + "schema_key": "resource_identity_schemas", + "protocol": "5.9 / 6.9", + "plugin_go": "v0.27.0 (2025-05)", + "notes": "Top-level map (resource type -> identity schema), not nested in the resource schema. No HashiCorp utility provider implements it (June 2026) \u2014 observed via the aws fixture instead: aws 6.14.1 ships identity on 475 resources, emitted identically by both products.", + "products": { + "terraform": { + "documented_emitted_from": "1.12.0", + "documented_ga": "1.12.0", + "observed_introduced": "1.12.0", + "observed_versions": [ + "1.12.0", + "1.13.0", + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": "1.12.0", + "documented_ga": "1.12.0", + "observed_introduced": "1.12.0", + "observed_versions": [ + "1.12.0", + "1.12.1" + ] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": [ + "aws_acm_certificate", + "aws_acmpca_certificate", + "aws_acmpca_certificate_authority", + "aws_acmpca_certificate_authority_certificate", + "aws_acmpca_policy", + "aws_alb", + "aws_alb_listener", + "aws_alb_listener_rule", + "aws_alb_target_group", + "aws_api_gateway_account", + "aws_api_gateway_domain_name_access_association", + "aws_api_gateway_rest_api_put", + "aws_appconfig_environment", + "aws_appfabric_app_authorization", + "aws_appfabric_app_authorization_connection", + "aws_appfabric_app_bundle", + "aws_appfabric_ingestion", + "aws_appfabric_ingestion_destination", + "aws_appflow_connector_profile", + "aws_appflow_flow", + "aws_apprunner_auto_scaling_configuration_version", + "aws_apprunner_default_auto_scaling_configuration_version", + "aws_apprunner_deployment", + "aws_apprunner_observability_configuration", + "aws_apprunner_service", + "aws_apprunner_vpc_connector", + "aws_apprunner_vpc_ingress_connection", + "aws_appsync_api", + "aws_appsync_channel_namespace", + "aws_appsync_source_api_association", + "aws_athena_capacity_reservation", + "aws_auditmanager_account_registration", + "aws_auditmanager_assessment", + "aws_auditmanager_assessment_delegation", + "aws_auditmanager_assessment_report", + "aws_auditmanager_control", + "aws_auditmanager_framework", + "aws_auditmanager_framework_share", + "aws_auditmanager_organization_admin_account_registration", + "aws_backup_logically_air_gapped_vault", + "aws_backup_region_settings", + "aws_backup_restore_testing_plan", + "aws_backup_restore_testing_selection", + "aws_batch_job_definition", + "aws_batch_job_queue", + "aws_bcmdataexports_export", + "aws_bedrock_custom_model", + "aws_bedrock_guardrail", + "aws_bedrock_guardrail_version", + "aws_bedrock_inference_profile", + "... (+425 more)" + ] + } + }, + "opentofu": { + "version": "1.12.1", + "providers": { + "hashicorp/aws": [ + "aws_acm_certificate", + "aws_acmpca_certificate", + "aws_acmpca_certificate_authority", + "aws_acmpca_certificate_authority_certificate", + "aws_acmpca_policy", + "aws_alb", + "aws_alb_listener", + "aws_alb_listener_rule", + "aws_alb_target_group", + "aws_api_gateway_account", + "aws_api_gateway_domain_name_access_association", + "aws_api_gateway_rest_api_put", + "aws_appconfig_environment", + "aws_appfabric_app_authorization", + "aws_appfabric_app_authorization_connection", + "aws_appfabric_app_bundle", + "aws_appfabric_ingestion", + "aws_appfabric_ingestion_destination", + "aws_appflow_connector_profile", + "aws_appflow_flow", + "aws_apprunner_auto_scaling_configuration_version", + "aws_apprunner_default_auto_scaling_configuration_version", + "aws_apprunner_deployment", + "aws_apprunner_observability_configuration", + "aws_apprunner_service", + "aws_apprunner_vpc_connector", + "aws_apprunner_vpc_ingress_connection", + "aws_appsync_api", + "aws_appsync_channel_namespace", + "aws_appsync_source_api_association", + "aws_athena_capacity_reservation", + "aws_auditmanager_account_registration", + "aws_auditmanager_assessment", + "aws_auditmanager_assessment_delegation", + "aws_auditmanager_assessment_report", + "aws_auditmanager_control", + "aws_auditmanager_framework", + "aws_auditmanager_framework_share", + "aws_auditmanager_organization_admin_account_registration", + "aws_backup_logically_air_gapped_vault", + "aws_backup_region_settings", + "aws_backup_restore_testing_plan", + "aws_backup_restore_testing_selection", + "aws_batch_job_definition", + "aws_batch_job_queue", + "aws_bcmdataexports_export", + "aws_bedrock_custom_model", + "aws_bedrock_guardrail", + "aws_bedrock_guardrail_version", + "aws_bedrock_inference_profile", + "... (+425 more)" + ] + } + } + } + }, + "list_resources": { + "title": "List resources / query", + "schema_key": "list_resource_schemas", + "protocol": "5.10 / 6.10", + "plugin_go": "v0.29.0 (2025-09)", + "notes": "Terraform-only (`terraform query`, *.tfquery.hcl). OpenTofu: open feature request opentofu/opentofu#3787, no milestone. Requires resource identity; observed via the aws fixture (4 list resources in aws 6.14.1); no small provider ships one yet (tfcoremock v0.6.0 unreleased).", + "products": { + "terraform": { + "documented_emitted_from": "1.14.0", + "documented_ga": "1.14.0", + "observed_introduced": "1.14.0", + "observed_versions": [ + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": null, + "documented_ga": null, + "observed_introduced": null, + "observed_versions": [] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": [ + "aws_batch_job_queue", + "aws_cloudwatch_log_group", + "aws_iam_role", + "aws_instance" + ] + } + } + } + }, + "actions": { + "title": "Actions", + "schema_key": "action_schemas", + "protocol": "5.10 / 6.10", + "plugin_go": "v0.29.0 (2025-09)", + "notes": "Terraform-only `action` blocks (lifecycle-triggered or `terraform apply -invoke`). Fixture evidence: hashicorp/local >= 2.6.0 ships action local_command; aws 6.14.1 ships 5 actions (incl. aws_lambda_invoke, aws_cloudfront_create_invalidation).", + "products": { + "terraform": { + "documented_emitted_from": "1.14.0", + "documented_ga": "1.14.0", + "observed_introduced": "1.14.0", + "observed_versions": [ + "1.14.0", + "1.15.0", + "1.15.6" + ] + }, + "opentofu": { + "documented_emitted_from": null, + "documented_ga": null, + "observed_introduced": null, + "observed_versions": [] + } + }, + "evidence": { + "terraform": { + "version": "1.15.6", + "providers": { + "hashicorp/aws": [ + "aws_cloudfront_create_invalidation", + "aws_ec2_stop_instance", + "aws_lambda_invoke", + "aws_ses_send_email", + "aws_sns_publish" + ], + "hashicorp/local": [ + "local_command" + ] + } + } + } + }, + "state_stores": { + "title": "Pluggable state stores", + "schema_key": "state_store_schemas", + "protocol": "6.11 only", + "plugin_go": "v0.30.0 (2026-02)", + "notes": "Protocol 6 only (no tfplugin5 counterpart). Terraform CLI serializer has the key from 1.15.0 but `state_store` is not GA in core through 1.15.x. OpenTofu has no equivalent (its differentiator is built-in state encryption, OpenTofu >= 1.7).", + "products": { + "terraform": { + "documented_emitted_from": "1.15.0", + "documented_ga": null, + "observed_introduced": null, + "observed_versions": [] + }, + "opentofu": { + "documented_emitted_from": null, + "documented_ga": null, + "observed_introduced": null, + "observed_versions": [] + } + }, + "evidence": {} + } + } +}