diff --git a/.projenrc.ts b/.projenrc.ts index bcd26b5d8..94512cef0 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1551,6 +1551,7 @@ const cliInteg = configureProject( '@octokit/rest@^20', // newer versions are ESM only '@aws-sdk/client-codeartifact', '@aws-sdk/client-cloudformation', + '@aws-sdk/client-dynamodb', '@aws-sdk/client-ecr', '@aws-sdk/client-ecr-public', '@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 16d6ea1c9..1531019ee 100644 --- a/packages/@aws-cdk-testing/cli-integ/.projen/deps.json +++ b/packages/@aws-cdk-testing/cli-integ/.projen/deps.json @@ -137,6 +137,10 @@ "name": "@aws-sdk/client-codeartifact", "type": "runtime" }, + { + "name": "@aws-sdk/client-dynamodb", + "type": "runtime" + }, { "name": "@aws-sdk/client-ecr", "type": "runtime" diff --git a/packages/@aws-cdk-testing/cli-integ/.projen/tasks.json b/packages/@aws-cdk-testing/cli-integ/.projen/tasks.json index 441a6382e..441bd209d 100644 --- a/packages/@aws-cdk-testing/cli-integ/.projen/tasks.json +++ b/packages/@aws-cdk-testing/cli-integ/.projen/tasks.json @@ -55,7 +55,7 @@ }, "steps": [ { - "exec": "yarn dlx npm-check-updates@20 --upgrade --target=minor --cooldown=3 --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,license-checker,nx,projen,ts-jest,@aws-sdk/client-cloudformation,@aws-sdk/client-codeartifact,@aws-sdk/client-ecr,@aws-sdk/client-ecr-public,@aws-sdk/client-ecs,@aws-sdk/client-iam,@aws-sdk/client-lambda,@aws-sdk/client-s3,@aws-sdk/client-secrets-manager,@aws-sdk/client-sns,@aws-sdk/client-sso,@aws-sdk/client-sts,@aws-sdk/credential-providers,@cdklabs/cdk-atmosphere-client,@smithy/types,@smithy/util-retry,fast-glob,node-pty,proxy-agent" + "exec": "yarn dlx npm-check-updates@20 --upgrade --target=minor --cooldown=3 --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,license-checker,nx,projen,ts-jest,@aws-sdk/client-cloudformation,@aws-sdk/client-codeartifact,@aws-sdk/client-dynamodb,@aws-sdk/client-ecr,@aws-sdk/client-ecr-public,@aws-sdk/client-ecs,@aws-sdk/client-iam,@aws-sdk/client-lambda,@aws-sdk/client-s3,@aws-sdk/client-secrets-manager,@aws-sdk/client-sns,@aws-sdk/client-sso,@aws-sdk/client-sts,@aws-sdk/credential-providers,@cdklabs/cdk-atmosphere-client,@smithy/types,@smithy/util-retry,fast-glob,node-pty,proxy-agent" } ] }, diff --git a/packages/@aws-cdk-testing/cli-integ/package.json b/packages/@aws-cdk-testing/cli-integ/package.json index c2cdaa6a2..554b50376 100644 --- a/packages/@aws-cdk-testing/cli-integ/package.json +++ b/packages/@aws-cdk-testing/cli-integ/package.json @@ -64,28 +64,28 @@ "license-checker": "^25.0.1", "nx": "^22.6.4", "prettier": "^2.8", - "projen": "^0.99.48", "ts-jest": "^29.2.5", "typescript": "5.9" }, "dependencies": { - "@aws-sdk/client-cloudformation": "^3.1028.0", - "@aws-sdk/client-codeartifact": "^3.1029.0", - "@aws-sdk/client-ecr": "^3.1028.0", - "@aws-sdk/client-ecr-public": "^3.1028.0", - "@aws-sdk/client-ecs": "^3.1028.0", - "@aws-sdk/client-iam": "^3.1028.0", - "@aws-sdk/client-lambda": "^3.1028.0", - "@aws-sdk/client-s3": "^3.1028.0", - "@aws-sdk/client-secrets-manager": "^3.1028.0", - "@aws-sdk/client-sns": "^3.1028.0", - "@aws-sdk/client-sso": "^3.1028.0", - "@aws-sdk/client-sts": "^3.1029.0", - "@aws-sdk/credential-providers": "^3.1029.0", + "@aws-sdk/client-cloudformation": "^3.1025.0", + "@aws-sdk/client-codeartifact": "^3.1025.0", + "@aws-sdk/client-dynamodb": "^3.1029.0", + "@aws-sdk/client-ecr": "^3.1025.0", + "@aws-sdk/client-ecr-public": "^3.1025.0", + "@aws-sdk/client-ecs": "^3.1025.0", + "@aws-sdk/client-iam": "^3.1025.0", + "@aws-sdk/client-lambda": "^3.1025.0", + "@aws-sdk/client-s3": "^3.1025.0", + "@aws-sdk/client-secrets-manager": "^3.1025.0", + "@aws-sdk/client-sns": "^3.1025.0", + "@aws-sdk/client-sso": "^3.1025.0", + "@aws-sdk/client-sts": "^3.1025.0", + "@aws-sdk/credential-providers": "^3.1025.0", "@cdklabs/cdk-atmosphere-client": "^0.0.96", "@octokit/rest": "^20", "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.1", + "@smithy/util-retry": "^4.3.0", "chalk": "^4", "fast-glob": "^3.3.3", "fs-extra": "^11", 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 0b0465603..f06f1c1e6 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..d565189b2 --- /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 { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb'; +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(); + + const dynamodb = new DynamoDBClient({ region: fixture.aws.region }); + + 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 dynamodb.send(new PutItemCommand({ + TableName: tableName!, + Item: { PK: { S: 'before-orphan' } }, + })); + + // Orphan the table + const orphanOutput = await fixture.cdk([ + 'orphan', + '--path', `${stackName}/MyTable`, + '--unstable=orphan', + '--force', + ]); + + // 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 dynamodb.send(new GetItemCommand({ + 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 + await dynamodb.send(new DeleteTableCommand({ TableName: tableName! })).catch(() => { + }); + } + }), +); 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..7b07b628e --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/index.ts @@ -0,0 +1,26 @@ +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; + + /** + * Whether to execute without prompting for confirmation. + * + * @default false + */ + readonly force?: boolean; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/orphaner.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/orphaner.ts new file mode 100644 index 000000000..81860d80a --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/orphaner.ts @@ -0,0 +1,335 @@ +import { PATH_METADATA_KEY } from '@aws-cdk/cloud-assembly-api'; +import type * as cxapi from '@aws-cdk/cloud-assembly-api'; +import { + findResourcesByPath, + hasAnyCdkPathMetadata, + replaceReferences, + removeDependsOn, + walkObject, + assertDeploySucceeded, + ensureNonEmptyResources, +} from './private'; +import type { ICloudFormationClient } from '../../api/aws-auth/sdk'; +import type { Deployments } from '../../api/deployments'; +import type { IoHelper } from '../../api/io/private'; +import { ToolkitError } from '../../toolkit/toolkit-error'; + +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 ?? {}; + + const logicalIds: string[] = []; + for (const p of constructPaths) { + logicalIds.push(...findResourcesByPath(resources, stack.stackName, p)); + } + + if (logicalIds.length === 0) { + const hint = !hasAnyCdkPathMetadata(resources) + ? ' (no resources in this stack have aws:cdk:path metadata — was it disabled?)' + : ''; + throw new ToolkitError('OrphanNoResources', `No resources found under construct path '${constructPaths.join(', ')}' in stack '${stack.stackName}'${hint}`); + } + + const orphanedResources: OrphanedResource[] = logicalIds.map(id => ({ + logicalId: id, + resourceType: resources[id].Type ?? 'Unknown', + cdkPath: resources[id].Metadata?.[PATH_METADATA_KEY] ?? id, + })); + + 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/actions/orphan/private/helpers.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/private/helpers.ts new file mode 100644 index 000000000..76312a211 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/private/helpers.ts @@ -0,0 +1,190 @@ +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 type { DeployStackResult, SuccessfulDeployStackResult } from '../../../api/deployments/deployment-result'; +import { ToolkitError } from '../../../toolkit/toolkit-error'; + +/** + * 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', + }, + }; + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/private/index.ts new file mode 100644 index 000000000..c5f595cf9 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/orphan/private/index.ts @@ -0,0 +1 @@ +export * from './helpers'; 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/resource-import/importer.ts b/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts index 98d03f65a..ec31fc22a 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 @@ -141,25 +141,35 @@ 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 pre-parsed mapping + */ + public async loadResourceIdentifiers( + available: ImportableResource[], + identifiers: Record, + ): Promise { + const remaining = { ...identifiers }; 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 dc9427c70..d769df1e9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -50,6 +50,8 @@ 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 { ResourceOrphaner } from '../actions/orphan/orphaner'; import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publish-assets'; import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; @@ -169,7 +171,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 @@ -1148,6 +1150,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 = parseConstructPaths(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 + await ioHelper.defaults.info(`Stack: ${plan.stackName}`); + await ioHelper.defaults.info(`Resources to orphan (${plan.orphanedResources.length}):`); + for (const r of plan.orphanedResources) { + await ioHelper.defaults.info(` ${r.logicalId} (${r.resourceType}) — ${r.cdkPath}`); + } + + // Confirm unless --force + if (!options.force) { + 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 + await ioHelper.defaults.info(`✅ Resources orphaned from ${plan.stackName}`); + await ioHelper.defaults.info(''); + await ioHelper.defaults.info('Next steps:'); + await ioHelper.defaults.info(' 1. Update your CDK code to use the new resource type'); + if (Object.keys(result.resourceMapping).length > 0) { + const mappingJson = JSON.stringify(result.resourceMapping); + await ioHelper.defaults.info(` 2. cdk import --resource-mapping-inline '${mappingJson}'`); + } else { + await ioHelper.defaults.info(' 2. cdk import'); + } + } + /** * Refactor Action. Moves resources from one location (stack + logical ID) to another. */ @@ -1579,3 +1647,38 @@ async function synthAndMeasure( function zeroTime(): ElapsedTime { return { asMs: 0, asSec: 0 }; } + +/** + * 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. + */ +function parseConstructPaths(paths: string[]): { stackId: string; constructPaths: string[] } { + if (paths.length === 0) { + throw new ToolkitError('MissingConstructPath', 'At least one construct path is required (e.g. --path 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 be in the format StackId/ConstructPath (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/test/api/orphan/orphan.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/orphan/orphan.test.ts new file mode 100644 index 000000000..f6436f7c2 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/orphan/orphan.test.ts @@ -0,0 +1,388 @@ +import { + DescribeStacksCommand, + GetTemplateCommand, + GetTemplateSummaryCommand, + ListStackResourcesCommand, + StackStatus, +} from '@aws-sdk/client-cloudformation'; +import { ResourceOrphaner } from '../../../lib/actions/orphan/orphaner'; +import { Deployments } from '../../../lib/api/deployments'; +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, +}); + +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('hints about disabled metadata when no metadata exists', async () => { + mockCloudFormationClient.on(GetTemplateCommand).resolves({ + TemplateBody: JSON.stringify({ + Resources: { + SomeResource: { Type: 'AWS::SNS::Topic', Properties: {} }, + }, + }), + }); + + await expect(orphaner.makePlan(STACK, ['MyTable'])) + .rejects.toThrow(/metadata.*disabled/); + }); + + 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 { replaceInObject } = require('../../../lib/actions/orphan/private/helpers'); + 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..778781f4c 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,71 @@ 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. + +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 any cross-resource references (`Ref`, `Fn::GetAtt`) to the orphaned resources and replacing them with their current physical values +2. Setting the `DeletionPolicy` to `Retain` and decoupling the resources from 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. + +```console +$ # Orphan a single resource +$ cdk orphan --unstable=orphan --path MyStack/MyTable + +$ # Orphan multiple resources +$ cdk orphan --unstable=orphan --path MyStack/MyTable --path 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 --path 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/bootstrap/bootstrap-template.yaml b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml index 0f777ebe8..d6e481eff 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml @@ -681,7 +681,8 @@ Resources: # Our CI/CD does not need DeleteStack, # but we also want to use this role from the CLI, # and there you can call `cdk destroy` - - cloudformation:DescribeStackEvents + - cloudformation:List* + - cloudformation:Describe* - cloudformation:GetTemplate - cloudformation:DeleteStack - cloudformation:UpdateTerminationProtection @@ -817,7 +818,7 @@ Resources: Name: Fn::Sub: '/cdk-bootstrap/${Qualifier}/version' # Also update this value below (see comment there) - Value: '31' + Value: '32' Outputs: BucketName: Description: The name of the S3 bucket owned by the CDK toolkit stack @@ -852,4 +853,4 @@ Outputs: # {Fn::GetAtt} on an SSM Parameter is eventually consistent, and can fail with "parameter # doesn't exist" even after just having been created. To reduce our deploy failure rate, we # duplicate the value here and use a build-time test to ensure the two values are the same. - Value: '31' + Value: '32' diff --git a/packages/aws-cdk/lib/api/index.ts b/packages/aws-cdk/lib/api/index.ts index 7b6596777..8471a1d40 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/actions/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 f1656556d..a7b5bfa83 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'], }); } @@ -969,6 +969,15 @@ export class CdkToolkit { }); } + public async orphan(options: OrphanOptions) { + await this.toolkit.orphan(this.props.cloudExecutable, { + constructPaths: options.constructPath, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + force: options.force, + }); + } + public async import(options: ImportOptions) { const stacks = await this.selectStacksForDeploy(options.selector, true, true, false); @@ -984,8 +993,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]; @@ -1006,9 +1015,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, JSON.parse(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.'); @@ -1926,6 +1940,13 @@ export interface RollbackOptions { readonly validateBootstrapStackVersion?: boolean; } +export interface OrphanOptions { + readonly constructPath: string[]; + readonly roleArn?: string; + readonly toolkitStackName?: string; + readonly force?: boolean; +} + export interface ImportOptions extends CfnDeployOptions { /** * Build a physical resource mapping and write it to the given file, without performing the actual import operation @@ -1942,6 +1963,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..fa652c699 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,21 @@ export async function makeConfig(): Promise { 'doctor': { description: 'Check your set-up for potential problems', }, + 'orphan': { + description: 'Detach resources from a CloudFormation stack without deleting them', + options: { + path: { + type: 'array', + requiresArg: true, + desc: 'Construct path(s) to orphan, e.g. MyStack/MyTable', + }, + force: { + type: 'boolean', + default: false, + desc: 'Do not ask for confirmation before orphaning', + }, + }, + }, '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..a42ae1ce9 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,21 @@ "doctor": { "description": "Check your set-up for potential problems" }, + "orphan": { + "description": "Detach resources from a CloudFormation stack without deleting them", + "options": { + "path": { + "type": "array", + "requiresArg": true, + "desc": "Construct path(s) to orphan, e.g. MyStack/MyTable" + }, + "force": { + "type": "boolean", + "default": false, + "desc": "Do not ask for confirmation before orphaning" + } + } + }, "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..039d41cee 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -452,6 +452,19 @@ 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,20 @@ export function parseCommandLineArguments(args: Array): any { }), ) .command('doctor', 'Check your set-up for potential problems') + .command('orphan', 'Detach resources from a CloudFormation stack without deleting them', (yargs: Argv) => + yargs + .option('path', { + type: 'array', + requiresArg: true, + desc: 'Construct path(s) to orphan, e.g. MyStack/MyTable', + nargs: 1, + }) + .option('force', { + default: false, + type: 'boolean', + desc: 'Do not ask for confirmation before orphaning', + }), + ) .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..14b91f09d 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,27 @@ export interface DocsOptions { readonly browser?: string; } +/** + * Detach resources from a CloudFormation stack without deleting them + * + * @struct + */ +export interface OrphanOptions { + /** + * Construct path(s) to orphan, e.g. MyStack/MyTable + * + * @default - undefined + */ + readonly path?: Array; + + /** + * Do not ask for confirmation before orphaning + * + * @default - false + */ + readonly force?: boolean; +} + /** * 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(), });