From 816d590329cee3498839bf0bc0c447e725e06c96 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 11:09:10 +0800 Subject: [PATCH 01/28] chore: vendor provider-feature availability matrix Merged sweep observations + source-verified CLI serializer history for the provider plugin-protocol capability families (provider functions, ephemeral resources, write-only attributes, resource identity, list resources, actions, state stores) across Terraform 1.5.7-1.15.x and OpenTofu 1.6.0-1.12.x. Source dataset for the providerFeatureConstraints map in packages/cdktn. Sweep tooling, fixtures, report and proposal live in open-constructs/cdktn-planning RFCS/04-provider-feature-availability (same split as tools/generate-function-bindings/function-availability). Co-Authored-By: Claude Fable 5 --- tools/provider-feature-availability/README.md | 32 + .../features-matrix.json | 856 ++++++++++++++++++ 2 files changed, 888 insertions(+) create mode 100644 tools/provider-feature-availability/README.md create mode 100644 tools/provider-feature-availability/features-matrix.json diff --git a/tools/provider-feature-availability/README.md b/tools/provider-feature-availability/README.md new file mode 100644 index 000000000..59a361728 --- /dev/null +++ b/tools/provider-feature-availability/README.md @@ -0,0 +1,32 @@ +# 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. + +## 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": {} + } + } +} From de9ad48815f356850713546a9d81ba583c7607d0 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 11:34:56 +0800 Subject: [PATCH 02/28] feat(lib): TerraformEphemeralResource with targetVersions validation New public base class for ephemeral resources (Terraform >=1.10 / OpenTofu >=1.11), synthesizing into the top-level `ephemeral` key of cdk.tf.json and an `ephemeral "type" "name"` block in HCL output. References resolve as ephemeral.... Ephemeral blocks support no provisioners/connection and are not importable/movable, so those APIs are omitted. The constructor registers ValidateFeatureTargetSupport (from #269) unconditionally: synth fails when the project's declared targetVersions admit releases without ephemeral resources, naming the offending range. providerFeatureConstraints is the hand-maintained per-product constraint map for the provider-protocol feature families, sourced from tools/provider-feature-availability/features-matrix.json; internal by design (generated bindings reach it by extending base classes). Part of RFC-04 (provider feature availability), rollout item 2. Co-Authored-By: Claude Fable 5 --- packages/cdktn/src/hcl/render.ts | 32 +++ packages/cdktn/src/index.ts | 1 + .../cdktn/src/provider-feature-constraints.ts | 20 ++ .../cdktn/src/terraform-ephemeral-resource.ts | 267 ++++++++++++++++++ packages/cdktn/src/terraform-stack.ts | 7 + .../test/terraform-ephemeral-resource.test.ts | 166 +++++++++++ 6 files changed, 493 insertions(+) create mode 100644 packages/cdktn/src/provider-feature-constraints.ts create mode 100644 packages/cdktn/src/terraform-ephemeral-resource.ts create mode 100644 packages/cdktn/test/terraform-ephemeral-resource.test.ts diff --git a/packages/cdktn/src/hcl/render.ts b/packages/cdktn/src/hcl/render.ts index cf0ac1405..bb240ab62 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["//"], + }, + }, + }, + }, + }; +} + /** * */ diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index 65d231d6e..227ad052c 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -10,6 +10,7 @@ export * from "./terraform-module"; export * from "./terraform-provider"; export * from "./terraform-resource"; export * from "./terraform-data-source"; +export * from "./terraform-ephemeral-resource"; export * from "./terraform-output"; export * from "./complex-computed-list"; export * from "./resource"; diff --git a/packages/cdktn/src/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts new file mode 100644 index 000000000..968973003 --- /dev/null +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { TerraformFeatureVersionConstraints } from "./validations/validate-terraform-feature-version"; + +/** + * 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). + * + * 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. + */ +export const providerFeatureConstraints = { + providerFunctions: { terraform: ">=1.8.0", opentofu: ">=1.7.0" }, // opentofu language support since 1.7.0 even though schema emission starts 1.8.0 + ephemeralResources: { terraform: ">=1.10.0", opentofu: ">=1.11.0" }, + writeOnlyAttributes: { terraform: ">=1.11.0", opentofu: ">=1.11.0" }, + resourceIdentity: { terraform: ">=1.12.0", opentofu: ">=1.12.0" }, +} as const satisfies Record; diff --git a/packages/cdktn/src/terraform-ephemeral-resource.ts b/packages/cdktn/src/terraform-ephemeral-resource.ts new file mode 100644 index 000000000..209d5e4d9 --- /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, + TerraformResourceLifecycle, + lifecycleToTerraform, +} from "./terraform-resource"; +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 { ValidateFeatureTargetSupport } from "./validations/target-versions"; +import { providerFeatureConstraints } 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", +); + +/** + * Tells the user where ephemeral resources ARE available, so they can adjust + * their declared targetVersions. + */ +const EPHEMERAL_RESOURCES_HINT = `Ephemeral resources are available in ${Object.entries( + providerFeatureConstraints.ephemeralResources, +) + .map(([product, range]) => `${product} ${range}`) + .join(" and ")}.`; + +export interface ITerraformEphemeralResource { + readonly terraformResourceType: string; + readonly fqn: string; + readonly friendlyUniqueId: string; + + dependsOn?: string[]; + count?: number | TerraformCount; + provider?: TerraformProvider; + lifecycle?: TerraformResourceLifecycle; + 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?: TerraformResourceLifecycle; + 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?: TerraformResourceLifecycle; + 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; + + this.node.addValidation( + new ValidateFeatureTargetSupport( + this, + "ephemeral resources", + providerFeatureConstraints.ephemeralResources, + EPHEMERAL_RESOURCES_HINT, + ), + ); + } + + 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-stack.ts b/packages/cdktn/src/terraform-stack.ts index 73649811e..1e17adfe8 100644 --- a/packages/cdktn/src/terraform-stack.ts +++ b/packages/cdktn/src/terraform-stack.ts @@ -26,6 +26,7 @@ import { TerraformResourceTargets } from "./terraform-resource-targets"; import { TerraformResource } from "./terraform-resource"; import { renderDatasource, + renderEphemeral, renderModule, renderMoved, renderOutput, @@ -283,6 +284,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/test/terraform-ephemeral-resource.test.ts b/packages/cdktn/test/terraform-ephemeral-resource.test.ts new file mode 100644 index 000000000..b9b21e375 --- /dev/null +++ b/packages/cdktn/test/terraform-ephemeral-resource.test.ts @@ -0,0 +1,166 @@ +// 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("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(); + }); +}); From 402f21b6e1ec85a56ac5dd31db3a4fdf830dcde4 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 11:35:12 +0800 Subject: [PATCH 03/28] feat(provider-generator): acquire newer provider-protocol schema sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of RFC-04 — schema acquisition correctness: - Type the newer providers-schema sections in @cdktn/commons: ephemeral_resource_schemas, functions (FunctionSignature), resource_identity_schemas, and the write_only attribute flag. List/action/state-store sections stay untyped (nothing consumes them; they pass through the in-place sanitizer untouched). - Extend sanitizeProviderSchema's attribute-doubling walk to ephemeral_resource_schemas. - Stamp fetched schemas with the fetching CLI ({cli_name, cli_version}, best-effort) — emission of the new sections is a property of the fetching CLI, not just the provider. - Make the experimental schema cache honest: the cache key now includes the fetching CLI product+minor, so upgrading the CLI re-fetches richer schemas instead of serving section-less ones forever. - Warn at fetch time when the CLI is the bottleneck: if targetVersions admit versions whose sections the fetching binary structurally cannot emit, log which features will be missing and the minimum CLI versions that would emit them. (Nothing passes targetVersions yet; the CLI thread-through is a follow-up rollout item.) Part of RFC-04 (provider feature availability), rollout item 3. Co-Authored-By: Claude Fable 5 --- .../@cdktn/commons/src/provider-schema.ts | 50 +++++++ packages/@cdktn/provider-schema/package.json | 4 +- .../provider-schema.test.ts.snap | 24 ++++ .../src/__tests__/cache.test.ts | 68 ++++++++++ .../src/__tests__/emission-check.test.ts | 92 +++++++++++++ .../src/__tests__/provider-schema.test.ts | 20 +++ .../__tests__/provider-schema.unit.test.ts | 20 ++- packages/@cdktn/provider-schema/src/cache.ts | 13 +- .../provider-schema/src/emission-check.ts | 127 ++++++++++++++++++ .../provider-schema/src/provider-schema.ts | 63 +++++++++ packages/@cdktn/provider-schema/src/read.ts | 43 +++++- pnpm-lock.yaml | 6 + 12 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 packages/@cdktn/provider-schema/src/__tests__/cache.test.ts create mode 100644 packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts create mode 100644 packages/@cdktn/provider-schema/src/emission-check.ts diff --git a/packages/@cdktn/commons/src/provider-schema.ts b/packages/@cdktn/commons/src/provider-schema.ts index 0b172c26b..3ea546263 100644 --- a/packages/@cdktn/commons/src/provider-schema.ts +++ b/packages/@cdktn/commons/src/provider-schema.ts @@ -13,12 +13,61 @@ 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/action/state-store sections are deliberately not typed - YAGNI, + // they pass through untouched. + 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 may carry additional fields (`allow_null_value`, + * `allow_unknown_values`) which we don't consume yet - kept out on purpose. + */ +export interface FunctionParameter { + name?: string; + type: AttributeType; + description?: string; + description_kind?: string; +} + +export interface FunctionSignature { + description?: string; + summary?: string; + description_kind?: 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 +107,7 @@ interface BaseAttribute { optional?: boolean; computed?: boolean; sensitive?: boolean; + write_only?: boolean; } interface NestedTypeAttribute extends BaseAttribute { 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..385a7e947 --- /dev/null +++ b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts @@ -0,0 +1,92 @@ +// 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([]); + }); +}); + +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/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..1db0a43f3 --- /dev/null +++ b/packages/@cdktn/provider-schema/src/emission-check.ts @@ -0,0 +1,127 @@ +// 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]; + +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; + const supportedRange = `>=${boundaries[product]}`; + return semver.intersects(targetRange, supportedRange); + }); + + 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..d60dec6b2 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: { @@ -245,6 +280,7 @@ export interface TerraformConfig { export async function readProviderSchema( target: ConstructsMakerProviderTarget, + targetVersions?: TerraformTargetVersions, ) { const config: TerraformConfig = { provider: {}, @@ -282,6 +318,32 @@ 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; + + if (targetVersions) { + 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, + )}`, + ); + } + } + } catch (error) { + logger.debug( + `Could not determine fetching CLI version to stamp provider schema: ${error}`, + ); + } + return sanitizeProviderSchema(providerSchema); } @@ -317,6 +379,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..064b437ef 100644 --- a/packages/@cdktn/provider-schema/src/read.ts +++ b/packages/@cdktn/provider-schema/src/read.ts @@ -14,10 +14,16 @@ import { LANGUAGES, ConstructsMakerProviderTarget, ConstructsMakerModuleTarget, + TerraformTargetVersions, Errors, + logger, } from "@cdktn/commons"; import deepmerge from "deepmerge"; -import { readModuleSchema, readProviderSchema } from "./provider-schema"; +import { + getFetchingCliVersion, + readModuleSchema, + readProviderSchema, +} from "./provider-schema"; import { cachedAccess } from "./cache"; export type Schema = { @@ -25,11 +31,44 @@ 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( + (target: ConstructsMakerProviderTarget) => + readProviderSchema(target, targetVersions), + cacheDir, + keySuffix, + ); const targets = constraints.map((constraint) => ConstructsMakerProviderTarget.from(constraint, LANGUAGES[0]), ); 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 From 4246f26bb3cb9458d4540358a706c5b44dda1db3 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 11:43:35 +0800 Subject: [PATCH 04/28] feat(provider-generator): generate ephemeral resource bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third schema family in buildResourceModels, mirroring the data-source pipeline with an ephemeral_ type prefix: random_password generates EphemeralRandomPassword in ephemeral-random-password/, extending cdktn.TerraformEphemeralResource (which enforces targetVersions at synth). Generated Config interfaces extend TerraformEphemeralMetaArguments (no provisioners/connection) and no generateConfigForImport is emitted — ephemeral resources are not importable. Ephemeral models are appended after resources and data sources so class-name dedup stays order-stable (zero churn in existing snapshots). Snapshot-tested against a real terraform 1.15.6 providers-schema fragment of hashicorp/random (ephemeral + managed coexistence). Part of RFC-04 (provider feature availability), rollout item 4. Co-Authored-By: Claude Fable 5 --- .../ephemeral-resources.test.ts.snap | 524 ++++++++++++++++++ .../generator/ephemeral-resources.test.ts | 46 ++ .../ephemeral-resources.test.fixture.json | 160 ++++++ .../get/generator/emitter/resource-emitter.ts | 30 +- .../get/generator/models/resource-model.ts | 20 +- .../src/get/generator/models/struct.ts | 10 +- .../src/get/generator/provider-generator.ts | 21 +- 7 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/ephemeral-resources.test.ts.snap create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/ephemeral-resources.test.ts create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/ephemeral-resources.test.fixture.json 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/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/generator/emitter/resource-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts index bf298e35a..01d4524a0 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 @@ -26,8 +26,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); @@ -136,7 +138,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,6 +159,8 @@ export class ResourceEmitter { if (resource.isProvider) { this.emitProviderSuper(resource); + } else if (resource.isEphemeralResource) { + this.emitEphemeralResourceSuper(resource); } else { this.emitResourceSuper(resource); } @@ -185,6 +195,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/resource-model.ts b/packages/@cdktn/provider-generator/src/get/generator/models/resource-model.ts index 097573135..9abbc8bca 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 @@ -81,7 +81,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 +107,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 +120,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 +139,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..58c114d83 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts @@ -185,7 +185,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) { From fe3ecf2d7d9316c14338d94a30bc76f964a074c1 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 12:03:55 +0800 Subject: [PATCH 05/28] feat(provider-generator): generate provider-defined function bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers shipping schema `functions` now get a per-provider provider-functions namespace: TimeProviderFunctions.rfc3339Parse(ts) renders ${provider::time::rfc3339_parse(...)}. Methods default the provider:: namespace to the registry short name and take an optional providerLocalName override (local names change the namespace, aliases do not). Type mapping follows the Fn bindings rules, extended with an object/map/dynamic -> any branch (the real time provider functions all return objects). Runtime flows through one public jsii chokepoint, TerraformProviderFunction.invoke, which records usage in a dedicated registry; TerraformStack now registers ValidateProviderFunctionTargetSupport unconditionally, so synth fails when provider functions are used and the declared targetVersions admit releases without them (terraform >=1.8.0 / opentofu >=1.7.0 — OpenTofu language support predates its schema emission, hence the asymmetry with the fetch-time boundary). Snapshot-tested against the real terraform 1.15.6 hashicorp/time schema fragment plus a synthetic fixture covering variadic parameters, primitive/list returns and reserved parameter names. Part of RFC-04 (provider feature availability), rollout item 5. Co-Authored-By: Claude Fable 5 --- .../provider-functions.test.ts.snap | 113 ++++++++++ ...ider-functions-synthetic.test.fixture.json | 29 +++ .../provider-functions.test.fixture.json | 163 ++++++++++++++ .../generator/provider-functions.test.ts | 75 +++++++ .../src/get/generator/emitter/index.ts | 1 + .../emitter/provider-functions-emitter.ts | 72 +++++++ .../src/get/generator/models/index.ts | 1 + .../models/provider-function-model.ts | 204 ++++++++++++++++++ .../src/get/generator/provider-generator.ts | 35 ++- .../cdktn/src/functions/provider-function.ts | 41 ++++ .../cdktn/src/functions/usage-registry.ts | 31 +++ packages/cdktn/src/index.ts | 1 + packages/cdktn/src/terraform-stack.ts | 2 + packages/cdktn/src/validations/index.ts | 1 + ...lidate-provider-function-target-support.ts | 61 ++++++ .../cdktn/test/provider-functions.test.ts | 80 +++++++ 16 files changed, 908 insertions(+), 2 deletions(-) create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/provider-functions.test.ts.snap create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-synthetic.test.fixture.json create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts create mode 100644 packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts create mode 100644 packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts create mode 100644 packages/cdktn/src/functions/provider-function.ts create mode 100644 packages/cdktn/src/validations/validate-provider-function-target-support.ts create mode 100644 packages/cdktn/test/provider-functions.test.ts 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..9dd65b360 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/provider-functions.test.ts.snap @@ -0,0 +1,113 @@ +// 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 { + + /** + * Joins the given strings together using the provided separator. + * @param separator + * @param values + * @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. + */ + public static joinStrings(separator: string, values: string[], providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "join_strings", [separator, ...values])); + } + + /** + * Returns a list of names prefixed with the given prefix. + * @param prefix + * @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. + */ + public static listPrefixedNames(prefix: string, providerLocalName?: string): string[] { + return cdktn.Token.asList(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "list_prefixed_names", [prefix])); + } + + /** + * Adds up a default starting value and any number of addends. + * @param defaultValue + * @param addends + * @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. + */ + public static sum(defaultValue: number, addends: number[], providerLocalName?: string): number { + return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "sum", [defaultValue, ...addends])); + } +} +" +`; + +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 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, 'providerFunctions', { get: function () { return require('./provider-functions'); } }); + +" +`; + +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 { + + /** + * Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. + * @param duration Go time package duration string to parse + * @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. + */ + public static durationParse(duration: string, providerLocalName?: string): any { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "duration_parse", [duration])) as any; + } + + /** + * Given an RFC3339 timestamp string, will parse and return an object representation of that date and time. + * @param timestamp RFC3339 timestamp string to parse + * @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. + */ + public static rfc3339Parse(timestamp: string, providerLocalName?: string): any { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "rfc3339_parse", [timestamp])) as any; + } + + /** + * 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 unixTimestamp Unix Timestamp integer to parse + * @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. + */ + public static unixTimestampParse(unixTimestamp: number, providerLocalName?: string): any { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "unix_timestamp_parse", [unixTimestamp])) as any; + } +} +" +`; 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..e0e4f9428 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-synthetic.test.fixture.json @@ -0,0 +1,29 @@ +{ + "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" }] + } + } + } + } +} 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..a793e474d --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json @@ -0,0 +1,163 @@ +{ + "provider_schemas": { + "registry.terraform.io/hashicorp/time": { + "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/provider-functions.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts new file mode 100644 index 000000000..81807ae03 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts @@ -0,0 +1,75 @@ +// 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 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 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"); +}); 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..c76cb1097 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts @@ -0,0 +1,72 @@ +// 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}`); + + for (const fn of model.functions) { + this.emitMethod(model.providerName, fn); + } + + this.code.closeBlock(); + } + + private emitMethod(providerName: string, fn: ProviderFunctionModel) { + this.code.line(); + + const comment = sanitizedComment(this.code); + const description = fn.description ?? fn.summary; + if (description) comment.line(description); + for (const param of [...fn.parameters, fn.variadicParameter].filter( + (p): p is NonNullable => !!p, + )) { + comment.line(`@param ${param.name} ${param.description ?? ""}`.trimEnd()); + } + comment.line(`@param providerLocalName ${PROVIDER_LOCAL_NAME_JSDOC}`); + comment.end(); + + const methodParams = [ + ...fn.parameters.map((p) => `${p.name}: ${p.tsType}`), + ...(fn.variadicParameter + ? [`${fn.variadicParameter.name}: ${fn.variadicParameter.tsType}[]`] + : []), + `providerLocalName?: string`, + ]; + + const invokeArgs = [ + ...fn.parameters.map((p) => p.name), + ...(fn.variadicParameter ? [`...${fn.variadicParameter.name}`] : []), + ]; + + const invokeExpression = `cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "${providerName}", "${fn.terraformName}", [${invokeArgs.join(", ")}])`; + + this.code.openBlock( + `public static ${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/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..5523f6056 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts @@ -0,0 +1,204 @@ +// 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; +} + +/** + * 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; + 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; mirrors tools/generate-function-bindings mapParameter. +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); +} + +/** + * Maps a provider function parameter's declared Terraform type to a + * jsii-safe TypeScript parameter type. Booleans can't be represented as + * tokens (see helpers.ts asBoolean), so they're typed `any` to still accept + * tokens; dynamic/map/object parameters are also `any` (no structural typing + * for arbitrary provider function args). + */ +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: "any", docstringType: "any" }; + if (type === "dynamic") return { tsType: "any", docstringType: "any" }; + if (Array.isArray(type) && (type[0] === "list" || type[0] === "set")) { + return { tsType: "any[]", docstringType: "Array" }; + } + if (Array.isArray(type) && (type[0] === "map" || type[0] === "object")) { + return { tsType: "any", docstringType: "any" }; + } + return { tsType: "any", docstringType: "any" }; +} + +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, + }; +} + +/** + * 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. 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`, + * returning `any` wrapped via `cdktn.Token.asString(...) as any` (mirroring + * `helpers.ts` `asAny`, which wraps as a string because jsii can't represent + * an unresolved value any other way). + */ +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") + ) { + return { + tsType: "string[]", + wrapReturn: (expr) => `cdktn.Token.asList(${expr})`, + }; + } + // dynamic, map, object: no structural typing for arbitrary provider + // function results - declared as `any`. + return { + tsType: "any", + wrapReturn: (expr) => `cdktn.Token.asString(${expr}) as any`, + }; +} + +function buildFunctionModel( + terraformName: string, + signature: FunctionSignature, +): ProviderFunctionModel { + const { tsType: returnTsType, wrapReturn } = mapReturnType( + signature.return_type, + ); + + return { + terraformName, + methodName: sanitizeMethodName(terraformName), + description: signature.description, + summary: signature.summary, + returnTsType, + wrapReturn, + parameters: (signature.parameters ?? []).map((parameter, index) => + buildParameterModel(parameter, `arg${index}`), + ), + variadicParameter: signature.variadic_parameter + ? buildParameterModel(signature.variadic_parameter, "values") + : undefined, + }; +} + +/** + * 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; + + return { + providerName, + className: `${toPascalCase(providerName)}ProviderFunctions`, + functions: entries + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, signature]) => buildFunctionModel(name, signature)), + }; +} 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 58c114d83..6fc6bfd0e 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,17 @@ import { TerraformProviderConstraint, } from "@cdktn/commons"; import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema"; -import { ResourceModel } from "./models"; +import { + ProviderFunctionsModel, + ResourceModel, + buildProviderFunctionsModel, +} from "./models"; import { ResourceParser } from "./resource-parser"; -import { ResourceEmitter, StructEmitter } from "./emitter"; +import { + ProviderFunctionsEmitter, + ResourceEmitter, + StructEmitter, +} from "./emitter"; export interface TerraformProviderGeneratorOptions { /** @@ -101,6 +109,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 } = {}; @@ -113,6 +122,7 @@ export class TerraformProviderGenerator { this.importExtension = options.importExtension ?? ""; this.resourceEmitter = new ResourceEmitter(this.code); this.structEmitter = new StructEmitter(this.code, this.importExtension); + this.providerFunctionsEmitter = new ProviderFunctionsEmitter(this.code); } private getProviderByConstraint( @@ -262,10 +272,31 @@ export class TerraformProviderGenerator { this.emitResourceReadme(providerResource); } + const providerFunctionsModel = buildProviderFunctionsModel( + name, + provider.functions, + ); + 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/src/functions/provider-function.ts b/packages/cdktn/src/functions/provider-function.ts new file mode 100644 index 000000000..31da9b357 --- /dev/null +++ b/packages/cdktn/src/functions/provider-function.ts @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { IResolvable } from "../tokens/resolvable"; +import { anyValue, terraformFunction, variadic } from "./helpers"; +import { recordProviderFunctionUsage } from "./usage-registry"; + +/** + * 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). + * + * @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, in declared order + */ + public static invoke( + providerLocalName: string, + functionName: string, + args: any[], + ): IResolvable { + const fullName = `provider::${providerLocalName}::${functionName}`; + recordProviderFunctionUsage(fullName); + // terraformFunction() also records `fullName` into the `Fn` usage + // registry (usedFunctions); this is harmless since + // ValidateFunctionVersionSupport only checks names present in its own + // functionVersionConstraints map, which provider functions never are. + return terraformFunction(fullName, [variadic(anyValue)])(args); + } + + private constructor() {} +} diff --git a/packages/cdktn/src/functions/usage-registry.ts b/packages/cdktn/src/functions/usage-registry.ts index 427779a54..78b9dcaeb 100644 --- a/packages/cdktn/src/functions/usage-registry.ts +++ b/packages/cdktn/src/functions/usage-registry.ts @@ -30,3 +30,34 @@ export function getUsedFunctions(): string[] { export function resetFunctionUsageRegistry(): void { usedFunctions.clear(); } + +/** + * Records which provider-defined functions (`provider::::`) + * have been called through `TerraformProviderFunction.invoke` so that + * synthesis-time validations can check them against the version of the + * selected Terraform-compatible CLI. + * + * Kept separate from `usedFunctions` above: provider-defined functions are + * validated against `providerFeatureConstraints.providerFunctions` (a single + * language-support constraint for the whole feature family), not against the + * per-function `functionVersionConstraints` map used for built-in `Fn.*` + * functions. + */ +const usedProviderFunctions = new Set(); + +// eslint-disable-next-line jsdoc/require-jsdoc +export function recordProviderFunctionUsage(fullName: string): void { + usedProviderFunctions.add(fullName); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export function getUsedProviderFunctions(): string[] { + return Array.from(usedProviderFunctions); +} + +/** + * Clears the recorded provider function usage; intended for tests. + */ +export function resetProviderFunctionUsageRegistry(): void { + usedProviderFunctions.clear(); +} diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index 227ad052c..5da99629d 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -44,3 +44,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/terraform-stack.ts b/packages/cdktn/src/terraform-stack.ts index 1e17adfe8..3cae03214 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"; @@ -102,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 { 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-provider-function-target-support.ts b/packages/cdktn/src/validations/validate-provider-function-target-support.ts new file mode 100644 index 000000000..9847fe6e3 --- /dev/null +++ b/packages/cdktn/src/validations/validate-provider-function-target-support.ts @@ -0,0 +1,61 @@ +// 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. + */ +export class ValidateProviderFunctionTargetSupport implements IValidation { + constructor(protected scope: IConstruct) {} + + public validate() { + const usedProviderFunctions = getUsedProviderFunctions().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..281347ddd --- /dev/null +++ b/packages/cdktn/test/provider-functions.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { App, Testing, TerraformOutput, TerraformStack } from "../src"; +import { TerraformProviderFunction } from "../src/functions/provider-function"; +import { resetProviderFunctionUsageRegistry } from "../src/functions/usage-registry"; +import { createTmpHelper } from "./helper/tmp"; + +const tmp = createTmpHelper(); + +beforeEach(() => { + resetProviderFunctionUsageRegistry(); +}); + +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\\")}" + } + } + }" + `); +}); + +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(); + }); +}); From 014763e25f8ff8837a79f9ee0c565c1ef809de91 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 12:04:14 +0800 Subject: [PATCH 06/28] feat(provider-generator): deprecate write-only attribute getters, validate usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_only attributes (e.g. the vault *_wo pairs) now flow into AttributeModel. The setter stays; the getter is emitted @deprecated with the protocol contract spelled out — providers never persist write-only values, so every read of that token is null and the state-backed getter is a trap. Removing it outright is JSII-breaking for regenerated/prebuilt providers, so: deprecate now, remove at the next prebuilt major. Setting a write-only attribute (via setter or constructor config, which bypasses the setter) calls the new protected TerraformResource.registerProviderFeatureUsage hook, which registers ValidateFeatureTargetSupport once per construct: synth fails when targetVersions admit terraform <1.11 / opentofu <1.11 with the standard upgrade-or-narrow-targets message. Generated bindings reach the validation machinery only by extending base classes, per the RFC guiding decision. Struct-level (nested) write-only attributes get the deprecated getter but no usage registration (no construct node) — accepted v1 limitation. Snapshot-tested against the real terraform 1.15.6 hashicorp/vault vault_alicloud_secret_backend fragment (secret_key_wo + secret_key_wo_version). Part of RFC-04 (provider feature availability), rollout item 6. Co-Authored-By: Claude Fable 5 --- .../__snapshots__/write-only.test.ts.snap | 231 ++++++++++++++++++ .../fixtures/write-only.test.fixture.json | 50 ++++ .../__tests__/generator/write-only.test.ts | 51 ++++ .../generator/emitter/attributes-emitter.ts | 48 +++- .../get/generator/emitter/resource-emitter.ts | 20 ++ .../get/generator/models/attribute-model.ts | 3 + .../src/get/generator/resource-parser.ts | 2 + packages/cdktn/src/errors.ts | 5 + .../cdktn/src/provider-feature-constraints.ts | 16 ++ packages/cdktn/src/terraform-resource.ts | 43 ++++ packages/cdktn/test/write-only.test.ts | 120 +++++++++ 11 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/write-only.test.ts.snap create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/write-only.test.fixture.json create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts create mode 100644 packages/cdktn/test/write-only.test.ts 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..7eb1175a7 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/__snapshots__/write-only.test.ts.snap @@ -0,0 +1,231 @@ +// 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; + /** + * 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; + if (config.secretKeyWo !== undefined) { this.registerProviderFeatureUsage("writeOnlyAttributes"); } + 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.registerProviderFeatureUsage("writeOnlyAttributes"); + this._secretKeyWo = value; + } + // Temporarily expose input value. Use with caution. + public get secretKeyWoInput() { + return this._secretKeyWo; + } + + // 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: cdktn.stringToTerraform(this._secretKeyWo), + 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: cdktn.stringToHclTerraform(this._secretKeyWo), + 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/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..3c2758049 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/write-only.test.fixture.json @@ -0,0 +1,50 @@ +{ + "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 + }, + "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/write-only.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts new file mode 100644 index 000000000..e658f70b7 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/generator/write-only.test.ts @@ -0,0 +1,51 @@ +// 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 registers the usage of the write-only-attributes feature ... + expect(output).toMatch( + /public set secretKeyWo\(value: string\) \{\n\s*this\.registerProviderFeatureUsage\("writeOnlyAttributes"\);/, + ); + // ... and so does the constructor-assigned config value. + expect(output).toMatch( + /if \(config\.secretKeyWo !== undefined\) \{ this\.registerProviderFeatureUsage\("writeOnlyAttributes"\); \}/, + ); + + // The non-write-only sibling attribute is untouched: its getter is + // emitted directly (no @deprecated JSDoc immediately above), and its + // setter has no registration call. + 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*\}/, + ); +}); 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..d65d543dd 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 @@ -16,7 +16,19 @@ function titleCase(value: string) { export class AttributesEmitter { constructor(private code: CodeMaker) {} - public emit(att: AttributeModel, escapeReset: boolean, escapeInput: boolean) { + /** + * @param registerWriteOnlyUsage Whether a write-only attribute's setter + * should emit a `registerProviderFeatureUsage` call. Only true for + * attributes emitted on classes that extend TerraformResource (managed + * resources); struct OutputReference classes have no construct node to + * register a validation against, so they only get the deprecated getter. + */ + public emit( + att: AttributeModel, + escapeReset: boolean, + escapeInput: boolean, + registerWriteOnlyUsage = false, + ) { this.code.line(); this.code.line( `// ${att.terraformName} - computed: ${att.computed}, optional: ${att.isOptional}, required: ${att.isRequired}`, @@ -43,12 +55,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 +73,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(); @@ -66,12 +81,18 @@ export class AttributesEmitter { } const setterType = att.setterType; + const emitWriteOnlyRegistration = att.isWriteOnly && registerWriteOnlyUsage; switch (setterType._type) { case "set": this.code.openBlock( `public set ${att.name}(value: ${setterType.type})`, ); + if (emitWriteOnlyRegistration) { + this.code.line( + `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + ); + } this.code.line(`this.${att.storageName} = value;`); this.code.closeBlock(); break; @@ -80,6 +101,11 @@ export class AttributesEmitter { this.code.openBlock( `public put${titleCase(att.name)}(value: ${setterType.type})`, ); + if (emitWriteOnlyRegistration) { + this.code.line( + `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + ); + } this.code.line(`this.${att.storageName} = value;`); this.code.closeBlock(); break; @@ -88,6 +114,11 @@ export class AttributesEmitter { this.code.openBlock( `public put${titleCase(att.name)}(value: ${setterType.type})`, ); + if (emitWriteOnlyRegistration) { + this.code.line( + `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + ); + } this.code.line(`this.${att.storageName}.internalValue = value;`); this.code.closeBlock(); break; @@ -123,6 +154,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); 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 01d4524a0..6ef7c9184 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 @@ -123,11 +123,19 @@ export class ResourceEmitter { } private emitResourceAttributes(resource: ResourceModel) { + // Only managed resources extend TerraformResource (and therefore have + // registerProviderFeatureUsage available); data sources, providers, and + // ephemeral resources do not, so their write-only attributes (if any) + // only get the deprecated getter. + const canRegisterProviderFeatureUsage = + resource.parentClassName === "TerraformResource"; + for (const att of resource.attributes) { this.attributesEmitter.emit( att, this.attributesEmitter.needsResetEscape(att, resource.attributes), this.attributesEmitter.needsInputEscape(att, resource.attributes), + canRegisterProviderFeatureUsage, ); } } @@ -166,6 +174,8 @@ export class ResourceEmitter { } // initialize config properties + const canRegisterProviderFeatureUsage = + resource.parentClassName === "TerraformResource"; for (const att of resource.configStruct.assignableAttributes) { if (att.setterType._type === "stored_class") { this.code.line( @@ -174,6 +184,16 @@ export class ResourceEmitter { } else { this.code.line(`this.${att.storageName} = config.${att.name};`); } + + // Constructor assignments bypass the setter above, so write-only + // usage must be registered here as well for the config value passed + // directly to the constructor (see the setter registration in + // AttributesEmitter for the corresponding property-set path). + if (att.isWriteOnly && canRegisterProviderFeatureUsage) { + this.code.line( + `if (config.${att.name} !== undefined) { this.registerProviderFeatureUsage("writeOnlyAttributes"); }`, + ); + } } this.code.closeBlock(); 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/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/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/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts index 968973003..a54b93a92 100644 --- a/packages/cdktn/src/provider-feature-constraints.ts +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -18,3 +18,19 @@ export const providerFeatureConstraints = { writeOnlyAttributes: { terraform: ">=1.11.0", opentofu: ">=1.11.0" }, resourceIdentity: { terraform: ">=1.12.0", opentofu: ">=1.12.0" }, } as const satisfies Record; + +/** + * 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< + keyof typeof providerFeatureConstraints, + string +> = { + providerFunctions: "provider functions", + ephemeralResources: "ephemeral resources", + writeOnlyAttributes: "write-only attributes", + resourceIdentity: "resource identity", +}; diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 675ea7ba2..40c3bf5ee 100644 --- a/packages/cdktn/src/terraform-resource.ts +++ b/packages/cdktn/src/terraform-resource.ts @@ -29,12 +29,18 @@ import { RemoteExecProvisioner, } from "./terraform-provisioner"; import { ValidateTerraformVersion } from "./validations/validate-terraform-version"; +import { ValidateFeatureTargetSupport } from "./validations/target-versions"; import { TerraformStack } from "./terraform-stack"; +import { + providerFeatureConstraints, + providerFeatureLabels, +} from "./provider-feature-constraints"; import { movedToResourceOfDifferentType, resourceGivenTwoMoveOperationsById, resourceGivenTwoMoveOperationsByTarget, resourceGivenTwoMoveOperationsByTargetAndId, + unknownProviderFeature, } from "./errors"; const TERRAFORM_RESOURCE_SYMBOL = Symbol.for("cdktf/TerraformResource"); @@ -151,6 +157,7 @@ export class TerraformResource private _movedByTarget?: TerraformResourceMoveByTarget; private _movedById?: TerraformResourceMoveById; private _hasMoved = false; + private readonly _registeredProviderFeatures = new Set(); constructor(scope: Construct, id: string, config: TerraformResourceConfig) { super(scope, id, config.terraformResourceType); @@ -177,6 +184,42 @@ export class TerraformResource ); } + /** + * 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 + * actually used (e.g. setting a write-only attribute); not intended + * to be called directly. + */ + protected registerProviderFeatureUsage(feature: string): void { + if (this._registeredProviderFeatures.has(feature)) { + return; + } + + const constraints = ( + providerFeatureConstraints as Record< + string, + (typeof providerFeatureConstraints)[keyof typeof providerFeatureConstraints] + > + )[feature]; + const label = (providerFeatureLabels as Record)[feature]; + if (!constraints || !label) { + throw unknownProviderFeature(feature); + } + + this._registeredProviderFeatures.add(feature); + + const hint = `${label.charAt(0).toUpperCase()}${label.slice( + 1, + )} are available in ${Object.entries(constraints) + .map(([product, range]) => `${product} ${range}`) + .join(" and ")}.`; + + this.node.addValidation( + new ValidateFeatureTargetSupport(this, label, constraints, hint), + ); + } + public hasResourceMove() { return this._movedById || this._movedByTarget; } diff --git a/packages/cdktn/test/write-only.test.ts b/packages/cdktn/test/write-only.test.ts new file mode 100644 index 000000000..80ff353ff --- /dev/null +++ b/packages/cdktn/test/write-only.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { App, TerraformResource, TerraformStack, Testing } 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 emits: the setter registers usage of + * the "writeOnlyAttributes" provider-protocol feature family. + */ +class TestWriteOnlyResource extends TerraformResource { + private _secretKeyWo?: string; + + constructor(scope: Construct, id: string, config: { secretKeyWo?: string }) { + super(scope, id, { + terraformResourceType: "test_write_only_resource", + }); + this._secretKeyWo = config.secretKeyWo; + if (config.secretKeyWo !== undefined) { + this.registerProviderFeatureUsage("writeOnlyAttributes"); + } + } + + public set secretKeyWo(value: string) { + this.registerProviderFeatureUsage("writeOnlyAttributes"); + this._secretKeyWo = value; + } + + public get secretKeyWo(): string { + return this._secretKeyWo as string; + } + + /** Exposes the protected hook directly, for the "unknown feature" test. */ + public callWithUnknownFeature() { + (this as any).registerProviderFeatureUsage("notARealFeature"); + } + + protected synthesizeAttributes(): { [name: string]: any } { + return { secret_key_wo: 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 }; +} + +describe("registerProviderFeatureUsage", () => { + test("feature not used: synth passes", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", {}); + + 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("repeated setter calls do not stack duplicate validations", () => { + const { app, stack } = appWithStack({ + targetVersions: { terraform: ">=1.5.7" }, + }); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + resource.secretKeyWo = "one"; + resource.secretKeyWo = "two"; + resource.secretKeyWo = "three"; + + 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. + " + `); + }); + + 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."`, + ); + }); +}); From d9321808ab1786987acf84230345567f39b1bd44 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 12:16:53 +0800 Subject: [PATCH 07/28] feat(cli): thread targetVersions into cdktn get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetOptions gains targetVersions, threaded from cdktf.json at all three creation sites (get, provider upgrade, runGetInDir) into ConstructsMaker and down to readSchema — driving the fetch-time warning when the fetching CLI structurally cannot emit schema sections the declared targets admit. constraints.json is additionally stamped with the targets and the fetching CLI identity for diagnostics and cache debugging. Both fields are diagnostics-only: targetVersions does not affect the generated surface (full surface always generated, narrowing happens at synth), so filterAlreadyGenerated's staleness check deliberately ignores them and legacy constraints.json files stay valid. Part of RFC-04 (provider feature availability), rollout item 7 — the last item of the RFC's PR split. Co-Authored-By: Claude Fable 5 --- .../@cdktn/cli-core/src/lib/cdktf-config.ts | 7 + packages/@cdktn/cli-core/src/lib/get.ts | 1 + .../src/__tests__/provider.test.ts | 3 + .../src/get/__tests__/target-versions.test.ts | 130 ++++++++++++++++++ .../src/get/constructs-maker.ts | 46 ++++++- packages/cdktn-cli/src/bin/cmds/handlers.ts | 2 + packages/cdktn-cli/src/bin/cmds/ui/get.ts | 4 + 7 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts index e75aa78a2..0a9155333 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts @@ -7,6 +7,7 @@ import { CONFIG_DEFAULTS, type LanguageOptions, type TerraformDependencyConstraint, + type TerraformTargetVersions, } from "@cdktn/commons"; import path from "path"; import { logger } from "@cdktn/commons"; @@ -87,6 +88,12 @@ export class CdktfConfig { return options; } + public get targetVersions(): TerraformTargetVersions | undefined { + return this.getProperty("targetVersions") as + | TerraformTargetVersions + | undefined; + } + 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/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__/target-versions.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts new file mode 100644 index 000000000..384a8f7b8 --- /dev/null +++ b/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts @@ -0,0 +1,130 @@ +// 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([]); + }); +}); 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-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(); From a382d96a92d713389ad0862bfb6b85c35a118b1c Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 15:20:36 +0800 Subject: [PATCH 08/28] fix(provider-generator): tolerate malformed targetVersions on the get path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CdktfConfig.targetVersions (used by runGetInDir) returned the raw cdktf.json value, bypassing the parseConfig validation the main get handler goes through — and a malformed range then made semver.intersects throw inside the fetch-time emission check, crashing cdktn get with a raw stack trace. Two layers: the getter now validates via commons validateTargetVersions (warn + ignore invalid targets; generation proceeds, only the warning/ stamp lose them), and checkSchemaEmissionGapFamilies treats an invalid target range as not-wanted instead of throwing — a best-effort diagnostics path must never break get. Found in draft-PR verification (#296 review notes). Co-Authored-By: Claude Fable 5 --- .../@cdktn/cli-core/src/lib/cdktf-config.ts | 15 ++++- .../src/test/lib/cdktf-config.test.ts | 55 +++++++++++++++++++ .../src/__tests__/emission-check.test.ts | 8 +++ .../provider-schema/src/emission-check.ts | 7 ++- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 packages/@cdktn/cli-core/src/test/lib/cdktf-config.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts index 0a9155333..7ee97eef3 100644 --- a/packages/@cdktn/cli-core/src/lib/cdktf-config.ts +++ b/packages/@cdktn/cli-core/src/lib/cdktf-config.ts @@ -5,6 +5,7 @@ import { Language, Errors, CONFIG_DEFAULTS, + validateTargetVersions, type LanguageOptions, type TerraformDependencyConstraint, type TerraformTargetVersions, @@ -89,9 +90,21 @@ export class CdktfConfig { } public get targetVersions(): TerraformTargetVersions | undefined { - return this.getProperty("targetVersions") as + 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)[] { 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/provider-schema/src/__tests__/emission-check.test.ts b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts index 385a7e947..9d7413461 100644 --- a/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts +++ b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts @@ -75,6 +75,14 @@ describe("checkSchemaEmissionGaps", () => { 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([]); + }); }); describe("suggestedEmittingCliVersions", () => { diff --git a/packages/@cdktn/provider-schema/src/emission-check.ts b/packages/@cdktn/provider-schema/src/emission-check.ts index 1db0a43f3..620869dfb 100644 --- a/packages/@cdktn/provider-schema/src/emission-check.ts +++ b/packages/@cdktn/provider-schema/src/emission-check.ts @@ -95,8 +95,13 @@ export function checkSchemaEmissionGapFamilies( 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]}`; - return semver.intersects(targetRange, supportedRange); + try { + return semver.intersects(targetRange, supportedRange); + } catch { + return false; + } }); if (wanted) gaps.push(family); From 45d521a28a8663dc663d766ce114c095d0a94ada Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 15:20:53 +0800 Subject: [PATCH 09/28] fix(lib): restrict ephemeral resource lifecycle to pre/postconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terraform ephemeral blocks support only precondition/postcondition in lifecycle — createBeforeDestroy, preventDestroy, ignoreChanges and replaceTriggeredBy are state-oriented concepts that do not apply to stateless ephemeral resources. New TerraformEphemeralResourceLifecycle replaces the full TerraformResourceLifecycle on the ephemeral API; narrowing later would be jsii-breaking, so it lands before first release. Found in draft-PR verification (#296 review notes). Co-Authored-By: Claude Fable 5 --- .../cdktn/src/terraform-ephemeral-resource.ts | 20 ++++++-- .../test/terraform-ephemeral-resource.test.ts | 50 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/cdktn/src/terraform-ephemeral-resource.ts b/packages/cdktn/src/terraform-ephemeral-resource.ts index 209d5e4d9..9376a6e37 100644 --- a/packages/cdktn/src/terraform-ephemeral-resource.ts +++ b/packages/cdktn/src/terraform-ephemeral-resource.ts @@ -6,9 +6,9 @@ import { TerraformElement } from "./terraform-element"; import { TerraformProvider } from "./terraform-provider"; import { TerraformProviderGeneratorMetadata, - TerraformResourceLifecycle, lifecycleToTerraform, } from "./terraform-resource"; +import { Precondition, Postcondition } from "./terraform-conditions"; import { keysToSnakeCase, deepMerge, @@ -40,6 +40,18 @@ const EPHEMERAL_RESOURCES_HINT = `Ephemeral resources are available in ${Object. .map(([product, range]) => `${product} ${range}`) .join(" and ")}.`; +/** + * 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; @@ -48,7 +60,7 @@ export interface ITerraformEphemeralResource { dependsOn?: string[]; count?: number | TerraformCount; provider?: TerraformProvider; - lifecycle?: TerraformResourceLifecycle; + lifecycle?: TerraformEphemeralResourceLifecycle; forEach?: ITerraformIterator; interpolationForAttribute(terraformAttribute: string): IResolvable; @@ -62,7 +74,7 @@ export interface TerraformEphemeralMetaArguments { readonly dependsOn?: ITerraformDependable[]; readonly count?: number | TerraformCount; readonly provider?: TerraformProvider; - readonly lifecycle?: TerraformResourceLifecycle; + readonly lifecycle?: TerraformEphemeralResourceLifecycle; readonly forEach?: ITerraformIterator; } @@ -87,7 +99,7 @@ export class TerraformEphemeralResource public dependsOn?: string[]; public count?: number | TerraformCount; public provider?: TerraformProvider; - public lifecycle?: TerraformResourceLifecycle; + public lifecycle?: TerraformEphemeralResourceLifecycle; public forEach?: ITerraformIterator; constructor( diff --git a/packages/cdktn/test/terraform-ephemeral-resource.test.ts b/packages/cdktn/test/terraform-ephemeral-resource.test.ts index b9b21e375..fa14d6d2f 100644 --- a/packages/cdktn/test/terraform-ephemeral-resource.test.ts +++ b/packages/cdktn/test/terraform-ephemeral-resource.test.ts @@ -95,6 +95,56 @@ test("renders an ephemeral HCL block", () => { `); }); +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"); From 0b938e09b9e00839044cc9ce0e5b997ceca0bee1 Mon Sep 17 00:00:00 2001 From: so0k Date: Thu, 2 Jul 2026 15:20:54 +0800 Subject: [PATCH 10/28] docs(provider-generator): warn against provider-fn self-reference in generated JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling a provider-defined function inside the configuration block of the same provider asks Terraform/OpenTofu to evaluate the function while that provider is still being configured — a self-referential cycle. Generated method JSDoc and TerraformProviderFunction.invoke now carry the caveat. Also documents why write-only usage registration deliberately skips ephemeral resources: write-only is a state concept, ephemeral resources have no state, and no provider schema in the RFC-04 sweep combines the two. From draft-PR verification (#296 review notes). Co-Authored-By: Claude Fable 5 --- .../__snapshots__/provider-functions.test.ts.snap | 6 ++++++ .../get/generator/emitter/provider-functions-emitter.ts | 4 ++++ .../src/get/generator/emitter/resource-emitter.ts | 9 +++++++++ packages/cdktn/src/functions/provider-function.ts | 4 ++++ 4 files changed, 23 insertions(+) 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 index 9dd65b360..4e317d1e4 100644 --- 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 @@ -12,6 +12,7 @@ export class ExampleProviderFunctions { /** * Joins the given strings together using the provided separator. + * 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 separator * @param values * @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. @@ -22,6 +23,7 @@ export class ExampleProviderFunctions { /** * Returns a list of names prefixed with the given prefix. + * 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 prefix * @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. */ @@ -31,6 +33,7 @@ export class ExampleProviderFunctions { /** * Adds up a default starting value and any number of addends. + * 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 defaultValue * @param addends * @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. @@ -84,6 +87,7 @@ export class TimeProviderFunctions { /** * Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. + * 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 duration Go time package duration string to parse * @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. */ @@ -93,6 +97,7 @@ export class TimeProviderFunctions { /** * Given an RFC3339 timestamp string, will parse and return an object representation of that date and time. + * 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 timestamp RFC3339 timestamp string to parse * @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. */ @@ -102,6 +107,7 @@ export class TimeProviderFunctions { /** * 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. + * 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 unixTimestamp Unix Timestamp integer to parse * @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. */ 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 index c76cb1097..a3cd98f1a 100644 --- 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 @@ -10,6 +10,9 @@ 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."; +const SELF_REFERENCE_CYCLE_JSDOC = + "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)."; + export class ProviderFunctionsEmitter { constructor(private readonly code: CodeMaker) {} @@ -40,6 +43,7 @@ export class ProviderFunctionsEmitter { const comment = sanitizedComment(this.code); const description = fn.description ?? fn.summary; if (description) comment.line(description); + comment.line(SELF_REFERENCE_CYCLE_JSDOC); for (const param of [...fn.parameters, fn.variadicParameter].filter( (p): p is NonNullable => !!p, )) { 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 6ef7c9184..eb41f688b 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 @@ -127,6 +127,15 @@ export class ResourceEmitter { // registerProviderFeatureUsage available); data sources, providers, and // ephemeral resources do not, so their write-only attributes (if any) // 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 more than an implementation detail: 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. const canRegisterProviderFeatureUsage = resource.parentClassName === "TerraformResource"; diff --git a/packages/cdktn/src/functions/provider-function.ts b/packages/cdktn/src/functions/provider-function.ts index 31da9b357..8582c31d1 100644 --- a/packages/cdktn/src/functions/provider-function.ts +++ b/packages/cdktn/src/functions/provider-function.ts @@ -15,6 +15,10 @@ 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 From 268d54c063fa425f4e508d5b8c283ac33021e8f3 Mon Sep 17 00:00:00 2001 From: so0k Date: Tue, 7 Jul 2026 22:24:45 +0800 Subject: [PATCH 11/28] fix(lib): scope usage registries to one App synthesis session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once any provider function was invoked in a long-lived process, later unrelated apps in the same process (sequential apps in one jest file being the common case) saw the process-global usedProviderFunctions set and could fail validation despite never using provider functions — the new validator registers unconditionally on every stack. The Fn registry shares the design and the same cross-App leak, but its validator is feature-flag-gated. The registries stay process-global (tokens are scope-free; per-stack attribution is impossible by design) — but App construction now resets both, scoping usage to the current synthesis session. Within one App the global registry remains by design: every stack resolves the same targetVersions from App context, so a sibling-stack validation reports the same error the using stack would. Regression tests per review: app1 uses a provider function under satisfying targets, then app2 with default targets and no usage synths clean in the same process; analogous flag-gated Fn-registry case. From PR #296 review. Co-Authored-By: Claude Fable 5 --- packages/cdktn/src/app.ts | 23 +++++++++++ .../cdktn/src/functions/usage-registry.ts | 12 ++++++ ...lidate-provider-function-target-support.ts | 4 +- .../cdktn/test/provider-functions.test.ts | 40 +++++++++++++++++++ packages/cdktn/test/validations.test.ts | 28 +++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/cdktn/src/app.ts b/packages/cdktn/src/app.ts index e399f7de1..908d877bc 100644 --- a/packages/cdktn/src/app.ts +++ b/packages/cdktn/src/app.ts @@ -13,6 +13,10 @@ import { noAppFound, } from "./errors"; import { FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS } from "./features"; +import { + resetFunctionUsageRegistry, + resetProviderFunctionUsageRegistry, +} from "./functions/usage-registry"; const APP_SYMBOL = Symbol.for("cdktf/App"); export const CONTEXT_ENV = "CDKTF_CONTEXT_JSON"; @@ -98,6 +102,25 @@ export class App extends Construct { super(undefined as any, ""); Object.defineProperty(this, APP_SYMBOL, { value: true }); + // A new App marks the start of a new synthesis session. The `Fn` and + // provider-function usage registries are process-global (tokens are + // scope-free, so usage can't be attributed to a stack or app reliably), + // so without a reset a usage recorded while synthesizing one App would + // leak into validations run for a later, unrelated App constructed in + // the same Node process (e.g. sequential apps within one test file). + // Resetting here scopes "global" usage to "global within this App", + // which is by design: every stack in an App resolves the same + // targetVersions from App context, so a validation firing on a sibling + // stack reports the same error the using stack would. + // + // Known limitation, intentionally not solved: if two Apps are + // constructed up front and then populated/synthesized interleaved, they + // still share the same global registries between their construction and + // synth calls. This is a pathological usage pattern; the common cases + // (one App per test, or sequential Apps in one process) are handled. + resetFunctionUsageRegistry(); + resetProviderFunctionUsageRegistry(); + this.outdir = config.outdir ?? process.env.CDKTF_OUTDIR ?? "cdktf.out"; const envHclOutput = process.env.SYNTH_HCL_OUTPUT; let hclOutput = config.hclOutput || false; diff --git a/packages/cdktn/src/functions/usage-registry.ts b/packages/cdktn/src/functions/usage-registry.ts index 78b9dcaeb..7434490c4 100644 --- a/packages/cdktn/src/functions/usage-registry.ts +++ b/packages/cdktn/src/functions/usage-registry.ts @@ -11,6 +11,13 @@ * stacks, so reliable per-stack attribution is impossible anyway. Functions * used through raw escape hatches (overrides, hand-built expression strings) * are not recorded. + * + * Because the registry is process-global, `App`'s constructor resets it: a + * new `App` marks a new synthesis session, so usage recorded while + * synthesizing one App must not leak into validations for a later, + * unrelated App constructed in the same Node process (e.g. sequential apps + * in one test file). Within a single App, sharing this registry across all + * of its stacks is by design (see the validation classes that consume it). */ const usedFunctions = new Set(); @@ -42,6 +49,11 @@ export function resetFunctionUsageRegistry(): void { * language-support constraint for the whole feature family), not against the * per-function `functionVersionConstraints` map used for built-in `Fn.*` * functions. + * + * Just like `usedFunctions`, this is process-global and is reset in `App`'s + * constructor for the same reason: a new App starts a new synthesis + * session, and usage must not leak across unrelated Apps in the same + * process. */ const usedProviderFunctions = new Set(); diff --git a/packages/cdktn/src/validations/validate-provider-function-target-support.ts b/packages/cdktn/src/validations/validate-provider-function-target-support.ts index 9847fe6e3..cacede569 100644 --- a/packages/cdktn/src/validations/validate-provider-function-target-support.ts +++ b/packages/cdktn/src/validations/validate-provider-function-target-support.ts @@ -22,7 +22,9 @@ import { getUsedProviderFunctions } from "../functions/usage-registry"; * 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. + * the check only ever fires when a provider function is actually used + * during the current synthesis session (i.e. since the enclosing `App` was + * constructed — see the usage-registry reset in `App`'s constructor). */ export class ValidateProviderFunctionTargetSupport implements IValidation { constructor(protected scope: IConstruct) {} diff --git a/packages/cdktn/test/provider-functions.test.ts b/packages/cdktn/test/provider-functions.test.ts index 281347ddd..ebc230155 100644 --- a/packages/cdktn/test/provider-functions.test.ts +++ b/packages/cdktn/test/provider-functions.test.ts @@ -77,4 +77,44 @@ describe("ValidateProviderFunctionTargetSupport", () => { 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", () => { + // Deliberately does NOT rely on the top-level beforeEach reset running + // between app1 and app2: both apps are constructed within this single + // test, so the only thing preventing app1's usage from leaking into + // app2's validation is the reset that happens in App's own constructor. + 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"); + } + }); }); diff --git a/packages/cdktn/test/validations.test.ts b/packages/cdktn/test/validations.test.ts index 896185da8..04d500dcc 100644 --- a/packages/cdktn/test/validations.test.ts +++ b/packages/cdktn/test/validations.test.ts @@ -553,4 +553,32 @@ 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", () => { + // Deliberately does NOT rely on the top-level beforeEach reset running + // between app1 and app2: both apps are constructed within this single + // test, so the only thing preventing app1's usage from leaking into + // app2's validation is the reset that happens in App's own constructor. + 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(); + }); }); From cd6d0805a58a92a91214cac8644375d6a92dbcf8 Mon Sep 17 00:00:00 2001 From: so0k Date: Tue, 7 Jul 2026 22:25:09 +0800 Subject: [PATCH 12/28] fix(provider-generator): warn about emission gaps on schema cache hits too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch-time emission-gap warning lived inside readProviderSchema, so cachedAccess bypassed it entirely on a hit: a schema cached by an old CLI was reused silently even when the project's targetVersions admit the newer sections that fetch could never have contained. The warning moves to readSchema and now runs after every provider schema resolves — cached or fresh — using the cli_name/cli_version stamps the schema itself carries (falling back to probing the current binary when stamps are absent). Regression test drives readSchema twice against a temp cache dir: one producer call, two warnings. From PR #296 review. Co-Authored-By: Claude Fable 5 --- .../read-emission-gap-warning.unit.test.ts | 73 +++++++++++++++++ .../warn-schema-emission-gaps.unit.test.ts | 80 +++++++++++++++++++ .../provider-schema/src/provider-schema.ts | 69 ++++++++++++---- packages/@cdktn/provider-schema/src/read.ts | 15 ++-- 4 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 packages/@cdktn/provider-schema/src/__tests__/read-emission-gap-warning.unit.test.ts create mode 100644 packages/@cdktn/provider-schema/src/__tests__/warn-schema-emission-gaps.unit.test.ts 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/provider-schema.ts b/packages/@cdktn/provider-schema/src/provider-schema.ts index d60dec6b2..b6e93f6b2 100644 --- a/packages/@cdktn/provider-schema/src/provider-schema.ts +++ b/packages/@cdktn/provider-schema/src/provider-schema.ts @@ -280,7 +280,6 @@ export interface TerraformConfig { export async function readProviderSchema( target: ConstructsMakerProviderTarget, - targetVersions?: TerraformTargetVersions, ) { const config: TerraformConfig = { provider: {}, @@ -325,19 +324,6 @@ export async function readProviderSchema( const cli = await getFetchingCliVersion(); providerSchema.cli_name = cli.name; providerSchema.cli_version = cli.version; - - if (targetVersions) { - 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, - )}`, - ); - } - } } catch (error) { logger.debug( `Could not determine fetching CLI version to stamp provider schema: ${error}`, @@ -347,6 +333,61 @@ export async function readProviderSchema( 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 { diff --git a/packages/@cdktn/provider-schema/src/read.ts b/packages/@cdktn/provider-schema/src/read.ts index 064b437ef..bfac2b2b3 100644 --- a/packages/@cdktn/provider-schema/src/read.ts +++ b/packages/@cdktn/provider-schema/src/read.ts @@ -23,6 +23,7 @@ import { getFetchingCliVersion, readModuleSchema, readProviderSchema, + warnIfSchemaEmissionGaps, } from "./provider-schema"; import { cachedAccess } from "./cache"; @@ -64,8 +65,7 @@ export async function readSchema( ): Promise { const keySuffix = await resolveCacheKeySuffix(cacheDir); const cachedReadProviderSchema = cachedAccess( - (target: ConstructsMakerProviderTarget) => - readProviderSchema(target, targetVersions), + readProviderSchema, cacheDir, keySuffix, ); @@ -81,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; + }), ), ); From b52a48b4a16b98c7fb05db6f016d5241084ac068 Mon Sep 17 00:00:00 2001 From: so0k Date: Tue, 7 Jul 2026 22:25:11 +0800 Subject: [PATCH 13/28] docs: point the hand-maintained constraint maps at the vendored matrix providerFeatureConstraints (usage boundaries, packages/cdktn) and SCHEMA_EMISSION_BOUNDARIES (fetching-CLI emission boundaries, @cdktn/provider-schema) now name features-matrix.json as their source, cross-reference each other, and spell out the deliberate OpenTofu provider-functions exception (language support 1.7.0 vs schema emission 1.8.0). The matrix README gains an in-repo consumers section. An automated drift check between the maps and the matrix is tracked in #309. From PR #296 review. Co-Authored-By: Claude Fable 5 --- .../provider-schema/src/emission-check.ts | 13 ++++++++++++ .../cdktn/src/provider-feature-constraints.ts | 18 +++++++++++++++- tools/provider-feature-availability/README.md | 21 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/@cdktn/provider-schema/src/emission-check.ts b/packages/@cdktn/provider-schema/src/emission-check.ts index 620869dfb..572965a50 100644 --- a/packages/@cdktn/provider-schema/src/emission-check.ts +++ b/packages/@cdktn/provider-schema/src/emission-check.ts @@ -24,6 +24,19 @@ export type SchemaEmissionFamily = 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 diff --git a/packages/cdktn/src/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts index a54b93a92..81af2d864 100644 --- a/packages/cdktn/src/provider-feature-constraints.ts +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -5,7 +5,23 @@ import { TerraformFeatureVersionConstraints } from "./validations/validate-terra /** * 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). + * (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 diff --git a/tools/provider-feature-availability/README.md b/tools/provider-feature-availability/README.md index 59a361728..5bb6f3dee 100644 --- a/tools/provider-feature-availability/README.md +++ b/tools/provider-feature-availability/README.md @@ -17,6 +17,27 @@ Note the provider-functions asymmetry: OpenTofu supports the 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 From f7c6c3b08952f09d8e351b05e711244df72947cc Mon Sep 17 00:00:00 2001 From: so0k Date: Wed, 8 Jul 2026 00:54:46 +0800 Subject: [PATCH 14/28] fix(lib): render null provider-function arguments instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TerraformProviderFunction.invoke validated its whole args array with a single variadic(anyValue) — and variadic()/listOf() filters out null/undefined entries, which is right for a genuinely variadic list but wrong for a fixed-arity positional call: invoke("cfncompat", "condition_if", [cond, null, value]) lost the null and Terraform failed with 'Not enough function arguments' (the shift makes it blame the wrong slot). Arguments are now validated per position, so every slot — including null/undefined — is preserved. Preserving the slot exposed a second latent defect: FunctionCall renders its arguments via Array.prototype.join, which collapses null/undefined entries to empty strings, so the slot rendered as '(cond, , value)' instead of the Terraform null keyword. resolveExpressionPart now renders resolved null/undefined as 'null' (a legal expression in every Terraform context this layer serves). Generated wrappers for genuinely variadic functions already flatten the trailing array before calling invoke, so each element stays its own positional slot — behavior unchanged. Reported in PR #296 verification against the cfncompat provider's condition_if (CloudFormation Fn::If polyfill). Co-Authored-By: Claude Fable 5 --- .../cdktn/src/functions/provider-function.ts | 24 ++++- packages/cdktn/src/tfExpression.ts | 10 +++ .../cdktn/test/provider-functions.test.ts | 88 ++++++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/cdktn/src/functions/provider-function.ts b/packages/cdktn/src/functions/provider-function.ts index 8582c31d1..af1f9267d 100644 --- a/packages/cdktn/src/functions/provider-function.ts +++ b/packages/cdktn/src/functions/provider-function.ts @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 import { IResolvable } from "../tokens/resolvable"; -import { anyValue, terraformFunction, variadic } from "./helpers"; +import { anyValue, terraformFunction } from "./helpers"; import { recordProviderFunctionUsage } from "./usage-registry"; /** @@ -25,7 +25,12 @@ export class TerraformProviderFunction { * 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, in declared order + * @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, @@ -38,7 +43,20 @@ export class TerraformProviderFunction { // registry (usedFunctions); this is harmless since // ValidateFunctionVersionSupport only checks names present in its own // functionVersionConstraints map, which provider functions never are. - return terraformFunction(fullName, [variadic(anyValue)])(args); + // + // 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/tfExpression.ts b/packages/cdktn/src/tfExpression.ts index 53a62d2e5..814de99cc 100644 --- a/packages/cdktn/src/tfExpression.ts +++ b/packages/cdktn/src/tfExpression.ts @@ -29,6 +29,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; } diff --git a/packages/cdktn/test/provider-functions.test.ts b/packages/cdktn/test/provider-functions.test.ts index ebc230155..2bcea07d8 100644 --- a/packages/cdktn/test/provider-functions.test.ts +++ b/packages/cdktn/test/provider-functions.test.ts @@ -1,9 +1,16 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { App, Testing, TerraformOutput, TerraformStack } from "../src"; +import { + App, + Testing, + Tokenization, + TerraformOutput, + TerraformStack, +} from "../src"; import { TerraformProviderFunction } from "../src/functions/provider-function"; import { resetProviderFunctionUsageRegistry } from "../src/functions/usage-registry"; import { createTmpHelper } from "./helper/tmp"; +import { TestProvider, TestResource } from "./helper"; const tmp = createTmpHelper(); @@ -32,6 +39,85 @@ test("invoke() renders provider::::(...) in synthesized output", `); }); +// 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."); From 68bdcdc56b6903199d26a748fbe04476c3a7b9d7 Mon Sep 17 00:00:00 2001 From: so0k Date: Wed, 8 Jul 2026 00:55:10 +0800 Subject: [PATCH 15/28] fix(provider-generator): return raw resolvable for dynamic provider-function results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrappers for functions whose schema return type is dynamic/object/map coerced the result through Token.asString(...) as any (mirroring the Fn asAny convention). That encoded-string token is not recognized by Tokenization.isResolvable(), so when the result was assigned to a struct-typed resource attribute the generated OutputReference setter treated it as a plain object with no known keys and the attribute silently vanished from synth output — no warning, no error. The generated body now returns the raw TerraformProviderFunction.invoke result (an IResolvable, which struct setters recognize and resolve correctly); the declared return type stays any. String, number, bool and list mappings are unchanged. Synthetic fixture gains a condition_if-style dynamic-return function so the dynamic branch has its own snapshot coverage. Reported in PR #296 verification: cfncompat condition_if results assigned to awscc struct attributes disappeared from terraform plan. Co-Authored-By: Claude Fable 5 --- .../provider-functions.test.ts.snap | 18 ++++++++++++--- ...ider-functions-synthetic.test.fixture.json | 10 +++++++++ .../models/provider-function-model.ts | 22 ++++++++++++++----- 3 files changed, 42 insertions(+), 8 deletions(-) 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 index 4e317d1e4..7983a29b6 100644 --- 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 @@ -10,6 +10,18 @@ import * as cdktn from 'cdktn'; */ export class ExampleProviderFunctions { + /** + * 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. + * 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 condition + * @param trueValue + * @param falseValue + * @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. + */ + public static conditionIf(condition: any, trueValue: any, falseValue: any, providerLocalName?: string): any { + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "condition_if", [condition, trueValue, falseValue]); + } + /** * Joins the given strings together using the provided separator. * 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). @@ -92,7 +104,7 @@ export class TimeProviderFunctions { * @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. */ public static durationParse(duration: string, providerLocalName?: string): any { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "duration_parse", [duration])) as any; + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "duration_parse", [duration]); } /** @@ -102,7 +114,7 @@ export class TimeProviderFunctions { * @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. */ public static rfc3339Parse(timestamp: string, providerLocalName?: string): any { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "rfc3339_parse", [timestamp])) as any; + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "rfc3339_parse", [timestamp]); } /** @@ -112,7 +124,7 @@ export class TimeProviderFunctions { * @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. */ public static unixTimestampParse(unixTimestamp: number, providerLocalName?: string): any { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "unix_timestamp_parse", [unixTimestamp])) as any; + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "unix_timestamp_parse", [unixTimestamp]); } } " 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 index e0e4f9428..c9f18191f 100644 --- 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 @@ -22,6 +22,16 @@ "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" } + ] } } } 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 index 5523f6056..05320041c 100644 --- 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 @@ -113,9 +113,16 @@ function buildParameterModel( * 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`, - * returning `any` wrapped via `cdktn.Token.asString(...) as any` (mirroring - * `helpers.ts` `asAny`, which wraps as a string because jsii can't represent - * an unresolved value any other way). + * declared `any` and returned as the RAW `invoke()` result (an + * `IResolvable`), unwrapped. 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; jsii can still hand it back as `any` without a cast because + * `IResolvable` is assignable to `any`. */ function mapReturnType(returnType: AttributeType): { tsType: string; @@ -151,10 +158,15 @@ function mapReturnType(returnType: AttributeType): { }; } // dynamic, map, object: no structural typing for arbitrary provider - // function results - declared as `any`. + // function results - declared as `any`, but 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). `IResolvable` is assignable to `any`, so no cast is + // needed either. return { tsType: "any", - wrapReturn: (expr) => `cdktn.Token.asString(${expr}) as any`, + wrapReturn: (expr) => expr, }; } From 82e34b415665e1c7110f6b8101fe744dc1f5c51b Mon Sep 17 00:00:00 2001 From: so0k Date: Mon, 13 Jul 2026 08:23:31 +0800 Subject: [PATCH 16/28] refactor(lib): type registerProviderFeatureUsage with a ProviderFeature enum The feature argument was an open-ended string validated only at runtime. Export a string-valued ProviderFeature enum (values unchanged, so wire format, registry keys, and error messages stay identical) and use it in the registerProviderFeatureUsage signature so misuse is caught at compile time in TypeScript and surfaced as a proper enum in all jsii languages. The runtime unknownProviderFeature guard stays as the backstop for plain-JS and non-TypeScript jsii callers. Generated bindings now emit cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES instead of the bare literal (compile-coupled to the signature change). TerraformEphemeralResource reuses providerFeatureLabels via the enum instead of duplicating the label literal; the constraint/label maps themselves stay internal. Addresses review feedback on #296. Co-Authored-By: Claude Fable 5 --- .../__snapshots__/write-only.test.ts.snap | 4 +- .../__tests__/generator/write-only.test.ts | 4 +- .../generator/emitter/attributes-emitter.ts | 6 +- .../get/generator/emitter/resource-emitter.ts | 2 +- packages/cdktn/src/index.ts | 2 + .../cdktn/src/provider-feature-constraints.ts | 71 +++++++++++++++---- .../cdktn/src/terraform-ephemeral-resource.ts | 12 ++-- packages/cdktn/src/terraform-resource.ts | 7 +- packages/cdktn/test/write-only.test.ts | 18 +++-- 9 files changed, 93 insertions(+), 33 deletions(-) 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 index 7eb1175a7..7ef07c1bc 100644 --- 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 @@ -95,7 +95,7 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { this._mount = config.mount; this._namespace = config.namespace; this._secretKeyWo = config.secretKeyWo; - if (config.secretKeyWo !== undefined) { this.registerProviderFeatureUsage("writeOnlyAttributes"); } + if (config.secretKeyWo !== undefined) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } this._secretKeyWoVersion = config.secretKeyWoVersion; } @@ -154,7 +154,7 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { return this.getStringAttribute('secret_key_wo'); } public set secretKeyWo(value: string) { - this.registerProviderFeatureUsage("writeOnlyAttributes"); + this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); this._secretKeyWo = value; } // Temporarily expose input value. Use with caution. 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 index e658f70b7..d337196e6 100644 --- 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 @@ -32,11 +32,11 @@ test("generate a vault_alicloud_secret_backend resource with a write-only attrib ); // ... its setter registers the usage of the write-only-attributes feature ... expect(output).toMatch( - /public set secretKeyWo\(value: string\) \{\n\s*this\.registerProviderFeatureUsage\("writeOnlyAttributes"\);/, + /public set secretKeyWo\(value: string\) \{\n\s*this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\);/, ); // ... and so does the constructor-assigned config value. expect(output).toMatch( - /if \(config\.secretKeyWo !== undefined\) \{ this\.registerProviderFeatureUsage\("writeOnlyAttributes"\); \}/, + /if \(config\.secretKeyWo !== undefined\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, ); // The non-write-only sibling attribute is untouched: its getter is 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 d65d543dd..2298280c8 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 @@ -90,7 +90,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, ); } this.code.line(`this.${att.storageName} = value;`); @@ -103,7 +103,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, ); } this.code.line(`this.${att.storageName} = value;`); @@ -116,7 +116,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage("writeOnlyAttributes");`, + `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, ); } this.code.line(`this.${att.storageName}.internalValue = value;`); 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 eb41f688b..ada512926 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 @@ -200,7 +200,7 @@ export class ResourceEmitter { // AttributesEmitter for the corresponding property-set path). if (att.isWriteOnly && canRegisterProviderFeatureUsage) { this.code.line( - `if (config.${att.name} !== undefined) { this.registerProviderFeatureUsage("writeOnlyAttributes"); }`, + `if (config.${att.name} !== undefined) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, ); } } diff --git a/packages/cdktn/src/index.ts b/packages/cdktn/src/index.ts index 5da99629d..75ace0f6b 100644 --- a/packages/cdktn/src/index.ts +++ b/packages/cdktn/src/index.ts @@ -9,6 +9,8 @@ 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"; diff --git a/packages/cdktn/src/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts index 81af2d864..da4bff847 100644 --- a/packages/cdktn/src/provider-feature-constraints.ts +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -2,6 +2,35 @@ // 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` @@ -26,14 +55,31 @@ import { TerraformFeatureVersionConstraints } from "./validations/validate-terra * 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. + * 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 = { - providerFunctions: { terraform: ">=1.8.0", opentofu: ">=1.7.0" }, // opentofu language support since 1.7.0 even though schema emission starts 1.8.0 - ephemeralResources: { terraform: ">=1.10.0", opentofu: ">=1.11.0" }, - writeOnlyAttributes: { terraform: ">=1.11.0", opentofu: ">=1.11.0" }, - resourceIdentity: { terraform: ">=1.12.0", opentofu: ">=1.12.0" }, -} as const satisfies Record; + [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 @@ -41,12 +87,9 @@ export const providerFeatureConstraints = { * errors read naturally (e.g. "write-only attributes requires terraform * >=1.11.0, ..."). */ -export const providerFeatureLabels: Record< - keyof typeof providerFeatureConstraints, - string -> = { - providerFunctions: "provider functions", - ephemeralResources: "ephemeral resources", - writeOnlyAttributes: "write-only attributes", - resourceIdentity: "resource identity", +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", }; diff --git a/packages/cdktn/src/terraform-ephemeral-resource.ts b/packages/cdktn/src/terraform-ephemeral-resource.ts index 9376a6e37..f0cdf9acf 100644 --- a/packages/cdktn/src/terraform-ephemeral-resource.ts +++ b/packages/cdktn/src/terraform-ephemeral-resource.ts @@ -22,7 +22,11 @@ import { IInterpolatingParent } from "./terraform-addressable"; import { ITerraformIterator } from "./terraform-iterator"; import { TerraformCount } from "./terraform-count"; import { ValidateFeatureTargetSupport } from "./validations/target-versions"; -import { providerFeatureConstraints } from "./provider-feature-constraints"; +import { + ProviderFeature, + providerFeatureConstraints, + providerFeatureLabels, +} from "./provider-feature-constraints"; // eslint-disable-next-line @typescript-eslint/no-require-imports import assert = require("assert"); @@ -35,7 +39,7 @@ const TERRAFORM_EPHEMERAL_RESOURCE_SYMBOL = Symbol.for( * their declared targetVersions. */ const EPHEMERAL_RESOURCES_HINT = `Ephemeral resources are available in ${Object.entries( - providerFeatureConstraints.ephemeralResources, + providerFeatureConstraints[ProviderFeature.EPHEMERAL_RESOURCES], ) .map(([product, range]) => `${product} ${range}`) .join(" and ")}.`; @@ -127,8 +131,8 @@ export class TerraformEphemeralResource this.node.addValidation( new ValidateFeatureTargetSupport( this, - "ephemeral resources", - providerFeatureConstraints.ephemeralResources, + providerFeatureLabels[ProviderFeature.EPHEMERAL_RESOURCES], + providerFeatureConstraints[ProviderFeature.EPHEMERAL_RESOURCES], EPHEMERAL_RESOURCES_HINT, ), ); diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 40c3bf5ee..841b5cdc9 100644 --- a/packages/cdktn/src/terraform-resource.ts +++ b/packages/cdktn/src/terraform-resource.ts @@ -32,6 +32,7 @@ import { ValidateTerraformVersion } from "./validations/validate-terraform-versi import { ValidateFeatureTargetSupport } from "./validations/target-versions"; import { TerraformStack } from "./terraform-stack"; import { + ProviderFeature, providerFeatureConstraints, providerFeatureLabels, } from "./provider-feature-constraints"; @@ -188,10 +189,10 @@ export class TerraformResource * 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 - * actually used (e.g. setting a write-only attribute); not intended - * to be called directly. + * actually used (e.g. `ProviderFeature.WRITE_ONLY_ATTRIBUTES` when a + * write-only attribute is set); not intended to be called directly. */ - protected registerProviderFeatureUsage(feature: string): void { + protected registerProviderFeatureUsage(feature: ProviderFeature): void { if (this._registeredProviderFeatures.has(feature)) { return; } diff --git a/packages/cdktn/test/write-only.test.ts b/packages/cdktn/test/write-only.test.ts index 80ff353ff..989e7fad0 100644 --- a/packages/cdktn/test/write-only.test.ts +++ b/packages/cdktn/test/write-only.test.ts @@ -1,6 +1,12 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { App, TerraformResource, TerraformStack, Testing } from "../src"; +import { + App, + ProviderFeature, + TerraformResource, + TerraformStack, + Testing, +} from "../src"; import { Construct } from "constructs"; import { TestProvider } from "./helper/provider"; import { createTmpHelper } from "./helper/tmp"; @@ -21,12 +27,12 @@ class TestWriteOnlyResource extends TerraformResource { }); this._secretKeyWo = config.secretKeyWo; if (config.secretKeyWo !== undefined) { - this.registerProviderFeatureUsage("writeOnlyAttributes"); + this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); } } public set secretKeyWo(value: string) { - this.registerProviderFeatureUsage("writeOnlyAttributes"); + this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); this._secretKeyWo = value; } @@ -34,7 +40,11 @@ class TestWriteOnlyResource extends TerraformResource { return this._secretKeyWo as string; } - /** Exposes the protected hook directly, for the "unknown feature" test. */ + /** + * 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"); } From 30b172423c3e96b149ca3ad56a1948f73dee82ba Mon Sep 17 00:00:00 2001 From: so0k Date: Mon, 13 Jul 2026 08:23:49 +0800 Subject: [PATCH 17/28] test: add version-boundary scenarios for target-version checks Cover the boundary conditions raised in review of #296: floors and pins exactly at a feature's minimum, pins and floors just below it, an empty targets object fed directly to the comparator, and prerelease-floor subset semantics (documented as inherent npm-semver behavior). On the emission-gap side, cover a fetching CLI exactly at a family boundary and one with no version metadata. In the get-time plumbing tests, cover a partial (single-product) targetVersions stamped verbatim without default injection, a schema without CLI identity omitting the cli stamp, and changed targetVersions between runs not forcing regeneration. Expected values verified against the repo's own semver (subset/lt/intersects) rather than assumed. Co-Authored-By: Claude Fable 5 --- .../src/get/__tests__/target-versions.test.ts | 76 +++++++++++++++++ .../src/__tests__/emission-check.test.ts | 48 +++++++++++ packages/cdktn/test/validations.test.ts | 82 +++++++++++++++++++ 3 files changed, 206 insertions(+) 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 index 384a8f7b8..9ad3b4623 100644 --- a/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts +++ b/packages/@cdktn/provider-generator/src/get/__tests__/target-versions.test.ts @@ -127,4 +127,80 @@ describe("targetVersions threading through 'cdktn get'", () => { 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-schema/src/__tests__/emission-check.test.ts b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts index 9d7413461..996a8fc9c 100644 --- a/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts +++ b/packages/@cdktn/provider-schema/src/__tests__/emission-check.test.ts @@ -83,6 +83,54 @@ describe("checkSchemaEmissionGaps", () => { 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", () => { diff --git a/packages/cdktn/test/validations.test.ts b/packages/cdktn/test/validations.test.ts index 04d500dcc..53ebeeb0e 100644 --- a/packages/cdktn/test/validations.test.ts +++ b/packages/cdktn/test/validations.test.ts @@ -240,6 +240,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 +319,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", () => { From 267360b3f2e78bbb7570a8df5eeae96cd1020745 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 15:21:56 +0700 Subject: [PATCH 18/28] fix(provider-generator): model provider-function schema JSON accurately The FunctionParameter/FunctionSignature types in commons diverged from what `providers schema -json` actually emits (verified against hashicorp/terraform internal/command/jsonfunction and the OpenTofu equivalent): - parameters carry `is_nullable` (from the protocol's AllowNullValue) - now modeled; the comment claiming `allow_null_value` / `allow_unknown_values` could appear in the JSON was wrong (those are plugin-protocol names; `allow_unknown_values` has no JSON representation at all). - function signatures carry `deprecation_message` (Terraform only; OpenTofu doesn't emit it - divergence noted in a comment). - neither functions nor their parameters have `description_kind` in the JSON (that field exists only on attribute/block schemas) - removed. Also spell out why list_resource_schemas / action_schemas / state_store_schemas stay untyped (real CLI sections, preserved verbatim through parse/sanitize/cache, consumption deferred per RFC-04 Phase 4). Consumption of the new fields lands in the follow-up commit. --- .../@cdktn/commons/src/provider-schema.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/@cdktn/commons/src/provider-schema.ts b/packages/@cdktn/commons/src/provider-schema.ts index 3ea546263..393ad5066 100644 --- a/packages/@cdktn/commons/src/provider-schema.ts +++ b/packages/@cdktn/commons/src/provider-schema.ts @@ -30,8 +30,13 @@ export interface Provider { provider: Schema; resource_schemas: { [type: string]: Schema }; data_source_schemas: { [type: string]: Schema }; - // List/action/state-store sections are deliberately not typed - YAGNI, - // they pass through untouched. + // 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 }; @@ -39,20 +44,26 @@ export interface Provider { /** * A single parameter (or the variadic parameter) of a provider function. - * The providers-schema JSON may carry additional fields (`allow_null_value`, - * `allow_unknown_values`) which we don't consume yet - kept out on purpose. + * 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; - description_kind?: string; + is_nullable?: boolean; } export interface FunctionSignature { description?: string; summary?: string; - description_kind?: 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; From 80945aa8c01dbf0f4932e4f5a138218c0677d3b4 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 15:33:40 +0700 Subject: [PATCH 19/28] feat(provider-generator): recursive, honest provider-function type mapping Addresses the type-fidelity review findings on the provider-function generator: - Parameter mapping is now recursive: bool becomes `boolean | IResolvable` (input-position union, matching the resource attribute convention), list/set recurse on their element type (list(number) -> number[], set(bool) -> Array, list(list(string)) -> string[][]), and map of a primitive gets a real indexed-signature type. Unrepresentable elements widen to any[] but the docstring keeps the honest element shape. - Return mapping: list/set(string) -> Token.asList, list/set(number) -> Token.asNumberList; every other non-primitive shape (object, map, dynamic, other collections) is declared `IResolvable` instead of `any` - the raw invoke() result was already returned, but `any` invited property access on a token (result.hours) that silently yields undefined; IResolvable surfaces that at compile time. - `is_nullable` is consumed: trailing-compatible nullable fixed params become jsii-optional (omission renders the Terraform null keyword), mid-position nullable params widen to any, nullable variadics widen to any[]; docstrings state `T | null` in all three cases. - `deprecation_message` is surfaced as an `@deprecated` JSDoc tag. - Generation now hard-fails with an actionable error when two function names or two parameter names sanitize to the same identifier, instead of silently shadowing/dropping one. Fixture coverage: number/bool/nested collections, object returns, typed and untyped maps, deprecation, all three nullability shapes, and unit tests pinning both collision errors. Snapshot churn is confined to provider-function outputs. --- .../provider-functions.test.ts.snap | 134 ++++++- ...ider-functions-synthetic.test.fixture.json | 90 +++++ .../generator/provider-functions.test.ts | 41 +++ .../emitter/provider-functions-emitter.ts | 21 +- .../models/provider-function-model.ts | 337 ++++++++++++++++-- 5 files changed, 564 insertions(+), 59 deletions(-) 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 index 7983a29b6..bf9fb1c96 100644 --- 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 @@ -10,49 +10,153 @@ import * as cdktn from 'cdktn'; */ export class ExampleProviderFunctions { + /** + * Returns an object describing the build that produced the given seed. + * 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 {string} seed + * @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. + */ + public static buildInfo(seed: string, providerLocalName?: string): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "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. * 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 condition - * @param trueValue - * @param falseValue + * @param {boolean | IResolvable} condition + * @param {any} trueValue + * @param {any} falseValue * @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. */ - public static conditionIf(condition: any, trueValue: any, falseValue: any, providerLocalName?: string): any { + public static conditionIf(condition: boolean | cdktn.IResolvable, trueValue: any, falseValue: any, providerLocalName?: string): cdktn.IResolvable { return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "condition_if", [condition, trueValue, falseValue]); } + /** + * Renders the given map of config objects as a single string. + * 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 {any} configs + * @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. + */ + public static configMap(configs: any, providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "config_map", [configs])); + } + + /** + * Returns true if any flag in the given set of flags is true. + * 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 {Array} flags + * @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. + */ + public static flagSet(flags: Array, providerLocalName?: string): cdktn.IResolvable { + return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "flag_set", [flags]); + } + /** * Joins the given strings together using the provided separator. * 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 separator - * @param values + * @param {string} separator + * @param {Array} values * @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. */ public static joinStrings(separator: string, values: string[], providerLocalName?: string): string { return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "join_strings", [separator, ...values])); } + /** + * A deprecated helper kept around for backwards compatibility. + * @deprecated Use new_helper instead. + * 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 {string} input + * @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. + */ + public static legacyHelper(input: string, providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "legacy_helper", [input])); + } + + /** + * Returns a list of numbers counting up from 1 to the given count. + * 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 {number} count + * @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. + */ + public static listNumbers(count: number, providerLocalName?: string): number[] { + return cdktn.Token.asNumberList(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "list_numbers", [count])); + } + /** * Returns a list of names prefixed with the given prefix. * 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 prefix + * @param {string} prefix * @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. */ public static listPrefixedNames(prefix: string, providerLocalName?: string): string[] { return cdktn.Token.asList(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "list_prefixed_names", [prefix])); } + /** + * Joins a possibly-null prefix with a required suffix. + * 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 {string | null} prefix + * @param {string} suffix + * @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. + */ + public static midNullable(prefix: any, suffix: string, providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "mid_nullable", [prefix, suffix])); + } + + /** + * Flattens a matrix (a list of lists of strings) into a single string. + * 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 {Array>} matrix + * @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. + */ + public static nestedList(matrix: string[][], providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "nested_list", [matrix])); + } + /** * Adds up a default starting value and any number of addends. * 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 defaultValue - * @param addends + * @param {number} defaultValue + * @param {Array} addends * @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. */ public static sum(defaultValue: number, addends: number[], providerLocalName?: string): number { return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "sum", [defaultValue, ...addends])); } + + /** + * Renders the given map of string tags as a single string. + * 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 {{ [key: string]: string }} tags + * @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. + */ + public static tagMap(tags: { [key: string]: string }, providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "tag_map", [tags])); + } + + /** + * Greets the given name, optionally with a custom greeting. + * 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 {string} name + * @param {string | null} greeting + * @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. + */ + public static trailingNullable(name: string, greeting?: string, providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "trailing_nullable", [name, greeting])); + } + + /** + * Joins a label with any number of possibly-null items. + * 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 {string} label + * @param {Array} items + * @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. + */ + public static variadicNullable(label: string, items: any[], providerLocalName?: string): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "variadic_nullable", [label, ...items])); + } } " `; @@ -100,30 +204,30 @@ export class TimeProviderFunctions { /** * Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. * 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 duration Go time package duration string to parse + * @param {string} duration Go time package duration string to parse * @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. */ - public static durationParse(duration: string, providerLocalName?: string): any { + public static durationParse(duration: string, providerLocalName?: string): cdktn.IResolvable { return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "duration_parse", [duration]); } /** * Given an RFC3339 timestamp string, will parse and return an object representation of that date and time. * 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 timestamp RFC3339 timestamp string to parse + * @param {string} timestamp RFC3339 timestamp string to parse * @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. */ - public static rfc3339Parse(timestamp: string, providerLocalName?: string): any { + public static rfc3339Parse(timestamp: string, providerLocalName?: string): cdktn.IResolvable { return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "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. * 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 unixTimestamp Unix Timestamp integer to parse + * @param {number} unixTimestamp Unix Timestamp integer to parse * @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. */ - public static unixTimestampParse(unixTimestamp: number, providerLocalName?: string): any { + public static unixTimestampParse(unixTimestamp: number, providerLocalName?: string): cdktn.IResolvable { return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "unix_timestamp_parse", [unixTimestamp]); } } 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 index c9f18191f..d70a4c17b 100644 --- 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 @@ -32,6 +32,96 @@ { "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/provider-functions.test.ts b/packages/@cdktn/provider-generator/src/get/__tests__/generator/provider-functions.test.ts index 81807ae03..e5cf6aaa7 100644 --- 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 @@ -3,6 +3,7 @@ import * as fs from "fs"; import * as path from "path"; import { TerraformProviderGenerator } from "../../generator/provider-generator"; +import { buildProviderFunctionsModel } from "../../generator/models/provider-function-model"; import { CodeMaker } from "codemaker"; import { createTmpHelper } from "../util"; @@ -73,3 +74,43 @@ test("generate provider functions covering variadic parameters, primitive/list r ); 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/); +}); 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 index a3cd98f1a..085bac552 100644 --- 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 @@ -43,17 +43,28 @@ export class ProviderFunctionsEmitter { const comment = sanitizedComment(this.code); const description = fn.description ?? fn.summary; if (description) comment.line(description); + if (fn.deprecationMessage) { + comment.line(`@deprecated ${fn.deprecationMessage}`); + } comment.line(SELF_REFERENCE_CYCLE_JSDOC); - for (const param of [...fn.parameters, fn.variadicParameter].filter( - (p): p is NonNullable => !!p, - )) { - comment.line(`@param ${param.name} ${param.description ?? ""}`.trimEnd()); + 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.line(`@param providerLocalName ${PROVIDER_LOCAL_NAME_JSDOC}`); comment.end(); const methodParams = [ - ...fn.parameters.map((p) => `${p.name}: ${p.tsType}`), + ...fn.parameters.map( + (p) => `${p.name}${p.optional ? "?" : ""}: ${p.tsType}`, + ), ...(fn.variadicParameter ? [`${fn.variadicParameter.name}: ${fn.variadicParameter.tsType}[]`] : []), 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 index 05320041c..11c0b5d4d 100644 --- 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 @@ -18,6 +18,13 @@ export interface ProviderFunctionParameterModel { 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; } /** @@ -29,6 +36,12 @@ export interface ProviderFunctionModel { 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 @@ -51,7 +64,12 @@ export interface ProviderFunctionsModel { } // Parameter names that collide with a reserved word/identifier in one of the -// jsii target languages; mirrors tools/generate-function-bindings mapParameter. +// 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 @@ -69,10 +87,24 @@ function sanitizeMethodName(name: string): string { /** * Maps a provider function parameter's declared Terraform type to a - * jsii-safe TypeScript parameter type. Booleans can't be represented as - * tokens (see helpers.ts asBoolean), so they're typed `any` to still accept - * tokens; dynamic/map/object parameters are also `any` (no structural typing - * for arbitrary provider function args). + * 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; @@ -80,15 +112,81 @@ function mapParameterType(type: AttributeType): { } { if (type === "string") return { tsType: "string", docstringType: "string" }; if (type === "number") return { tsType: "number", docstringType: "number" }; - if (type === "bool") return { tsType: "any", docstringType: "any" }; + 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")) { - return { tsType: "any[]", docstringType: "Array" }; + 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" || type[0] === "object")) { + + 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" }; } - return { tsType: "any", docstringType: "any" }; + + // object: structural input helpers are deferred to a follow-up issue. + return { tsType: "any", docstringType: "object" }; } function buildParameterModel( @@ -106,23 +204,84 @@ function buildParameterModel( }; } +/** + * 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. 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`, - * declared `any` and returned as the RAW `invoke()` result (an - * `IResolvable`), unwrapped. 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; jsii can still hand it back as `any` without a cast because - * `IResolvable` is assignable to `any`. + * `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; @@ -152,20 +311,35 @@ function mapReturnType(returnType: AttributeType): { 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: "string[]", - wrapReturn: (expr) => `cdktn.Token.asList(${expr})`, + tsType: "cdktn.IResolvable", + wrapReturn: (expr) => expr, }; } // dynamic, map, object: no structural typing for arbitrary provider - // function results - declared as `any`, but returned as the raw - // `IResolvable` from invoke() (NOT wrapped in `cdktn.Token.asString(...)`, + // 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). `IResolvable` is assignable to `any`, so no cast is - // needed either. + // docstring above). return { - tsType: "any", + tsType: "cdktn.IResolvable", wrapReturn: (expr) => expr, }; } @@ -178,22 +352,100 @@ function buildFunctionModel( 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: (signature.parameters ?? []).map((parameter, index) => - buildParameterModel(parameter, `arg${index}`), - ), - variadicParameter: signature.variadic_parameter - ? buildParameterModel(signature.variadic_parameter, "values") - : undefined, + 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 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); + } +} + /** * Builds the model for a provider's `provider-functions/index.ts` file from * its provider schema `functions` map. Returns `undefined` when the provider @@ -206,11 +458,18 @@ export function buildProviderFunctionsModel( 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); + for (const fn of mappedFunctions) { + assertNoParameterNameCollisions(providerName, fn); + } + return { providerName, className: `${toPascalCase(providerName)}ProviderFunctions`, - functions: entries - .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, signature]) => buildFunctionModel(name, signature)), + functions: mappedFunctions, }; } From 8a1af87391b06718c4f31e46485fa9e6154203cf Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 15:44:54 +0700 Subject: [PATCH 20/28] fix(lib): own function usage registries by App instead of process The Fn.* and provider-function usage registries were process-global Sets reset by App's constructor. That approximation had a real false negative (flagged in review): construct App A, use a gated function, construct App B before synthesizing A - B's constructor reset wiped A's recorded usage, so unsupported target versions passed validation silently. Usage is now recorded at token-RESOLVE time in FunctionCall.resolve(): IResolveContext.scope is always the resolving TerraformStack, so records are keyed by its node.root - the owning App - in WeakMaps. Validations read only their own root's records. Ownership is structural: interleaved Apps cannot cross-contaminate, the App-constructor resets (and the "known limitation" disclaimer) are gone, and App.synth()'s ordering guarantee (prepareStack's preparing resolve for all stacks runs before any stack's validations) means recorded usage is always visible to the validations that read it. Semantics sharpened as a side effect: a function token that never lands in any rendered output is no longer validated - only usage that actually reaches synthesized configuration matters. Regression tests pin the interleaved-App scenario for both registries. --- packages/cdktn/src/app.ts | 23 ---- packages/cdktn/src/functions/helpers.ts | 2 - .../cdktn/src/functions/provider-function.ts | 10 +- .../cdktn/src/functions/usage-registry.ts | 109 +++++++++--------- packages/cdktn/src/tfExpression.ts | 18 +++ .../validate-function-version-support.ts | 2 +- ...lidate-provider-function-target-support.ts | 11 +- .../cdktn/test/provider-functions.test.ts | 43 +++++-- packages/cdktn/test/validations.test.ts | 43 +++++-- 9 files changed, 153 insertions(+), 108 deletions(-) diff --git a/packages/cdktn/src/app.ts b/packages/cdktn/src/app.ts index 908d877bc..e399f7de1 100644 --- a/packages/cdktn/src/app.ts +++ b/packages/cdktn/src/app.ts @@ -13,10 +13,6 @@ import { noAppFound, } from "./errors"; import { FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS } from "./features"; -import { - resetFunctionUsageRegistry, - resetProviderFunctionUsageRegistry, -} from "./functions/usage-registry"; const APP_SYMBOL = Symbol.for("cdktf/App"); export const CONTEXT_ENV = "CDKTF_CONTEXT_JSON"; @@ -102,25 +98,6 @@ export class App extends Construct { super(undefined as any, ""); Object.defineProperty(this, APP_SYMBOL, { value: true }); - // A new App marks the start of a new synthesis session. The `Fn` and - // provider-function usage registries are process-global (tokens are - // scope-free, so usage can't be attributed to a stack or app reliably), - // so without a reset a usage recorded while synthesizing one App would - // leak into validations run for a later, unrelated App constructed in - // the same Node process (e.g. sequential apps within one test file). - // Resetting here scopes "global" usage to "global within this App", - // which is by design: every stack in an App resolves the same - // targetVersions from App context, so a validation firing on a sibling - // stack reports the same error the using stack would. - // - // Known limitation, intentionally not solved: if two Apps are - // constructed up front and then populated/synthesized interleaved, they - // still share the same global registries between their construction and - // synth calls. This is a pathological usage pattern; the common cases - // (one App per test, or sequential Apps in one process) are handled. - resetFunctionUsageRegistry(); - resetProviderFunctionUsageRegistry(); - this.outdir = config.outdir ?? process.env.CDKTF_OUTDIR ?? "cdktf.out"; const envHclOutput = process.env.SYNTH_HCL_OUTPUT; let hclOutput = config.hclOutput || false; 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 index af1f9267d..28fb583c2 100644 --- a/packages/cdktn/src/functions/provider-function.ts +++ b/packages/cdktn/src/functions/provider-function.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: MPL-2.0 import { IResolvable } from "../tokens/resolvable"; import { anyValue, terraformFunction } from "./helpers"; -import { recordProviderFunctionUsage } from "./usage-registry"; /** * Runtime entry point invoked by generated provider function bindings @@ -38,11 +37,10 @@ export class TerraformProviderFunction { args: any[], ): IResolvable { const fullName = `provider::${providerLocalName}::${functionName}`; - recordProviderFunctionUsage(fullName); - // terraformFunction() also records `fullName` into the `Fn` usage - // registry (usedFunctions); this is harmless since - // ValidateFunctionVersionSupport only checks names present in its own - // functionVersionConstraints map, which provider functions never are. + // 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 diff --git a/packages/cdktn/src/functions/usage-registry.ts b/packages/cdktn/src/functions/usage-registry.ts index 7434490c4..cfe1675d2 100644 --- a/packages/cdktn/src/functions/usage-registry.ts +++ b/packages/cdktn/src/functions/usage-registry.ts @@ -1,75 +1,76 @@ // 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: * - * Because the registry is process-global, `App`'s constructor resets it: a - * new `App` marks a new synthesis session, so usage recorded while - * synthesizing one App must not leak into validations for a later, - * unrelated App constructed in the same Node process (e.g. sequential apps - * in one test file). Within a single App, sharing this registry across all - * of its stacks is by design (see the validation classes that consume it). + * - 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. + * + * `App.synth()` runs a full preparing resolve of every stack's + * `toTerraform()` for ALL of the App's stacks before any stack's + * synthesizer runs validations, so usage recorded here during that pass 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 -export function recordFunctionUsage(functionName: string): void { - usedFunctions.add(functionName); +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 getUsedFunctions(): string[] { - return Array.from(usedFunctions); +export function recordFunctionUsage( + root: IConstruct, + functionName: string, +): void { + recordIn(usedFunctionsByRoot, root, functionName); } -/** - * Clears the recorded function usage; intended for tests. - */ -export function resetFunctionUsageRegistry(): void { - usedFunctions.clear(); -} - -/** - * Records which provider-defined functions (`provider::::`) - * have been called through `TerraformProviderFunction.invoke` so that - * synthesis-time validations can check them against the version of the - * selected Terraform-compatible CLI. - * - * Kept separate from `usedFunctions` above: provider-defined functions are - * validated against `providerFeatureConstraints.providerFunctions` (a single - * language-support constraint for the whole feature family), not against the - * per-function `functionVersionConstraints` map used for built-in `Fn.*` - * functions. - * - * Just like `usedFunctions`, this is process-global and is reset in `App`'s - * constructor for the same reason: a new App starts a new synthesis - * session, and usage must not leak across unrelated Apps in the same - * process. - */ -const usedProviderFunctions = new Set(); - // eslint-disable-next-line jsdoc/require-jsdoc -export function recordProviderFunctionUsage(fullName: string): void { - usedProviderFunctions.add(fullName); +export function getUsedFunctions(root: IConstruct): string[] { + return Array.from(usedFunctionsByRoot.get(root) ?? []); } // eslint-disable-next-line jsdoc/require-jsdoc -export function getUsedProviderFunctions(): string[] { - return Array.from(usedProviderFunctions); +export function recordProviderFunctionUsage( + root: IConstruct, + fullName: string, +): void { + recordIn(usedProviderFunctionsByRoot, root, fullName); } -/** - * Clears the recorded provider function usage; intended for tests. - */ -export function resetProviderFunctionUsageRegistry(): void { - usedProviderFunctions.clear(); +// eslint-disable-next-line jsdoc/require-jsdoc +export function getUsedProviderFunctions(root: IConstruct): string[] { + return Array.from(usedProviderFunctionsByRoot.get(root) ?? []); } diff --git a/packages/cdktn/src/tfExpression.ts b/packages/cdktn/src/tfExpression.ts index 814de99cc..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]*$/; @@ -372,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/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 index cacede569..8c1eb373a 100644 --- a/packages/cdktn/src/validations/validate-provider-function-target-support.ts +++ b/packages/cdktn/src/validations/validate-provider-function-target-support.ts @@ -22,15 +22,18 @@ import { getUsedProviderFunctions } from "../functions/usage-registry"; * 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 - * during the current synthesis session (i.e. since the enclosing `App` was - * constructed — see the usage-registry reset in `App`'s constructor). + * 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().sort(); + const usedProviderFunctions = getUsedProviderFunctions( + this.scope.node.root, + ).sort(); // no provider-defined functions in use: nothing to validate if (usedProviderFunctions.length === 0) { diff --git a/packages/cdktn/test/provider-functions.test.ts b/packages/cdktn/test/provider-functions.test.ts index 2bcea07d8..5c7c20486 100644 --- a/packages/cdktn/test/provider-functions.test.ts +++ b/packages/cdktn/test/provider-functions.test.ts @@ -8,16 +8,11 @@ import { TerraformStack, } from "../src"; import { TerraformProviderFunction } from "../src/functions/provider-function"; -import { resetProviderFunctionUsageRegistry } from "../src/functions/usage-registry"; import { createTmpHelper } from "./helper/tmp"; import { TestProvider, TestResource } from "./helper"; const tmp = createTmpHelper(); -beforeEach(() => { - resetProviderFunctionUsageRegistry(); -}); - test("invoke() renders provider::::(...) in synthesized output", () => { const app = Testing.app(); const stack = new TerraformStack(app, "test"); @@ -165,10 +160,10 @@ describe("ValidateProviderFunctionTargetSupport", () => { }); test("does not leak provider function usage from one App into a later, unrelated App in the same process", () => { - // Deliberately does NOT rely on the top-level beforeEach reset running - // between app1 and app2: both apps are constructed within this single - // test, so the only thing preventing app1's usage from leaking into - // app2's validation is the reset that happens in App's own constructor. + // 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" }, }); @@ -203,4 +198,34 @@ describe("ValidateProviderFunctionTargetSupport", () => { 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. + " + `); + }); }); diff --git a/packages/cdktn/test/validations.test.ts b/packages/cdktn/test/validations.test.ts index 53ebeeb0e..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(); @@ -479,10 +478,6 @@ describe("ValidateFeatureTargetSupport", () => { }); describe("ValidateFunctionVersionSupport", () => { - beforeEach(() => { - resetFunctionUsageRegistry(); - }); - function appWithStack(context?: Record) { const outdir = tmp("cdktf.outdir."); const app = Testing.stubVersion( @@ -637,10 +632,10 @@ describe("ValidateFunctionVersionSupport", () => { }); test("does not leak Fn usage from one App into a later, unrelated App in the same process", () => { - // Deliberately does NOT rely on the top-level beforeEach reset running - // between app1 and app2: both apps are constructed within this single - // test, so the only thing preventing app1's usage from leaking into - // app2's validation is the reset that happens in App's own constructor. + // 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" }, @@ -663,4 +658,34 @@ describe("ValidateFunctionVersionSupport", () => { ); 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. + " + `); + }); }); From 8bf9ddf86bff876897dd6fef3d228ce3dc687885 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 16:02:08 +0700 Subject: [PATCH 21/28] feat(provider-generator): move provider functions onto the provider class Provider-defined function bindings become instance methods reachable from the generated provider construct: const time = new TimeProvider(this, "time"); time.functions.rfc3339Parse(input); replacing the static class + trailing `providerLocalName?` parameter. The `functions` getter (emitted only for providers that declare functions) constructs the wrapper with `this.terraformResourceType` - the exact value terraform-provider.ts keys required_providers off, so the namespace is correct by construction, including cdktf.json `name` constraint overrides. The wrapper's constructor still takes a local name, remaining usable directly for exotic setups. This resolves the review discussion around the parameter's naming (`providerLocalName` vs `providerAlias`) by eliminating it: users no longer supply a namespace at all, and provider config aliases - which never selected function namespaces - can't be confused into that role. It also nudges users toward having the provider declared before calling its functions, which required_providers rendering needs anyway. The per-method self-reference-cycle JSDoc is dropped (review consensus: too narrow an edge case to repeat on every method); the warning remains once on TerraformProviderFunction.invoke and moves to the docs. Generation hard-fails with an actionable error if a provider's own config schema would emit a `functions` attribute getter that the new getter would collide with (no provider in the sweep does). --- .../provider-functions.test.ts.snap | 241 +++++++++++++----- ...ider-functions-collision.test.fixture.json | 31 +++ .../provider-functions.test.fixture.json | 7 + .../generator/provider-functions.test.ts | 40 ++- .../get/__tests__/generator/provider.test.ts | 8 + .../emitter/provider-functions-emitter.ts | 26 +- .../get/generator/emitter/resource-emitter.ts | 53 +++- .../models/provider-function-model.ts | 27 ++ .../get/generator/models/resource-model.ts | 8 + .../src/get/generator/provider-generator.ts | 26 +- 10 files changed, 382 insertions(+), 85 deletions(-) create mode 100644 packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions-collision.test.fixture.json 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 index bf9fb1c96..e906ab044 100644 --- 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 @@ -9,153 +9,130 @@ import * as cdktn from 'cdktn'; * Provider-defined functions of the example provider. */ export class ExampleProviderFunctions { + /** + * @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(private readonly providerLocalName: string) { + } /** * Returns an object describing the build that produced the given seed. - * 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 {string} seed - * @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. */ - public static buildInfo(seed: string, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "build_info", [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. - * 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 {boolean | IResolvable} condition * @param {any} trueValue * @param {any} falseValue - * @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. */ - public static conditionIf(condition: boolean | cdktn.IResolvable, trueValue: any, falseValue: any, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "condition_if", [condition, trueValue, 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. - * 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 {any} configs - * @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. */ - public static configMap(configs: any, providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "config_map", [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. - * 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 {Array} flags - * @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. */ - public static flagSet(flags: Array, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "flag_set", [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. - * 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 {string} separator * @param {Array} values - * @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. */ - public static joinStrings(separator: string, values: string[], providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "join_strings", [separator, ...values])); + public joinStrings(separator: string, values: string[]): 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. - * 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 {string} input - * @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. */ - public static legacyHelper(input: string, providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "legacy_helper", [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. - * 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 {number} count - * @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. */ - public static listNumbers(count: number, providerLocalName?: string): number[] { - return cdktn.Token.asNumberList(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "list_numbers", [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. - * 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 {string} prefix - * @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. */ - public static listPrefixedNames(prefix: string, providerLocalName?: string): string[] { - return cdktn.Token.asList(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "list_prefixed_names", [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. - * 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 {string | null} prefix * @param {string} suffix - * @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. */ - public static midNullable(prefix: any, suffix: string, providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "mid_nullable", [prefix, 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. - * 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 {Array>} matrix - * @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. */ - public static nestedList(matrix: string[][], providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "nested_list", [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. - * 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 {number} defaultValue * @param {Array} addends - * @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. */ - public static sum(defaultValue: number, addends: number[], providerLocalName?: string): number { - return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "sum", [defaultValue, ...addends])); + public sum(defaultValue: number, addends: number[]): number { + return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "sum", [defaultValue, ...addends])); } /** * Renders the given map of string tags as a single string. - * 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 {{ [key: string]: string }} tags - * @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. */ - public static tagMap(tags: { [key: string]: string }, providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "tag_map", [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. - * 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 {string} name * @param {string | null} greeting - * @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. */ - public static trailingNullable(name: string, greeting?: string, providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "trailing_nullable", [name, 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. - * 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 {string} label * @param {Array} items - * @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. */ - public static variadicNullable(label: string, items: any[], providerLocalName?: string): string { - return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "example", "variadic_nullable", [label, ...items])); + public variadicNullable(label: string, items: any[]): string { + return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "variadic_nullable", [label, ...items])); } } " @@ -178,6 +155,7 @@ Object.defineProperty(exports, 'providerFunctions', { get: function () { return 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'; " @@ -186,11 +164,139 @@ 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'; +/** +* 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 @@ -200,35 +306,34 @@ import * as cdktn from 'cdktn'; * Provider-defined functions of the time provider. */ export class TimeProviderFunctions { + /** + * @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(private readonly providerLocalName: string) { + } /** * Given a [Go duration string](https://pkg.go.dev/time#ParseDuration), will parse and return an object representation of that duration. - * 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 {string} duration Go time package duration string to parse - * @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. */ - public static durationParse(duration: string, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "duration_parse", [duration]); + 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. - * 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 {string} timestamp RFC3339 timestamp string to parse - * @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. */ - public static rfc3339Parse(timestamp: string, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "rfc3339_parse", [timestamp]); + 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. - * 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 {number} unixTimestamp Unix Timestamp integer to parse - * @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. */ - public static unixTimestampParse(unixTimestamp: number, providerLocalName?: string): cdktn.IResolvable { - return cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "time", "unix_timestamp_parse", [unixTimestamp]); + 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/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.test.fixture.json b/packages/@cdktn/provider-generator/src/get/__tests__/generator/fixtures/provider-functions.test.fixture.json index a793e474d..294f28a84 100644 --- 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 @@ -1,6 +1,13 @@ { "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, 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 index e5cf6aaa7..a9947c75f 100644 --- 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 @@ -3,7 +3,10 @@ import * as fs from "fs"; import * as path from "path"; import { TerraformProviderGenerator } from "../../generator/provider-generator"; -import { buildProviderFunctionsModel } from "../../generator/models/provider-function-model"; +import { + assertNoFunctionsGetterCollision, + buildProviderFunctionsModel, +} from "../../generator/models/provider-function-model"; import { CodeMaker } from "codemaker"; import { createTmpHelper } from "../util"; @@ -27,6 +30,12 @@ test("generate provider functions for the time provider (real terraform 1.15.6 s ); 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", @@ -114,3 +123,32 @@ test("buildProviderFunctionsModel throws when two parameter names collapse withi }), ).toThrow(/some__value/); }); + +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/generator/emitter/provider-functions-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts index 085bac552..ce270f246 100644 --- 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 @@ -10,9 +10,6 @@ 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."; -const SELF_REFERENCE_CYCLE_JSDOC = - "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)."; - export class ProviderFunctionsEmitter { constructor(private readonly code: CodeMaker) {} @@ -30,14 +27,26 @@ export class ProviderFunctionsEmitter { comment.end(); this.code.openBlock(`export class ${model.className}`); + this.emitConstructor(); + for (const fn of model.functions) { - this.emitMethod(model.providerName, fn); + this.emitMethod(fn); } this.code.closeBlock(); } - private emitMethod(providerName: string, fn: ProviderFunctionModel) { + private emitConstructor() { + const comment = sanitizedComment(this.code); + comment.line(`@param providerLocalName ${PROVIDER_LOCAL_NAME_JSDOC}`); + comment.end(); + this.code.openBlock( + `constructor(private readonly providerLocalName: string)`, + ); + this.code.closeBlock(); + } + + private emitMethod(fn: ProviderFunctionModel) { this.code.line(); const comment = sanitizedComment(this.code); @@ -46,7 +55,6 @@ export class ProviderFunctionsEmitter { if (fn.deprecationMessage) { comment.line(`@deprecated ${fn.deprecationMessage}`); } - comment.line(SELF_REFERENCE_CYCLE_JSDOC); for (const param of fn.parameters) { comment.line( `@param {${param.docstringType}} ${param.name} ${param.description ?? ""}`.trimEnd(), @@ -58,7 +66,6 @@ export class ProviderFunctionsEmitter { `@param {Array<${param.docstringType}>} ${param.name} ${param.description ?? ""}`.trimEnd(), ); } - comment.line(`@param providerLocalName ${PROVIDER_LOCAL_NAME_JSDOC}`); comment.end(); const methodParams = [ @@ -68,7 +75,6 @@ export class ProviderFunctionsEmitter { ...(fn.variadicParameter ? [`${fn.variadicParameter.name}: ${fn.variadicParameter.tsType}[]`] : []), - `providerLocalName?: string`, ]; const invokeArgs = [ @@ -76,10 +82,10 @@ export class ProviderFunctionsEmitter { ...(fn.variadicParameter ? [`...${fn.variadicParameter.name}`] : []), ]; - const invokeExpression = `cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "${providerName}", "${fn.terraformName}", [${invokeArgs.join(", ")}])`; + const invokeExpression = `cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "${fn.terraformName}", [${invokeArgs.join(", ")}])`; this.code.openBlock( - `public static ${fn.methodName}(${methodParams.join(", ")}): ${fn.returnTsType}`, + `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 ada512926..7877b3c74 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${this.importExtension}';`, + ); + } + const comment = sanitizedComment(this.code); comment.line( `Represents a {@link ${resource.linkToDocs} ${resource.terraformResourceType}}`, @@ -37,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); @@ -45,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)); 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 index 11c0b5d4d..8cf34a830 100644 --- 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 @@ -446,6 +446,33 @@ function assertNoParameterNameCollisions( } } +/** + * 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 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 9abbc8bca..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; 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 6fc6bfd0e..b3d62eadd 100644 --- a/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts +++ b/packages/@cdktn/provider-generator/src/get/generator/provider-generator.ts @@ -13,6 +13,7 @@ import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema"; import { ProviderFunctionsModel, ResourceModel, + assertNoFunctionsGetterCollision, buildProviderFunctionsModel, } from "./models"; import { ResourceParser } from "./resource-parser"; @@ -120,7 +121,7 @@ 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); } @@ -254,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, @@ -268,14 +279,19 @@ 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); } - const providerFunctionsModel = buildProviderFunctionsModel( - name, - provider.functions, - ); if (providerFunctionsModel) { files.push(this.emitProviderFunctions(name, providerFunctionsModel)); } From 99ab8e58a8939d2c81e40ce8abd9e9b745b8e7f3 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 16:12:53 +0700 Subject: [PATCH 22/28] fix(lib): provider-feature hook on TerraformElement, honest enum indexing, deliberate write-only null rule Four review cleanups on the provider-feature validation machinery: - registerProviderFeatureUsage (and its per-instance dedup set) moves from TerraformResource up to TerraformElement, so any element subclass is covered without moving it again; TerraformEphemeralResource now uses the hook instead of hand-building the same validation. - The Record casts around the feature maps are gone: a hasOwnProperty membership guard (for jsii callers passing arbitrary strings at runtime) followed by direct ProviderFeature-keyed indexing keeps the compile-time relationship the enum exists for. - The validation hint is no longer derived by capitalizing the label at runtime; a providerFeatureHints map sits next to providerFeatureConstraints/providerFeatureLabels as its own deliberate source of truth. Rendered messages are byte-identical. - Write-only usage registration treats explicit null as omission (Terraform semantics: null == omitted): the generated constructor guard becomes `!= null` and the setter path gets the same guard. Tests pin all four cases (constructor/setter x null/value). --- .../__snapshots__/write-only.test.ts.snap | 4 +- .../__tests__/generator/write-only.test.ts | 11 ++-- .../generator/emitter/attributes-emitter.ts | 6 +- .../get/generator/emitter/resource-emitter.ts | 10 ++-- .../cdktn/src/provider-feature-constraints.ts | 16 ++++++ packages/cdktn/src/terraform-element.ts | 56 +++++++++++++++++- .../cdktn/src/terraform-ephemeral-resource.ts | 32 +++-------- packages/cdktn/src/terraform-resource.ts | 44 -------------- packages/cdktn/test/write-only.test.ts | 57 +++++++++++++++++-- 9 files changed, 148 insertions(+), 88 deletions(-) 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 index 7ef07c1bc..169298aa7 100644 --- 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 @@ -95,7 +95,7 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { this._mount = config.mount; this._namespace = config.namespace; this._secretKeyWo = config.secretKeyWo; - if (config.secretKeyWo !== undefined) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } + if (config.secretKeyWo != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } this._secretKeyWoVersion = config.secretKeyWoVersion; } @@ -154,7 +154,7 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { return this.getStringAttribute('secret_key_wo'); } public set secretKeyWo(value: string) { - this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); + if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } this._secretKeyWo = value; } // Temporarily expose input value. Use with caution. 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 index d337196e6..f1b35d55e 100644 --- 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 @@ -30,13 +30,16 @@ test("generate a vault_alicloud_secret_backend resource with a write-only attrib expect(output).toMatch( /@deprecated Write-only: the provider never returns this value[\s\S]*?public get secretKeyWo\(\)/, ); - // ... its setter registers the usage of the write-only-attributes feature ... + // ... its setter registers the usage of the write-only-attributes feature, + // but only when the assigned value isn't null/undefined (setting an + // attribute to null is equivalent to omitting it in Terraform) ... expect(output).toMatch( - /public set secretKeyWo\(value: string\) \{\n\s*this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\);/, + /public set secretKeyWo\(value: string\) \{\n\s*if \(value != null\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, ); - // ... and so does the constructor-assigned config value. + // ... and so does the constructor-assigned config value, with the same + // null-or-undefined-means-omitted guard. expect(output).toMatch( - /if \(config\.secretKeyWo !== undefined\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, + /if \(config\.secretKeyWo != null\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, ); // The non-write-only sibling attribute is untouched: its getter is 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 2298280c8..db202d7cf 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 @@ -90,7 +90,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, + `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, ); } this.code.line(`this.${att.storageName} = value;`); @@ -103,7 +103,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, + `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, ); } this.code.line(`this.${att.storageName} = value;`); @@ -116,7 +116,7 @@ export class AttributesEmitter { ); if (emitWriteOnlyRegistration) { this.code.line( - `this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES);`, + `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, ); } this.code.line(`this.${att.storageName}.internalValue = value;`); 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 7877b3c74..b5280fe24 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 @@ -174,14 +174,14 @@ export class ResourceEmitter { } private emitResourceAttributes(resource: ResourceModel) { - // Only managed resources extend TerraformResource (and therefore have - // registerProviderFeatureUsage available); data sources, providers, and - // ephemeral resources do not, so their write-only attributes (if any) + // registerProviderFeatureUsage lives on TerraformElement, so every + // generated class could call it - but only managed resources 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 more than an implementation detail: ephemeral + // 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 @@ -251,7 +251,7 @@ export class ResourceEmitter { // AttributesEmitter for the corresponding property-set path). if (att.isWriteOnly && canRegisterProviderFeatureUsage) { this.code.line( - `if (config.${att.name} !== undefined) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, + `if (config.${att.name} != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, ); } } diff --git a/packages/cdktn/src/provider-feature-constraints.ts b/packages/cdktn/src/provider-feature-constraints.ts index da4bff847..cfe3298dc 100644 --- a/packages/cdktn/src/provider-feature-constraints.ts +++ b/packages/cdktn/src/provider-feature-constraints.ts @@ -93,3 +93,19 @@ export const providerFeatureLabels: Record = { [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/terraform-element.ts b/packages/cdktn/src/terraform-element.ts index c4512c3d5..4cffe6adc 100644 --- a/packages/cdktn/src/terraform-element.ts +++ b/packages/cdktn/src/terraform-element.ts @@ -5,7 +5,14 @@ import { Construct } 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"); @@ -32,6 +39,14 @@ export class TerraformElement extends Construct { */ private readonly _elementType?: string; + /** + * Provider-protocol feature families (see `ProviderFeature`) already + * registered via `registerProviderFeatureUsage` on this element, so + * repeated usage (e.g. a setter called multiple times) doesn't stack + * duplicate synth-time validations. + */ + private readonly _registeredProviderFeatures = new Set(); + constructor(scope: Construct, id: string, elementType?: string) { super(scope, id); Object.defineProperty(this, TERRAFORM_ELEMENT_SYMBOL, { value: true }); @@ -52,6 +67,45 @@ 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 + * actually used (e.g. `ProviderFeature.WRITE_ONLY_ATTRIBUTES` when a + * write-only attribute is set); not intended to be called directly. Lives + * on `TerraformElement` (rather than `TerraformResource`) so it covers any + * element subclass that needs it, e.g. `TerraformEphemeralResource`. + */ + protected registerProviderFeatureUsage(feature: ProviderFeature): void { + if (this._registeredProviderFeatures.has(feature)) { + return; + } + + if ( + !Object.prototype.hasOwnProperty.call(providerFeatureConstraints, feature) + ) { + throw unknownProviderFeature(feature); + } + + this._registeredProviderFeatures.add(feature); + + const constraints = providerFeatureConstraints[feature]; + const hint = `${providerFeatureHints[feature]} are available in ${Object.entries( + constraints, + ) + .map(([product, range]) => `${product} ${range}`) + .join(" and ")}.`; + + this.node.addValidation( + new ValidateFeatureTargetSupport( + this, + providerFeatureLabels[feature], + constraints, + hint, + ), + ); + } + public toTerraform(): any { return {}; } diff --git a/packages/cdktn/src/terraform-ephemeral-resource.ts b/packages/cdktn/src/terraform-ephemeral-resource.ts index f0cdf9acf..6e2aedcc1 100644 --- a/packages/cdktn/src/terraform-ephemeral-resource.ts +++ b/packages/cdktn/src/terraform-ephemeral-resource.ts @@ -21,12 +21,7 @@ import { IResolvable } from "./tokens/resolvable"; import { IInterpolatingParent } from "./terraform-addressable"; import { ITerraformIterator } from "./terraform-iterator"; import { TerraformCount } from "./terraform-count"; -import { ValidateFeatureTargetSupport } from "./validations/target-versions"; -import { - ProviderFeature, - providerFeatureConstraints, - providerFeatureLabels, -} from "./provider-feature-constraints"; +import { ProviderFeature } from "./provider-feature-constraints"; // eslint-disable-next-line @typescript-eslint/no-require-imports import assert = require("assert"); @@ -34,16 +29,6 @@ const TERRAFORM_EPHEMERAL_RESOURCE_SYMBOL = Symbol.for( "cdktf/TerraformEphemeralResource", ); -/** - * Tells the user where ephemeral resources ARE available, so they can adjust - * their declared targetVersions. - */ -const EPHEMERAL_RESOURCES_HINT = `Ephemeral resources are available in ${Object.entries( - providerFeatureConstraints[ProviderFeature.EPHEMERAL_RESOURCES], -) - .map(([product, range]) => `${product} ${range}`) - .join(" and ")}.`; - /** * Lifecycle options supported by Terraform ephemeral blocks. Unlike managed * resources, ephemeral resources have no state, so Terraform only supports @@ -128,14 +113,13 @@ export class TerraformEphemeralResource this.lifecycle = config.lifecycle; this.forEach = config.forEach; - this.node.addValidation( - new ValidateFeatureTargetSupport( - this, - providerFeatureLabels[ProviderFeature.EPHEMERAL_RESOURCES], - providerFeatureConstraints[ProviderFeature.EPHEMERAL_RESOURCES], - EPHEMERAL_RESOURCES_HINT, - ), - ); + // 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( diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 841b5cdc9..675ea7ba2 100644 --- a/packages/cdktn/src/terraform-resource.ts +++ b/packages/cdktn/src/terraform-resource.ts @@ -29,19 +29,12 @@ import { RemoteExecProvisioner, } from "./terraform-provisioner"; import { ValidateTerraformVersion } from "./validations/validate-terraform-version"; -import { ValidateFeatureTargetSupport } from "./validations/target-versions"; import { TerraformStack } from "./terraform-stack"; -import { - ProviderFeature, - providerFeatureConstraints, - providerFeatureLabels, -} from "./provider-feature-constraints"; import { movedToResourceOfDifferentType, resourceGivenTwoMoveOperationsById, resourceGivenTwoMoveOperationsByTarget, resourceGivenTwoMoveOperationsByTargetAndId, - unknownProviderFeature, } from "./errors"; const TERRAFORM_RESOURCE_SYMBOL = Symbol.for("cdktf/TerraformResource"); @@ -158,7 +151,6 @@ export class TerraformResource private _movedByTarget?: TerraformResourceMoveByTarget; private _movedById?: TerraformResourceMoveById; private _hasMoved = false; - private readonly _registeredProviderFeatures = new Set(); constructor(scope: Construct, id: string, config: TerraformResourceConfig) { super(scope, id, config.terraformResourceType); @@ -185,42 +177,6 @@ export class TerraformResource ); } - /** - * 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 - * actually used (e.g. `ProviderFeature.WRITE_ONLY_ATTRIBUTES` when a - * write-only attribute is set); not intended to be called directly. - */ - protected registerProviderFeatureUsage(feature: ProviderFeature): void { - if (this._registeredProviderFeatures.has(feature)) { - return; - } - - const constraints = ( - providerFeatureConstraints as Record< - string, - (typeof providerFeatureConstraints)[keyof typeof providerFeatureConstraints] - > - )[feature]; - const label = (providerFeatureLabels as Record)[feature]; - if (!constraints || !label) { - throw unknownProviderFeature(feature); - } - - this._registeredProviderFeatures.add(feature); - - const hint = `${label.charAt(0).toUpperCase()}${label.slice( - 1, - )} are available in ${Object.entries(constraints) - .map(([product, range]) => `${product} ${range}`) - .join(" and ")}.`; - - this.node.addValidation( - new ValidateFeatureTargetSupport(this, label, constraints, hint), - ); - } - public hasResourceMove() { return this._movedById || this._movedByTarget; } diff --git a/packages/cdktn/test/write-only.test.ts b/packages/cdktn/test/write-only.test.ts index 989e7fad0..bc64b7af5 100644 --- a/packages/cdktn/test/write-only.test.ts +++ b/packages/cdktn/test/write-only.test.ts @@ -16,23 +16,33 @@ const tmp = createTmpHelper(); /** * Stand-in for a generated resource binding that has a write-only attribute. * Mirrors what the provider-generator emits: the setter registers usage of - * the "writeOnlyAttributes" provider-protocol feature family. + * the "writeOnlyAttributes" provider-protocol feature family, but only when + * the assigned value is neither `null` nor `undefined` - setting an + * attribute to `null` is equivalent to omitting it in Terraform, and a + * non-TypeScript jsii caller (Python `None`, Go `nil`, ...) can pass `null` + * through even though the TypeScript setter signature itself is non-null. */ class TestWriteOnlyResource extends TerraformResource { private _secretKeyWo?: string; - constructor(scope: Construct, id: string, config: { secretKeyWo?: string }) { + constructor( + scope: Construct, + id: string, + config: { secretKeyWo?: string | null }, + ) { super(scope, id, { terraformResourceType: "test_write_only_resource", }); - this._secretKeyWo = config.secretKeyWo; - if (config.secretKeyWo !== undefined) { + this._secretKeyWo = config.secretKeyWo ?? undefined; + if (config.secretKeyWo != null) { this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); } } public set secretKeyWo(value: string) { - this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); + if (value != null) { + this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); + } this._secretKeyWo = value; } @@ -117,6 +127,43 @@ describe("registerProviderFeatureUsage", () => { `); }); + test("constructor: explicit null is treated as omission, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: null, + }); + + expect(() => app.synth()).not.toThrow(); + expect(resource.secretKeyWo).toBeUndefined(); + }); + + test("constructor: a real value registers usage, synth fails", () => { + const { app, stack } = appWithStack(); + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); + + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + + test("setter: assigning null is treated as omission, synth passes", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + + resource.secretKeyWo = null as unknown as string; + + expect(() => app.synth()).not.toThrow(); + }); + + test("setter: assigning a real value registers usage, synth fails", () => { + const { app, stack } = appWithStack(); + const resource = new TestWriteOnlyResource(stack, "testResource", {}); + + resource.secretKeyWo = "shh"; + + expect(() => app.synth()).toThrow(/write-only attributes requires/); + }); + test("unknown feature key throws", () => { const { stack } = appWithStack(); const resource = new TestWriteOnlyResource(stack, "testResource", {}); From d6e859fe50b0f75eddb716e1e57e3c292a45f273 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 18:20:01 +0700 Subject: [PATCH 23/28] fix(provider-generator): make generated provider-function code run under nodenext and Node type-stripping Three defects surfaced by exercising the generated bindings in a real app (cdktn-provider-features-demo) instead of only snapshotting them: - The provider class's sibling import of the functions wrapper lacked /index, so nodenext/ESM resolution looked for ../provider-functions.js and failed; now emits '../provider-functions/index'. - The wrapper constructor used a TS parameter property, which Node's native type-stripping rejects with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX (it requires a synthesized assignment, not type erasure) - breaking any project that executes .ts directly. Now a declared field plus a body assignment. (jsii accepted the parameter property; Node's stripper is the stricter consumer.) - Variadic rest parameters rendered as `T[]`, which for a union element type binds as `boolean | (IResolvable[])`; now `Array`. Validated end-to-end against the demo harness: tsc --noEmit clean, all 9 examples synthesize, provider::::() expressions in output, and the generated package still passes tsc + jsii + pacmak. --- .../provider-functions.test.ts.snap | 18 ++++++++++------ .../emitter/provider-functions-emitter.ts | 21 +++++++++++++++---- .../get/generator/emitter/resource-emitter.ts | 2 +- 3 files changed, 30 insertions(+), 11 deletions(-) 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 index e906ab044..be158604a 100644 --- 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 @@ -9,10 +9,13 @@ 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(private readonly providerLocalName: string) { + constructor(providerLocalName: string) { + this.providerLocalName = providerLocalName; } /** @@ -54,7 +57,7 @@ export class ExampleProviderFunctions { * @param {string} separator * @param {Array} values */ - public joinStrings(separator: string, values: string[]): string { + public joinStrings(separator: string, values: Array): string { return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "join_strings", [separator, ...values])); } @@ -105,7 +108,7 @@ export class ExampleProviderFunctions { * @param {number} defaultValue * @param {Array} addends */ - public sum(defaultValue: number, addends: number[]): number { + public sum(defaultValue: number, addends: Array): number { return cdktn.Token.asNumber(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "sum", [defaultValue, ...addends])); } @@ -131,7 +134,7 @@ export class ExampleProviderFunctions { * @param {string} label * @param {Array} items */ - public variadicNullable(label: string, items: any[]): string { + public variadicNullable(label: string, items: Array): string { return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(this.providerLocalName, "variadic_nullable", [label, ...items])); } } @@ -188,7 +191,7 @@ export interface TimeProviderConfig { readonly alias?: string; } -import { TimeProviderFunctions } from '../provider-functions'; +import { TimeProviderFunctions } from '../provider-functions/index'; /** * Represents a {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs time} */ @@ -306,10 +309,13 @@ 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(private readonly providerLocalName: string) { + constructor(providerLocalName: string) { + this.providerLocalName = providerLocalName; } /** 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 index ce270f246..2957e1208 100644 --- 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 @@ -37,12 +37,20 @@ export class ProviderFunctionsEmitter { } 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(private readonly providerLocalName: string)`, - ); + this.code.openBlock(`constructor(providerLocalName: string)`); + this.code.line(`this.providerLocalName = providerLocalName;`); this.code.closeBlock(); } @@ -73,7 +81,12 @@ export class ProviderFunctionsEmitter { (p) => `${p.name}${p.optional ? "?" : ""}: ${p.tsType}`, ), ...(fn.variadicParameter - ? [`${fn.variadicParameter.name}: ${fn.variadicParameter.tsType}[]`] + ? // `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}>`, + ] : []), ]; 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 b5280fe24..0c6b3235e 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 @@ -24,7 +24,7 @@ export class ResourceEmitter { // 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${this.importExtension}';`, + `import { ${resource.providerFunctionsModel.className} } from '../provider-functions/index${this.importExtension}';`, ); } From 58cd428338d826cb7425bbf9320252599d762306 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 19:25:39 +0700 Subject: [PATCH 24/28] test(provider-generator): edge-provider compile coverage for functions, ephemeral resources, write-only Closes #311. Extends the edge-provider schema - whose bindings are jsii-compiled and pacmak-codegenned for all five languages at postbuild and native-compiled by the test//edge integration suites - with the three new-protocol surfaces from this PR: - nine provider-defined functions, one per generator branch: plain string, nullable trailing (jsii-optional), nullable mid-position (widens to any), nullable variadic, bool variadic (union-element rest param), deprecation_message, object return, list(number) return, map(string) parameter; - an ephemeral resource (required/optional/computed attributes plus a nested block) extending TerraformEphemeralResource; - a write_only attribute on an existing managed resource, so the deprecated getter and both != null registration guards land in compiled bindings. This is the compile-slice of the CI-coverage gap: every workflow's Terraform tops out at 1.6.5 (pre-1.8), so no CI job can fetch a schema containing these sections - the edge fixture is binary-independent and rides existing jobs. The runtime slice (real cdktn get on terraform 1.15.x + opentofu 1.12.x) is tracked in #337. Verified locally: jsii + pacmak --code-only clean for all five targets; typescript/go/csharp edge integration tests pass against the regenerated bindings (java needs mvn, unavailable in this environment; python edge remains describe.skip'd - both pre-existing gaps noted in #311). --- .../edge-provider-schema.test.ts.snap | 4 + .../__tests__/edge-provider-schema/builder.ts | 18 ++- .../__tests__/edge-provider-schema/index.ts | 123 +++++++++++++++++- 3 files changed, 143 insertions(+), 2 deletions(-) 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..267ad51cd 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,17 @@ 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 + `registerProviderFeatureUsage` setter/constructor guards on an + // existing managed resource. Left optional so no existing usage of + // OptionalAttributeResource across the edge test suite needs updating. + .attribute({ + name: "secretWo", + type: "string", + required: false, + computed: false, + writeOnly: true, + }) .build(); const optional_computed_attribute_resource = new S() @@ -145,6 +156,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 +275,8 @@ export const edgeSchema: ProviderSchema = schema({ map_list_resource, }, dataSources: {}, + ephemeralResources: { + cached_secret, + }, + functions, }); From 045ecb2f128f4b0bc03c3efa78edf4b1e3a070f0 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 21:58:14 +0700 Subject: [PATCH 25/28] fix(provider-generator): reserve generated wrapper member names in function collision checks A provider function named "constructor" emitted a second class constructor (invalid TypeScript), and "provider_local_name" camelCased into a method colliding with the wrapper's private providerLocalName field - both produced broken output instead of the diagnostic that function-to-function collisions already get. buildProviderFunctionsModel now rejects any function whose sanitized method name lands on a member the emitter itself puts on the wrapper class (constructor, providerLocalName), with an actionable error naming the provider, the Terraform function name, and the generated identifier, thrown at the model level - before any output is written. Parameter names are deliberately not checked against providerLocalName: they live in method scope and merely shadow the field. --- .../generator/provider-functions.test.ts | 41 ++++++++++++++ .../models/provider-function-model.ts | 54 +++++++++++++++++++ 2 files changed, 95 insertions(+) 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 index a9947c75f..a04759b47 100644 --- 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 @@ -8,6 +8,7 @@ import { buildProviderFunctionsModel, } from "../../generator/models/provider-function-model"; import { CodeMaker } from "codemaker"; +import { FunctionSignature } from "@cdktn/commons"; import { createTmpHelper } from "../util"; const tmp = createTmpHelper(); @@ -124,6 +125,46 @@ test("buildProviderFunctionsModel throws when two parameter names collapse withi ).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"]), 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 index 8cf34a830..caff37b6b 100644 --- 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 @@ -85,6 +85,24 @@ function sanitizeMethodName(name: string): string { 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. @@ -417,6 +435,41 @@ function assertNoMethodNameCollisions( } } +/** + * 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 @@ -490,6 +543,7 @@ export function buildProviderFunctionsModel( .map(([name, signature]) => buildFunctionModel(name, signature)); assertNoMethodNameCollisions(providerName, mappedFunctions); + assertNoReservedWrapperMemberCollisions(providerName, mappedFunctions); for (const fn of mappedFunctions) { assertNoParameterNameCollisions(providerName, fn); } From 60d933cc6851ac541a19338fb435ab5334d2e3a6 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sat, 18 Jul 2026 21:58:14 +0700 Subject: [PATCH 26/28] fix(lib)!: write-only target validation derives from rendered values, not mutation history Registration of WRITE_ONLY_ATTRIBUTES usage was event-based and sticky: the first non-null constructor/setter value armed the target-version validation forever, so clearing the value (explicit null or reset) before synth still failed projects targeting < 1.11 - an error claiming something false about the synthesized artifact. A Lazy/IResolvable producer that resolved to nothing also counted as usage, penalizing exactly the conditional/mixin patterns targetVersions encourages. Registration now happens at token-resolve time, mirroring the function-usage registry: generated synthesizeAttributes()/ synthesizeHclAttributes() wrap write-only values in TerraformResource.markWriteOnlyAttribute(), a transparent IResolvable that defers to the real resolver and registers usage only when the RESOLVED result is non-null. undefined/null pass through unwrapped, so omission filtering is untouched. The mutation-time guards in the generated constructor and setters are deleted entirely - validation describes what renders, structurally. Consequences, all pinned by tests: set-then-null, set-then-reset, and constructor-then-reset synth cleanly on old targets with the attribute absent; a value present at synth still fails; re-setting after a clear re-arms; a Lazy producer resolving to undefined does not register while the same producer resolving to a value does; dual resolve passes (preparing + final) dedup to one validation. The write-only fixture gains an optional attribute so the reset path is exercised (the required one cannot generate a reset method). --- .../__tests__/edge-provider-schema/index.ts | 8 +- .../__snapshots__/write-only.test.ts.snap | 39 +++- .../fixtures/write-only.test.fixture.json | 8 + .../__tests__/generator/write-only.test.ts | 38 +++- .../generator/emitter/attributes-emitter.ts | 79 +++++---- .../get/generator/emitter/resource-emitter.ts | 69 ++++---- packages/cdktn/src/terraform-resource.ts | 62 ++++++- packages/cdktn/test/write-only.test.ts | 166 ++++++++++++++---- 8 files changed, 351 insertions(+), 118 deletions(-) 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 267ad51cd..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 @@ -12,9 +12,11 @@ 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 + `registerProviderFeatureUsage` setter/constructor guards on an - // existing managed resource. Left optional so no existing usage of - // OptionalAttributeResource across the edge test suite needs updating. + // 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", 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 index 169298aa7..b746a62f8 100644 --- 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 @@ -35,6 +35,12 @@ export interface AlicloudSecretBackendConfig extends cdktn.TerraformMetaArgument */ 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} @@ -95,7 +101,7 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { this._mount = config.mount; this._namespace = config.namespace; this._secretKeyWo = config.secretKeyWo; - if (config.secretKeyWo != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } + this._secondarySecretKeyWo = config.secondarySecretKeyWo; this._secretKeyWoVersion = config.secretKeyWoVersion; } @@ -154,7 +160,6 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { return this.getStringAttribute('secret_key_wo'); } public set secretKeyWo(value: string) { - if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); } this._secretKeyWo = value; } // Temporarily expose input value. Use with caution. @@ -162,6 +167,25 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { 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() { @@ -184,7 +208,8 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { access_key: cdktn.stringToTerraform(this._accessKey), mount: cdktn.stringToTerraform(this._mount), namespace: cdktn.stringToTerraform(this._namespace), - secret_key_wo: cdktn.stringToTerraform(this._secretKeyWo), + 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), }; } @@ -210,7 +235,13 @@ export class AlicloudSecretBackend extends cdktn.TerraformResource { storageClassType: "string", }, secret_key_wo: { - value: cdktn.stringToHclTerraform(this._secretKeyWo), + 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", 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 index 3c2758049..af6b0adaa 100644 --- 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 @@ -33,6 +33,14 @@ "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.", 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 index f1b35d55e..d96a2d152 100644 --- 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 @@ -30,25 +30,45 @@ test("generate a vault_alicloud_secret_backend resource with a write-only attrib expect(output).toMatch( /@deprecated Write-only: the provider never returns this value[\s\S]*?public get secretKeyWo\(\)/, ); - // ... its setter registers the usage of the write-only-attributes feature, - // but only when the assigned value isn't null/undefined (setting an - // attribute to null is equivalent to omitting it in Terraform) ... + // ... its setter is a plain assignment - no registration happens at + // mutation time anymore ... expect(output).toMatch( - /public set secretKeyWo\(value: string\) \{\n\s*if \(value != null\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, + /public set secretKeyWo\(value: string\) \{\n\s*this\._secretKeyWo = value;\n\s*\}/, ); - // ... and so does the constructor-assigned config value, with the same - // null-or-undefined-means-omitted guard. + // ... 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( - /if \(config\.secretKeyWo != null\) \{ this\.registerProviderFeatureUsage\(cdktn\.ProviderFeature\.WRITE_ONLY_ATTRIBUTES\); \}/, + /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), and its - // setter has no registration call. + // 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/generator/emitter/attributes-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts index db202d7cf..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 @@ -16,19 +16,7 @@ function titleCase(value: string) { export class AttributesEmitter { constructor(private code: CodeMaker) {} - /** - * @param registerWriteOnlyUsage Whether a write-only attribute's setter - * should emit a `registerProviderFeatureUsage` call. Only true for - * attributes emitted on classes that extend TerraformResource (managed - * resources); struct OutputReference classes have no construct node to - * register a validation against, so they only get the deprecated getter. - */ - public emit( - att: AttributeModel, - escapeReset: boolean, - escapeInput: boolean, - registerWriteOnlyUsage = false, - ) { + public emit(att: AttributeModel, escapeReset: boolean, escapeInput: boolean) { this.code.line(); this.code.line( `// ${att.terraformName} - computed: ${att.computed}, optional: ${att.isOptional}, required: ${att.isRequired}`, @@ -81,18 +69,16 @@ export class AttributesEmitter { } const setterType = att.setterType; - const emitWriteOnlyRegistration = att.isWriteOnly && registerWriteOnlyUsage; + // 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( `public set ${att.name}(value: ${setterType.type})`, ); - if (emitWriteOnlyRegistration) { - this.code.line( - `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, - ); - } this.code.line(`this.${att.storageName} = value;`); this.code.closeBlock(); break; @@ -101,11 +87,6 @@ export class AttributesEmitter { this.code.openBlock( `public put${titleCase(att.name)}(value: ${setterType.type})`, ); - if (emitWriteOnlyRegistration) { - this.code.line( - `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, - ); - } this.code.line(`this.${att.storageName} = value;`); this.code.closeBlock(); break; @@ -114,11 +95,6 @@ export class AttributesEmitter { this.code.openBlock( `public put${titleCase(att.name)}(value: ${setterType.type})`, ); - if (emitWriteOnlyRegistration) { - this.code.line( - `if (value != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, - ); - } this.code.line(`this.${att.storageName}.internalValue = value;`); this.code.closeBlock(); break; @@ -250,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; @@ -269,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; @@ -281,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; @@ -300,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/resource-emitter.ts b/packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts index 0c6b3235e..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 @@ -139,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(`};`); @@ -165,37 +170,44 @@ 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(); } - private emitResourceAttributes(resource: ResourceModel) { - // registerProviderFeatureUsage lives on TerraformElement, so every - // generated class could call it - but only managed resources 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. - const canRegisterProviderFeatureUsage = - resource.parentClassName === "TerraformResource"; + // `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( att, this.attributesEmitter.needsResetEscape(att, resource.attributes), this.attributesEmitter.needsInputEscape(att, resource.attributes), - canRegisterProviderFeatureUsage, ); } } @@ -234,8 +246,13 @@ export class ResourceEmitter { } // initialize config properties - const canRegisterProviderFeatureUsage = - resource.parentClassName === "TerraformResource"; + // + // 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( @@ -244,16 +261,6 @@ export class ResourceEmitter { } else { this.code.line(`this.${att.storageName} = config.${att.name};`); } - - // Constructor assignments bypass the setter above, so write-only - // usage must be registered here as well for the config value passed - // directly to the constructor (see the setter registration in - // AttributesEmitter for the corresponding property-set path). - if (att.isWriteOnly && canRegisterProviderFeatureUsage) { - this.code.line( - `if (config.${att.name} != null) { this.registerProviderFeatureUsage(cdktn.ProviderFeature.WRITE_ONLY_ATTRIBUTES); }`, - ); - } } this.code.closeBlock(); diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 675ea7ba2..83279339f 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,64 @@ 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. + * + * This mirrors the resolve-time function-usage registry (see + * `FunctionCall.resolve()` in `tfExpression.ts`): only usage that + * actually reaches synthesized configuration is validated, and + * `App.synth()`'s ordering guarantee - prepareStack's preparing + * resolve of every element's `toTerraform()`, for ALL stacks, runs + * before ANY stack's validations - means the registration this + * produces is always visible to the validation that reads it. + */ + 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.registerProviderFeatureUsage( + 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/test/write-only.test.ts b/packages/cdktn/test/write-only.test.ts index bc64b7af5..0a9e006e8 100644 --- a/packages/cdktn/test/write-only.test.ts +++ b/packages/cdktn/test/write-only.test.ts @@ -1,12 +1,6 @@ // Copyright (c) HashiCorp, Inc // SPDX-License-Identifier: MPL-2.0 -import { - App, - ProviderFeature, - TerraformResource, - TerraformStack, - Testing, -} from "../src"; +import { App, Lazy, Testing, TerraformResource, TerraformStack } from "../src"; import { Construct } from "constructs"; import { TestProvider } from "./helper/provider"; import { createTmpHelper } from "./helper/tmp"; @@ -15,12 +9,15 @@ const tmp = createTmpHelper(); /** * Stand-in for a generated resource binding that has a write-only attribute. - * Mirrors what the provider-generator emits: the setter registers usage of - * the "writeOnlyAttributes" provider-protocol feature family, but only when - * the assigned value is neither `null` nor `undefined` - setting an - * attribute to `null` is equivalent to omitting it in Terraform, and a - * non-TypeScript jsii caller (Python `None`, Go `nil`, ...) can pass `null` - * through even though the TypeScript setter signature itself is non-null. + * 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; @@ -34,15 +31,9 @@ class TestWriteOnlyResource extends TerraformResource { terraformResourceType: "test_write_only_resource", }); this._secretKeyWo = config.secretKeyWo ?? undefined; - if (config.secretKeyWo != null) { - this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); - } } public set secretKeyWo(value: string) { - if (value != null) { - this.registerProviderFeatureUsage(ProviderFeature.WRITE_ONLY_ATTRIBUTES); - } this._secretKeyWo = value; } @@ -50,6 +41,10 @@ class TestWriteOnlyResource extends TerraformResource { 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 @@ -60,7 +55,9 @@ class TestWriteOnlyResource extends TerraformResource { } protected synthesizeAttributes(): { [name: string]: any } { - return { secret_key_wo: this._secretKeyWo }; + return { + secret_key_wo: this.markWriteOnlyAttribute(this._secretKeyWo), + }; } } @@ -74,11 +71,24 @@ function appWithStack(context?: Record) { return { app, stack }; } -describe("registerProviderFeatureUsage", () => { +/** + * 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(); }); @@ -109,14 +119,19 @@ describe("registerProviderFeatureUsage", () => { expect(() => app.synth()).not.toThrow(); }); - test("repeated setter calls do not stack duplicate validations", () => { + 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" }, }); - const resource = new TestWriteOnlyResource(stack, "testResource", {}); - resource.secretKeyWo = "one"; - resource.secretKeyWo = "two"; - resource.secretKeyWo = "three"; + new TestWriteOnlyResource(stack, "testResource", { + secretKeyWo: "shh", + }); expect(() => app.synth()).toThrowErrorMatchingInlineSnapshot(` "Validation failed with the following errors: @@ -127,43 +142,124 @@ describe("registerProviderFeatureUsage", () => { `); }); - test("constructor: explicit null is treated as omission, synth passes", () => { + // (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: null, + secretKeyWo: "shh", }); + resource.resetSecretKeyWo(); + expect(synthesizedSecretKeyWo(stack)).toBeUndefined(); expect(() => app.synth()).not.toThrow(); - expect(resource.secretKeyWo).toBeUndefined(); }); - test("constructor: a real value registers usage, synth fails", () => { + // (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/); }); - test("setter: assigning null is treated as omission, synth passes", () => { + // (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(() => app.synth()).not.toThrow(); + expect(synthesizedSecretKeyWo(stack)).toBe("shh again"); + expect(() => app.synth()).toThrow(/write-only attributes requires/); }); - test("setter: assigning a real value registers usage, synth fails", () => { + // (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 }); - resource.secretKeyWo = "shh"; + 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", {}); From fc5cd9dd31f977fb0e52abb5c45be715131c5a04 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 19 Jul 2026 02:18:55 +0700 Subject: [PATCH 27/28] fix(lib): HCL renderer omits attribute descriptors whose value resolved to undefined In HCL synthesis, attributes are {value, isBlock, type, storageClassType} descriptors. A token value (e.g. a write-only marker wrapping a Lazy that produces undefined) survives the generated pre-resolution filter; generic resolution then strips only the undefined `value` key, and renderAttributes() fell through to renderFuzzyJsonExpression() on the metadata-only shell - emitting a bogus assignment built from internal descriptor fields instead of omitting the attribute. renderAttributes() now recognizes the orphaned-descriptor shape (the exact metadata key combination the generator emits, with both `value` and `dynamic` absent - only `value` can ever be token-typed, so the shell is unambiguous) and omits the attribute entirely. Explicit null values keep rendering as null assignments. Not specific to write-only: any attribute whose token resolves to undefined in HCL mode hit this path (the #313 HCL-defects family) - the write-only marker contract just made it load-bearing. Regression tests land with the companion lifecycle commit (shared write-only test harness). --- packages/cdktn/src/hcl/render.ts | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/cdktn/src/hcl/render.ts b/packages/cdktn/src/hcl/render.ts index bb240ab62..4df93ec03 100644 --- a/packages/cdktn/src/hcl/render.ts +++ b/packages/cdktn/src/hcl/render.ts @@ -615,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") + ); +} + /** * */ @@ -647,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)} From 0eb1a1a4f780f3b008aa02f9e2cccb87688a9320 Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Sun, 19 Jul 2026 02:18:56 +0700 Subject: [PATCH 28/28] fix(lib): provider-feature and function usage represent the current synthesis pass Round-6 review findings: resolve-time discovery only worked through App.synth() (which happens to prepare stacks before validating), and registrations were lifetime-sticky once a value had rendered. Usage now has explicit epoch semantics - it describes the current synthesis pass, discovered by a prepare step every validation-enabled entry point establishes: - Element registrations are mode-tagged: structural (the element's existence is the usage - ephemeral resources; never reset) vs resolve-discovered (write-only; deactivated at the start of each preparing resolve, re-armed only when resolution rediscovers a real value). constructs has no removeValidation, so the installed validation is a thin gate that reports nothing while inactive. - ISynthesisSession gains optional `stacksPrepared`; App.synth() sets it (it already prepares all stacks first), and StackSynthesizer.synthesize() prepares the stack itself - including the per-root function-usage epoch reset - when reached without it. - Testing.synth()/synthHcl() with validations now run the discovery half of prepareStack() (split out as an internal method so no backend is injected into existing snapshots) plus the function-epoch reset before validating. Previously these entry points validated before any resolve ran, silently passing configured write-only values and gated functions on unsupported targets. - The function-usage registries adopt the same epoch: recorded usage clears at the start of each synthesis session, so render-once, remove, re-synth no longer reports stale usage - for Fn.*, provider functions, and write-only alike. Regressions pinned: old-target failures now surface through Testing.synth, Testing.synthHcl, and direct synthesizer use (for both write-only and provider functions); synth-fail -> reset -> re-synth passes; a Lazy producer flipping value -> undefined across passes passes; multi-stack Apps keep sibling usage intact; structural (ephemeral) registrations survive epochs; HCL output omits write-only attributes whose token resolves to undefined (regressions for the companion renderer fix). Doc comments on the marker and registries now state exactly this invariant instead of the stronger claims the review called out. BREAKING CHANGE (testing semantics): Testing.synth/synthHcl with runValidations=true now run the preparing resolve before validations, so validations see late-bound (token/Lazy) usage - previously they ran against pre-resolve state and could miss it. Consumers who added custom prepare wrappers around Testing APIs can remove them. --- packages/cdktn/src/app.ts | 11 + .../cdktn/src/functions/usage-registry.ts | 31 ++- packages/cdktn/src/synthesize/synthesizer.ts | 21 ++ packages/cdktn/src/synthesize/types.ts | 23 ++ packages/cdktn/src/terraform-element.ts | 133 +++++++++-- packages/cdktn/src/terraform-resource.ts | 23 +- packages/cdktn/src/terraform-stack.ts | 29 ++- packages/cdktn/src/testing/index.ts | 15 ++ .../cdktn/test/provider-functions.test.ts | 85 +++++++ .../test/terraform-ephemeral-resource.test.ts | 16 ++ packages/cdktn/test/write-only.test.ts | 215 ++++++++++++++++++ 11 files changed, 576 insertions(+), 26 deletions(-) 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/functions/usage-registry.ts b/packages/cdktn/src/functions/usage-registry.ts index cfe1675d2..9177c6a10 100644 --- a/packages/cdktn/src/functions/usage-registry.ts +++ b/packages/cdktn/src/functions/usage-registry.ts @@ -27,10 +27,21 @@ import { IConstruct } from "constructs"; * expression strings) are never wrapped in a `FunctionCall` token and so * are never recorded either. * - * `App.synth()` runs a full preparing resolve of every stack's - * `toTerraform()` for ALL of the App's stacks before any stack's - * synthesizer runs validations, so usage recorded here during that pass is - * always visible to the validations that read it afterwards. + * 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 usedFunctionsByRoot = new WeakMap>(); const usedProviderFunctionsByRoot = new WeakMap>(); @@ -74,3 +85,15 @@ export function recordProviderFunctionUsage( export function getUsedProviderFunctions(root: IConstruct): string[] { return Array.from(usedProviderFunctionsByRoot.get(root) ?? []); } + +/** + * 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 resetUsageForRoot(root: IConstruct): void { + usedFunctionsByRoot.delete(root); + usedProviderFunctionsByRoot.delete(root); +} 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 4cffe6adc..fd2e5d0f9 100644 --- a/packages/cdktn/src/terraform-element.ts +++ b/packages/cdktn/src/terraform-element.ts @@ -1,7 +1,7 @@ // 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"; @@ -16,6 +16,47 @@ import { 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; @@ -41,11 +82,18 @@ export class TerraformElement extends Construct { /** * Provider-protocol feature families (see `ProviderFeature`) already - * registered via `registerProviderFeatureUsage` on this element, so - * repeated usage (e.g. a setter called multiple times) doesn't stack - * duplicate synth-time validations. + * 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 Set(); + private readonly _registeredProviderFeatures = new Map< + ProviderFeature, + ProviderFeatureRegistration + >(); constructor(scope: Construct, id: string, elementType?: string) { super(scope, id); @@ -71,13 +119,71 @@ export class TerraformElement extends Construct { * 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 - * actually used (e.g. `ProviderFeature.WRITE_ONLY_ATTRIBUTES` when a - * write-only attribute is set); not intended to be called directly. Lives - * on `TerraformElement` (rather than `TerraformResource`) so it covers any - * element subclass that needs it, e.g. `TerraformEphemeralResource`. + * 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 { - if (this._registeredProviderFeatures.has(feature)) { + 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; } @@ -87,8 +193,6 @@ export class TerraformElement extends Construct { throw unknownProviderFeature(feature); } - this._registeredProviderFeatures.add(feature); - const constraints = providerFeatureConstraints[feature]; const hint = `${providerFeatureHints[feature]} are available in ${Object.entries( constraints, @@ -96,7 +200,7 @@ export class TerraformElement extends Construct { .map(([product, range]) => `${product} ${range}`) .join(" and ")}.`; - this.node.addValidation( + const validation = new GatedFeatureValidation( new ValidateFeatureTargetSupport( this, providerFeatureLabels[feature], @@ -104,6 +208,9 @@ export class TerraformElement extends Construct { hint, ), ); + + this._registeredProviderFeatures.set(feature, { mode, validation }); + this.node.addValidation(validation); } public toTerraform(): any { diff --git a/packages/cdktn/src/terraform-resource.ts b/packages/cdktn/src/terraform-resource.ts index 83279339f..60b0efeab 100644 --- a/packages/cdktn/src/terraform-resource.ts +++ b/packages/cdktn/src/terraform-resource.ts @@ -280,13 +280,20 @@ export class TerraformResource * leaves anything behind in the synthesized attribute - the omission * behaves exactly as if the attribute had never been set. * - * This mirrors the resolve-time function-usage registry (see - * `FunctionCall.resolve()` in `tfExpression.ts`): only usage that - * actually reaches synthesized configuration is validated, and - * `App.synth()`'s ordering guarantee - prepareStack's preparing - * resolve of every element's `toTerraform()`, for ALL stacks, runs - * before ANY stack's validations - means the registration this - * produces is always visible to the validation that reads it. + * 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) { @@ -299,7 +306,7 @@ export class TerraformResource resolve(context: IResolveContext): any { const resolved = context.resolve(value); if (resolved != null) { - resource.registerProviderFeatureUsage( + resource._registerResolveDiscoveredProviderFeatureUsage( ProviderFeature.WRITE_ONLY_ATTRIBUTES, ); } diff --git a/packages/cdktn/src/terraform-stack.ts b/packages/cdktn/src/terraform-stack.ts index 3cae03214..cef7a48eb 100644 --- a/packages/cdktn/src/terraform-stack.ts +++ b/packages/cdktn/src/terraform-stack.ts @@ -160,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), ); 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/test/provider-functions.test.ts b/packages/cdktn/test/provider-functions.test.ts index 5c7c20486..40eed929e 100644 --- a/packages/cdktn/test/provider-functions.test.ts +++ b/packages/cdktn/test/provider-functions.test.ts @@ -229,3 +229,88 @@ describe("ValidateProviderFunctionTargetSupport", () => { `); }); }); + +// 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 index fa14d6d2f..47245e0cb 100644 --- a/packages/cdktn/test/terraform-ephemeral-resource.test.ts +++ b/packages/cdktn/test/terraform-ephemeral-resource.test.ts @@ -213,4 +213,20 @@ describe("ephemeral resource target version validation", () => { 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/write-only.test.ts b/packages/cdktn/test/write-only.test.ts index 0a9e006e8..bbee1e87f 100644 --- a/packages/cdktn/test/write-only.test.ts +++ b/packages/cdktn/test/write-only.test.ts @@ -271,3 +271,218 @@ describe("registerProviderFeatureUsage (write-only attributes)", () => { ); }); }); + +// 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"/); + }); +});