diff --git a/packages/@winglang/platform-awscdk/src/bucket.ts b/packages/@winglang/platform-awscdk/src/bucket.ts index 0c04077feeb..699de9d4706 100644 --- a/packages/@winglang/platform-awscdk/src/bucket.ts +++ b/packages/@winglang/platform-awscdk/src/bucket.ts @@ -41,14 +41,16 @@ export class Bucket extends cloud.Bucket implements IAwsBucket { private readonly bucket: S3Bucket; private readonly public: boolean; + private readonly forceDestroy: boolean; private bucketDeployment?: BucketDeployment; constructor(scope: Construct, id: string, props: cloud.BucketProps = {}) { super(scope, id, props); this.public = props.public ?? false; + this.forceDestroy = props.forceDeploy ?? false; - this.bucket = createEncryptedBucket(this, this.public); + this.bucket = createEncryptedBucket(this, this.public, this.forceDestroy); if (props.cors ?? true) { this.addCorsRule( @@ -254,6 +256,7 @@ export class Bucket extends cloud.Bucket implements IAwsBucket { export function createEncryptedBucket( scope: Construct, isPublic: boolean, + forceDestroy: boolean, name: string = "Default" ): S3Bucket { const isTestEnvironment = App.of(scope).isTestEnvironment; @@ -271,5 +274,6 @@ export function createEncryptedBucket( publicReadAccess: isPublic ? true : false, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: isTestEnvironment ? true : false, + forceDestroy: forceDestroy, }); } diff --git a/packages/@winglang/platform-awscdk/test/bucket.test.ts b/packages/@winglang/platform-awscdk/test/bucket.test.ts index 64e60ca1778..54c3f2def8f 100644 --- a/packages/@winglang/platform-awscdk/test/bucket.test.ts +++ b/packages/@winglang/platform-awscdk/test/bucket.test.ts @@ -42,6 +42,17 @@ test("bucket is public", () => { expect(awscdkSanitize(template)).toMatchSnapshot(); }); +test("bucket is force destroy", () => { + // GIVEN + const app = new AwsCdkApp(); + new cloud.Bucket(app, "my_bucket", { forceDestroy: true }); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + expect(awscdkSanitize(template)).toMatchSnapshot(); +}); + test("bucket with two preflight objects", () => { // GIVEN const app = new AwsCdkApp(); diff --git a/packages/@winglang/sdk/src/cloud/bucket.ts b/packages/@winglang/sdk/src/cloud/bucket.ts index 7907ac817a4..3399791333c 100644 --- a/packages/@winglang/sdk/src/cloud/bucket.ts +++ b/packages/@winglang/sdk/src/cloud/bucket.ts @@ -61,6 +61,12 @@ export interface BucketProps { */ readonly public?: boolean; + /** + * Whether to allow the bucket to be deleted even if it is not empty. + * @default false + */ + readonly forceDestroy?: boolean; + /** * Whether to add default cors configuration. * diff --git a/packages/@winglang/sdk/src/target-sim/bucket.ts b/packages/@winglang/sdk/src/target-sim/bucket.ts index 1e8a859f96a..06575559595 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.ts @@ -25,6 +25,7 @@ export class Bucket extends cloud.Bucket implements ISimulatorResource { } private readonly public: boolean; + private readonly forceDestroy: boolean; private readonly initialObjects: Record = {}; private readonly policy: Policy; @@ -32,6 +33,7 @@ export class Bucket extends cloud.Bucket implements ISimulatorResource { super(scope, id, props); this.public = props.public ?? false; + this.forceDestroy = props.forceDestroy ?? false; this.policy = new Policy(this, "Policy", { principal: this }); } @@ -129,6 +131,7 @@ export class Bucket extends cloud.Bucket implements ISimulatorResource { public toSimulator(): ToSimulatorOutput { const props: BucketSchema = { public: this.public, + forceDeploy: this.forceDestroy, initialObjects: this.initialObjects, topics: this.convertTopicsToHandles(), }; diff --git a/packages/@winglang/sdk/src/target-sim/schema-resources.ts b/packages/@winglang/sdk/src/target-sim/schema-resources.ts index 338485dab9b..d9198ad8ee1 100644 --- a/packages/@winglang/sdk/src/target-sim/schema-resources.ts +++ b/packages/@winglang/sdk/src/target-sim/schema-resources.ts @@ -134,6 +134,8 @@ export interface TopicSubscriber extends EventSubscription { export interface BucketSchema { /** Whether the bucket should be publicly accessible. */ readonly public: boolean; + /** Whether to allow the bucket to be deleted even if it is not empty. */ + readonly forceDestroy: boolean; /** The initial objects uploaded to the bucket. */ readonly initialObjects: Record; /** Event notification topics- the record has BucketEventType as a key and a topic handle as a value */ diff --git a/packages/@winglang/sdk/src/target-tf-aws/bucket.ts b/packages/@winglang/sdk/src/target-tf-aws/bucket.ts index b3b5ce03559..25c3fdd4e81 100644 --- a/packages/@winglang/sdk/src/target-tf-aws/bucket.ts +++ b/packages/@winglang/sdk/src/target-tf-aws/bucket.ts @@ -65,6 +65,7 @@ export class Bucket extends cloud.Bucket implements IAwsBucket { private readonly bucket: S3Bucket; private readonly public: boolean; + private readonly forceDestroy: boolean; private readonly notificationTopics: S3BucketNotificationTopic[] = []; private readonly notificationDependencies: ITerraformDependable[] = []; private readonly corsRules: cloud.BucketCorsOptions[] = []; @@ -74,8 +75,9 @@ export class Bucket extends cloud.Bucket implements IAwsBucket { super(scope, id, props); this.public = props.public ?? false; + this.forceDestroy = props.forceDestroy ?? false; - this.bucket = createEncryptedBucket(this, this.public); + this.bucket = createEncryptedBucket(this, this.public, this.forceDestroy); if (props.cors ?? true) { this.addCorsRule( @@ -220,6 +222,7 @@ export class Bucket extends cloud.Bucket implements IAwsBucket { export function createEncryptedBucket( scope: Construct, isPublic: boolean, + forceDestroy: boolean, name: string = "Default" ): S3Bucket { const bucketPrefix = ResourceNames.generateName(scope, BUCKET_PREFIX_OPTS); @@ -275,5 +278,9 @@ export function createEncryptedBucket( }); } + if (forceDestroy) { + bucket.forceDestroy = true; + } + return bucket; } diff --git a/packages/@winglang/sdk/src/target-tf-azure/bucket.ts b/packages/@winglang/sdk/src/target-tf-azure/bucket.ts index 2943dd41f13..0713968d76e 100644 --- a/packages/@winglang/sdk/src/target-tf-azure/bucket.ts +++ b/packages/@winglang/sdk/src/target-tf-azure/bucket.ts @@ -61,12 +61,14 @@ export class Bucket extends cloud.Bucket { public readonly storageContainer: StorageContainer; private readonly public: boolean; + private readonly forceDestroy: boolean; private readonly storageAccount: StorageAccount; constructor(scope: Construct, id: string, props: cloud.BucketProps = {}) { super(scope, id, props); this.public = props.public ?? false; + this.forceDestroy = props.forceDestroy ?? false; const app = App.of(this) as App; if (app._target !== "tf-azure") { @@ -93,6 +95,7 @@ export class Bucket extends cloud.Bucket { name: storageContainerName, storageAccountName: this.storageAccount.name, containerAccessType: this.public ? "blob" : "private", + rm_delete: this.forceDestroy, }); } diff --git a/packages/@winglang/sdk/src/target-tf-gcp/bucket.ts b/packages/@winglang/sdk/src/target-tf-gcp/bucket.ts index 015db2cff15..33d3b8fcc59 100644 --- a/packages/@winglang/sdk/src/target-tf-gcp/bucket.ts +++ b/packages/@winglang/sdk/src/target-tf-gcp/bucket.ts @@ -91,7 +91,7 @@ export class Bucket extends cloud.Bucket { // recommended by GCP: https://cloud.google.com/storage/docs/uniform-bucket-level-access#should-you-use uniformBucketLevelAccess: true, publicAccessPrevention: props.public ? "inherited" : "enforced", - forceDestroy: !!isTestEnvironment, + forceDestroy: !!isTestEnvironment || props.forceDestroy, dependsOn: [iamServiceAccountCredentialsApi], }); diff --git a/packages/@winglang/sdk/test/target-tf-aws/bucket.test.ts b/packages/@winglang/sdk/test/target-tf-aws/bucket.test.ts index e175f01ef04..c04489b8a82 100644 --- a/packages/@winglang/sdk/test/target-tf-aws/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-tf-aws/bucket.test.ts @@ -56,6 +56,20 @@ test("bucket is public", () => { expect(treeJsonOf(app.outdir)).toMatchSnapshot(); }); +test("bucket is force destroy", () => { + // GIVEN + const app = new AwsApp(); + new Bucket(app, "my_bucket", { forceDestroy: true }); + const output = app.synth(); + + // THEN + expect( + JSON.parse(output).resource.aws_s3_bucket.my_bucket.force_destroy + ).toBe(true); + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + test("bucket with cors disabled", () => { // GIVEN const app = new AwsApp(); diff --git a/packages/@winglang/sdk/test/target-tf-azure/bucket.test.ts b/packages/@winglang/sdk/test/target-tf-azure/bucket.test.ts index 2df8f93317e..134efc3423e 100644 --- a/packages/@winglang/sdk/test/target-tf-azure/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-tf-azure/bucket.test.ts @@ -62,6 +62,18 @@ test("bucket is public", () => { expect(treeJsonOf(app.outdir)).toMatchSnapshot(); }); +test("bucket is force destroy", () => { + // GIVEN + const app = new AzureApp(); + new Bucket(app, "my_bucket", { forceDestroy: true }); + const output = app.synth(); + + // THEN + expect(tfResourcesOfCount(output, "azurerm_storage_container")).toEqual(1); + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + test("bucket with two preflight objects", () => { // GIVEN const app = new AzureApp(); diff --git a/packages/@winglang/sdk/test/target-tf-gcp/bucket.test.ts b/packages/@winglang/sdk/test/target-tf-gcp/bucket.test.ts index a53edccacaf..34053303168 100644 --- a/packages/@winglang/sdk/test/target-tf-gcp/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-tf-gcp/bucket.test.ts @@ -41,6 +41,17 @@ test("bucket is public", () => { expect(treeJsonOf(app.outdir)).toMatchSnapshot(); }); +test("bucket is force destroy", () => { + // GIVEN + const app = new GcpApp(); + new Bucket(app, "my_bucket", { forceDestroy: true }); + const output = app.synth(); + + // THEN + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + test("two buckets", () => { // GIVEN const app = new GcpApp();