Skip to content

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

Open
rix0rrr wants to merge 10 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 10 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

@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).

Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts Outdated
}

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

Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts Outdated
}

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 thread packages/aws-cdk/lib/cli/cdk-toolkit.ts Outdated
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.

Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts
Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts
auto-merge was automatically disabled April 9, 2026 17:48

Head branch was pushed to by a user without write access

@LeeroyHannigan LeeroyHannigan force-pushed the lhnng-orphan-resource branch from 82e0371 to 8ad5411 Compare April 9, 2026 18:12
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 71.42857% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.14%. Comparing base (b96bcce) to head (f576cc1).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
packages/aws-cdk/lib/cli/cli.ts 7.14% 13 Missing ⚠️
packages/aws-cdk/lib/cli/cdk-toolkit.ts 79.41% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1324      +/-   ##
==========================================
- Coverage   88.29%   88.14%   -0.15%     
==========================================
  Files          73       74       +1     
  Lines       10386    10494     +108     
  Branches     1413     1428      +15     
==========================================
+ Hits         9170     9250      +80     
- Misses       1189     1216      +27     
- Partials       27       28       +1     
Flag Coverage Δ
suite.unit 88.14% <71.42%> (-0.15%) ⬇️

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.

// Put an item in the table before orphan
const itemFile = path.join(fixture.integTestDir, 'test-item.json');
await fs.writeFile(itemFile, JSON.stringify({ PK: { S: 'before-orphan' } }));
await fixture.shell([
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 are in a programming language, and we have an AWS SDK available to us (or we could without a lot of effort). I would prefer us using @aws-sdk/client-dynamodb directly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wasn't sure how to easily import the SDK, but its done now, thanks.

new GetTemplateCommand({ StackName: stackName }),
);
const templateBody = yaml.parse(templateAfter.TemplateBody!);
expect(templateBody.Resources).not.toHaveProperty('MyTable794EDED1');
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 would also silently succeed if the logical ID changed, for some reason. We should also check that the expected resource is present before we start orphaning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Added expect(templateBodyBefore.Resources).toHaveProperty('MyTable794EDED1') before orphan


// Verify the Lambda still exists and its env vars have been replaced with literals
// (Ref -> physical table name, GetAtt -> physical ARN)
const lambdaResource = Object.values(templateBody.Resources).find(
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 is very hard to read, and if any of these steps fail the output will not be helpful to diagnose what the problem is.

I'd rather use a single structural assertion like:

expect(templateBody).toMatchObject({
  Resources: {
    MyFunction1234: {
     Properties: {
       ... 
     }
   }
  }
});

etc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Single toMatchObject with expect.objectContaining and expect.stringContaining

'--region', fixture.aws.region,
'--output', 'json',
]);
expect(getItemOutput).toContain('before-orphan');
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 need to clean up that table otherwise it will leak.

Stick it in a try/finally, I really don't want to leak it even if the test fails.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test body in try, DeleteTableCommand in finally

* Construct path prefix(es) to orphan. Each path must be in the format
* `StackName/ConstructPath`, e.g. `MyStack/MyTable`.
*
* The stack is derived from the path — all paths must reference the same stack.
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.

Ah yeah? 😅

Bummer. But acceptable for now I guess.


for (const output of stackDesc.Stacks?.[0]?.Outputs ?? []) {
if (!output.OutputKey || !output.OutputValue) continue;
const ref = getAttRefs.find(r => r.outputKey === output.OutputKey);
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 getAttRefs was a map or an object, we wouldn't need to search a list here.

Doubly-indexing maps is a bit brain breaking but perfectly legit and more concise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Converted to map lookup

Comment on lines +316 to +320
// Only include the primary resource for each construct path
// e.g. for path "MyTable", match "StackName/MyTable/Resource" exactly
const cdkPath = resource.Metadata?.[PATH_METADATA_KEY] ?? '';
const primaryPaths = constructPaths.map(p => `${stack.stackName}/${p}/Resource`);
if (!primaryPaths.includes(cdkPath)) continue;
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 smells to me. I can't be bothered to try to understand what this is doing exactly, but it doesn't feel right.

Why the interpolation with stackName? Why only exactly this L1 reosurce? Why includes(cdkPath) instead of startsWith(cdkPath)?

What are we trying to do here?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removed the path filter entirely. Will need to discuss.

Comment on lines +340 to +341
} catch {
await this.ioHelper.defaults.debug('Could not retrieve resource identifier summaries');
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.

Wait what?

There was an exception, we're not stopping, we're not even logging the except, we're just chugging along as if nothing happened? And whispering that "something might be off" at debug level?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Now warn with the actual error message instead of debug with a generic string. Added unit test.

Comment on lines +1655 to +1656
const thisStack = p.substring(0, slashIdx);
const constructPath = p.substring(slashIdx + 1);
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.

Unfortunately a construct ID !== a stack name.

"Construct ID" is a CDK concept, "Stack Name" is a CloudFormation concept.

Yes, by default the stack name of a Stack is the same as its Construct ID, but not necessarily.

new Stack(this, 'A', {    // <-- construct ID, in the path
  stackName: 'B'          // <-- Stack Name in CFN
});

The translation of construct path to stack name needs to go through the assembly. To be frank, I don't know the best way offhand (it should probably be somewhere in the manifest), but perhaps the magic black box can come up with an idea.

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, now matches stack.hierarchicalId instead of using the path segment as a CloudFormation stack name pattern

Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts Outdated
Comment on lines +946 to +947
await this.ioHost.asIoHelper().defaults.error((e as Error).message);
throw e;
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.

Logging and rethrowing is usually an antipattern, leading to duplicated error messages in the output. Is this what we do in other places?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was following the refactor implementation here:

But I'll remove the try catch

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

Comment thread packages/aws-cdk/lib/cli/cdk-toolkit.ts Outdated
Comment on lines +938 to +948
try {
await this.toolkit.orphan(this.props.cloudExecutable, {
constructPaths: options.constructPath,
roleArn: options.roleArn,
toolkitStackName: options.toolkitStackName,
force: options.force,
});
} catch (e) {
await this.ioHost.asIoHelper().defaults.error((e as Error).message);
throw e;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
try {
await this.toolkit.orphan(this.props.cloudExecutable, {
constructPaths: options.constructPath,
roleArn: options.roleArn,
toolkitStackName: options.toolkitStackName,
force: options.force,
});
} catch (e) {
await this.ioHost.asIoHelper().defaults.error((e as Error).message);
throw e;
}
await this.toolkit.orphan(this.props.cloudExecutable, {
constructPaths: options.constructPath,
roleArn: options.roleArn,
toolkitStackName: options.toolkitStackName,
force: options.force,
});

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

Lee Hannigan added 7 commits April 13, 2026 19:30
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.
# `cdk import`
- cloudformation:GetTemplateSummary
# `cdk orphan`
- cloudformation:ListStackResources
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 needs an update to the bootstrap stack version as well (in 2 places)

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

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