From 84b86fefa44af8c6d611b0479cfb983cfe6cc98c Mon Sep 17 00:00:00 2001 From: Elliot Schot Date: Sun, 13 Apr 2025 17:11:32 +1000 Subject: [PATCH 1/2] feat(lib): add the `remove` function to `TerraformResource` Adds the `remove` function to the `TerraformResource` that enables support for the underlying terraform `removed` block (context: https://developer.hashicorp.com/terraform/language/resources/syntax#removing-resources). Includes rendering support and unit tests. --- packages/cdktf/lib/errors.ts | 28 +++ packages/cdktf/lib/hcl/render.ts | 17 ++ packages/cdktf/lib/terraform-provisioner.ts | 41 ++++ packages/cdktf/lib/terraform-resource.ts | 106 +++++++++ packages/cdktf/lib/terraform-stack.ts | 5 + packages/cdktf/test/resource.test.ts | 225 ++++++++++++++++++++ 6 files changed, 422 insertions(+) diff --git a/packages/cdktf/lib/errors.ts b/packages/cdktf/lib/errors.ts index 5a8b918eba..9a8855b5ea 100644 --- a/packages/cdktf/lib/errors.ts +++ b/packages/cdktf/lib/errors.ts @@ -90,6 +90,34 @@ export const modulesWithSameAlias = (alias: string) => Each provider must have a unique alias when passing multiple providers of the same type to modules. `); +export const cannotRemoveMovedResource = (resourceId: string) => + new Error( + `Cannot remove the resource "${resourceId}" because it has already been marked for a move operation. + +A resource cannot be moved and removed at the same time. Please ensure the resource is not moved before attempting to remove it.`, + ); + +export const cannotMoveRemovedResource = (resourceId: string) => + new Error( + `Cannot move the resource "${resourceId}" because it has already been marked for removal. + +A resource cannot be moved and removed at the same time. Please ensure the resource is not removed before attempting to move it.`, + ); + +export const cannotRemoveImportedResource = (resourceId: string) => + new Error( + `Cannot remove the resource "${resourceId}" because it has already been marked for import. + +A resource cannot be imported and removed at the same time. Please ensure the resource is not imported before attempting to remove it.`, + ); + +export const cannotImportRemovedResource = (resourceId: string) => + new Error( + `Cannot import the resource "${resourceId}" because it has already been marked for removal. + +A resource cannot be imported and removed at the same time. Please ensure the resource is not removed before attempting to import it.`, + ); + export const moveTargetAlreadySet = ( target: string, friendlyUniqueId: string | undefined, diff --git a/packages/cdktf/lib/hcl/render.ts b/packages/cdktf/lib/hcl/render.ts index dc33dff293..981545fe4f 100644 --- a/packages/cdktf/lib/hcl/render.ts +++ b/packages/cdktf/lib/hcl/render.ts @@ -460,6 +460,23 @@ ${renderAttributes(importBlock)} return importBlocks.join("\n"); } +/** + * + */ +export function renderRemoved(removed: any) { + const removedBlocks = removed.map((removedBlock: any) => { + const { provisioner, ...otherAttrs } = removedBlock; + const hcl = [`removed {`]; + const attrs = renderAttributes(otherAttrs); + if (attrs) hcl.push(attrs); + if (provisioner) hcl.push(renderProvisionerBlock(provisioner)); + hcl.push("}"); + return hcl.join("\n"); + }); + + return removedBlocks.join("\n"); +} + /** * */ diff --git a/packages/cdktf/lib/terraform-provisioner.ts b/packages/cdktf/lib/terraform-provisioner.ts index 7669f3ec92..b69ab595eb 100644 --- a/packages/cdktf/lib/terraform-provisioner.ts +++ b/packages/cdktf/lib/terraform-provisioner.ts @@ -345,6 +345,47 @@ export interface RemoteExecProvisioner { readonly connection?: SSHProvisionerConnection | WinrmProvisionerConnection; } +/** + * A removed block specific local-exec provisioner invokes a local executable after a resource is destroyed. + * This invokes a process on the machine running Terraform, not on the resource. + * + * See {@link https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec local-exec} + */ +export interface RemovedBlockLocalExecProvisioner { + readonly type: "local-exec"; + /** + * This is the command to execute. + * It can be provided as a relative path to the current working directory or as an absolute path. + * It is evaluated in a shell, and can use environment variables or Terraform variables. + */ + readonly command: string; + /** + * If provided, specifies the working directory where command will be executed. + * It can be provided as a relative path to the current working directory or as an absolute path. + * The directory must exist. + */ + readonly workingDir?: string; + /** + * If provided, this is a list of interpreter arguments used to execute the command. + * The first argument is the interpreter itself. + * It can be provided as a relative path to the current working directory or as an absolute path + * The remaining arguments are appended prior to the command. + * This allows building command lines of the form "/bin/bash", "-c", "echo foo". + * If interpreter is unspecified, sensible defaults will be chosen based on the system OS. + */ + readonly interpreter?: string[]; + /** + * A record of key value pairs representing the environment of the executed command. + * It inherits the current process environment. + */ + readonly environment?: Record; + /** + * Specifies when Terraform will execute the command. + * For example, when = destroy specifies that the provisioner will run when the associated resource is destroyed + */ + readonly when: "destroy"; +} + /** * Expressions in connection blocks cannot refer to their parent resource by name. * References create dependencies, and referring to a resource by name within its own block would create a dependency cycle. diff --git a/packages/cdktf/lib/terraform-resource.ts b/packages/cdktf/lib/terraform-resource.ts index 675ea7ba29..0692e8c81d 100644 --- a/packages/cdktf/lib/terraform-resource.ts +++ b/packages/cdktf/lib/terraform-resource.ts @@ -18,6 +18,7 @@ import { ITerraformIterator } from "./terraform-iterator"; import { Precondition, Postcondition } from "./terraform-conditions"; import { TerraformCount } from "./terraform-count"; import { + RemovedBlockLocalExecProvisioner, SSHProvisionerConnection, WinrmProvisionerConnection, } from "./terraform-provisioner"; @@ -31,6 +32,10 @@ import { import { ValidateTerraformVersion } from "./validations/validate-terraform-version"; import { TerraformStack } from "./terraform-stack"; import { + cannotImportRemovedResource, + cannotMoveRemovedResource, + cannotRemoveImportedResource, + cannotRemoveMovedResource, movedToResourceOfDifferentType, resourceGivenTwoMoveOperationsById, resourceGivenTwoMoveOperationsByTarget, @@ -128,6 +133,19 @@ export interface TerraformResourceImport { readonly provider?: TerraformProvider; } +export interface TerraformResourceRemoveLifecycle { + readonly destroy: boolean; +} + +export type TerraformResourceRemoveProvisioner = + RemovedBlockLocalExecProvisioner; + +export interface TerraformResourceRemove { + readonly from: string; + readonly lifecycle?: TerraformResourceRemoveLifecycle; + readonly provisioners?: Array; +} + // eslint-disable-next-line jsdoc/require-jsdoc export class TerraformResource extends TerraformElement @@ -148,6 +166,7 @@ export class TerraformResource FileProvisioner | LocalExecProvisioner | RemoteExecProvisioner >; private _imported?: TerraformResourceImport; + private _removed?: TerraformResourceRemove; private _movedByTarget?: TerraformResourceMoveByTarget; private _movedById?: TerraformResourceMoveById; private _hasMoved = false; @@ -271,6 +290,22 @@ export class TerraformResource ...this.constructNodeMetadata, }; + // If we are removing a resource imports and moved blocks are not supported + if (this._removed) { + const { provisioners, ...props } = this._removed; + return { + resource: undefined, + removed: [ + { + ...props, + provisioner: provisioners?.map(({ type, ...props }) => ({ + [type]: keysToSnakeCase(props), + })), + }, + ], + }; + } + const movedBlock = this._buildMovedBlock(); return { resource: this._hasMoved @@ -322,6 +357,24 @@ export class TerraformResource ...this.constructNodeMetadata, }; + // If we are removing a resource imports and moved blocks are not supported + if (this._removed) { + const { provisioners, ...props } = this._removed; + return { + resource: undefined, + removed: [ + { + ...props, + provisioner: provisioners?.map(({ type, ...props }) => ({ + [type]: { + value: keysToSnakeCase(props), + }, + })), + }, + ], + }; + } + const movedBlock = this._buildMovedBlock(); return { resource: this._hasMoved @@ -393,6 +446,11 @@ export class TerraformResource [this.terraformResourceType]: [this.friendlyUniqueId], } : undefined, + removed: this._removed + ? { + [this.terraformResourceType]: [this.friendlyUniqueId], + } + : undefined, }; } @@ -406,6 +464,9 @@ export class TerraformResource } public importFrom(id: string, provider?: TerraformProvider) { + if (this._removed) { + throw cannotImportRemovedResource(this.node.id); + } this._imported = { id, provider }; this.node.addValidation( new ValidateTerraformVersion( @@ -415,6 +476,42 @@ export class TerraformResource ); } + /** + * Remove this resource, this will destroy the resource and place it within the removed block + * @param lifecycle The lifecycle block to be used for the removed resource + * @param provisioners Optional The provisioners to be used for the removed resource + */ + public remove( + lifecycle: TerraformResourceRemoveLifecycle, + provisioners?: Array, + ) { + if (this._movedByTarget) { + throw cannotRemoveMovedResource(this.node.id); + } + if (this._imported) { + throw cannotRemoveImportedResource(this.node.id); + } + this.node.addValidation( + new ValidateTerraformVersion( + ">=1.7", + `Removed blocks are only supported for Terraform >=1.7. Please upgrade your Terraform version.`, + ), + ); + if (provisioners) { + this.node.addValidation( + new ValidateTerraformVersion( + ">=1.9", + `A Removed block provisioner is only supported for Terraform >=1.9. Please upgrade your Terraform version.`, + ), + ); + } + this._removed = { + from: `${this.terraformResourceType}.${this.friendlyUniqueId}`, + lifecycle, + provisioners, + }; + } + private _getResourceTarget(moveTarget: string) { return TerraformStack.of(this).moveTargets.getResourceByTarget(moveTarget); } @@ -472,6 +569,9 @@ export class TerraformResource * @param index Optional The index corresponding to the key the resource is to appear in the foreach of a resource to move to */ public moveTo(moveTarget: string, index?: string | number) { + if (this._removed) { + throw cannotMoveRemovedResource(this.node.id); + } if (this._movedByTarget) { throw resourceGivenTwoMoveOperationsByTarget( this.friendlyUniqueId, @@ -496,6 +596,9 @@ export class TerraformResource * @param id Full id of resource to move to, e.g. "aws_s3_bucket.example" */ public moveToId(id: string) { + if (this._removed) { + throw cannotMoveRemovedResource(this.node.id); + } if (this._movedById) { throw resourceGivenTwoMoveOperationsById( this.node.id, @@ -519,6 +622,9 @@ export class TerraformResource * @param id Full id of resource being moved from, e.g. "aws_s3_bucket.example" */ public moveFromId(id: string) { + if (this._removed) { + throw cannotMoveRemovedResource(this.node.id); + } if (this._movedById) { throw resourceGivenTwoMoveOperationsById( this.node.id, diff --git a/packages/cdktf/lib/terraform-stack.ts b/packages/cdktf/lib/terraform-stack.ts index 59bc3eef57..26eddaaffa 100644 --- a/packages/cdktf/lib/terraform-stack.ts +++ b/packages/cdktf/lib/terraform-stack.ts @@ -32,6 +32,7 @@ import { renderVariable, renderImport, cleanForMetadata, + renderRemoved, } from "./hcl/render"; import { noStackForConstruct, @@ -305,6 +306,10 @@ export class TerraformStack extends Construct { res = [res, renderImport(frag.import)].join("\n\n"); } + if (frag.removed) { + res = [res, renderRemoved(frag.removed)].join("\n\n"); + } + if (frag.locals) { deepMerge(locals, frag); } diff --git a/packages/cdktf/test/resource.test.ts b/packages/cdktf/test/resource.test.ts index f9c71c775f..0f3dce7148 100644 --- a/packages/cdktf/test/resource.test.ts +++ b/packages/cdktf/test/resource.test.ts @@ -716,3 +716,228 @@ it("override logical ID - before move from id", () => { "simple", ); }); + +test("Only removes the specified resource", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + const removeResource = new TestResource(stack, "remove", { + name: "fooA", + }); + + new TestResource(stack, "keep", { + name: "fooB", + }); + + removeResource.remove({ destroy: true }); + + const synthedStack = JSON.parse(Testing.synth(stack)); + // We should only have 1 removed item + expect(synthedStack.removed).toHaveLength(1); + expect(synthedStack.removed[0].from).toEqual("test_resource.remove"); + expect(synthedStack.removed[0].lifecycle).toEqual({ destroy: true }); + // We should only have 1 retained resource + expect(Object.keys(synthedStack.resource)).toHaveLength(1); + expect(Object.keys(synthedStack.resource.test_resource)).toContain("keep"); + expect(Object.keys(synthedStack.resource.test_resource)).not.toContain( + "remove", + ); +}); + +test("Can remove multiple resources", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + const removeResourceA = new TestResource(stack, "removeA", { + name: "fooA", + }); + + const removeResourceB = new TestResource(stack, "removeB", { + name: "fooB", + }); + + new TestResource(stack, "keep", { + name: "fooC", + }); + + removeResourceA.remove({ destroy: true }); + removeResourceB.remove({ destroy: true }, [ + { + type: "local-exec", + command: "echo 'Resource removed'", + when: "destroy", + }, + ]); + + const synthedStack = JSON.parse(Testing.synth(stack)); + // We should only have 1 removed item + expect(synthedStack.removed).toHaveLength(2); + expect(synthedStack.removed.map((item: any) => item.from)).toContain( + "test_resource.removeA", + ); + expect(synthedStack.removed.map((item: any) => item.from)).toContain( + "test_resource.removeB", + ); + // We should only have 1 retained resource + expect(Object.keys(synthedStack.resource)).toHaveLength(1); + expect(Object.keys(synthedStack.resource.test_resource)).toContain("keep"); + expect(Object.keys(synthedStack.resource.test_resource)).not.toContain( + "remove", + ); +}); + +test("Should properly generate hcl", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + const removeResourceA = new TestResource(stack, "removeA", { + name: "fooA", + }); + + const removeResourceB = new TestResource(stack, "removeB", { + name: "fooB", + }); + + new TestResource(stack, "keep", { + name: "fooC", + }); + + removeResourceA.remove({ destroy: true }); + removeResourceB.remove({ destroy: true }, [ + { + type: "local-exec", + command: "echo 'Resource removed'", + when: "destroy", + }, + ]); + + expect(Testing.synthHcl(stack)).toMatchInlineSnapshot(` +"terraform { +required_providers { + test = { +version = "~> 2.0" +} +} + + +} + +provider "test" { +} + +removed { +from = "test_resource.removeA" +lifecycle { + destroy = true +} +} + +removed { +from = "test_resource.removeB" +lifecycle { + destroy = true +} +provisioner "local-exec" { +command = "echo 'Resource removed'" +when = "destroy" +} +} +resource "test_resource" "keep" { +name = "fooC" +}" +`); +}); + +test("remove resource with lifecycle and provisioner", () => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + const resource = new TestResource(stack, "test", { + name: "foo", + }); + + resource.remove({ destroy: true }, [ + { + type: "local-exec", + command: "echo 'Resource removed'", + when: "destroy", + }, + ]); + + const synthedStack = JSON.parse(Testing.synth(stack)); + expect(synthedStack.removed[0]).toEqual({ + from: "test_resource.test", + lifecycle: { destroy: true }, + provisioner: [ + { + "local-exec": { + command: "echo 'Resource removed'", + when: "destroy", + }, + }, + ], + }); +}); + +test.each([ + { + description: "cannot remove a resource marked for import", + setup: (resource: TestResource) => resource.importFrom("testId"), + action: (resource: TestResource) => resource.remove({ destroy: true }), + expectedError: ` + "Cannot remove the resource "test" because it has already been marked for import. + + A resource cannot be imported and removed at the same time. Please ensure the resource is not imported before attempting to remove it." + `, + }, + { + description: "cannot import a resource marked for removal", + setup: (resource: TestResource) => resource.remove({ destroy: true }), + action: (resource: TestResource) => resource.importFrom("testId"), + expectedError: ` + "Cannot import the resource "test" because it has already been marked for removal. + + A resource cannot be imported and removed at the same time. Please ensure the resource is not removed before attempting to import it." + `, + }, + { + description: "cannot remove a resource marked for a move operation", + setup: (resource: TestResource) => resource.moveTo("moveTarget"), + action: (resource: TestResource) => resource.remove({ destroy: true }), + expectedError: ` + "Cannot remove the resource "test" because it has already been marked for a move operation. + + A resource cannot be moved and removed at the same time. Please ensure the resource is not moved before attempting to remove it." + `, + }, + { + description: "cannot move a resource marked for removal", + setup: (resource: TestResource) => resource.remove({ destroy: true }), + action: (resource: TestResource) => resource.moveTo("moveTarget"), + expectedError: ` + "Cannot move the resource "test" because it has already been marked for removal. + + A resource cannot be moved and removed at the same time. Please ensure the resource is not removed before attempting to move it." + `, + }, +])("$description", ({ setup, action, expectedError }) => { + const app = Testing.app(); + const stack = new TerraformStack(app, "test"); + new TestProvider(stack, "provider", {}); + + const resource = new TestResource(stack, "test", { + name: "foo", + }); + + // Perform the setup step + setup(resource); + + // Assert that the action throws the expected error + expect(() => action(resource)).toThrowErrorMatchingInlineSnapshot( + expectedError, + ); +}); From da8d864ef199d0d848bd17fe264b22b62851a8f9 Mon Sep 17 00:00:00 2001 From: Elliot Schot Date: Sun, 13 Apr 2025 17:20:04 +1000 Subject: [PATCH 2/2] fix: add temporary TODO --- packages/cdktf/lib/terraform-resource.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cdktf/lib/terraform-resource.ts b/packages/cdktf/lib/terraform-resource.ts index 0692e8c81d..d5d00fc709 100644 --- a/packages/cdktf/lib/terraform-resource.ts +++ b/packages/cdktf/lib/terraform-resource.ts @@ -446,6 +446,7 @@ export class TerraformResource [this.terraformResourceType]: [this.friendlyUniqueId], } : undefined, + // TODO: its was unclear to me if how removed should interact with `toMetadata` removed: this._removed ? { [this.terraformResourceType]: [this.friendlyUniqueId],