diff --git a/.projenrc.ts b/.projenrc.ts index 9ee25af70..60cc99772 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1559,6 +1559,7 @@ const cliInteg = configureProject( '@octokit/rest@^20', // newer versions are ESM only sdkDep('@aws-sdk/client-codeartifact'), sdkDep('@aws-sdk/client-cloudformation'), + sdkDep('@aws-sdk/client-dynamodb'), sdkDep('@aws-sdk/client-ecr'), sdkDep('@aws-sdk/client-ecr-public'), sdkDep('@aws-sdk/client-ecs'), diff --git a/packages/@aws-cdk-testing/cli-integ/.projen/deps.json b/packages/@aws-cdk-testing/cli-integ/.projen/deps.json index 329e667ee..07d21c89b 100644 --- a/packages/@aws-cdk-testing/cli-integ/.projen/deps.json +++ b/packages/@aws-cdk-testing/cli-integ/.projen/deps.json @@ -139,6 +139,11 @@ "version": "^3", "type": "runtime" }, + { + "name": "@aws-sdk/client-dynamodb", + "version": "^3", + "type": "runtime" + }, { "name": "@aws-sdk/client-ecr-public", "version": "^3", diff --git a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts index 79d682e5b..e8f417b38 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts @@ -5,6 +5,7 @@ import { UpdateTerminationProtectionCommand, type Stack, } from '@aws-sdk/client-cloudformation'; +import { DynamoDB } from '@aws-sdk/client-dynamodb'; import { DeleteRepositoryCommand, ECRClient } from '@aws-sdk/client-ecr'; import { ECRPUBLICClient } from '@aws-sdk/client-ecr-public'; import { ECSClient } from '@aws-sdk/client-ecs'; @@ -55,6 +56,7 @@ export class AwsClients { public readonly lambda: LambdaClient; public readonly sts: STSClient; public readonly secretsManager: SecretsManagerClient; + public readonly dynamoDb: DynamoDB; private constructor( /** A random string to use for temporary resources, like roles (should preferably match unique test-specific randomString) */ @@ -79,6 +81,7 @@ export class AwsClients { this.lambda = new LambdaClient(this.config); this.sts = new STSClient(this.config); this.secretsManager = new SecretsManagerClient(this.config); + this.dynamoDb = new DynamoDB(this.config); } public addCleanup(cleanup: () => Promise) { diff --git a/packages/@aws-cdk-testing/cli-integ/package.json b/packages/@aws-cdk-testing/cli-integ/package.json index 7492b2a12..2b79fe150 100644 --- a/packages/@aws-cdk-testing/cli-integ/package.json +++ b/packages/@aws-cdk-testing/cli-integ/package.json @@ -71,6 +71,7 @@ "dependencies": { "@aws-sdk/client-cloudformation": "^3", "@aws-sdk/client-codeartifact": "^3", + "@aws-sdk/client-dynamodb": "^3", "@aws-sdk/client-ecr": "^3", "@aws-sdk/client-ecr-public": "^3", "@aws-sdk/client-ecs": "^3", diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index 9f34778e7..0bcf1c019 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -479,6 +479,35 @@ class LambdaStack extends cdk.Stack { } } +class OrphanableStack extends cdk.Stack { + constructor(parent, id, props) { + super(parent, id, props); + + const table = new cdk.aws_dynamodb.Table(this, 'MyTable', { + partitionKey: { name: 'PK', type: cdk.aws_dynamodb.AttributeType.STRING }, + billingMode: cdk.aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // Lambda that references the table via Ref (TABLE_NAME) and GetAtt (TABLE_ARN) + const fn = new lambda.Function(this, 'Consumer', { + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + code: lambda.Code.fromInline('exports.handler = async () => {}'), + environment: { + TABLE_NAME: table.tableName, + TABLE_ARN: table.tableArn, + }, + }); + + table.grantReadData(fn); + + new cdk.CfnOutput(this, 'TableName', { value: table.tableName }); + new cdk.CfnOutput(this, 'TableArn', { value: table.tableArn }); + new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName }); + } +} + class DriftableStack extends cdk.Stack { constructor(parent, id, props) { const synthesizer = parent.node.tryGetContext('legacySynth') === 'true' ? @@ -1103,6 +1132,8 @@ switch (stackSet) { new DriftableStack(app, `${stackPrefix}-driftable`); + new OrphanableStack(app, `${stackPrefix}-orphanable`); + new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack1`); new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack2`); break; diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/orphan/cdk-orphan-detaches-resource.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/orphan/cdk-orphan-detaches-resource.integtest.ts new file mode 100644 index 000000000..119bf4aa9 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/orphan/cdk-orphan-detaches-resource.integtest.ts @@ -0,0 +1,86 @@ +import { DescribeStacksCommand, GetTemplateCommand } from '@aws-sdk/client-cloudformation'; +import * as yaml from 'yaml'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest( + 'cdk orphan detaches a resource from the stack without deleting it', + withDefaultFixture(async (fixture) => { + const stackName = fixture.fullStackName('orphanable'); + + // Deploy the stack with a DynamoDB table + Lambda consumer + await fixture.cdkDeploy('orphanable'); + + // Get outputs + const describeResponse = await fixture.aws.cloudFormation.send( + new DescribeStacksCommand({ StackName: stackName }), + ); + const outputs = describeResponse.Stacks?.[0]?.Outputs ?? []; + const tableName = outputs.find((o) => o.OutputKey === 'TableName')?.OutputValue; + expect(tableName).toBeDefined(); + + try { + // Verify the table resource exists in the template before orphaning + const templateBefore = await fixture.aws.cloudFormation.send( + new GetTemplateCommand({ StackName: stackName }), + ); + const templateBodyBefore = yaml.parse(templateBefore.TemplateBody!); + expect(templateBodyBefore.Resources).toHaveProperty('MyTable794EDED1'); + + // Put an item in the table before orphan + await fixture.aws.dynamoDb.putItem({ + TableName: tableName!, + Item: { PK: { S: 'before-orphan' } }, + }); + + // Orphan the table + const orphanOutput = await fixture.cdk([ + 'orphan', + `${stackName}/MyTable`, + '--unstable=orphan', + '--yes', + ]); + + // Verify the output contains a resource mapping for import + expect(orphanOutput).toContain('resource-mapping-inline'); + expect(orphanOutput).toContain('TableName'); + + // Verify the template after orphan: table gone, Lambda env vars replaced with literals + const templateAfter = await fixture.aws.cloudFormation.send( + new GetTemplateCommand({ StackName: stackName }), + ); + const templateBody = yaml.parse(templateAfter.TemplateBody!); + + expect(templateBody.Resources).not.toHaveProperty('MyTable794EDED1'); + expect(templateBody).toMatchObject({ + Resources: expect.objectContaining({ + Consumer8D6BE417: expect.objectContaining({ + Type: 'AWS::Lambda::Function', + Properties: expect.objectContaining({ + Environment: { + Variables: { + TABLE_NAME: expect.stringContaining('MyTable'), + TABLE_ARN: expect.stringContaining('arn:aws:dynamodb'), + }, + }, + }), + }), + }), + }); + + // Verify the table still exists and data is intact (strongly consistent read) + const getItemResult = await fixture.aws.dynamoDb.getItem({ + TableName: tableName!, + Key: { PK: { S: 'before-orphan' } }, + ConsistentRead: true, + }); + expect(getItemResult.Item?.PK?.S).toBe('before-orphan'); + } finally { + // Clean up the retained table to avoid leaking resources + try { + await fixture.aws.dynamoDb.deleteTable({ TableName: tableName! }); + } catch (e) { + // Ignore + } + } + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 87621e031..7a0c6cb6d 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -133,6 +133,7 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I8900` | Refactor result | `result` | {@link RefactorResult} | | `CDK_TOOLKIT_I8910` | Confirm refactor | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_W8010` | Refactor execution not yet supported | `warn` | n/a | +| `CDK_TOOLKIT_I8810` | Confirm orphan resources | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_I9000` | Provides bootstrap times | `info` | {@link Duration} | | `CDK_TOOLKIT_I9100` | Bootstrap progress | `info` | {@link BootstrapEnvironmentProgress} | | `CDK_TOOLKIT_I9210` | Confirm the deletion of a batch of assets | `info` | {@link AssetBatchDeletionRequest} | diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index 754cfbec5..1ddf10dd2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -4,6 +4,7 @@ export * from './destroy'; export * from './diff'; export * from './drift'; export * from './list'; +export * from './orphan'; export * from './publish-assets'; export * from './refactor'; export * from './rollback'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/index.ts new file mode 100644 index 000000000..54952988b --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/index.ts @@ -0,0 +1,19 @@ +export interface OrphanOptions { + /** + * 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. + */ + readonly constructPaths: string[]; + + /** + * Role to assume in the target environment. + */ + readonly roleArn?: string; + + /** + * Toolkit stack name for bootstrap resources. + */ + readonly toolkitStackName?: string; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 0e0d24fc0..5308693b3 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -424,6 +424,13 @@ export const IO = { description: 'Refactor execution not yet supported', }), + // Orphan (88xx) + CDK_TOOLKIT_I8810: make.confirm({ + code: 'CDK_TOOLKIT_I8810', + description: 'Confirm orphan resources', + interface: 'ConfirmationRequest', + }), + // 9: Bootstrap, gc, flags & publish (9xxx) CDK_TOOLKIT_I9000: make.info({ code: 'CDK_TOOLKIT_I9000', diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts index cc495924e..8a1c8985f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts @@ -20,4 +20,5 @@ export type ToolkitAction = | 'init' | 'migrate' | 'refactor' +| 'orphan' | 'flags'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/orphan/orphaner.ts b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/orphaner.ts new file mode 100644 index 000000000..4b6a70460 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/orphaner.ts @@ -0,0 +1,342 @@ +import type * as cxapi from '@aws-cdk/cloud-assembly-api'; +import { + replaceReferences, + removeDependsOn, + walkObject, + assertDeploySucceeded, + ensureNonEmptyResources, +} from './private'; +import { ToolkitError } from '../../toolkit/toolkit-error'; +import type { ICloudFormationClient } from '../aws-auth/sdk'; +import type { Deployments } from '../deployments'; +import type { IoHelper } from '../io/private'; + +interface ResolvedValues { + ref: string; + attrs: Record; +} + +export interface ResourceOrphanerProps { + readonly deployments: Deployments; + readonly ioHelper: IoHelper; + readonly roleArn?: string; + readonly toolkitStackName?: string; +} + +/** + * A resource that will be orphaned. + */ +export interface OrphanedResource { + readonly logicalId: string; + readonly resourceType: string; + readonly cdkPath: string; +} + +/** + * The result of planning an orphan operation. + */ +export interface OrphanPlan { + /** The stack being modified */ + readonly stackName: string; + /** Resources that will be detached from the stack */ + readonly orphanedResources: OrphanedResource[]; + /** Execute the orphan operation (3 CloudFormation deployments) */ + execute(): Promise; +} + +/** + * The result of executing an orphan operation. + */ +export interface OrphanResult { + /** Resource mapping JSON for use with `cdk import --resource-mapping` */ + readonly resourceMapping: Record>; +} + +/** + * Orphans all resources under construct path(s) from a CloudFormation stack. + * + * Usage: + * const plan = await orphaner.makePlan(stack, constructPaths); + * // inspect plan.orphanedResources + * const result = await plan.execute(); + */ +export class ResourceOrphaner { + private readonly deployments: Deployments; + private readonly ioHelper: IoHelper; + private readonly roleArn?: string; + private readonly toolkitStackName?: string; + + constructor(props: ResourceOrphanerProps) { + this.deployments = props.deployments; + this.ioHelper = props.ioHelper; + this.roleArn = props.roleArn; + this.toolkitStackName = props.toolkitStackName; + } + + /** + * Analyze the stack and build a plan of what will be orphaned. + * This is read-only — no changes are made until `plan.execute()` is called. + */ + public async makePlan(stack: cxapi.CloudFormationStackArtifact, constructPaths: string[]): Promise { + const currentTemplate = await this.deployments.readCurrentTemplate(stack); + const resources = currentTemplate.Resources ?? {}; + + // Build a map of construct path -> logical ID from the local assembly + const pathToLogicalId = new Map(); + for (const md of stack.findMetadataByType('aws:cdk:logicalId' as any)) { + pathToLogicalId.set(md.path, md.data as string); + } + + // Find logical IDs matching the given construct paths (prefix match) + const matched: { logicalId: string; path: string }[] = []; + for (const constructPath of constructPaths) { + const prefix = `/${stack.hierarchicalId}/${constructPath}/`; + for (const [path, logicalId] of pathToLogicalId) { + if (path.startsWith(prefix) && resources[logicalId]) { + matched.push({ logicalId, path }); + } + } + } + + if (matched.length === 0) { + throw new ToolkitError('OrphanNoResources', `No resources found under construct path '${constructPaths.join(', ')}' in stack '${stack.stackName}'`); + } + + const logicalIds = matched.map(m => m.logicalId); + const orphanedResources: OrphanedResource[] = matched.map(m => ({ + logicalId: m.logicalId, + resourceType: resources[m.logicalId].Type ?? 'Unknown', + cdkPath: m.path, + })); + + return { + stackName: stack.stackName, + orphanedResources, + execute: () => this.execute(stack, logicalIds, currentTemplate), + }; + } + + private async execute( + stack: cxapi.CloudFormationStackArtifact, + logicalIds: string[], + currentTemplate: any, + ): Promise { + const env = await this.deployments.envs.accessStackForReadOnlyStackOperations(stack); + const cfn = env.sdk.cloudFormation(); + + // Get physical resource IDs (Ref values) + const stackResources = await cfn.listStackResources({ StackName: stack.stackName }); + const physicalIds = new Map(); + for (const res of stackResources) { + if (res.LogicalResourceId && res.PhysicalResourceId) { + physicalIds.set(res.LogicalResourceId, res.PhysicalResourceId); + } + } + + // Step 1/3: Resolve GetAtt attribute values via temporary stack outputs + await this.ioHelper.defaults.info('Step 1/3: Resolving attribute values...'); + const resolvedValues = await this.resolveGetAttValues(stack, cfn, logicalIds, currentTemplate, physicalIds); + + // Step 2/3: Decouple — set RETAIN, replace all Ref/GetAtt with literals, remove DependsOn + await this.ioHelper.defaults.info('Step 2/3: Decoupling resources...'); + const decoupledTemplate = JSON.parse(JSON.stringify(currentTemplate)); + for (const id of logicalIds) { + replaceReferences(decoupledTemplate, id, resolvedValues.get(id)!); + removeDependsOn(decoupledTemplate, id); + decoupledTemplate.Resources[id].DeletionPolicy = 'Retain'; + decoupledTemplate.Resources[id].UpdateReplacePolicy = 'Retain'; + } + const step2Result = await this.deployStack(stack, decoupledTemplate, 'cdk-orphan-step2'); + assertDeploySucceeded(step2Result, 'Step 2'); + + // Step 3/3: Remove orphaned resources from the template + await this.ioHelper.defaults.info('Step 3/3: Removing resources from stack...'); + const removalTemplate = JSON.parse(JSON.stringify(decoupledTemplate)); + for (const id of logicalIds) { + delete removalTemplate.Resources[id]; + } + ensureNonEmptyResources(removalTemplate); + const step3Result = await this.deployStack(stack, removalTemplate, 'cdk-orphan-step3'); + assertDeploySucceeded(step3Result, 'Step 3'); + if (step3Result.noOp) { + throw new ToolkitError( + 'OrphanNoOp', + 'Orphan step 3 was unexpectedly a no-op — the resources were not removed from the stack. ' + + 'If this issue persists, please open an issue at https://github.com/aws/aws-cdk-cli/issues ' + + 'with your stack template attached.', + ); + } + + const resourceMapping = await this.getResourceIdentifiers(stack, logicalIds, physicalIds, currentTemplate); + return { resourceMapping }; + } + + /** + * Deploy a template override to the stack. + */ + private async deployStack(stack: cxapi.CloudFormationStackArtifact, template: any, changeSetName: string) { + return this.deployments.deployStack({ + stack, + roleArn: this.roleArn, + toolkitStackName: this.toolkitStackName, + deploymentMethod: { method: 'change-set', changeSetName }, + overrideTemplate: template, + usePreviousParameters: true, + forceDeployment: true, + }); + } + + /** + * Resolve GetAtt attribute values for orphaned resources. + * + * Current strategy: inject temporary Outputs into the stack that reference + * each GetAtt, deploy, then read the resolved values from DescribeStacks. + * + * This function is intentionally decoupled from the rest of the orphan flow + * so it can be replaced with a Cloud Control API-based approach later. + * + * Returns a complete map of resolved values (Ref + attrs) for each logical ID. + */ + private async resolveGetAttValues( + stack: cxapi.CloudFormationStackArtifact, + cfn: ICloudFormationClient, + logicalIds: string[], + currentTemplate: any, + physicalIds: Map, + ): Promise> { + // Build Ref values from physical IDs + const values = new Map(); + for (const id of logicalIds) { + const physicalId = physicalIds.get(id); + if (!physicalId) { + throw new ToolkitError('OrphanMissingPhysicalId', `Could not resolve physical resource ID for '${id}'`); + } + values.set(id, { ref: physicalId, attrs: {} }); + } + + const getAttRefs = this.findGetAttReferences(currentTemplate, logicalIds); + + // If there are no GetAtt references, skip the deploy + if (getAttRefs.size === 0) { + return values; + } + + // Inject temporary outputs so CloudFormation resolves the GetAtt values + const resolveTemplate = JSON.parse(JSON.stringify(currentTemplate)); + if (!resolveTemplate.Outputs) { + resolveTemplate.Outputs = {}; + } + for (const [outputKey, ref] of getAttRefs) { + resolveTemplate.Outputs[outputKey] = { + Value: { 'Fn::GetAtt': [ref.logicalId, ref.attr] }, + }; + } + + const step1Result = await this.deployStack(stack, resolveTemplate, 'cdk-orphan-step1'); + assertDeploySucceeded(step1Result, 'Step 1'); + + // Read resolved values from stack outputs + const stackDesc = await cfn.describeStacks({ StackName: stack.stackName }); + for (const output of stackDesc.Stacks?.[0]?.Outputs ?? []) { + if (!output.OutputKey || !output.OutputValue) continue; + const ref = getAttRefs.get(output.OutputKey); + if (ref) { + values.get(ref.logicalId)!.attrs[ref.attr] = output.OutputValue; + } + } + + return values; + } + + private findGetAttReferences(template: any, logicalIds: string[]): Map { + const refs = new Map(); + + const addRef = (id: string, attr: string) => { + const outputKey = `CdkOrphan${id}${attr}`.replace(/[^a-zA-Z0-9]/g, ''); + if (!refs.has(outputKey)) { + refs.set(outputKey, { logicalId: id, attr }); + } + }; + + const scanSubString = (str: string) => { + // Match ${LogicalId.Attr} patterns in Fn::Sub format strings + const pattern = /\$\{([^}.]+)\.([^}]+)\}/g; + let match; + while ((match = pattern.exec(str)) !== null) { + const [, id, attr] = match; + if (logicalIds.includes(id)) { + addRef(id, attr); + } + } + }; + + walkObject(template, (value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + // Explicit Fn::GetAtt + const getAtt = value['Fn::GetAtt']; + if (Array.isArray(getAtt) && logicalIds.includes(getAtt[0])) { + addRef(getAtt[0], getAtt[1]); + } + + // Implicit GetAtt inside Fn::Sub + const sub = value['Fn::Sub']; + if (typeof sub === 'string') { + scanSubString(sub); + } else if (Array.isArray(sub) && typeof sub[0] === 'string') { + scanSubString(sub[0]); + } + } + }); + + return refs; + } + + private async getResourceIdentifiers( + stack: cxapi.CloudFormationStackArtifact, + logicalIds: string[], + physicalIds: Map, + template: any, + ): Promise>> { + const result: Record> = {}; + + try { + const summaries = await this.deployments.resourceIdentifierSummaries(stack); + + const identifiersByType = new Map(); + for (const summary of summaries) { + if (summary.ResourceType && summary.ResourceIdentifiers) { + identifiersByType.set(summary.ResourceType, summary.ResourceIdentifiers); + } + } + + const resources = template.Resources ?? {}; + + for (const id of logicalIds) { + const resource = resources[id]; + if (!resource) continue; + + const identifierProps = identifiersByType.get(resource.Type); + if (!identifierProps || identifierProps.length === 0) continue; + + const identifier: Record = {}; + const props = resource.Properties ?? {}; + + for (const prop of identifierProps) { + if (props[prop] && typeof props[prop] === 'string') { + identifier[prop] = props[prop]; + } else if (identifierProps.length === 1 && physicalIds.has(id)) { + identifier[prop] = physicalIds.get(id)!; + } + } + + if (Object.keys(identifier).length > 0) { + result[id] = identifier; + } + } + } catch (e) { + await this.ioHelper.defaults.warn(`Could not retrieve resource identifiers for import: ${(e as Error).message}`); + } + + return result; + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/helpers.ts b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/helpers.ts new file mode 100644 index 000000000..a4979addf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/helpers.ts @@ -0,0 +1,224 @@ +import { PATH_METADATA_KEY } from '@aws-cdk/cloud-assembly-api'; + +/** + * Walk an object tree depth-first, calling visitor on every node. + */ +export function walkObject(obj: any, visitor: (value: any) => void): void { + if (obj === null || obj === undefined) return; + visitor(obj); + if (typeof obj === 'object') { + for (const value of Object.values(obj)) { + walkObject(value, visitor); + } + } +} + +/** + * Replace all {Ref}, {Fn::GetAtt}, and {Fn::Sub} references to a logical ID with literal values. + */ +export function replaceInObject(obj: any, logicalId: string, values: { ref: string; attrs: Record }): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => replaceInObject(item, logicalId, values)); + } + + if (Object.keys(obj).length === 1 && obj.Ref === logicalId) { + return values.ref; + } + + if (Object.keys(obj).length === 1 && Array.isArray(obj['Fn::GetAtt']) && obj['Fn::GetAtt'][0] === logicalId) { + const attr = obj['Fn::GetAtt'][1]; + if (values.attrs[attr]) { + return values.attrs[attr]; + } + } + + // Handle Fn::Sub implicit references: ${LogicalId} and ${LogicalId.Attr} + if (obj['Fn::Sub'] !== undefined) { + const sub = obj['Fn::Sub']; + const replaceSubString = (str: string): string => { + // Replace ${LogicalId.Attr} with the resolved attribute value + for (const [attr, val] of Object.entries(values.attrs)) { + str = str.replace(new RegExp(`\\$\\{${logicalId}\\.${attr}\\}`, 'g'), val); + } + // Replace ${LogicalId} with the resolved Ref value + str = str.replace(new RegExp(`\\$\\{${logicalId}\\}`, 'g'), values.ref); + return str; + }; + + if (typeof sub === 'string') { + return { 'Fn::Sub': replaceSubString(sub) }; + } + if (Array.isArray(sub) && typeof sub[0] === 'string') { + return { + 'Fn::Sub': [ + replaceSubString(sub[0]), + sub[1] ? replaceInObject(sub[1], logicalId, values) : sub[1], + ], + }; + } + } + + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = replaceInObject(value, logicalId, values); + } + return result; +} + +/** + * Replace all references to a logical ID across Resources, Outputs, and Conditions. + */ +export function replaceReferences( + template: any, + logicalId: string, + values: { ref: string; attrs: Record }, +): void { + for (const section of ['Resources', 'Outputs', 'Conditions']) { + if (!template[section]) continue; + for (const [key, value] of Object.entries(template[section])) { + if (section === 'Resources' && key === logicalId) continue; + template[section][key] = replaceInObject(value, logicalId, values); + } + } +} + +/** + * Remove all DependsOn references to a logical ID from the template. + */ +export function removeDependsOn(template: any, logicalId: string): void { + for (const resource of Object.values(template.Resources ?? {})) { + const res = resource as any; + if (Array.isArray(res.DependsOn)) { + res.DependsOn = res.DependsOn.filter((dep: string) => dep !== logicalId); + if (res.DependsOn.length === 0) delete res.DependsOn; + } else if (res.DependsOn === logicalId) { + delete res.DependsOn; + } + } +} + +/** + * Find all resources whose aws:cdk:path starts with `//`. + */ +export function findResourcesByPath(resources: Record, stackName: string, constructPath: string): string[] { + const prefix = `${stackName}/${constructPath}/`; + const ids: string[] = []; + for (const [id, resource] of Object.entries(resources)) { + const cdkPath = resource.Metadata?.[PATH_METADATA_KEY] ?? ''; + if (cdkPath.startsWith(prefix)) { + ids.push(id); + } + } + return ids; +} + +/** + * Find resources in the remaining template that still reference any of the orphaned logical IDs. + */ +export function findBlockingResources(remainingTemplate: any, orphanedIds: string[], fullTemplate: any): string[] { + const blockers: string[] = []; + const remaining = remainingTemplate.Resources ?? {}; + const full = fullTemplate.Resources ?? {}; + + for (const [id, resource] of Object.entries(full) as [string, any][]) { + if (orphanedIds.includes(id)) continue; + if (!remaining[id]) continue; + + let references = false; + walkObject(resource, (value) => { + if (references) return; + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (value.Ref && orphanedIds.includes(value.Ref)) references = true; + const getAtt = value['Fn::GetAtt']; + if (Array.isArray(getAtt) && orphanedIds.includes(getAtt[0])) references = true; + } + }); + + const deps = resource.DependsOn; + if (typeof deps === 'string' && orphanedIds.includes(deps)) references = true; + if (Array.isArray(deps) && deps.some((d: string) => orphanedIds.includes(d))) references = true; + + if (references) { + const path = (resource as any).Metadata?.[PATH_METADATA_KEY] ?? id; + blockers.push(path); + } + } + + return blockers; +} + +/** + * Check if any resources in the template have aws:cdk:path metadata at all. + * Used to detect if metadata has been disabled. + */ +export function hasAnyCdkPathMetadata(resources: Record): boolean { + for (const resource of Object.values(resources)) { + if ((resource as any).Metadata?.[PATH_METADATA_KEY]) { + return true; + } + } + return false; +} + +import { ToolkitError } from '../../../toolkit/toolkit-error'; +import type { DeployStackResult, SuccessfulDeployStackResult } from '../../deployments/deployment-result'; + +/** + * Verify a deploy result completed successfully. + */ +export function assertDeploySucceeded(result: DeployStackResult, step: string): asserts result is SuccessfulDeployStackResult { + if (result.type !== 'did-deploy-stack') { + throw new ToolkitError('OrphanDeployFailed', `${step}: unexpected deployment result '${result.type}'`); + } +} + +/** + * CloudFormation requires at least one resource in the template. + * Add a placeholder if all resources were removed. + */ +export function ensureNonEmptyResources(template: any): void { + if (Object.keys(template.Resources ?? {}).length === 0) { + template.Resources = { + CDKOrphanPlaceholder: { + Type: 'AWS::CloudFormation::WaitConditionHandle', + }, + }; + } +} + +/** + * Parse construct paths like `/MyStack/MyTable` or `MyStack/MyTable` into + * a stack construct ID and construct-level paths. + * + * All paths must reference the same stack. + */ +export function parseAndValidateConstructPaths(paths: string[]): { stackId: string; constructPaths: string[] } { + if (paths.length === 0) { + throw new ToolkitError('MissingConstructPath', 'At least one construct path is required (e.g. cdk orphan MyStack/MyTable)'); + } + + const constructPaths: string[] = []; + let stackId: string | undefined; + + for (const raw of paths) { + const p = raw.replace(/^\//, ''); // strip leading slash + const slashIdx = p.indexOf('/'); + if (slashIdx < 0) { + throw new ToolkitError('InvalidConstructPath', `Construct path '${raw}' must include both a stack name and a construct path separated by '/' (e.g. MyStack/MyTable)`); + } + + const thisStack = p.substring(0, slashIdx); + const constructPath = p.substring(slashIdx + 1); + + if (stackId && thisStack !== stackId) { + throw new ToolkitError('MultipleStacks', `All construct paths must reference the same stack, but got '${stackId}' and '${thisStack}'`); + } + stackId = thisStack; + constructPaths.push(constructPath); + } + + return { stackId: stackId!, constructPaths }; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/index.ts new file mode 100644 index 000000000..c5f595cf9 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/orphan/private/index.ts @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts b/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts index 15e863f22..e59f5b9cf 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts @@ -6,6 +6,7 @@ import type { ResourceIdentifierSummary, ResourceToImport } from '@aws-sdk/clien import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import type { DeploymentMethod } from '../../actions/deploy'; +import { ToolkitError } from '../../toolkit/toolkit-error'; import type { Deployments } from '../deployments'; import { assertIsSuccessfulDeployStackResult } from '../deployments'; import { DiffFormatter } from '../diff'; @@ -141,25 +142,45 @@ export class ResourceImporter { /** * Load the resources to import from a file */ - public async loadResourceIdentifiers(available: ImportableResource[], filename: string): Promise { - const contents = await fs.readJson(filename); + public async loadResourceIdentifiersFromFile(available: ImportableResource[], fileName: string): Promise { + const contents = await fs.readJson(fileName); + return this.loadResourceIdentifiers(available, contents); + } + /** + * Load the resources to import from a mapping (JSON string or pre-parsed object) + */ + public async loadResourceIdentifiers( + available: ImportableResource[], + identifiers: string | Record, + ): Promise { + let parsed: Record; + if (typeof identifiers === 'string') { + try { + parsed = JSON.parse(identifiers); + } catch { + throw new ToolkitError('InvalidResourceMapping', `Could not parse resource mapping as JSON: ${identifiers}`); + } + } else { + parsed = identifiers; + } + const remaining = { ...parsed }; const ret: ImportMap = { importResources: [], resourceMap: {} }; for (const resource of available) { const descr = this.describeResource(resource.logicalId); - const idProps = contents[resource.logicalId]; + const idProps = remaining[resource.logicalId]; if (idProps) { await this.ioHelper.defaults.info(format('%s: importing using %s', chalk.blue(descr), chalk.blue(fmtdict(idProps)))); ret.importResources.push(resource); ret.resourceMap[resource.logicalId] = idProps; - delete contents[resource.logicalId]; + delete remaining[resource.logicalId]; } else { await this.ioHelper.defaults.info(format('%s: skipping', chalk.blue(descr))); } } - const unknown = Object.keys(contents); + const unknown = Object.keys(remaining); if (unknown.length > 0) { await this.ioHelper.defaults.warn(`Unrecognized resource identifiers in mapping file: ${unknown.join(', ')}`); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 283bfd0b5..da9b83f5e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -50,11 +50,13 @@ import type { DiffOptions } from '../actions/diff'; import { appendObject, prepareDiff } from '../actions/diff/private'; import type { DriftOptions, DriftResult } from '../actions/drift'; import { type ListOptions } from '../actions/list'; +import type { OrphanOptions } from '../actions/orphan'; import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publish-assets'; import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; import type { IWatcher, WatchOptions } from '../actions/watch'; +import { countAssemblyResults } from './private/count-assembly-results'; import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private'; import { BaseCredentials, @@ -80,6 +82,8 @@ import type { IIoHost, IoMessageLevel, ToolkitAction } from '../api/io'; import type { ElapsedTime, IoHelper } from '../api/io/private'; import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; +import { ResourceOrphaner } from '../api/orphan/orphaner'; +import { parseAndValidateConstructPaths } from '../api/orphan/private/helpers'; import { Mode, PluginHost } from '../api/plugin'; import { formatAmbiguousMappings, @@ -102,7 +106,6 @@ import { formatErrorMessage, formatTime, obscureTemplate, serializeStructure, va import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; -import { countAssemblyResults } from './private/count-assembly-results'; export interface ToolkitOptions { /** @@ -169,7 +172,7 @@ export interface ToolkitOptions { * Names of toolkit features that are still under development, and may change in * the future. */ -export type UnstableFeature = 'refactor' | 'flags' | 'publish-assets'; +export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets'; /** * The AWS CDK Programmatic Toolkit @@ -1153,6 +1156,72 @@ export class Toolkit extends CloudAssemblySourceBuilder { return ret; } + /** + * Orphan Action. Detaches resources from a CloudFormation stack without deleting them. + */ + public async orphan(cx: ICloudAssemblySource, options: OrphanOptions): Promise { + this.requireUnstableFeature('orphan'); + + const ioHelper = asIoHelper(this.ioHost, 'orphan'); + + // Parse construct paths into stack construct ID + construct-level paths. + const parsed = parseAndValidateConstructPaths(options.constructPaths); + + // Synth all stacks, then find the one whose hierarchicalId matches the stack construct ID. + await using assembly = await synthAndMeasure(ioHelper, cx, ALL_STACKS); + const allStacks = await assembly.selectStacksV2(ALL_STACKS); + const stack = allStacks.stackArtifacts.find(s => s.hierarchicalId === parsed.stackId); + + if (!stack) { + throw new ToolkitError( + 'StackNotFound', + `No stack found with construct ID '${parsed.stackId}'. Available stacks: ${allStacks.stackArtifacts.map(s => s.hierarchicalId).join(', ')}`, + ); + } + const deployments = await this.deploymentsForAction('orphan'); + + const orphaner = new ResourceOrphaner({ + deployments, + ioHelper, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName ?? this.toolkitStackName, + }); + + const plan = await orphaner.makePlan(stack, parsed.constructPaths); + + // Show the plan + const resourceLines = plan.orphanedResources + .map((r) => ` ${r.logicalId} (${r.resourceType}) - ${r.cdkPath}`) + .join('\n'); + await ioHelper.defaults.info( + `Stack: ${plan.stackName}\n` + + `Resources to orphan (${plan.orphanedResources.length}):\n` + + resourceLines, + ); + + // Confirm before orphaning + const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I8810.req( + 'Do you wish to orphan these resources? This will perform 3 CloudFormation deployments.', { + motivation: 'User confirmation is needed before orphaning resources', + })); + if (!confirmed) { + throw new ToolkitError('OrphanAborted', 'Aborted by user'); + } + + const result = await plan.execute(); + + // Output next steps + const mappingJson = Object.keys(result.resourceMapping).length > 0 + ? ` --resource-mapping-inline '${JSON.stringify(result.resourceMapping)}'` + : ''; + await ioHelper.defaults.info( + `✅ Resources orphaned from ${plan.stackName}\n\n` + + 'Next steps:\n' + + ' 1. Update your CDK code to use the new resource type\n' + + ` 2. cdk import${mappingJson}`, + ); + } + /** * Refactor Action. Moves resources from one location (stack + logical ID) to another. */ diff --git a/packages/@aws-cdk/toolkit-lib/test/api/orphan/orphan.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/orphan/orphan.test.ts new file mode 100644 index 000000000..009986c15 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/orphan/orphan.test.ts @@ -0,0 +1,394 @@ +import { + DescribeStacksCommand, + GetTemplateCommand, + GetTemplateSummaryCommand, + ListStackResourcesCommand, + StackStatus, +} from '@aws-sdk/client-cloudformation'; +import { Deployments } from '../../../lib/api/deployments'; +import { ResourceOrphaner } from '../../../lib/api/orphan/orphaner'; +import { replaceInObject } from '../../../lib/api/orphan/private/helpers'; +import { testStack } from '../../_helpers/assembly'; +import { MockSdkProvider, mockCloudFormationClient, restoreSdkMocksToDefault } from '../../_helpers/mock-sdk'; +import { TestIoHost } from '../../_helpers/test-io-host'; + +const DEPLOYED_TEMPLATE = { + Resources: { + MyTable: { + Type: 'AWS::DynamoDB::Table', + Metadata: { 'aws:cdk:path': 'TestStack/MyTable/Resource' }, + Properties: { + TableName: 'my-table', + KeySchema: [{ AttributeName: 'PK', KeyType: 'HASH' }], + BillingMode: 'PAY_PER_REQUEST', + }, + }, + MyTableReplica: { + Type: 'Custom::DynamoDBReplica', + Metadata: { 'aws:cdk:path': 'TestStack/MyTable/Replicaeu-north-1/Default' }, + Properties: { + TableName: { Ref: 'MyTable' }, + Region: 'eu-north-1', + }, + DependsOn: ['MyTable'], + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/MyFunction/Resource' }, + Properties: { + Environment: { + Variables: { + TABLE_NAME: { Ref: 'MyTable' }, + TABLE_ARN: { 'Fn::GetAtt': ['MyTable', 'Arn'] }, + STREAM_ARN: { 'Fn::GetAtt': ['MyTable', 'StreamArn'] }, + // Fn::Sub with direct string (implicit Ref and GetAtt) + ENDPOINT: { 'Fn::Sub': 'https://${MyTable}.dynamodb.amazonaws.com/${MyTable.Arn}' }, + // Fn::Sub with array form + CONNECTION: { 'Fn::Sub': ['arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${MyTable}/${MyTable.StreamArn}', {}] }, + }, + }, + }, + }, + }, + Outputs: { + TableName: { Value: { Ref: 'MyTable' } }, + TableArn: { Value: { 'Fn::GetAtt': ['MyTable', 'Arn'] } }, + }, +}; + +const STACK = testStack({ + stackName: 'TestStack', + template: DEPLOYED_TEMPLATE, + metadata: { + '/TestStack/MyTable/Resource': [{ type: 'aws:cdk:logicalId', data: 'MyTable' }], + '/TestStack/MyTable/Replicaeu-north-1/Default': [{ type: 'aws:cdk:logicalId', data: 'MyTableReplica' }], + '/TestStack/MyFunction/Resource': [{ type: 'aws:cdk:logicalId', data: 'MyFunction' }], + }, +}); + +let deployments: Deployments; +let ioHost: TestIoHost; +let orphaner: ResourceOrphaner; +let deployedTemplates: any[]; + +beforeEach(() => { + restoreSdkMocksToDefault(); + jest.resetAllMocks(); + + const sdkProvider = new MockSdkProvider(); + ioHost = new TestIoHost(); + const ioHelper = ioHost.asHelper('orphan'); + deployments = new Deployments({ sdkProvider, ioHelper }); + + orphaner = new ResourceOrphaner({ deployments, ioHelper }); + deployedTemplates = []; + + mockCloudFormationClient.on(GetTemplateCommand).resolves({ + TemplateBody: JSON.stringify(DEPLOYED_TEMPLATE), + }); + + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [{ + StackName: 'TestStack', + StackStatus: StackStatus.UPDATE_COMPLETE, + CreationTime: new Date(), + Outputs: [ + { OutputKey: 'CdkOrphanMyTableArn', OutputValue: 'arn:aws:dynamodb:us-east-1:123456789012:table/my-table' }, + { OutputKey: 'CdkOrphanMyTableStreamArn', OutputValue: 'arn:aws:dynamodb:us-east-1:123456789012:table/my-table/stream/2026-01-01T00:00:00.000' }, + ], + }], + }); + + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [ + { LogicalResourceId: 'MyTable', PhysicalResourceId: 'my-table', ResourceType: 'AWS::DynamoDB::Table', ResourceStatus: 'CREATE_COMPLETE', LastUpdatedTimestamp: new Date() }, + { LogicalResourceId: 'MyTableReplica', PhysicalResourceId: 'eu-north-1', ResourceType: 'Custom::DynamoDBReplica', ResourceStatus: 'CREATE_COMPLETE', LastUpdatedTimestamp: new Date() }, + { LogicalResourceId: 'MyFunction', PhysicalResourceId: 'my-function-xyz', ResourceType: 'AWS::Lambda::Function', ResourceStatus: 'CREATE_COMPLETE', LastUpdatedTimestamp: new Date() }, + ], + }); + + mockCloudFormationClient.on(GetTemplateSummaryCommand).resolves({ + ResourceIdentifierSummaries: [ + { ResourceType: 'AWS::DynamoDB::Table', ResourceIdentifiers: ['TableName'] }, + ], + }); + + jest.spyOn(deployments, 'deployStack').mockImplementation(async (opts: any) => { + deployedTemplates.push(opts.overrideTemplate); + return { type: 'did-deploy-stack', noOp: false, outputs: {}, stackArn: 'arn' }; + }); +}); + +describe('ResourceOrphaner', () => { + describe('makePlan', () => { + test('returns orphaned resources with metadata', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + expect(plan.stackName).toBe('TestStack'); + expect(plan.orphanedResources).toHaveLength(2); + expect(plan.orphanedResources).toEqual(expect.arrayContaining([ + expect.objectContaining({ logicalId: 'MyTable', resourceType: 'AWS::DynamoDB::Table' }), + expect.objectContaining({ logicalId: 'MyTableReplica', resourceType: 'Custom::DynamoDBReplica' }), + ])); + }); + + test('throws if no resources match path', async () => { + await expect(orphaner.makePlan(STACK, ['NonExistent'])) + .rejects.toThrow(/No resources found/); + }); + + test('throws if construct path does not match any assembly metadata', async () => { + const stackWithNoMetadata = testStack({ + stackName: 'EmptyStack', + template: { + Resources: { + SomeResource: { Type: 'AWS::SNS::Topic', Properties: {} }, + }, + }, + }); + + await expect(orphaner.makePlan(stackWithNoMetadata, ['MyTable'])) + .rejects.toThrow(/No resources found/); + }); + + test('does not deploy anything', async () => { + await orphaner.makePlan(STACK, ['MyTable']); + expect(deployments.deployStack).not.toHaveBeenCalled(); + }); + }); + + describe('execute', () => { + test('step 1 injects temporary outputs for GetAtt resolution', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const resolveTemplate = deployedTemplates[0]; + expect(resolveTemplate.Outputs.CdkOrphanMyTableArn).toEqual({ + Value: { 'Fn::GetAtt': ['MyTable', 'Arn'] }, + }); + expect(resolveTemplate.Outputs.CdkOrphanMyTableStreamArn).toEqual({ + Value: { 'Fn::GetAtt': ['MyTable', 'StreamArn'] }, + }); + }); + + test('step 2 sets RETAIN on matched resources', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + expect(decoupledTemplate.Resources.MyTable.DeletionPolicy).toBe('Retain'); + expect(decoupledTemplate.Resources.MyTableReplica.DeletionPolicy).toBe('Retain'); + }); + + test('step 2 replaces Ref with physical resource ID', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + expect(decoupledTemplate.Resources.MyFunction.Properties.Environment.Variables.TABLE_NAME).toBe('my-table'); + expect(decoupledTemplate.Outputs.TableName.Value).toBe('my-table'); + }); + + test('step 2 replaces GetAtt with resolved literals', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + expect(decoupledTemplate.Resources.MyFunction.Properties.Environment.Variables.TABLE_ARN) + .toBe('arn:aws:dynamodb:us-east-1:123456789012:table/my-table'); + expect(decoupledTemplate.Resources.MyFunction.Properties.Environment.Variables.STREAM_ARN) + .toBe('arn:aws:dynamodb:us-east-1:123456789012:table/my-table/stream/2026-01-01T00:00:00.000'); + }); + + test('step 2 replaces implicit Ref and GetAtt in Fn::Sub string form', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + expect(decoupledTemplate.Resources.MyFunction.Properties.Environment.Variables.ENDPOINT) + .toEqual({ 'Fn::Sub': 'https://my-table.dynamodb.amazonaws.com/arn:aws:dynamodb:us-east-1:123456789012:table/my-table' }); + }); + + test('step 2 replaces implicit Ref and GetAtt in Fn::Sub array form', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + const connection = decoupledTemplate.Resources.MyFunction.Properties.Environment.Variables.CONNECTION; + expect(connection['Fn::Sub'][0]) + .toBe('arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/my-table/arn:aws:dynamodb:us-east-1:123456789012:table/my-table/stream/2026-01-01T00:00:00.000'); + }); + + test('step 1 discovers GetAtt refs inside Fn::Sub for resolution', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const resolveTemplate = deployedTemplates[0]; + // The Fn::Sub contains ${MyTable.Arn} and ${MyTable.StreamArn} which need temp outputs + expect(resolveTemplate.Outputs.CdkOrphanMyTableArn).toBeDefined(); + expect(resolveTemplate.Outputs.CdkOrphanMyTableStreamArn).toBeDefined(); + }); + + test('step 1 discovers GetAtt refs that only appear in Fn::Sub (not in explicit Fn::GetAtt)', async () => { + // Template where an attribute is ONLY referenced via Fn::Sub, not via Fn::GetAtt + mockCloudFormationClient.on(GetTemplateCommand).resolves({ + TemplateBody: JSON.stringify({ + Resources: { + MyTable: { + Type: 'AWS::DynamoDB::Table', + Metadata: { 'aws:cdk:path': 'TestStack/MyTable/Resource' }, + Properties: { TableName: 'my-table' }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/MyFunction/Resource' }, + Properties: { + Environment: { + Variables: { + // Only Fn::Sub references the Arn — no explicit Fn::GetAtt anywhere + ENDPOINT: { 'Fn::Sub': 'https://${MyTable.Arn}/stream' }, + }, + }, + }, + }, + }, + }), + }); + + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const resolveTemplate = deployedTemplates[0]; + expect(resolveTemplate.Outputs.CdkOrphanMyTableArn).toEqual({ + Value: { 'Fn::GetAtt': ['MyTable', 'Arn'] }, + }); + }); + + test('step 2 removes DependsOn references', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const decoupledTemplate = deployedTemplates[1]; + expect(decoupledTemplate.Resources.MyTableReplica.DependsOn).toBeUndefined(); + }); + + test('step 3 removes orphaned resources', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + const removalTemplate = deployedTemplates[2]; + expect(removalTemplate.Resources.MyTable).toBeUndefined(); + expect(removalTemplate.Resources.MyTableReplica).toBeUndefined(); + expect(removalTemplate.Resources.MyFunction).toBeDefined(); + }); + + test('calls deployStack three times', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await plan.execute(); + expect(deployments.deployStack).toHaveBeenCalledTimes(3); + }); + + test('throws if step 3 is a no-op', async () => { + let callCount = 0; + (deployments.deployStack as jest.Mock).mockImplementation(async (opts: any) => { + callCount++; + deployedTemplates.push(opts.overrideTemplate); + return { type: 'did-deploy-stack', noOp: callCount > 2, outputs: {}, stackArn: 'arn' }; + }); + + const plan = await orphaner.makePlan(STACK, ['MyTable']); + await expect(plan.execute()).rejects.toThrow(/unexpectedly a no-op/); + }); + + test('returns resource mapping for all identifiable orphaned resources', async () => { + const plan = await orphaner.makePlan(STACK, ['MyTable']); + const result = await plan.execute(); + expect(result.resourceMapping).toEqual({ + MyTable: { TableName: 'my-table' }, + }); + }); + + test('includes all orphaned resources that have import identifiers', async () => { + mockCloudFormationClient.on(GetTemplateSummaryCommand).resolves({ + ResourceIdentifierSummaries: [ + { ResourceType: 'AWS::DynamoDB::Table', ResourceIdentifiers: ['TableName'] }, + { ResourceType: 'Custom::DynamoDBReplica', ResourceIdentifiers: ['Region'] }, + ], + }); + + const plan = await orphaner.makePlan(STACK, ['MyTable']); + const result = await plan.execute(); + expect(result.resourceMapping).toEqual({ + MyTable: { TableName: 'my-table' }, + MyTableReplica: { Region: 'eu-north-1' }, + }); + }); + + test('warns but does not fail if resource identifier lookup throws', async () => { + jest.spyOn(deployments, 'resourceIdentifierSummaries').mockRejectedValue(new Error('GetTemplateSummary failed')); + + const plan = await orphaner.makePlan(STACK, ['MyTable']); + const result = await plan.execute(); + + expect(result.resourceMapping).toEqual({}); + const messages = ioHost.messages.map((m: any) => m.message ?? m); + expect(messages).toEqual(expect.arrayContaining([ + expect.stringContaining('Could not retrieve resource identifiers'), + ])); + }); + }); +}); + +describe('replaceInObject - Fn::Sub handling', () => { + const values = { ref: 'my-table-physical', attrs: { Arn: 'arn:aws:dynamodb:us-east-1:123:table/t', StreamArn: 'arn:stream' } }; + + test('replaces implicit Ref ${LogicalId} in Fn::Sub string', () => { + const obj = { 'Fn::Sub': 'prefix-${MyTable}-suffix' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': 'prefix-my-table-physical-suffix' }); + }); + + test('replaces implicit GetAtt ${LogicalId.Attr} in Fn::Sub string', () => { + const obj = { 'Fn::Sub': 'arn=${MyTable.Arn}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': 'arn=arn:aws:dynamodb:us-east-1:123:table/t' }); + }); + + test('replaces both Ref and GetAtt in same Fn::Sub string', () => { + const obj = { 'Fn::Sub': '${MyTable}/${MyTable.Arn}/${MyTable.StreamArn}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': 'my-table-physical/arn:aws:dynamodb:us-east-1:123:table/t/arn:stream' }); + }); + + test('replaces in Fn::Sub array form', () => { + const obj = { 'Fn::Sub': ['table=${MyTable}', {}] }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': ['table=my-table-physical', {}] }); + }); + + test('replaces explicit Fn::GetAtt in Fn::Sub array variables', () => { + const obj = { 'Fn::Sub': ['${Var}', { Var: { 'Fn::GetAtt': ['MyTable', 'Arn'] } }] }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': ['${Var}', { Var: 'arn:aws:dynamodb:us-east-1:123:table/t' }] }); + }); + + test('does not replace pseudo-references like ${AWS::StackName}', () => { + const obj = { 'Fn::Sub': '${AWS::StackName}-${MyTable}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': '${AWS::StackName}-my-table-physical' }); + }); + + test('does not replace references to other logical IDs', () => { + const obj = { 'Fn::Sub': '${OtherResource}-${MyTable}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': '${OtherResource}-my-table-physical' }); + }); + + test('leaves Fn::Sub unchanged when no references match', () => { + const obj = { 'Fn::Sub': '${OtherResource.Arn}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': '${OtherResource.Arn}' }); + }); + + test('handles unresolved attr gracefully (leaves interpolation in place)', () => { + const obj = { 'Fn::Sub': '${MyTable.UnknownAttr}' }; + const result = replaceInObject(obj, 'MyTable', values); + // UnknownAttr is not in values.attrs, so it stays as-is + expect(result).toEqual({ 'Fn::Sub': '${MyTable.UnknownAttr}' }); + }); + + test('handles multiple occurrences of same reference', () => { + const obj = { 'Fn::Sub': '${MyTable}-${MyTable}-${MyTable.Arn}' }; + const result = replaceInObject(obj, 'MyTable', values); + expect(result).toEqual({ 'Fn::Sub': 'my-table-physical-my-table-physical-arn:aws:dynamodb:us-east-1:123:table/t' }); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 95f7c4872..7a3dd1a11 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -22,6 +22,7 @@ The AWS CDK Toolkit provides the `cdk` command-line interface that can be used t | [`cdk publish-assets`](#cdk-publish-assets) | Publish assets for stack(s) without deploying | | [`cdk rollback`](#cdk-rollback) | Roll back a failed deployment | | [`cdk import`](#cdk-import) | Import existing AWS resources into a CDK stack | +| [`cdk orphan`](#cdk-orphan) | Detach resources from a stack without deleting them | | [`cdk migrate`](#cdk-migrate) | Migrate AWS resources, CloudFormation stacks, and CloudFormation templates to CDK | | [`cdk watch`](#cdk-watch) | Watches a CDK app for deployable and hotswappable changes | | [`cdk destroy`](#cdk-destroy) | Deletes a stack from an AWS account | @@ -792,6 +793,73 @@ This feature currently has the following limitations: IAM permissions to the `deploy-role`. +### `cdk orphan` + +> `cdk orphan` is currently experimental, meaning we reserve the right to change +> options and flag names in the future. Pass the `--unstable=orphan` flag when using +> this command and be aware of this when using it in scripts. + +Safely detaches one or more resources from a CloudFormation stack without deleting them. +This is useful when you need to migrate a resource from one construct type to another +(for example, migrating a DynamoDB `Table` to `TableV2`) without any downtime or data loss. + +The orphan command works by: + +1. Resolving cross-resource references (`Ref`, `Fn::GetAtt`, `Fn::Sub`) to the orphaned resources, so that other resources in the stack that depend on them continue to work after the orphaned resources are removed +2. Setting the `DeletionPolicy` to `Retain`, replacing all cross-resource references with literal values, and removing `DependsOn` entries to isolate the resources from the rest of the stack +3. Removing the resources from the CloudFormation template (they continue to exist in your AWS account) + +After orphaning, you can update your CDK code and use `cdk import` to bring the resource back under management with the new construct type. + +All construct paths must reference the same stack. Wildcard patterns are not supported; paths are matched as exact prefixes. + +```console +$ # Orphan a single resource +$ cdk orphan --unstable=orphan MyStack/MyTable + +$ # Orphan multiple resources +$ cdk orphan --unstable=orphan MyStack/MyTable MyStack/MyBucket +``` + +#### Example: Migrating DynamoDB Table to TableV2 + +1. Deploy your stack with the original `Table` construct: + + ```ts + const table = new dynamodb.Table(this, 'MyTable', { + tableName: 'my-table', + partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING }, + }); + ``` + +2. Orphan the table from the stack: + + ```console + $ cdk orphan --unstable=orphan MyStack/MyTable + ``` + + The command outputs next steps including a `cdk import` command with the resource mapping. + +3. Update your CDK code to use `TableV2`: + + ```ts + const table = new dynamodb.TableV2(this, 'MyTable', { + tableName: 'my-table', + partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING }, + }); + ``` + +4. Import the existing table into the new construct: + + ```console + $ cdk import --resource-mapping-inline '{"MyTable794EDED1":{"TableName":"my-table"}}' + ``` + + The import command will show any pending drift and offer to run `cdk deploy` afterwards to reconcile it. + +The table remains available throughout the entire process with zero downtime. + + ### `cdk migrate` ⚠️**CAUTION**⚠️: CDK Migrate is currently experimental and may have breaking changes in the future. diff --git a/packages/aws-cdk/lib/api/index.ts b/packages/aws-cdk/lib/api/index.ts index 7b6596777..b0ad9e701 100644 --- a/packages/aws-cdk/lib/api/index.ts +++ b/packages/aws-cdk/lib/api/index.ts @@ -11,6 +11,7 @@ export * from '../../../@aws-cdk/toolkit-lib/lib/api/diff'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/io'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/logs-monitor'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/resource-import'; +export * from '../../../@aws-cdk/toolkit-lib/lib/api/orphan/orphaner'; export { RWLock, type IReadLock } from '../../../@aws-cdk/toolkit-lib/lib/api/rwlock'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/toolkit-info'; export { loadTree, some } from '../../../@aws-cdk/toolkit-lib/lib/api/tree'; diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index d077f7c5d..35a61775f 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -207,7 +207,7 @@ export class CdkToolkit { emojis: true, ioHost: this.ioHost, toolkitStackName: this.toolkitStackName, - unstableFeatures: ['refactor', 'flags', 'publish-assets'], + unstableFeatures: ['refactor', 'orphan', 'flags', 'publish-assets'], }); } @@ -972,6 +972,14 @@ export class CdkToolkit { }); } + public async orphan(options: OrphanOptions) { + await this.toolkit.orphan(this.props.cloudExecutable, { + constructPaths: options.constructPath, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + }); + } + public async import(options: ImportOptions) { const stacks = await this.selectStacksForDeploy(options.selector, true, true, false); @@ -987,8 +995,8 @@ export class CdkToolkit { ); } - if (!process.stdout.isTTY && !options.resourceMappingFile) { - throw new ToolkitError('ResourceMappingRequired', '--resource-mapping is required when input is not a terminal'); + if (!process.stdout.isTTY && !options.resourceMappingFile && !options.resourceMappingInline) { + throw new ToolkitError('ResourceMappingRequired', '--resource-mapping or --resource-mapping-inline is required when input is not a terminal'); } const stack = stacks.stackArtifacts[0]; @@ -1026,9 +1034,14 @@ export class CdkToolkit { } // Prepare a mapping of physical resources to CDK constructs - const actualImport = !options.resourceMappingFile - ? await resourceImporter.askForResourceIdentifiers(additions) - : await resourceImporter.loadResourceIdentifiers(additions, options.resourceMappingFile); + let actualImport: Awaited>; + if (options.resourceMappingInline) { + actualImport = await resourceImporter.loadResourceIdentifiers(additions, options.resourceMappingInline); + } else if (options.resourceMappingFile) { + actualImport = await resourceImporter.loadResourceIdentifiersFromFile(additions, options.resourceMappingFile); + } else { + actualImport = await resourceImporter.askForResourceIdentifiers(additions); + } if (actualImport.importResources.length === 0) { await this.ioHost.asIoHelper().defaults.warn('No resources selected for import.'); @@ -1971,6 +1984,12 @@ export interface RollbackOptions { readonly validateBootstrapStackVersion?: boolean; } +export interface OrphanOptions { + readonly constructPath: string[]; + readonly roleArn?: string; + readonly toolkitStackName?: string; +} + export interface ImportOptions extends CfnDeployOptions { /** * Build a physical resource mapping and write it to the given file, without performing the actual import operation @@ -1987,6 +2006,13 @@ export interface ImportOptions extends CfnDeployOptions { */ readonly resourceMappingFile?: string; + /** + * Inline JSON string with the physical resource mapping + * + * @default - No inline mapping + */ + readonly resourceMappingInline?: string; + /** * Allow non-addition changes to the template * diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 8ee7a5135..83694de61 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -292,6 +292,11 @@ export async function makeConfig(): Promise { desc: 'If specified, CDK will use the given file to map physical resources to CDK resources for import, instead of interactively ' + 'asking the user. Can be run from scripts', }, + 'resource-mapping-inline': { + type: 'string', + requiresArg: true, + desc: 'Inline JSON resource mapping, e.g. \'{"MyResource":{"TableName":"my-table"}}\'', + }, }, }, 'watch': { @@ -494,6 +499,14 @@ export async function makeConfig(): Promise { 'doctor': { description: 'Check your set-up for potential problems', }, + 'orphan': { + arg: { + name: 'PATHS', + variadic: true, + }, + description: 'Detach resources from a CloudFormation stack without deleting them', + options: {}, + }, 'refactor': { description: 'Moves resources between stacks or within the same stack', options: { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index eae7c5c71..50363244c 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -683,6 +683,11 @@ "alias": "m", "requiresArg": true, "desc": "If specified, CDK will use the given file to map physical resources to CDK resources for import, instead of interactively asking the user. Can be run from scripts" + }, + "resource-mapping-inline": { + "type": "string", + "requiresArg": true, + "desc": "Inline JSON resource mapping, e.g. '{\"MyResource\":{\"TableName\":\"my-table\"}}'" } } }, @@ -1086,6 +1091,14 @@ "doctor": { "description": "Check your set-up for potential problems" }, + "orphan": { + "arg": { + "name": "PATHS", + "variadic": true + }, + "description": "Detach resources from a CloudFormation stack without deleting them", + "options": {} + }, "refactor": { "description": "Moves resources between stacks or within the same stack", "options": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 8aa9bfe54..04e005335 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -452,6 +452,18 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { alias: 'm', requiresArg: true, desc: 'If specified, CDK will use the given file to map physical resources to CDK resources for import, instead of interactively asking the user. Can be run from scripts', + }) + .option('resource-mapping-inline', { + default: undefined, + type: 'string', + requiresArg: true, + desc: 'Inline JSON resource mapping, e.g. \'{"MyResource":{"TableName":"my-table"}}\'', }), ) .command('watch [STACKS..]', "Shortcut for 'deploy --watch'", (yargs: Argv) => @@ -1053,6 +1059,7 @@ export function parseCommandLineArguments(args: Array): any { }), ) .command('doctor', 'Check your set-up for potential problems') + .command('orphan [PATHS..]', 'Detach resources from a CloudFormation stack without deleting them', (yargs: Argv) => yargs) .command('refactor [STACKS..]', 'Moves resources between stacks or within the same stack', (yargs: Argv) => yargs .option('additional-stack-name', { diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 0963c2eed..5d08eb1a2 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -38,6 +38,7 @@ export enum Command { DOCS = 'docs', DOC = 'doc', DOCTOR = 'doctor', + ORPHAN = 'orphan', REFACTOR = 'refactor', DRIFT = 'drift', CLI_TELEMETRY = 'cli-telemetry', diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 33f8d9160..7178be165 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -134,6 +134,11 @@ export interface UserInput { */ readonly doctor?: {}; + /** + * Detach resources from a CloudFormation stack without deleting them + */ + readonly orphan?: OrphanOptions; + /** * Moves resources between stacks or within the same stack */ @@ -1109,6 +1114,13 @@ export interface ImportOptions { */ readonly resourceMapping?: string; + /** + * Inline JSON resource mapping, e.g. '{"MyResource":{"TableName":"my-table"}}' + * + * @default - undefined + */ + readonly resourceMappingInline?: string; + /** * Positional argument for import */ @@ -1653,6 +1665,18 @@ export interface DocsOptions { readonly browser?: string; } +/** + * Detach resources from a CloudFormation stack without deleting them + * + * @struct + */ +export interface OrphanOptions { + /** + * Positional argument for orphan + */ + readonly PATHS?: Array; +} + /** * Moves resources between stacks or within the same stack * diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 9fa4751a8..9df389afe 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -167,6 +167,7 @@ describe('config', () => { flags: expect.anything(), doctor: expect.anything(), docs: expect.anything(), + orphan: expect.anything(), refactor: expect.anything(), cliTelemetry: expect.anything(), }); diff --git a/packages/aws-cdk/test/cli/parse-command-line-arguments.test.ts b/packages/aws-cdk/test/cli/parse-command-line-arguments.test.ts index 05b8f326e..467584405 100644 --- a/packages/aws-cdk/test/cli/parse-command-line-arguments.test.ts +++ b/packages/aws-cdk/test/cli/parse-command-line-arguments.test.ts @@ -38,3 +38,14 @@ describe('cdk docs', () => { expect(argv.browser).toBe(browser); }); }); + +test('cdk orphan accepts positional construct paths', async () => { + const argv = await parseCommandLineArguments(['orphan', 'MyStack/MyTable', 'MyStack/MyBucket']); + expect(argv.PATHS).toEqual(['MyStack/MyTable', 'MyStack/MyBucket']); +}); + +test('cdk orphan accepts positional construct paths with --unstable=orphan', async () => { + const argv = await parseCommandLineArguments(['orphan', '--unstable=orphan', 'MyStack/MyTable', 'MyStack/MyBucket']); + expect(argv.PATHS).toEqual(['MyStack/MyTable', 'MyStack/MyBucket']); + expect(argv.unstable).toEqual(['orphan']); +}); diff --git a/yarn.lock b/yarn.lock index 2e1c2d21f..506c9a6bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13,6 +13,7 @@ __metadata: "@aws-cdk/yarn-cling": "npm:^0.0.0" "@aws-sdk/client-cloudformation": "npm:^3" "@aws-sdk/client-codeartifact": "npm:^3" + "@aws-sdk/client-dynamodb": "npm:^3" "@aws-sdk/client-ecr": "npm:^3" "@aws-sdk/client-ecr-public": "npm:^3" "@aws-sdk/client-ecs": "npm:^3" @@ -1086,6 +1087,56 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-dynamodb@npm:^3": + version: 3.1036.0 + resolution: "@aws-sdk/client-dynamodb@npm:3.1036.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.5" + "@aws-sdk/credential-provider-node": "npm:^3.972.36" + "@aws-sdk/dynamodb-codec": "npm:^3.973.5" + "@aws-sdk/middleware-endpoint-discovery": "npm:^3.972.11" + "@aws-sdk/middleware-host-header": "npm:^3.972.10" + "@aws-sdk/middleware-logger": "npm:^3.972.10" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.11" + "@aws-sdk/middleware-user-agent": "npm:^3.972.35" + "@aws-sdk/region-config-resolver": "npm:^3.972.13" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.10" + "@aws-sdk/util-user-agent-node": "npm:^3.973.21" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/core": "npm:^3.23.17" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/hash-node": "npm:^4.2.14" + "@smithy/invalid-dependency": "npm:^4.2.14" + "@smithy/middleware-content-length": "npm:^4.2.14" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-retry": "npm:^4.5.5" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-body-length-browser": "npm:^4.2.2" + "@smithy/util-body-length-node": "npm:^4.2.3" + "@smithy/util-defaults-mode-browser": "npm:^4.3.49" + "@smithy/util-defaults-mode-node": "npm:^4.2.54" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.4" + "@smithy/util-utf8": "npm:^4.2.2" + "@smithy/util-waiter": "npm:^4.2.16" + tslib: "npm:^2.6.2" + checksum: 10c0/9cbd8198bbaf6edef98728a611178cb867514fba16b7955b4c84359bb39bb9c8e0dc78715f37d738d6a9d32b2644253941a74966d02c71007e912942ccfbcaa7 + languageName: node + linkType: hard + "@aws-sdk/client-ec2@npm:^3": version: 3.1036.0 resolution: "@aws-sdk/client-ec2@npm:3.1036.0" @@ -2271,6 +2322,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/dynamodb-codec@npm:^3.973.5": + version: 3.973.5 + resolution: "@aws-sdk/dynamodb-codec@npm:3.973.5" + dependencies: + "@aws-sdk/core": "npm:^3.974.5" + "@smithy/core": "npm:^3.23.17" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-base64": "npm:^4.3.2" + tslib: "npm:^2.6.2" + checksum: 10c0/26c4a71a8728338c1eca9d8502fa6039c83b35e2afe5bda16f92a8c81d9f9988e75ac87ccc4c412dde93cab3a9e881ecab972b550ee31c363f16ef0fddbc93df + languageName: node + linkType: hard + "@aws-sdk/ec2-metadata-service@npm:^3": version: 3.1036.0 resolution: "@aws-sdk/ec2-metadata-service@npm:3.1036.0" @@ -2286,6 +2350,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/endpoint-cache@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/endpoint-cache@npm:3.972.5" + dependencies: + mnemonist: "npm:0.38.3" + tslib: "npm:^2.6.2" + checksum: 10c0/10707330728ef1f9ca74134ed19a5d93c28d9af4cf785d53ced74a645ae3f6ccc7cb75ffda552dad49ad3ef1aaa27f829431851f530be2c914b61399dd5342b6 + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3": version: 3.1036.0 resolution: "@aws-sdk/lib-storage@npm:3.1036.0" @@ -2319,6 +2393,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-endpoint-discovery@npm:^3.972.11": + version: 3.972.11 + resolution: "@aws-sdk/middleware-endpoint-discovery@npm:3.972.11" + dependencies: + "@aws-sdk/endpoint-cache": "npm:^3.972.5" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10c0/c448ec87dafdba2f25d3e28baf64942779e3f98bb4bb426566dc53accaf1b8e46225a375ffcb877bf6a3849e59f4ea843fd1e181c4e0ae86dc946182fa51a465 + languageName: node + linkType: hard + "@aws-sdk/middleware-expect-continue@npm:^3.972.10": version: 3.972.10 resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.10" @@ -13840,6 +13928,15 @@ __metadata: languageName: node linkType: hard +"mnemonist@npm:0.38.3": + version: 0.38.3 + resolution: "mnemonist@npm:0.38.3" + dependencies: + obliterator: "npm:^1.6.1" + checksum: 10c0/064aa1ee1a89fce2754423b3617c598fd65bc34311eb3c01dc063976f6b819b073bd23532415cf8c92240157b4c8fbb7ec5d79d717f2bd4fcd95d8131cb23acb + languageName: node + linkType: hard + "mock-fs@npm:^5, mock-fs@npm:^5.5.0": version: 5.5.0 resolution: "mock-fs@npm:5.5.0" @@ -14604,6 +14701,13 @@ __metadata: languageName: node linkType: hard +"obliterator@npm:^1.6.1": + version: 1.6.1 + resolution: "obliterator@npm:1.6.1" + checksum: 10c0/5fad57319aae0ef6e34efa640541d41c2dd9790a7ab808f17dcb66c83a81333963fc2dfcfa6e1b62158e5cef6291cdcf15c503ad6c3de54b2227dd4c3d7e1b55 + languageName: node + linkType: hard + "on-finished@npm:~2.3.0": version: 2.3.0 resolution: "on-finished@npm:2.3.0"