feat(cli): add cdk orphan command to detach resources from a stack#1324
feat(cli): add cdk orphan command to detach resources from a stack#1324
cdk orphan command to detach resources from a stack#1324Conversation
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.
|
This is actually @LeeroyHannigan's change, I'm just turning it into a PR so I can leave comments more conveniently. |
rix0rrr
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Do we need this method? We probably should just inline this at the call site.
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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> {There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| const stack = stacks.stackArtifacts[0]; | ||
| await this.ioHost.asIoHelper().defaults.info(chalk.bold(`Orphaning construct '${options.constructPath}' from ${stack.displayName}`)); |
There was a problem hiding this comment.
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();
}
}
}There was a problem hiding this comment.
Done. Implemented exactly as described:
makePlan(stack, constructPaths)→ returnsOrphanPlanwithstackName,orphanedResources(each withlogicalId,resourceType,cdkPath), and anexecute()method. Read-only, no deployments.Toolkit.orphan()shows the plan, then confirms viarequestResponse(skippable with--force).plan.execute()runs the 3 CloudFormation deployments, returnsOrphanResultwithresourceMapping.
| roleArn: options.roleArn, | ||
| toolkitStackName: this.toolkitStackName, |
There was a problem hiding this comment.
Feels like both of these should be constructor arguments.
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This feature needs to be --unstable. Check out other code to see how we do that.
| }); | ||
| } | ||
|
|
||
| public async orphan(options: OrphanOptions) { |
There was a problem hiding this comment.
this should be implemented fully in toolkit-lib's Toolkit class, with the CLI passing through. There are existing actions that follow that pattern.
There was a problem hiding this comment.
Good call, I thought I check that but apparently didn't.
Head branch was pushed to by a user without write access
82e0371 to
8ad5411
Compare
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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:cdk importcommand with the resource mappingAlso adds inline JSON support for
--resource-mappingincdk import, and exposesstackSdk()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