diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts index f2428cc42..71300e36b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts @@ -18,3 +18,4 @@ export * from './logs-monitor'; export * from './hotswap'; export * from './gc'; export * from './import'; +export * from './resource-details'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts new file mode 100644 index 000000000..1ec950e24 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts @@ -0,0 +1,111 @@ +/** + * Details about a CloudFormation resource (without stackId, for use in nested output) + */ +export interface ResourceInfo { + /** + * The CloudFormation logical ID + */ + readonly logicalId: string; + + /** + * The CloudFormation resource type (e.g., AWS::Lambda::Function) + */ + readonly type: string; + + /** + * The CDK construct path (from aws:cdk:path metadata) + * Will be '' if metadata is not available + */ + readonly constructPath: string; + + /** + * Resources this resource depends on (from DependsOn) + */ + readonly dependsOn: string[]; + + /** + * Cross-stack imports (from Fn::ImportValue) + */ + readonly imports: string[]; + + /** + * The removal policy if specified + */ + readonly removalPolicy?: 'retain' | 'destroy' | 'snapshot'; +} + +/** + * Resources grouped by stack (for JSON output) + */ +export interface StackResources { + /** + * The stack ID + */ + readonly stackId: string; + + /** + * Resources in this stack + */ + readonly resources: ResourceInfo[]; +} + +/** + * Details about a CloudFormation resource + */ +export interface ResourceDetails { + /** + * The stack containing this resource + */ + readonly stackId: string; + + /** + * The CloudFormation logical ID + */ + readonly logicalId: string; + + /** + * The CloudFormation resource type (e.g., AWS::Lambda::Function) + */ + readonly type: string; + + /** + * The CDK construct path (from aws:cdk:path metadata) + * Will be '' if metadata is not available + */ + readonly constructPath: string; + + /** + * Resources this resource depends on (from DependsOn) + */ + readonly dependsOn: string[]; + + /** + * Cross-stack imports (from Fn::ImportValue) + */ + readonly imports: string[]; + + /** + * The removal policy if specified + */ + readonly removalPolicy?: 'retain' | 'destroy' | 'snapshot'; +} + +/** + * Extended details for --explain output + */ +export interface ResourceExplainDetails extends ResourceDetails { + /** + * The Condition attached to this resource, if any + */ + readonly condition?: string; + + /** + * Update policy if specified + */ + readonly updatePolicy?: string; + + /** + * Creation policy if specified + */ + readonly creationPolicy?: string; +} diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 2c10171aa..45bb4d5a9 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -34,6 +34,7 @@ import type { Deployments, SuccessfulDeployStackResult } from '../api/deployment import { mappingsByEnvironment, parseMappingGroups } from '../api/refactor'; import { type Tag } from '../api/tags'; import { StackActivityProgress } from '../commands/deploy'; +import { listResources, explainResource } from '../commands/list-resources'; import { listStacks } from '../commands/list-stacks'; import type { FromScan, GenerateTemplateOutput } from '../commands/migrate'; import { @@ -1044,6 +1045,143 @@ export class CdkToolkit { return 0; // exit-code } + /** + * List all resources in the specified stack(s) + */ + public async resources( + selectors: string[], + options: { json?: boolean; long?: boolean; all?: boolean; type?: string; explain?: string; ignoreCase?: boolean } = {}, + ): Promise { + const io = this.ioHost.asIoHelper(); + + // Handle --explain mode (requires single stack) + if (options.explain) { + const resource = await explainResource(this, { + selectors, + logicalId: options.explain, + ignoreCase: options.ignoreCase, + }); + + if (!resource) { + throw new ToolkitError(`Resource '${options.explain}' not found`); + } + + await printSerializedObject(io, resource, options.json ?? false); + return 0; + } + + // List all resources (with optional type filter) + const resources = await listResources(this, { selectors, type: options.type, all: options.all, ignoreCase: options.ignoreCase }); + + if (resources.length === 0) { + if (options.type) { + await io.defaults.info(`No resources of type '${options.type}' found`); + } else { + await io.defaults.info('No resources found'); + } + return 0; + } + + if (options.json) { + // Group resources by stack for cleaner JSON output + const byStack = new Map(); + for (const r of resources) { + const list = byStack.get(r.stackId) ?? []; + list.push(r); + byStack.set(r.stackId, list); + } + + const groupedOutput = [...byStack.entries()].map(([stackId, stackResources]) => ({ + stackId, + resources: stackResources.map(({ stackId: _, ...rest }) => rest), + })); + + await printSerializedObject(io, groupedOutput, true); + return 0; + } + + // Group resources by type + const byType = new Map(); + for (const r of resources) { + const list = byType.get(r.type) ?? []; + list.push(r); + byType.set(r.type, list); + } + + // Sort types by count (descending), then alphabetically + const sortedTypes = [...byType.entries()].sort((a, b) => { + const countDiff = b[1].length - a[1].length; + if (countDiff !== 0) return countDiff; + return a[0].localeCompare(b[0]); + }); + + // Show detailed grouped output if --long or --type filter is used + if (options.long || options.type) { + const lines: string[] = []; + + for (const [type, typeResources] of sortedTypes) { + lines.push(''); + lines.push(chalk.bold.cyan(`${type} (${typeResources.length})`)); + + for (const r of typeResources) { + lines.push(` ${chalk.white(r.logicalId.padEnd(40))} ${chalk.gray(r.constructPath)}`); + } + } + + lines.push(''); + lines.push(`${resources.length} resource(s) total`); + + await io.defaults.result(lines.join('\n')); + return 0; + } + + // Default: Summary mode - show counts by type, grouped by stack + const byStack = new Map(); + for (const r of resources) { + const list = byStack.get(r.stackId) ?? []; + list.push(r); + byStack.set(r.stackId, list); + } + + const lines: string[] = []; + + for (const [stackId, stackResources] of byStack) { + if (lines.length > 0) { + lines.push(''); // Blank line between stacks + } + + lines.push(`${chalk.bold(stackId)}: ${stackResources.length} resources`); + lines.push(''); + + // Group by type within this stack + const stackByType = new Map(); + for (const r of stackResources) { + const list = stackByType.get(r.type) ?? []; + list.push(r); + stackByType.set(r.type, list); + } + + // Sort types by count (descending), then alphabetically + const stackSortedTypes = [...stackByType.entries()].sort((a, b) => { + const countDiff = b[1].length - a[1].length; + if (countDiff !== 0) return countDiff; + return a[0].localeCompare(b[0]); + }); + + for (const [type, typeResources] of stackSortedTypes) { + const shortType = type.replace(/^AWS::/, ''); + const dots = '.'.repeat(Math.max(1, 45 - shortType.length)); + lines.push(`${shortType} ${chalk.gray(dots)} ${typeResources.length}`); + } + } + + lines.push(''); + lines.push(chalk.gray(`Use ${chalk.white('--long')} for full list, ${chalk.white('--type ')} to filter, ${chalk.white('--all')} to include permissions`)); + + await io.defaults.result(lines.join('\n')); + return 0; + } + /** * Synthesize the given set of stacks (called when the user runs 'cdk synth') * @@ -2175,3 +2313,4 @@ function requiresApproval(requireApproval: RequireApproval, permissionChangeType return requireApproval === RequireApproval.ANYCHANGE || requireApproval === RequireApproval.BROADENING && permissionChangeType === PermissionChangeType.BROADENING; } + diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 162b6eca5..8c328be9a 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -59,6 +59,44 @@ export async function makeConfig(): Promise { 'show-dependencies': { type: 'boolean', default: false, alias: 'd', desc: 'Display stack dependency information for each stack' }, }, }, + 'resources': { + arg: { + name: 'STACKS', + variadic: true, + }, + description: 'Lists all CloudFormation resources in the specified stack(s)', + options: { + 'long': { + type: 'boolean', + alias: 'l', + default: false, + desc: 'Display full resource list grouped by type (default shows summary counts)', + }, + 'all': { + type: 'boolean', + alias: 'a', + default: false, + desc: 'Include all resources (by default, noisy types like Lambda::Permission are hidden)', + }, + 'type': { + type: 'string', + alias: 't', + desc: 'Filter resources by type (case-insensitive partial match, e.g., "lambda" or "dynamodb")', + requiresArg: true, + }, + 'explain': { + type: 'string', + desc: 'Show detailed information for a specific resource by logical ID', + requiresArg: true, + }, + 'ignore-case': { + type: 'boolean', + alias: 'i', + default: false, + desc: 'Use case-insensitive matching for stack name patterns', + }, + }, + }, 'synth': { arg: { name: 'STACKS', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index db494fc7e..ddb938a33 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -160,6 +160,44 @@ } } }, + "resources": { + "arg": { + "name": "STACKS", + "variadic": true + }, + "description": "Lists all CloudFormation resources in the specified stack(s)", + "options": { + "long": { + "type": "boolean", + "alias": "l", + "default": false, + "desc": "Display full resource list grouped by type (default shows summary counts)" + }, + "all": { + "type": "boolean", + "alias": "a", + "default": false, + "desc": "Include all resources (by default, noisy types like Lambda::Permission are hidden)" + }, + "type": { + "type": "string", + "alias": "t", + "desc": "Filter resources by type (case-insensitive partial match, e.g., \"lambda\" or \"dynamodb\")", + "requiresArg": true + }, + "explain": { + "type": "string", + "desc": "Show detailed information for a specific resource by logical ID", + "requiresArg": true + }, + "ignore-case": { + "type": "boolean", + "alias": "i", + "default": false, + "desc": "Use case-insensitive matching for stack name patterns" + } + } + }, "synth": { "arg": { "name": "STACKS", diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a29f56432..16f76bf09 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -296,6 +296,17 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Display stack dependency information for each stack', }), ) + .command('resources [STACKS..]', 'Lists all CloudFormation resources in the specified stack(s)', (yargs: Argv) => + yargs + .option('long', { + default: false, + type: 'boolean', + alias: 'l', + desc: 'Display full resource list grouped by type (default shows summary counts)', + }) + .option('all', { + default: false, + type: 'boolean', + alias: 'a', + desc: 'Include all resources (by default, noisy types like Lambda::Permission are hidden)', + }) + .option('type', { + default: undefined, + type: 'string', + alias: 't', + desc: 'Filter resources by type (case-insensitive partial match, e.g., "lambda" or "dynamodb")', + requiresArg: true, + }) + .option('explain', { + default: undefined, + type: 'string', + desc: 'Show detailed information for a specific resource by logical ID', + requiresArg: true, + }) + .option('ignore-case', { + default: false, + type: 'boolean', + alias: 'i', + desc: 'Use case-insensitive matching for stack name patterns', + }), + ) .command(['synth [STACKS..]', 'synthesize [STACKS..]'], 'Synthesizes and prints the CloudFormation template for this stack', (yargs: Argv) => yargs .option('exclusively', { diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 733f56a83..295ab80c7 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -15,6 +15,7 @@ const CONTEXT_KEY = 'context'; export enum Command { LS = 'ls', LIST = 'list', + RESOURCES = 'resources', DIFF = 'diff', BOOTSTRAP = 'bootstrap', DEPLOY = 'deploy', diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 735198270..f9f8eaa89 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -28,6 +28,11 @@ export interface UserInput { */ readonly list?: ListOptions; + /** + * Lists all CloudFormation resources in the specified stack(s) + */ + readonly resources?: ResourcesOptions; + /** * Synthesizes and prints the CloudFormation template for this stack * @@ -368,6 +373,61 @@ export interface ListOptions { readonly STACKS?: Array; } +/** + * Lists all CloudFormation resources in the specified stack(s) + * + * @struct + */ +export interface ResourcesOptions { + /** + * Display full resource list grouped by type (default shows summary counts) + * + * aliases: l + * + * @default - false + */ + readonly long?: boolean; + + /** + * Include all resources (by default, noisy types like Lambda::Permission are hidden) + * + * aliases: a + * + * @default - false + */ + readonly all?: boolean; + + /** + * Filter resources by type (case-insensitive partial match, e.g., "lambda" or "dynamodb") + * + * aliases: t + * + * @default - undefined + */ + readonly type?: string; + + /** + * Show detailed information for a specific resource by logical ID + * + * @default - undefined + */ + readonly explain?: string; + + /** + * Use case-insensitive matching for stack name patterns + * + * aliases: i + * + * @default - false + */ + readonly ignoreCase?: boolean; + + /** + * Positional argument for resources + */ + readonly STACKS?: Array; +} + /** * Synthesizes and prints the CloudFormation template for this stack * diff --git a/packages/aws-cdk/lib/commands/list-resources.ts b/packages/aws-cdk/lib/commands/list-resources.ts new file mode 100644 index 000000000..390f7d905 --- /dev/null +++ b/packages/aws-cdk/lib/commands/list-resources.ts @@ -0,0 +1,269 @@ +import type { ResourceDetails, ResourceExplainDetails } from '@aws-cdk/toolkit-lib'; +import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { minimatch } from 'minimatch'; +import type { CdkToolkit } from '../cli/cdk-toolkit'; +import { DefaultSelection, ExtendedStackSelection } from '../cxapp'; + +const PATH_METADATA_KEY = 'aws:cdk:path'; +const RESOURCE_SUFFIX = '/Resource'; + +/** + * Resource types that are hidden by default (noisy/derivative resources) + */ +const HIDDEN_RESOURCE_TYPES = [ + 'AWS::Lambda::Permission', +]; + +/** + * Options for listing resources + */ +export interface ListResourcesOptions { + /** + * Stack selectors (names or patterns) + */ + readonly selectors: string[]; + + /** + * Filter by resource type (e.g., AWS::Lambda::Function) + */ + readonly type?: string; + + /** + * Include all resources (including hidden types like Lambda::Permission) + */ + readonly all?: boolean; + + /** + * Use case-insensitive matching for stack name patterns + */ + readonly ignoreCase?: boolean; +} + +/** + * List all resources in the specified stack(s) + */ +export async function listResources( + toolkit: CdkToolkit, + options: ListResourcesOptions, +): Promise { + const assembly = await toolkit.assembly(); + + // When ignoreCase is true, we get all stacks and filter manually with case-insensitive matching + const useManualFiltering = options.ignoreCase && options.selectors.length > 0; + + const stacks = await assembly.selectStacks( + { patterns: useManualFiltering ? [] : options.selectors }, + { + extend: ExtendedStackSelection.None, + defaultBehavior: DefaultSelection.AllStacks, + }, + ); + + if (stacks.stackCount === 0) { + return []; + } + + // Filter stacks manually when using case-insensitive matching + let stackArtifacts = stacks.stackArtifacts; + if (useManualFiltering) { + stackArtifacts = stackArtifacts.filter(stack => + options.selectors.some(pattern => + minimatch(stack.hierarchicalId, pattern, { nocase: true }), + ), + ); + if (stackArtifacts.length === 0) { + return []; + } + } + + const resources: ResourceDetails[] = []; + + for (const stack of stackArtifacts) { + const template = stack.template; + const templateResources = template.Resources ?? {}; + + for (const [logicalId, resource] of Object.entries(templateResources)) { + const resourceObj = resource as any; + const resourceType = resourceObj.Type ?? ''; + + // Filter by type if specified (case-insensitive partial match) + if (options.type && !resourceType.toLowerCase().includes(options.type.toLowerCase())) { + continue; + } + + // Hide noisy resource types by default (unless --all or explicitly filtering for them) + if (!options.all && !options.type && HIDDEN_RESOURCE_TYPES.includes(resourceType)) { + continue; + } + + // Strip stack name prefix from construct path + const fullPath = resourceObj.Metadata?.[PATH_METADATA_KEY] ?? ''; + const constructPath = stripStackPrefix(fullPath, stack.id); + + resources.push({ + stackId: stack.id, + logicalId, + type: resourceType, + constructPath, + dependsOn: normalizeDependsOn(resourceObj.DependsOn), + imports: extractImportValues(resourceObj), + removalPolicy: mapDeletionPolicy(resourceObj.DeletionPolicy), + }); + } + } + + // Sort by type first, then by logical ID + resources.sort((a, b) => { + const typeCompare = a.type.localeCompare(b.type); + if (typeCompare !== 0) return typeCompare; + return a.logicalId.localeCompare(b.logicalId); + }); + + return resources; +} + +/** + * Get detailed information about a specific resource + * Note: --explain requires a single stack to be selected + */ +export async function explainResource( + toolkit: CdkToolkit, + options: ListResourcesOptions & { logicalId: string }, +): Promise { + const assembly = await toolkit.assembly(); + + // When ignoreCase is true, we get all stacks and filter manually with case-insensitive matching + const useManualFiltering = options.ignoreCase && options.selectors.length > 0; + + const stacks = await assembly.selectStacks( + { patterns: useManualFiltering ? [] : options.selectors }, + { + extend: ExtendedStackSelection.None, + defaultBehavior: useManualFiltering ? DefaultSelection.AllStacks : DefaultSelection.OnlySingle, + }, + ); + + if (stacks.stackCount === 0) { + return undefined; + } + + // Filter stacks manually when using case-insensitive matching + let stack = stacks.firstStack; + if (useManualFiltering) { + const matchingStacks = stacks.stackArtifacts.filter(s => + options.selectors.some(pattern => + minimatch(s.hierarchicalId, pattern, { nocase: true }), + ), + ); + if (matchingStacks.length === 0) { + return undefined; + } + if (matchingStacks.length > 1) { + throw new ToolkitError(`--explain requires exactly one stack, but found ${matchingStacks.length} matching stacks`); + } + stack = matchingStacks[0]; + } + + const template = stack.template; + const resource = template.Resources?.[options.logicalId] as any; + + if (!resource) { + return undefined; + } + + // Strip stack name prefix from construct path + const fullPath = resource.Metadata?.[PATH_METADATA_KEY] ?? ''; + const constructPath = stripStackPrefix(fullPath, stack.id); + + return { + stackId: stack.id, + logicalId: options.logicalId, + type: resource.Type ?? '', + constructPath, + dependsOn: normalizeDependsOn(resource.DependsOn), + imports: extractImportValues(resource), + removalPolicy: mapDeletionPolicy(resource.DeletionPolicy), + condition: resource.Condition, + updatePolicy: resource.UpdatePolicy ? JSON.stringify(resource.UpdatePolicy) : undefined, + creationPolicy: resource.CreationPolicy ? JSON.stringify(resource.CreationPolicy) : undefined, + }; +} + +/** + * Normalize DependsOn to always be an array + */ +function normalizeDependsOn(dependsOn: unknown): string[] { + if (Array.isArray(dependsOn)) return dependsOn; + if (dependsOn) return [dependsOn as string]; + return []; +} + +/** + * Extract Fn::ImportValue references from a resource + */ +function extractImportValues(resource: any): string[] { + const imports: string[] = []; + + function walk(obj: any) { + if (obj === null || obj === undefined) return; + + // Check array first since Array.isArray is more specific than typeof === 'object' + if (Array.isArray(obj)) { + for (const item of obj) { + walk(item); + } + } else if (typeof obj === 'object') { + if ('Fn::ImportValue' in obj) { + const importRef = obj['Fn::ImportValue']; + if (typeof importRef === 'string') { + imports.push(importRef); + } else if (typeof importRef === 'object' && 'Fn::Sub' in importRef) { + imports.push(`\${${importRef['Fn::Sub']}}`); + } + } + + for (const value of Object.values(obj)) { + walk(value); + } + } + } + + walk(resource.Properties); + return imports; +} + +/** + * Map CloudFormation DeletionPolicy to removal policy + */ +function mapDeletionPolicy(policy?: string): 'retain' | 'destroy' | 'snapshot' | undefined { + switch (policy) { + case 'Retain': return 'retain'; + case 'Delete': return 'destroy'; + case 'Snapshot': return 'snapshot'; + default: return undefined; + } +} + +/** + * Strip the stack name prefix and /Resource suffix from a construct path + * e.g., "WebhookDeliveryStack/ReceiverApi/Account" -> "ReceiverApi/Account" + * e.g., "WebhookDeliveryStack/ApiLambda/Resource" -> "ApiLambda" + */ +function stripStackPrefix(path: string, stackId: string): string { + if (path === '') return path; + + let result = path; + + // Strip stack prefix + const prefix = `${stackId}/`; + if (result.startsWith(prefix)) { + result = result.slice(prefix.length); + } + + // Strip /Resource suffix (common CDK L2 pattern) + if (result.endsWith(RESOURCE_SUFFIX)) { + result = result.slice(0, -RESOURCE_SUFFIX.length); + } + + return result; +} diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 45c611918..1c4213527 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -193,6 +193,389 @@ describe('list', () => { }); }); +describe('resources', () => { + test('lists resources in summary mode by default', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyBucket/Resource' }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyFunction/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack']); + + // THEN + expect(result).toEqual(0); + // Summary mode should show stack name and resource count + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'result', + message: expect.stringContaining('ResourceStack'), + })); + }); + + test('lists resources in JSON mode', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyBucket/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack'], { json: true }); + + // THEN + expect(result).toEqual(0); + // JSON mode should output parseable JSON + const resultCalls = notifySpy.mock.calls.filter(([msg]: [any]) => msg.level === 'result'); + expect(resultCalls.length).toBeGreaterThan(0); + const jsonOutput = resultCalls[0][0].message; + const parsed = JSON.parse(jsonOutput); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toHaveProperty('stackId', 'ResourceStack'); + expect(parsed[0].resources[0]).toHaveProperty('logicalId', 'MyBucket'); + expect(parsed[0].resources[0]).toHaveProperty('type', 'AWS::S3::Bucket'); + // stackId should not be on individual resources + expect(parsed[0].resources[0]).not.toHaveProperty('stackId'); + }); + + test('lists resources in long mode', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyBucket/Resource' }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyFunction/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack'], { long: true }); + + // THEN + expect(result).toEqual(0); + // Long mode should show resource count + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'result', + message: expect.stringContaining('resource(s) total'), + })); + }); + + test('explains a specific resource', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyBucket/Resource' }, + DeletionPolicy: 'Retain', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack'], { explain: 'MyBucket' }); + + // THEN + expect(result).toEqual(0); + // Should output the resource details + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'result', + message: expect.stringContaining('MyBucket'), + })); + }); + + test('throws error when explaining non-existent resource', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN/THEN + await expect(toolkit.resources(['ResourceStack'], { explain: 'NonExistent' })) + .rejects.toThrow("Resource 'NonExistent' not found"); + }); + + test('shows info message when no resources found', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'EmptyStack', + template: { Resources: {} }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['EmptyStack']); + + // THEN + expect(result).toEqual(0); + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'info', + message: expect.stringContaining('No resources found'), + })); + }); + + test('shows info message when no resources match type filter', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack'], { type: 'Lambda' }); + + // THEN + expect(result).toEqual(0); + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'info', + message: expect.stringContaining("No resources of type 'Lambda'"), + })); + }); + + test('filters resources by type', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyBucket/Resource' }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyFunction/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN + const result = await toolkit.resources(['ResourceStack'], { type: 'Lambda', json: true }); + + // THEN + expect(result).toEqual(0); + const resultCalls = notifySpy.mock.calls.filter(([msg]: [any]) => msg.level === 'result'); + const jsonOutput = resultCalls[0][0].message; + const parsed = JSON.parse(jsonOutput); + expect(parsed).toHaveLength(1); + expect(parsed[0].resources).toHaveLength(1); + expect(parsed[0].resources[0].type).toBe('AWS::Lambda::Function'); + }); + + test('hides Lambda::Permission by default', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyFunction/Resource' }, + }, + MyPermission: { + Type: 'AWS::Lambda::Permission', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyPermission/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN - without --all + const result = await toolkit.resources(['ResourceStack'], { json: true }); + + // THEN - should only have 1 resource (Lambda::Permission hidden) + expect(result).toEqual(0); + const resultCalls = notifySpy.mock.calls.filter(([msg]: [any]) => msg.level === 'result'); + const jsonOutput = resultCalls[0][0].message; + const parsed = JSON.parse(jsonOutput); + expect(parsed).toHaveLength(1); + expect(parsed[0].resources).toHaveLength(1); + expect(parsed[0].resources[0].type).toBe('AWS::Lambda::Function'); + }); + + test('shows Lambda::Permission with --all flag', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'ResourceStack', + template: { + Resources: { + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyFunction/Resource' }, + }, + MyPermission: { + Type: 'AWS::Lambda::Permission', + Metadata: { 'aws:cdk:path': 'ResourceStack/MyPermission/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + ioHost, + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: instanceMockFrom(Deployments), + }); + + // WHEN - with --all + const result = await toolkit.resources(['ResourceStack'], { json: true, all: true }); + + // THEN - should have both resources + expect(result).toEqual(0); + const resultCalls = notifySpy.mock.calls.filter(([msg]: [any]) => msg.level === 'result'); + const jsonOutput = resultCalls[0][0].message; + const parsed = JSON.parse(jsonOutput); + expect(parsed).toHaveLength(1); + expect(parsed[0].resources).toHaveLength(2); + }); +}); + describe('deploy', () => { test('fails when no valid stack names are given', async () => { // GIVEN diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 3c3269361..5ce8ada56 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -142,6 +142,7 @@ describe('config', () => { doctor: expect.anything(), docs: expect.anything(), refactor: expect.anything(), + resources: expect.anything(), cliTelemetry: expect.anything(), }); }); diff --git a/packages/aws-cdk/test/commands/list-resources.test.ts b/packages/aws-cdk/test/commands/list-resources.test.ts new file mode 100644 index 000000000..ce1bbaad0 --- /dev/null +++ b/packages/aws-cdk/test/commands/list-resources.test.ts @@ -0,0 +1,722 @@ +import { Bootstrapper } from '../../lib/api/bootstrap'; +import { Deployments } from '../../lib/api/deployments'; +import { CdkToolkit } from '../../lib/cli/cdk-toolkit'; +import { listResources, explainResource } from '../../lib/commands/list-resources'; +import { instanceMockFrom, MockCloudExecutable } from '../_helpers'; + +describe('listResources', () => { + let cloudFormation: jest.Mocked; + let bootstrapper: jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + + cloudFormation = instanceMockFrom(Deployments); + + bootstrapper = instanceMockFrom(Bootstrapper); + bootstrapper.bootstrapEnvironment.mockResolvedValue({ noOp: false, outputs: {} } as any); + }); + + test('lists resources from a single stack', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'TestStack/MyBucket/Resource', + }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { + 'aws:cdk:path': 'TestStack/MyFunction/Resource', + }, + DependsOn: ['MyBucket'], + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + expect(resources).toHaveLength(2); + expect(resources).toContainEqual(expect.objectContaining({ + logicalId: 'MyBucket', + type: 'AWS::S3::Bucket', + constructPath: 'MyBucket', + })); + expect(resources).toContainEqual(expect.objectContaining({ + logicalId: 'MyFunction', + type: 'AWS::Lambda::Function', + constructPath: 'MyFunction', + dependsOn: ['MyBucket'], + })); + }); + + test('lists resources from multiple stacks', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [ + { + stackName: 'ApiStack', + template: { + Resources: { + ApiFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'ApiStack/ApiFunction/Resource' }, + }, + ApiTable: { + Type: 'AWS::DynamoDB::Table', + Metadata: { 'aws:cdk:path': 'ApiStack/ApiTable/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }, + { + stackName: 'StorageStack', + template: { + Resources: { + DataBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'StorageStack/DataBucket/Resource' }, + DeletionPolicy: 'Retain', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }, + ], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // Test explicit multi-stack selection + const resources = await listResources(toolkit, { selectors: ['ApiStack', 'StorageStack'] }); + + expect(resources).toHaveLength(3); + expect(resources).toEqual([ + expect.objectContaining({ + stackId: 'ApiStack', + logicalId: 'ApiTable', + type: 'AWS::DynamoDB::Table', + constructPath: 'ApiTable', + }), + expect.objectContaining({ + stackId: 'ApiStack', + logicalId: 'ApiFunction', + type: 'AWS::Lambda::Function', + constructPath: 'ApiFunction', + }), + expect.objectContaining({ + stackId: 'StorageStack', + logicalId: 'DataBucket', + type: 'AWS::S3::Bucket', + constructPath: 'DataBucket', + removalPolicy: 'retain', + }), + ]); + + // Test wildcard matching multiple stacks + const wildcardResources = await listResources(toolkit, { selectors: ['*Stack'] }); + + expect(wildcardResources).toHaveLength(3); + expect(wildcardResources.map(r => r.stackId)).toEqual( + expect.arrayContaining(['ApiStack', 'ApiStack', 'StorageStack']), + ); + + // Test selecting all stacks (empty selector with DefaultSelection.AllStacks) + const allResources = await listResources(toolkit, { selectors: [] }); + + expect(allResources).toHaveLength(3); + expect(allResources.filter(r => r.stackId === 'ApiStack')).toHaveLength(2); + expect(allResources.filter(r => r.stackId === 'StorageStack')).toHaveLength(1); + }); + + test('returns empty array for stack with no resources', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'EmptyStack', + template: { Resources: {} }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['EmptyStack'] }); + + expect(resources).toHaveLength(0); + }); + + test('handles missing metadata gracefully', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + NoMetadata: { + Type: 'AWS::S3::Bucket', + // No Metadata + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + expect(resources).toHaveLength(1); + expect(resources[0].constructPath).toBe(''); + }); + + test('extracts Fn::ImportValue references', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyResource: { + Type: 'AWS::Lambda::Function', + Metadata: { + 'aws:cdk:path': 'TestStack/MyResource/Resource', + }, + Properties: { + Environment: { + Variables: { + BUCKET_ARN: { 'Fn::ImportValue': 'SharedBucketArn' }, + }, + }, + }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + expect(resources).toHaveLength(1); + expect(resources[0].imports).toContain('SharedBucketArn'); + }); + + test('maps DeletionPolicy to removalPolicy', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + RetainedBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'TestStack/RetainedBucket/Resource', + }, + DeletionPolicy: 'Retain', + }, + DeletedBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'TestStack/DeletedBucket/Resource', + }, + DeletionPolicy: 'Delete', + }, + SnapshotBucket: { + Type: 'AWS::RDS::DBInstance', + Metadata: { + 'aws:cdk:path': 'TestStack/SnapshotBucket/Resource', + }, + DeletionPolicy: 'Snapshot', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + const retained = resources.find(r => r.logicalId === 'RetainedBucket'); + const deleted = resources.find(r => r.logicalId === 'DeletedBucket'); + const snapshot = resources.find(r => r.logicalId === 'SnapshotBucket'); + + expect(retained?.removalPolicy).toBe('retain'); + expect(deleted?.removalPolicy).toBe('destroy'); + expect(snapshot?.removalPolicy).toBe('snapshot'); + }); + + test('sorts resources by type and logical ID', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + ZFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/ZFunction/Resource' }, + }, + AFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/AFunction/Resource' }, + }, + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'TestStack/MyBucket/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + // Should be sorted: Lambda::Function (A then Z), then S3::Bucket + expect(resources[0].logicalId).toBe('AFunction'); + expect(resources[1].logicalId).toBe('ZFunction'); + expect(resources[2].logicalId).toBe('MyBucket'); + }); + + test('filters by resource type (case-insensitive)', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/MyFunction/Resource' }, + }, + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'TestStack/MyBucket/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // Filter for lambda (lowercase) + const lambdaResources = await listResources(toolkit, { selectors: ['TestStack'], type: 'lambda' }); + expect(lambdaResources).toHaveLength(1); + expect(lambdaResources[0].type).toBe('AWS::Lambda::Function'); + + // Filter for S3 (uppercase) + const s3Resources = await listResources(toolkit, { selectors: ['TestStack'], type: 'S3' }); + expect(s3Resources).toHaveLength(1); + expect(s3Resources[0].type).toBe('AWS::S3::Bucket'); + }); + + test('matches stack names case-insensitively with ignoreCase option', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'WebhookDeliveryStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'WebhookDeliveryStack/MyBucket/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // Without ignoreCase - lowercase pattern should NOT match + const noMatchResources = await listResources(toolkit, { selectors: ['webhookdeliverystack'] }); + expect(noMatchResources).toHaveLength(0); + + // With ignoreCase - lowercase pattern SHOULD match + const matchResources = await listResources(toolkit, { selectors: ['webhookdeliverystack'], ignoreCase: true }); + expect(matchResources).toHaveLength(1); + expect(matchResources[0].stackId).toBe('WebhookDeliveryStack'); + + // With ignoreCase - wildcard pattern should match case-insensitively + const wildcardResources = await listResources(toolkit, { selectors: ['webhook*'], ignoreCase: true }); + expect(wildcardResources).toHaveLength(1); + expect(wildcardResources[0].stackId).toBe('WebhookDeliveryStack'); + }); + + test('hides Lambda::Permission by default', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/MyFunction/Resource' }, + }, + MyPermission: { + Type: 'AWS::Lambda::Permission', + Metadata: { 'aws:cdk:path': 'TestStack/MyPermission/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // Without --all flag + const defaultResources = await listResources(toolkit, { selectors: ['TestStack'] }); + expect(defaultResources).toHaveLength(1); + expect(defaultResources[0].type).toBe('AWS::Lambda::Function'); + + // With --all flag + const allResources = await listResources(toolkit, { selectors: ['TestStack'], all: true }); + expect(allResources).toHaveLength(2); + }); + + test('shows Lambda::Permission when filtering by type', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { 'aws:cdk:path': 'TestStack/MyFunction/Resource' }, + }, + MyPermission: { + Type: 'AWS::Lambda::Permission', + Metadata: { 'aws:cdk:path': 'TestStack/MyPermission/Resource' }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // When filtering by type, hidden types should be included + const permissionResources = await listResources(toolkit, { selectors: ['TestStack'], type: 'Permission' }); + expect(permissionResources).toHaveLength(1); + expect(permissionResources[0].type).toBe('AWS::Lambda::Permission'); + }); + + test('strips stack name prefix and /Resource suffix from construct paths', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'MyStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'MyStack/Api/Handler/Resource', + }, + }, + MyFunction: { + Type: 'AWS::Lambda::Function', + Metadata: { + 'aws:cdk:path': 'MyStack/Api/Handler', + }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['MyStack'] }); + + const bucket = resources.find(r => r.logicalId === 'MyBucket'); + const fn = resources.find(r => r.logicalId === 'MyFunction'); + + // Should strip 'MyStack/' prefix and '/Resource' suffix + expect(bucket?.constructPath).toBe('Api/Handler'); + // Should only strip 'MyStack/' prefix (no /Resource suffix) + expect(fn?.constructPath).toBe('Api/Handler'); + }); + + test('handles DependsOn as string or array', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + ResourceA: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'TestStack/ResourceA/Resource' }, + }, + ResourceB: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'TestStack/ResourceB/Resource' }, + DependsOn: 'ResourceA', // String form + }, + ResourceC: { + Type: 'AWS::S3::Bucket', + Metadata: { 'aws:cdk:path': 'TestStack/ResourceC/Resource' }, + DependsOn: ['ResourceA', 'ResourceB'], // Array form + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); + + const resourceA = resources.find(r => r.logicalId === 'ResourceA'); + const resourceB = resources.find(r => r.logicalId === 'ResourceB'); + const resourceC = resources.find(r => r.logicalId === 'ResourceC'); + + expect(resourceA?.dependsOn).toEqual([]); + expect(resourceB?.dependsOn).toEqual(['ResourceA']); + expect(resourceC?.dependsOn).toEqual(['ResourceA', 'ResourceB']); + }); +}); + +describe('explainResource', () => { + let cloudFormation: jest.Mocked; + let bootstrapper: jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + + cloudFormation = instanceMockFrom(Deployments); + + bootstrapper = instanceMockFrom(Bootstrapper); + bootstrapper.bootstrapEnvironment.mockResolvedValue({ noOp: false, outputs: {} } as any); + }); + + test('returns detailed info for existing resource', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'TestStack/MyBucket/Resource', + }, + Condition: 'CreateBucket', + DeletionPolicy: 'Retain', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resource = await explainResource(toolkit, { + selectors: ['TestStack'], + logicalId: 'MyBucket', + }); + + expect(resource).toBeDefined(); + expect(resource?.logicalId).toBe('MyBucket'); + expect(resource?.type).toBe('AWS::S3::Bucket'); + expect(resource?.condition).toBe('CreateBucket'); + expect(resource?.removalPolicy).toBe('retain'); + }); + + test('returns undefined for non-existent resource', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { Resources: {} }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resource = await explainResource(toolkit, { + selectors: ['TestStack'], + logicalId: 'NonExistent', + }); + + expect(resource).toBeUndefined(); + }); + + test('includes update and creation policies', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyASG: { + Type: 'AWS::AutoScaling::AutoScalingGroup', + Metadata: { + 'aws:cdk:path': 'TestStack/MyASG/Resource', + }, + UpdatePolicy: { + AutoScalingRollingUpdate: { + MinInstancesInService: 1, + }, + }, + CreationPolicy: { + ResourceSignal: { + Count: 1, + Timeout: 'PT5M', + }, + }, + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + const resource = await explainResource(toolkit, { + selectors: ['TestStack'], + logicalId: 'MyASG', + }); + + expect(resource).toBeDefined(); + expect(resource?.updatePolicy).toBeDefined(); + expect(resource?.creationPolicy).toBeDefined(); + expect(JSON.parse(resource!.updatePolicy!)).toEqual({ + AutoScalingRollingUpdate: { MinInstancesInService: 1 }, + }); + expect(JSON.parse(resource!.creationPolicy!)).toEqual({ + ResourceSignal: { Count: 1, Timeout: 'PT5M' }, + }); + }); + + test('returns undefined for non-matching stack selector', async () => { + const cloudExecutable = await MockCloudExecutable.create({ + stacks: [{ + stackName: 'TestStack', + template: { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + env: 'aws://123456789012/us-east-1', + }], + }); + + const toolkit = new CdkToolkit({ + cloudExecutable, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + deployments: cloudFormation, + }); + + // When no stacks match the selector, explainResource returns undefined + // (The CdkToolkit.resources() method handles throwing the appropriate error) + const resource = await explainResource(toolkit, { + selectors: ['NonExistentStack'], + logicalId: 'MyBucket', + }); + + expect(resource).toBeUndefined(); + }); +});