Skip to content

feat(cli): add cdk orphan command to detach resources from a stack#1324

Open
rix0rrr wants to merge 6 commits intoaws:mainfrom
LeeroyHannigan:lhnng-orphan-resource
Open

feat(cli): add cdk orphan command to detach resources from a stack#1324
rix0rrr wants to merge 6 commits intoaws:mainfrom
LeeroyHannigan:lhnng-orphan-resource

Conversation

@rix0rrr
Copy link
Copy Markdown
Contributor

@rix0rrr rix0rrr commented Apr 9, 2026

Adds a new CLI command that safely removes resources from a CloudFormation stack without deleting them, enabling resource type migrations (e.g. DynamoDB Table to GlobalTable).

cdk orphan --path <ConstructPath> will:

  • Find all resources under the construct path via aws:cdk:path metadata
  • Resolve {Ref} values via DescribeStackResources
  • Resolve {Fn::GetAtt} values by injecting temporary stack Outputs
  • Set DeletionPolicy: Retain on all matched resources
  • Remove the resources from the stack
  • Output an inline cdk import command with the resource mapping

Also adds inline JSON support for --resource-mapping in cdk import, and exposes stackSdk() on Deployments for read-only SDK access.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

Adds a new CLI command that safely removes resources from a CloudFormation
stack without deleting them, enabling resource type migrations (e.g.
DynamoDB Table to GlobalTable).

`cdk orphan --path <ConstructPath>` will:
- Find all resources under the construct path via aws:cdk:path metadata
- Resolve {Ref} values via DescribeStackResources
- Resolve {Fn::GetAtt} values by injecting temporary stack Outputs
- Set DeletionPolicy: Retain on all matched resources
- Remove the resources from the stack
- Output an inline `cdk import` command with the resource mapping

Also adds inline JSON support for `--resource-mapping` in `cdk import`,
and exposes `stackSdk()` on Deployments for read-only SDK access.
@rix0rrr
Copy link
Copy Markdown
Contributor Author

rix0rrr commented Apr 9, 2026

This is actually @LeeroyHannigan's change, I'm just turning it into a PR so I can leave comments more conveniently.

@github-actions github-actions bot added the p2 label Apr 9, 2026
@aws-cdk-automation aws-cdk-automation requested a review from a team April 9, 2026 08:14
Copy link
Copy Markdown
Contributor Author

@rix0rrr rix0rrr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great start!

Initial round of comments on this.

This also needs an integ test (and to be frank probably more than 1 😉 ).

* Get the CloudFormation SDK client for a stack's environment.
* Used by the orphaner to call DescribeStackResources.
*/
public async stackSdk(stackArtifact: cxapi.CloudFormationStackArtifact) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this method? We probably should just inline this at the call site.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed stackSdk() from Deployments. The orphaner accesses the SDK via deployments.envs.accessStackForReadOnlyStackOperations(stack) directly.

