From e960bb9604ce140698d879cdf4b17e6d9c1a9d8a Mon Sep 17 00:00:00 2001 From: Cory Hall <43035978+corymhall@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:42:38 -0500 Subject: [PATCH] CDK RemovalPolicy maps to Pulumi retainOnDelete (#223) This PR maps CloudFormation [DeletionPolicy](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-deletionpolicy.html) to Pulumi `retainOnDelete`. **`DeletionPolicy` => `retainOnDelete`** - `Delete` => `false` - `Retain` => `true` - `RetainExceptOnCreate` => `true` - `Snapshot` => `true` Looking specifically for validation on `Snapshot` mapping. This functionality does not exist in Pulumi so I decided to err on the side of caution and retain the resource instead of deleting it. We log a warning in this case and they should see the retention in the preview. closes #188 --- .github/workflows/acceptance-tests.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/run-acceptance-tests.yml | 2 +- examples/cloudfront-lambda-urls/index.ts | 5 +- .../src/s3-object-lambda-stack.ts | 1 + go.mod | 21 +- go.sum | 44 +-- integration/cloudfront/index.ts | 6 +- integration/examples_nodejs_test.go | 81 ++++++ integration/removal-policy/Pulumi.yaml | 3 + integration/removal-policy/index.ts | 20 ++ integration/removal-policy/package.json | 15 + integration/removal-policy/step2/Pulumi.yaml | 3 + integration/removal-policy/step2/index.ts | 20 ++ integration/removal-policy/step2/package.json | 15 + .../removal-policy/step2/tsconfig.json | 18 ++ integration/removal-policy/tsconfig.json | 18 ++ integration/secretsmanager/index.ts | 2 + src/cfn.ts | 3 + src/converters/app-converter.ts | 42 ++- tests/cfn-resource-mappings.test.ts | 2 - tests/options.test.ts | 257 ++++++++++++++++++ 23 files changed, 542 insertions(+), 42 deletions(-) create mode 100644 integration/removal-policy/Pulumi.yaml create mode 100644 integration/removal-policy/index.ts create mode 100644 integration/removal-policy/package.json create mode 100644 integration/removal-policy/step2/Pulumi.yaml create mode 100644 integration/removal-policy/step2/index.ts create mode 100644 integration/removal-policy/step2/package.json create mode 100644 integration/removal-policy/step2/tsconfig.json create mode 100644 integration/removal-policy/tsconfig.json create mode 100644 tests/options.test.ts diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index b2e198e6..71db8db6 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -7,7 +7,7 @@ on: description: The folder in which to run tests env: - AWS_REGION: us-west-2 + AWS_REGION: us-east-2 jobs: acceptance-tests: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e0d4f13a..5aa3f491 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,5 @@ env: - AWS_REGION: us-west-2 + AWS_REGION: us-east-2 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd0ed45a..bfca341b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ env: - AWS_REGION: us-west-2 + AWS_REGION: us-east-2 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/run-acceptance-tests.yml b/.github/workflows/run-acceptance-tests.yml index fb0a8a67..15d39ee2 100644 --- a/.github/workflows/run-acceptance-tests.yml +++ b/.github/workflows/run-acceptance-tests.yml @@ -1,5 +1,5 @@ env: - AWS_REGION: us-west-2 + AWS_REGION: us-east-2 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/examples/cloudfront-lambda-urls/index.ts b/examples/cloudfront-lambda-urls/index.ts index cef39243..46cc7381 100644 --- a/examples/cloudfront-lambda-urls/index.ts +++ b/examples/cloudfront-lambda-urls/index.ts @@ -12,6 +12,7 @@ import { import { FunctionUrlOrigin, S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { RemovalPolicy } from 'aws-cdk-lib'; class CloudFrontAppStack extends pulumicdk.Stack { public cloudFrontUrl: pulumi.Output; @@ -32,7 +33,9 @@ class CloudFrontAppStack extends pulumicdk.Stack { authType: FunctionUrlAuthType.NONE, }); - const bucket = new Bucket(this, 'Bucket'); + const bucket = new Bucket(this, 'Bucket', { + removalPolicy: RemovalPolicy.DESTROY, + }); const distro = new Distribution(this, 'distro', { defaultBehavior: { diff --git a/examples/s3-object-lambda/src/s3-object-lambda-stack.ts b/examples/s3-object-lambda/src/s3-object-lambda-stack.ts index ccf009be..c4585afb 100644 --- a/examples/s3-object-lambda/src/s3-object-lambda-stack.ts +++ b/examples/s3-object-lambda/src/s3-object-lambda-stack.ts @@ -27,6 +27,7 @@ export class S3ObjectLambdaStack extends pulumicdk.Stack { accessControl: s3.BucketAccessControl.BUCKET_OWNER_FULL_CONTROL, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Delegating access control to access points diff --git a/go.mod b/go.mod index c3a7a5a3..c2ca990a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module github.com/pulumi/pulumi-cdk go 1.22.5 require ( + github.com/aws/aws-sdk-go v1.50.36 + github.com/aws/aws-sdk-go-v2/config v1.27.11 + github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0 + github.com/aws/smithy-go v1.22.0 github.com/gorilla/websocket v1.5.3 github.com/pulumi/pulumi/pkg/v3 v3.137.0 github.com/stretchr/testify v1.9.0 @@ -31,21 +35,22 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go v1.50.36 // indirect - github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect - github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect + github.com/aws/aws-sdk-go-v2 v1.32.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect - github.com/aws/smithy-go v1.20.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect diff --git a/go.sum b/go.sum index 28c4fe2b..e3550346 100644 --- a/go.sum +++ b/go.sum @@ -53,10 +53,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.50.36 h1:PjWXHwZPuTLMR1NIb8nEjLucZBMzmf84TLoLbD8BZqk= github.com/aws/aws-sdk-go v1.50.36/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= -github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= +github.com/aws/aws-sdk-go-v2 v1.32.4 h1:S13INUiTxgrPueTmrm5DZ+MiAo99zYzHEFh1UNkOxNE= +github.com/aws/aws-sdk-go-v2 v1.32.4/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE= github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= @@ -65,36 +65,36 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYh github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 h1:7Zwtt/lP3KNRkeZre7soMELMGNoBrutx8nobg1jKWmo= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15/go.mod h1:436h2adoHb57yd+8W+gYPrrA9U/R/SuAuOO42Ushzhw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23 h1:A2w6m6Tmr+BNXjDsr7M90zkWjsu4JXHwrzPg235STs4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23/go.mod h1:35EVp9wyeANdujZruvHiQUAo9E3vbhnIO1mTCAxMlY0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23 h1:pgYW9FCabt2M25MoHYCfMrVY2ghiiBKYWUVXfwZs+sU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23/go.mod h1:c48kLgzO19wAu3CPkDWC28JbaJ+hfQlsdl7I2+oqIbk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 h1:81KE7vaZzrl7yHBYHVEzYB8sypz11NMOZ40YlWvPxsU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5/go.mod h1:LIt2rg7Mcgn09Ygbdh/RdIm0rQ+3BNkbP1gyVMFtRK0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23 h1:1SZBDiRzzs3sNhOMVApyWPduWYGAX0imGy06XiBnCAM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23/go.mod h1:i9TkxgbZmHVh2S0La6CAXtnyFhlCX/pJ0JsOvBAS6Mk= github.com/aws/aws-sdk-go-v2/service/iam v1.31.4 h1:eVm30ZIDv//r6Aogat9I88b5YX1xASSLcEDqHYRPVl0= github.com/aws/aws-sdk-go-v2/service/iam v1.31.4/go.mod h1:aXWImQV0uTW35LM0A/T4wEg6R1/ReXUu4SM6/lUHYK0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 h1:ZMeFZ5yk+Ek+jNr1+uwCd2tG89t6oTS5yVWpa6yy2es= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7/go.mod h1:mxV05U+4JiHqIpGqqYXOHLPKUC6bDXC44bsUhNjOEwY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 h1:f9RyWNtS8oH7cZlbn+/JNPpjUk5+5fLd5lM9M0i49Ys= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5/go.mod h1:h5CoMZV2VF297/VLhRhO1WF+XYWOzXo+4HsObA4HjBQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4 h1:aaPpoG15S2qHkWm4KlEyF01zovK1nW4BBbyXuHNSE90= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4/go.mod h1:eD9gS2EARTKgGr/W5xwgY/ik9z/zqpW+m/xOQbVxrMk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.4 h1:tHxQi/XHPK0ctd/wdOw0t7Xrc2OxcRCnVzv8lwWPu0c= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.4/go.mod h1:4GQbF1vJzG60poZqWatZlhP31y8PGCCVTvIGPdaaYJ0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4 h1:E5ZAVOmI2apR8ADb72Q63KqwwwdW1XcMeXIlrZ1Psjg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4/go.mod h1:wezzqVUOVVdk+2Z/JzQT4NxAU0NbhRe5W8pIE72jsWI= github.com/aws/aws-sdk-go-v2/service/kms v1.30.1 h1:SBn4I0fJXF9FYOVRSVMWuhvEKoAHDikjGpS3wlmw5DE= github.com/aws/aws-sdk-go-v2/service/kms v1.30.1/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 h1:6cnno47Me9bRykw9AEv9zkXE+5or7jz8TsskTTccbgc= -github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o= +github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0 h1:SwaJ0w0MOp0pBTIKTamLVeTKD+iOWyNJRdJ2KCQRg6Q= +github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0/go.mod h1:TMhLIyRIyoGVlaEMAt+ITMbwskSTpcGsCPDq91/ihY0= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= -github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= -github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= +github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= diff --git a/integration/cloudfront/index.ts b/integration/cloudfront/index.ts index c8b76778..e248402b 100644 --- a/integration/cloudfront/index.ts +++ b/integration/cloudfront/index.ts @@ -4,13 +4,15 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; import * as pulumicdk from '@pulumi/cdk'; import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; -import { Duration } from 'aws-cdk-lib'; +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; class CloudFrontStack extends pulumicdk.Stack { public readonly bucketName: pulumi.Output; constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { super(app, id, options); - const bucket = new s3.Bucket(this, 'Bucket'); + const bucket = new s3.Bucket(this, 'Bucket', { + removalPolicy: RemovalPolicy.DESTROY, + }); this.bucketName = this.asOutput(bucket.bucketName); const cachePolicy = new cloudfront.CachePolicy(this, 'CachePolicy', { maxTtl: Duration.days(1), diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go index d6be63fe..5992ac88 100644 --- a/integration/examples_nodejs_test.go +++ b/integration/examples_nodejs_test.go @@ -16,10 +16,18 @@ package examples import ( "bytes" + "context" + "errors" + "fmt" + "math/rand" "path/filepath" "testing" "time" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" "github.com/pulumi/pulumi/pkg/v3/testing/integration" "github.com/stretchr/testify/assert" ) @@ -175,6 +183,62 @@ func TestReplaceOnChanges(t *testing.T) { integration.ProgramTest(t, &test) } +func TestRemovalPolicy(t *testing.T) { + // Since we are creating two tests we have to set `NoParallel` on each test + // and set parallel here. + t.Parallel() + ctx := context.Background() + config, err := config.LoadDefaultConfig(ctx) + assert.NoError(t, err) + client := s3.NewFromConfig(config) + + suffix := rand.Intn(10000) + bucketName := fmt.Sprintf("pulumi-cdk-removal-test-%d", suffix) + t.Logf("Bucket name: %s", bucketName) + + testConfig := map[string]string{ + "bucketName": bucketName, + } + + // ---------------------------------------------------------- + // Step 1: Create a bucket with a removal policy of 'retain' + // ---------------------------------------------------------- + test1 := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "removal-policy"), + NoParallel: true, + Config: testConfig, + }) + + integration.ProgramTest(t, &test1) + + // Assert that the bucket still exists + exists, err := bucketExists(ctx, client, bucketName) + assert.NoError(t, err) + assert.True(t, exists) + + // Delete the bucket before Step 2. + _, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{ + Bucket: &bucketName, + }) + assert.NoError(t, err) + + // ---------------------------------------------------------- + // Step 2: Create a new stack with the same bucket name and a removal policy of 'destroy' + // ---------------------------------------------------------- + test2 := getJSBaseOptions(t).With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "removal-policy/step2"), + NoParallel: true, + Config: testConfig, + }) + integration.ProgramTest(t, &test2) + + // Assert that the bucket no longer exists + exists, err = bucketExists(ctx, client, bucketName) + assert.NoError(t, err) + assert.False(t, exists) +} + func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions { base := getBaseOptions(t) baseJS := base.With(integration.ProgramTestOptions{ @@ -185,3 +249,20 @@ func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions { return baseJS } + +func bucketExists(ctx context.Context, client *s3.Client, bucketName string) (bool, error) { + _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: &bucketName, + }) + if err != nil { + var apiError smithy.APIError + if errors.As(err, &apiError) { + switch apiError.(type) { + case *types.NotFound: + return false, nil + } + } + return false, err + } + return true, nil +} diff --git a/integration/removal-policy/Pulumi.yaml b/integration/removal-policy/Pulumi.yaml new file mode 100644 index 00000000..ff9c7e20 --- /dev/null +++ b/integration/removal-policy/Pulumi.yaml @@ -0,0 +1,3 @@ +name: pulumi-aws-removal-policy +runtime: nodejs +description: removal-policy integration test diff --git a/integration/removal-policy/index.ts b/integration/removal-policy/index.ts new file mode 100644 index 00000000..01649e46 --- /dev/null +++ b/integration/removal-policy/index.ts @@ -0,0 +1,20 @@ +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as pulumicdk from '@pulumi/cdk'; +import { RemovalPolicy } from 'aws-cdk-lib'; +import { Config } from '@pulumi/pulumi'; +const config = new Config(); +const bucketName = config.require('bucketName'); + +class RemovalPolicyStack extends pulumicdk.Stack { + constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { + super(app, id, options); + new s3.Bucket(this, 'testbucket', { + bucketName: bucketName, + removalPolicy: RemovalPolicy.RETAIN, + }); + } +} + +new pulumicdk.App('app', (scope: pulumicdk.App) => { + new RemovalPolicyStack(scope, 'teststack'); +}); diff --git a/integration/removal-policy/package.json b/integration/removal-policy/package.json new file mode 100644 index 00000000..06206ee1 --- /dev/null +++ b/integration/removal-policy/package.json @@ -0,0 +1,15 @@ +{ + "name": "pulumi-aws-cdk", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/aws-native": "^1.8.0", + "@pulumi/cdk": "^0.5.0", + "@pulumi/pulumi": "^3.0.0", + "aws-cdk-lib": "2.156.0", + "constructs": "10.3.0", + "esbuild": "^0.24.0" + } +} diff --git a/integration/removal-policy/step2/Pulumi.yaml b/integration/removal-policy/step2/Pulumi.yaml new file mode 100644 index 00000000..ff9c7e20 --- /dev/null +++ b/integration/removal-policy/step2/Pulumi.yaml @@ -0,0 +1,3 @@ +name: pulumi-aws-removal-policy +runtime: nodejs +description: removal-policy integration test diff --git a/integration/removal-policy/step2/index.ts b/integration/removal-policy/step2/index.ts new file mode 100644 index 00000000..f7be9882 --- /dev/null +++ b/integration/removal-policy/step2/index.ts @@ -0,0 +1,20 @@ +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as pulumicdk from '@pulumi/cdk'; +import { RemovalPolicy } from 'aws-cdk-lib'; +import { Config } from '@pulumi/pulumi'; +const config = new Config(); +const bucketName = config.require('bucketName'); + +class RemovalPolicyStack extends pulumicdk.Stack { + constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { + super(app, id, options); + new s3.Bucket(this, 'testbucket', { + bucketName: bucketName, + removalPolicy: RemovalPolicy.DESTROY, + }); + } +} + +new pulumicdk.App('app', (scope: pulumicdk.App) => { + new RemovalPolicyStack(scope, 'teststack'); +}); diff --git a/integration/removal-policy/step2/package.json b/integration/removal-policy/step2/package.json new file mode 100644 index 00000000..06206ee1 --- /dev/null +++ b/integration/removal-policy/step2/package.json @@ -0,0 +1,15 @@ +{ + "name": "pulumi-aws-cdk", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/aws-native": "^1.8.0", + "@pulumi/cdk": "^0.5.0", + "@pulumi/pulumi": "^3.0.0", + "aws-cdk-lib": "2.156.0", + "constructs": "10.3.0", + "esbuild": "^0.24.0" + } +} diff --git a/integration/removal-policy/step2/tsconfig.json b/integration/removal-policy/step2/tsconfig.json new file mode 100644 index 00000000..eac442cb --- /dev/null +++ b/integration/removal-policy/step2/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2019", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "./*.ts" + ] +} diff --git a/integration/removal-policy/tsconfig.json b/integration/removal-policy/tsconfig.json new file mode 100644 index 00000000..eac442cb --- /dev/null +++ b/integration/removal-policy/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2019", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "./*.ts" + ] +} diff --git a/integration/secretsmanager/index.ts b/integration/secretsmanager/index.ts index 3b6f7aaa..32a1716e 100644 --- a/integration/secretsmanager/index.ts +++ b/integration/secretsmanager/index.ts @@ -4,6 +4,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as secrets from 'aws-cdk-lib/aws-secretsmanager'; import * as pulumicdk from '@pulumi/cdk'; +import { RemovalPolicy } from 'aws-cdk-lib'; class SecretsManagerStack extends pulumicdk.Stack { constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { @@ -27,6 +28,7 @@ class SecretsManagerStack extends pulumicdk.Stack { }), vpcSubnets: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_ISOLATED }), credentials: rds.Credentials.fromGeneratedSecret('admin'), + removalPolicy: RemovalPolicy.DESTROY, }); const role = new iam.Role(this, 'Role', { diff --git a/src/cfn.ts b/src/cfn.ts index e7eb61ae..95b8ff3e 100644 --- a/src/cfn.ts +++ b/src/cfn.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CfnDeletionPolicy } from 'aws-cdk-lib/core'; + export interface CloudFormationParameter { readonly Type: string; readonly Default?: any; @@ -21,6 +23,7 @@ export interface CloudFormationResource { readonly Type: string; readonly Properties: any; readonly Condition?: string; + readonly DeletionPolicy?: CfnDeletionPolicy; readonly DependsOn?: string | string[]; } diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index fff20753..25c60c6a 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -6,7 +6,7 @@ import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph'; import { ArtifactConverter } from './artifact-converter'; import { lift, Mapping, AppComponent } from '../types'; import { CdkConstruct, ResourceAttributeMapping, ResourceMapping } from '../interop'; -import { debug } from '@pulumi/pulumi/log'; +import { debug, warn } from '@pulumi/pulumi/log'; import { cidr, getAccountId, @@ -141,7 +141,7 @@ export class StackConverter extends ArtifactConverter { debug(`Processing node with template: ${JSON.stringify(cfn)}`); debug(`Creating resource for ${n.logicalId}`); const props = this.processIntrinsics(cfn.Properties); - const options = this.processOptions(cfn, parent); + const options = this.processOptions(n.logicalId, cfn, parent); const mapped = this.mapResource(n.logicalId, cfn.Type, props, options); this.registerResource(mapped, n); @@ -301,10 +301,46 @@ export class StackConverter extends ArtifactConverter { return cfnMapping; } - private processOptions(resource: CloudFormationResource, parent: pulumi.Resource): pulumi.ResourceOptions { + /** + * Converts a CloudFormation deletion policy to a Pulumi retainOnDelete value. + * + * When a CloudFormation resource is set to Snapshot, CloudFormation will first + * create a snapshot of the resource before deleting it. Pulumi does not have the same + * capability, which means the user would need to manually create the snapshot before deleting. + * to be on the safe side, we will retain the resource + * + * @param logicalId - The logicalId of the resource + * @param resource - The CloudFormation resource + * @returns - The retainOnDelete value + */ + private getRetainOnDelete(logicalId: string, resource: CloudFormationResource): boolean | undefined { + if (resource.DeletionPolicy === undefined) { + return undefined; + } + switch (resource.DeletionPolicy) { + case cdk.CfnDeletionPolicy.DELETE: + return false; + case cdk.CfnDeletionPolicy.RETAIN: + case cdk.CfnDeletionPolicy.RETAIN_EXCEPT_ON_CREATE: + // RETAIN_EXCEPT_ON_CREATE only applies to CloudFormation because CloudFormation will rollback a stack + // if it fails to deploy. Pulumi does not have the same behavior, so we will treat it as RETAIN + return true; + case cdk.CfnDeletionPolicy.SNAPSHOT: + warn(`DeletionPolicy Snapshot is not supported. Resource '${logicalId}' will be retained.`); + return true; + } + } + + private processOptions( + logicalId: string, + resource: CloudFormationResource, + parent: pulumi.Resource, + ): pulumi.ResourceOptions { const dependsOn = getDependsOn(resource); + const retainOnDelete = this.getRetainOnDelete(logicalId, resource); return { parent: parent, + retainOnDelete, dependsOn: dependsOn?.flatMap((id) => { const resource = this.resources.get(id); if (resource === undefined) { diff --git a/tests/cfn-resource-mappings.test.ts b/tests/cfn-resource-mappings.test.ts index 636270d4..ae4eb033 100644 --- a/tests/cfn-resource-mappings.test.ts +++ b/tests/cfn-resource-mappings.test.ts @@ -1,7 +1,5 @@ -import { CustomResource } from '@pulumi/pulumi'; import { mapToCfnResource } from '../src/cfn-resource-mappings'; import * as aws from '@pulumi/aws-native'; -import { moduleName, typeName } from '../src/naming'; class MockResource { constructor(args: { [key: string]: any }) { diff --git a/tests/options.test.ts b/tests/options.test.ts new file mode 100644 index 00000000..068b67df --- /dev/null +++ b/tests/options.test.ts @@ -0,0 +1,257 @@ +import * as pulumi from '@pulumi/pulumi'; +import { StackManifest } from '../src/assembly'; +import { StackConverter } from '../src/converters/app-converter'; +import { MockAppComponent } from './mocks'; +import { CfnDeletionPolicy } from 'aws-cdk-lib'; +jest.mock('@pulumi/pulumi', () => { + return { + ...jest.requireActual('@pulumi/pulumi'), + CustomResource: jest.fn().mockImplementation(() => { + return {}; + }), + }; +}); + +afterAll(() => { + jest.resetAllMocks(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('options', () => { + test('retainOnDelete true when DeletionPolicy=Retain', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + DeletionPolicy: CfnDeletionPolicy.RETAIN, + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.objectContaining({ + retainOnDelete: true, + }), + ); + }); + + test('retainOnDelete true when DeletionPolicy=RetainExceptOnCreate', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + DeletionPolicy: CfnDeletionPolicy.RETAIN_EXCEPT_ON_CREATE, + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.objectContaining({ + retainOnDelete: true, + }), + ); + }); + + test('retainOnDelete false when DeletionPolicy=Delete', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + DeletionPolicy: CfnDeletionPolicy.DELETE, + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.objectContaining({ + retainOnDelete: false, + }), + ); + }); + + test('retainOnDelete true when DeletionPolicy=Snapshot', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + DeletionPolicy: CfnDeletionPolicy.SNAPSHOT, + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.objectContaining({ + retainOnDelete: true, + }), + ); + }); + + test('retainOnDelete not set', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.not.objectContaining({ + retainOnDelete: false, + }), + ); + }); +});