Skip to content

Commit 4b205a9

Browse files
committed
feat(resources): Add JSONata propertyTransforms support for diff suppression
Implements CloudFormation-compatible property transforms using JSONata expressions. These transforms normalize cloud provider responses to prevent spurious diffs during refresh/update cycles. Key changes: - Extract propertyTransforms from CloudFormation schemas during code generation - Add JSONata evaluation engine (blues/jsonata-go) for runtime transforms - Apply transforms during diff calculation to normalize values before comparison - Support all JSONata functions from CF schemas ($lowercase, $uppercase, $join, etc.) 75 resources now have propertyTransforms including: - RDS: $lowercase(DBClusterIdentifier), $lowercase(Engine) - EFS: $uppercase() for replicationOverwriteProtection enum normalization - Various AWS services with identifier case normalization Includes E2E tests validating: - EFS DISABLED→REPLICATING enum transition handling - RDS UPPERCASE→lowercase identifier normalization
1 parent ba476b8 commit 4b205a9

File tree

14 files changed

+2270
-192
lines changed

14 files changed

+2270
-192
lines changed

provider/cmd/pulumi-resource-aws-native/metadata.json

Lines changed: 366 additions & 68 deletions
Large diffs are not rendered by default.

provider/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ require (
6363
github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect
6464
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect
6565
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
66+
github.com/blues/jsonata-go v1.5.4 // indirect
6667
github.com/charmbracelet/bubbles v0.16.1 // indirect
6768
github.com/charmbracelet/bubbletea v0.25.0 // indirect
6869
github.com/charmbracelet/lipgloss v0.7.1 // indirect

provider/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
147147
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
148148
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
149149
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
150+
github.com/blues/jsonata-go v1.5.4 h1:XCsXaVVMrt4lcpKeJw6mNJHqQpWU751cnHdCFUq3xd8=
151+
github.com/blues/jsonata-go v1.5.4/go.mod h1:uns2jymDrnI7y+UFYCqsRTEiAH22GyHnNXrkupAVFWI=
150152
github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M=
151153
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
152154
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=

provider/pkg/metadata/metadata.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ type CloudAPIResource struct {
5454

5555
// ListHandlerSchema contains a minimal subset of the CloudFormation list handler schema for a resource.
5656
ListHandlerSchema *ListHandlerSchema `json:"listHandlerSchema,omitempty"`
57+
58+
// PropertyTransforms maps SDK property paths to JSONata expressions for drift detection.
59+
// CloudFormation schemas include these transforms to define how property values should be
60+
// normalized during comparison (e.g., case normalization, numeric to string mappings).
61+
//
62+
// Paths use lowerCamelCase with "/" separators and "*" for array elements.
63+
// Example: {"securityGroupEgress/*/ipProtocol": "$lowercase(IpProtocol)"}
64+
//
65+
// The expressions are evaluated using JSONata (https://jsonata.org/).
66+
PropertyTransforms map[string]string `json:"propertyTransforms,omitempty"`
5767
}
5868

5969
type AutoNamingSpec struct {

provider/pkg/provider/provider.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ type cfnProvider struct {
104104
forbiddenAccountIds []string
105105
defaultTags map[string]string
106106

107-
pulumiSchema []byte
107+
pulumiSchema []byte
108+
transformCache *resources.TransformCache
108109

109110
cfn *cloudformation.Client
110111
ccc client.CloudControlClient
@@ -127,12 +128,13 @@ func NewAwsNativeProvider(host *provider.HostClient, name, version string,
127128
}
128129

129130
return &cfnProvider{
130-
host: host,
131-
canceler: makeCancellationContext(),
132-
name: name,
133-
version: version,
134-
resourceMap: resourceMap,
135-
pulumiSchema: pulumiSchema,
131+
host: host,
132+
canceler: makeCancellationContext(),
133+
name: name,
134+
version: version,
135+
resourceMap: resourceMap,
136+
pulumiSchema: pulumiSchema,
137+
transformCache: resources.NewTransformCache(),
136138
}, nil
137139
}
138140

@@ -841,6 +843,17 @@ func (p *cfnProvider) Diff(ctx context.Context, req *pulumirpc.DiffRequest) (*pu
841843
return &pulumirpc.DiffResponse{Changes: pulumirpc.DiffResponse_DIFF_NONE}, nil
842844
}
843845

846+
// Apply propertyTransform-based diff suppression for semantically equivalent values.
847+
// This handles cases where AWS returns normalized values (e.g., lowercase identifiers,
848+
// REPLICATING instead of DISABLED for EFS replication) that should not trigger updates.
849+
resourceToken := string(urn.Type())
850+
if spec, hasSpec := p.resourceMap.Resources[resourceToken]; hasSpec {
851+
diff = resources.SuppressAWSManagedDiffs(resourceToken, &spec, diff, oldInputs, p.transformCache)
852+
if diff == nil || (len(diff.Adds) == 0 && len(diff.Updates) == 0 && len(diff.Deletes) == 0) {
853+
return &pulumirpc.DiffResponse{Changes: pulumirpc.DiffResponse_DIFF_NONE}, nil
854+
}
855+
}
856+
844857
return &pulumirpc.DiffResponse{
845858
Changes: pulumirpc.DiffResponse_DIFF_UNKNOWN,
846859
DeleteBeforeReplace: true,
@@ -1062,7 +1075,7 @@ func (p *cfnProvider) Read(ctx context.Context, req *pulumirpc.ReadRequest) (*pu
10621075
// 4. Suppress AWS-managed changes from the diff. This removes:
10631076
// - aws:* prefixed tags that AWS adds automatically (users cannot manage these)
10641077
// - Resource-specific state transitions (e.g., EFS replication protection)
1065-
diff = resources.SuppressAWSManagedDiffs(resourceToken, &spec, diff, inputs)
1078+
diff = resources.SuppressAWSManagedDiffs(resourceToken, &spec, diff, inputs, p.transformCache)
10661079
// 5. Apply this difference to the actual inputs (not a projection) that we have in state.
10671080
newInputs = resources.ApplyDiff(inputs, diff)
10681081
}

provider/pkg/provider/provider_2e2_test.go

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ const (
7777
)
7878

7979
func TestEfsReplicationProtection(t *testing.T) {
80-
// Skip: takes ~45 min to run. Manual testing only.
80+
// Skip: takes ~45 min to run due to EFS replication setup time. Manual testing only.
8181
// Run with: go test -v -timeout 45m -run TestEfsReplicationProtection ./pkg/provider
82-
t.Skip("Manual test: EFS replication takes ~45 minutes")
82+
// The REPLICATING state suppression is now handled via propertyTransform.
83+
t.Skip("Manual test: EFS replication setup takes ~45 minutes")
8384

8485
skipIfShort(t)
8586

@@ -182,6 +183,94 @@ func TestEfsReplicationProtection(t *testing.T) {
182183
t.Log("Test passed: Refresh and subsequent Up succeeded with REPLICATING enum value")
183184
}
184185

186+
// TestRdsLowercaseTransforms tests that RDS DBCluster propertyTransforms correctly
187+
// suppress spurious diffs when AWS returns lowercase values for identifiers.
188+
//
189+
// RDS DBCluster has propertyTransforms like:
190+
// - "$lowercase(DBClusterIdentifier)"
191+
// - "$lowercase(Engine)"
192+
// - "$lowercase(DBSubnetGroupName)"
193+
//
194+
// AWS always returns these values in lowercase, even if you provide uppercase input.
195+
// Without propertyTransforms, Pulumi would detect a diff and try to update.
196+
func TestRdsLowercaseTransforms(t *testing.T) {
197+
// Skip: takes ~15-20 min to run (Aurora cluster creation). Manual testing only.
198+
// Run with: go test -v -timeout 30m -run TestRdsLowercaseTransforms ./pkg/provider
199+
t.Skip("Manual test: RDS cluster creation takes ~15-20 minutes")
200+
201+
skipIfShort(t)
202+
203+
// Use a random suffix to avoid naming conflicts
204+
clusterSuffix := fmt.Sprintf("%d", rand.Intn(10000))
205+
206+
pt := pulumitest.NewPulumiTest(t, filepath.Join("testdata", "rds-lowercase-transforms"),
207+
opttest.Env("PULUMI_DEBUG_GRPC", "true"),
208+
opttest.LocalProviderPath("aws-native", filepath.Join("..", "..", "..", "bin")),
209+
)
210+
pt.SetConfig(t, "aws-native:region", "us-west-2")
211+
pt.SetConfig(t, "clusterSuffix", clusterSuffix)
212+
defer pt.Destroy(t)
213+
214+
// Phase 1: Initial deployment with UPPERCASE values
215+
t.Log("=== Phase 1: Initial Preview and Up with UPPERCASE values ===")
216+
pt.Preview(t)
217+
218+
up := pt.Up(t)
219+
assert.NotNil(t, up.Summary)
220+
221+
// Verify AWS returned values
222+
engine, ok := up.Outputs["engine"].Value.(string)
223+
assert.True(t, ok, "engine output should be a string")
224+
t.Logf("Engine after up: %s", engine)
225+
assert.Equal(t, "aurora-mysql", engine, "Engine should be aurora-mysql")
226+
227+
// The key test: dbClusterIdentifier was specified as UPPERCASE but AWS returns lowercase
228+
dbClusterIdentifier, ok := up.Outputs["dbClusterIdentifier"].Value.(string)
229+
assert.True(t, ok, "dbClusterIdentifier output should be a string")
230+
t.Logf("DBClusterIdentifier after up: %s (input was UPPERCASE)", dbClusterIdentifier)
231+
// AWS normalizes identifiers to lowercase
232+
expectedIdentifier := strings.ToLower(fmt.Sprintf("RDS-TRANSFORM-CLUSTER-%s", clusterSuffix))
233+
assert.Equal(t, expectedIdentifier, dbClusterIdentifier,
234+
"AWS should return dbClusterIdentifier in lowercase even though input was UPPERCASE")
235+
236+
// Log all outputs for debugging
237+
t.Log("=== Outputs after initial up ===")
238+
for k, v := range up.Outputs {
239+
t.Logf(" %s = %v", k, v.Value)
240+
}
241+
242+
// Phase 2: Refresh to get the current state from AWS
243+
t.Log("=== Phase 2: Refresh to sync state with AWS ===")
244+
refreshResult := pt.Refresh(t)
245+
assert.NotNil(t, refreshResult.Summary, "Refresh should complete successfully")
246+
247+
t.Logf("=== Refresh StdOut ===\n%s", refreshResult.StdOut)
248+
if refreshResult.StdErr != "" {
249+
t.Logf("=== Refresh StdErr ===\n%s", refreshResult.StdErr)
250+
}
251+
252+
// Phase 3: Run up again - this should NOT try to change anything
253+
// The propertyTransforms should suppress the diff between UPPERCASE (input) and lowercase (AWS)
254+
t.Log("=== Phase 3: Up after refresh (should have no changes) ===")
255+
upAfterRefresh := pt.Up(t)
256+
assert.NotNil(t, upAfterRefresh.Summary, "Up after refresh should complete successfully")
257+
258+
// Verify no changes were made
259+
if upAfterRefresh.Summary.ResourceChanges != nil {
260+
changes := *upAfterRefresh.Summary.ResourceChanges
261+
t.Logf("Resource changes after refresh: %+v", changes)
262+
// The only changes should be "same", not "update"
263+
assert.Zero(t, changes["update"], "There should be no updates - propertyTransforms should suppress case diffs")
264+
}
265+
266+
t.Log("=== Outputs after up (post-refresh) ===")
267+
for k, v := range upAfterRefresh.Outputs {
268+
t.Logf(" %s = %v", k, v.Value)
269+
}
270+
271+
t.Log("Test passed: RDS lowercase propertyTransforms correctly suppressed case diffs")
272+
}
273+
185274
func testUpgradeFrom(t *testing.T, test *pulumitest.PulumiTest, version string) {
186275
result := providertest.PreviewProviderUpgrade(t, test, "aws-native", version)
187276
assertpreview.HasNoChanges(t, result)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: rds-lowercase-transforms
2+
runtime: yaml
3+
description: Test RDS DBCluster lowercase propertyTransforms
4+
5+
config:
6+
clusterSuffix:
7+
type: string
8+
default: "test"
9+
10+
resources:
11+
# Create a DB subnet group (required for Aurora)
12+
dbSubnetGroup:
13+
type: aws-native:rds:DbSubnetGroup
14+
properties:
15+
# Use UPPERCASE to test the $lowercase transform
16+
dbSubnetGroupName: RDS-TRANSFORM-TEST-${clusterSuffix}
17+
dbSubnetGroupDescription: Test subnet group for RDS propertyTransform testing
18+
subnetIds:
19+
- ${subnet1.id}
20+
- ${subnet2.id}
21+
22+
# VPC for the RDS cluster
23+
vpc:
24+
type: aws-native:ec2:Vpc
25+
properties:
26+
cidrBlock: 10.0.0.0/16
27+
enableDnsHostnames: true
28+
enableDnsSupport: true
29+
30+
# Subnets in different AZs (required for Aurora)
31+
subnet1:
32+
type: aws-native:ec2:Subnet
33+
properties:
34+
vpcId: ${vpc.id}
35+
cidrBlock: 10.0.1.0/24
36+
availabilityZone: us-west-2a
37+
38+
subnet2:
39+
type: aws-native:ec2:Subnet
40+
properties:
41+
vpcId: ${vpc.id}
42+
cidrBlock: 10.0.2.0/24
43+
availabilityZone: us-west-2b
44+
45+
# Aurora Serverless v2 cluster with UPPERCASE identifiers
46+
# Note: engine must be lowercase (AWS requirement), but identifiers can be UPPERCASE
47+
# AWS will normalize identifiers to lowercase, which propertyTransforms handle
48+
dbCluster:
49+
type: aws-native:rds:DbCluster
50+
properties:
51+
# Engine must be lowercase (AWS requirement)
52+
engine: aurora-mysql
53+
engineMode: provisioned
54+
# Use UPPERCASE to test the $lowercase(DBClusterIdentifier) transform
55+
# AWS will normalize this to lowercase
56+
dbClusterIdentifier: RDS-TRANSFORM-CLUSTER-${clusterSuffix}
57+
masterUsername: admin
58+
manageMasterUserPassword: true
59+
# dbSubnetGroupName comes from the subnet group output (already lowercase from AWS)
60+
dbSubnetGroupName: ${dbSubnetGroup.dbSubnetGroupName}
61+
serverlessV2ScalingConfiguration:
62+
minCapacity: 0.5
63+
maxCapacity: 2
64+
# Note: AWS Native doesn't have skipFinalSnapshot - CloudFormation handles this differently
65+
# deletionProtection defaults to false
66+
options:
67+
dependsOn:
68+
- ${dbSubnetGroup}
69+
70+
outputs:
71+
# The engine should be returned lowercase by AWS
72+
engine: ${dbCluster.engine}
73+
# The cluster ID should be returned lowercase by AWS
74+
dbClusterIdentifier: ${dbCluster.dbClusterIdentifier}
75+
# The subnet group name should be returned lowercase by AWS
76+
dbSubnetGroupName: ${dbSubnetGroup.dbSubnetGroupName}
77+
# Full cluster for debugging
78+
clusterArn: ${dbCluster.dbClusterArn}

0 commit comments

Comments
 (0)