*/
public async loadResourceIdentifiers(available: ImportableResource[], filename: string): Promise<ImportMap> {
const contents = await fs.readJson(filename);
public async loadResourceIdentifiers(available: ImportableResource[], filenameOrJson: string): Promise<ImportMap> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of implicitly overloading an argument like this. I'd rather be explicit, like this:

type ResourceIdentifiersSource = 
  | { type: 'file'; fileName: string }
  | { type: 'direct'; resourceIdentifiers: Record<string, ResourceIdentifierProperties> };


 public async loadResourceIdentifiers(available: ImportableResource[], source: ResourceIdentifiersSource): Promise<ImportMap> {

Or honestly, even simpler

 public async loadResourceIdentifiersFromFile(available: ImportableResource[], fileName: string): Promise<ImportMap> {
   const contents = /* load file */;
   return this.loadResourceIdentifiers(available, contents);
}

 public async loadResourceIdentifiers(available: ImportableResource[], identifiers: Record<string, ResourceIdentifierProperties>): Promise<ImportMap> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Split into loadResourceIdentifiersFromFile(available, fileName) which loads the file and calls loadResourceIdentifiers(available, identifiers). The CLI layer decides which to call. Also added --resource-mapping-inline as a separate CLI option for passing JSON directly (used by cdk orphan output).

}

public async orphan(options: OrphanOptions) {
const stacks = await this.selectStacksForDeploy(options.selector, true, true, false);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the argument is one or more construct paths (which it should be) then the stack selection is implicit. No need for the user to pick the stack.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

const stack = stacks.stackArtifacts[0];
await this.ioHost.asIoHelper().defaults.info(chalk.bold(`Orphaning construct '${options.constructPath}' from ${stack.displayName}`));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should split this into "plan" and "execute" stages:

  • First determine actually which constructs are going to be orphaned, by evaluating the construct path. And include what resources are going to have their references replaced with literals. It should probably be an error if there are 0. Then we print a proper report of what we're going to do to the user (not a guess at the result of their instruction). We should probably give them a chance to confirm as well.
  • Only after they confirm do we do the actual work.

I would like to see that in the API in some way. For example:

class Orphaner {
  constructor(...) { }

  public async makePlan(...): Promise<OrphanPlan> { 
    // ...
  }
}

class OrphanPlan {
   // The properties below here are purely hypothetical to show the idea! I have not done enough
   // mental design to think about whether these are the best to expose.
   public readonly orphanedResoures: OrpanPlanResource[];
   public readonly affectedResources: OrpanPlanResource[];
   public readonly stacks: OrphanPlanStack[];

   public async execute() {
     // ...
   }
}


class Toolkit {

   public async orphan(...) { 

     // And then something like this
     const orphaner = new Orphaner(...):
     const plan = await orphaner.makePlan(...);
    
     await showPlanToUser(plan, this.ioHost);
     const yes = await this.ioHost.confirm(...);
     if (yes) {
       await plan.execute();
     }
   }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Implemented exactly as described:

  • makePlan(stack, constructPaths) → returns OrphanPlan with stackName, orphanedResources (each with logicalId, resourceType, cdkPath), and an execute() method. Read-only, no deployments.
  • Toolkit.orphan() shows the plan, then confirms via requestResponse (skippable with --force).
  • plan.execute() runs the 3 CloudFormation deployments, returns OrphanResult with resourceMapping.

Comment on lines +959 to +960
roleArn: options.roleArn,
toolkitStackName: this.toolkitStackName,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like both of these should be constructor arguments.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. roleArn and toolkitStackName are on ResourceOrphanerProps (the constructor), not on the orphan() method options.

const cfn = sdk.cloudFormation();

// Get physical resource IDs (Ref values) from CloudFormation
const describeResult = await cfn.describeStackResources({ StackName: stack.stackName });
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this paginated?

Copy link
Copy Markdown

@LeeroyHannigan LeeroyHannigan Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch, its not (truncates at 100). I used listStackResources which is paginated, instead. This meant I needed permissions on the CLI role, to run integ tests. LMK if thats not correct.

}
}

private replaceInObject(obj: any, logicalId: string, values: { ref: string; attrs: Record<string, string> }): any {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't use this so doesn't need to be a method. Could be a helper function.

And in fact should be since it operates on a CFN template.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, walkObject, replaceInObject, replaceReferences, removeDependsOn, findResourcesByPath, findBlockingResources, hasAnyCdkPathMetadata, and assertSafeDeployResult are all standalone functions in actions/orphan/private/helpers.ts. None of them are methods on the class.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept them scoped to orphan for now since no other code needs them yet, happy to move to a shared location if you'd prefer, or as a follow-up when other commands need similar utilities.

return result;
}

private removeDependsOn(template: any, logicalId: string): void {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't use this so doesn't need to be a method. Could be a helper function.

And in fact should be since it operates on a CFN template.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, walkObject, replaceInObject, replaceReferences, removeDependsOn, findResourcesByPath, findBlockingResources, hasAnyCdkPathMetadata, and assertSafeDeployResult are all standalone functions in actions/orphan/private/helpers.ts. None of them are methods on the class.

}
}

private walkObject(obj: any, visitor: (value: any) => void): void {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't use this so doesn't need to be a method. Could be a helper function.

And in fact should be since it operates on a CFN template.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, walkObject, replaceInObject, replaceReferences, removeDependsOn, findResourcesByPath, findBlockingResources, hasAnyCdkPathMetadata, and assertSafeDeployResult are all standalone functions in actions/orphan/private/helpers.ts. None of them are methods on the class.

});
}

public async orphan(options: OrphanOptions) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature needs to be --unstable. Check out other code to see how we do that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

});
}

public async orphan(options: OrphanOptions) {
Copy link
Copy Markdown
Contributor

@mrgrain mrgrain Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be implemented fully in toolkit-lib's Toolkit class, with the CLI passing through. There are existing actions that follow that pattern.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I thought I check that but apparently didn't.

auto-merge was automatically disabled April 9, 2026 17:48

Head branch was pushed to by a user without write access

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 53.33333% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.99%. Comparing base (2a848bc) to head (5f1493d).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
packages/aws-cdk/lib/cli/cdk-toolkit.ts 43.58% 22 Missing ⚠️
packages/aws-cdk/lib/cli/cli.ts 7.14% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1324      +/-   ##
==========================================
- Coverage   88.20%   87.99%   -0.21%     
==========================================
  Files          73       73              
  Lines       10386    10455      +69     
  Branches     1409     1410       +1     
==========================================
+ Hits         9161     9200      +39     
- Misses       1198     1228      +30     
  Partials       27       27              
Flag Coverage Δ
suite.unit 87.99% <53.33%> (-0.21%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants