From e79ecb91aa634078724d114fdbc14a82d00fc36b Mon Sep 17 00:00:00 2001 From: lousydropout Date: Tue, 25 Nov 2025 00:18:12 -0600 Subject: [PATCH 1/5] feat(cli): add cdk resources command to list stack resources Add a new CLI command `cdk resources ` that lists all CloudFormation resources synthesized by CDK for a given stack. Features: - Summary view (default): resource counts by type - Long mode (--long): full list grouped by type - Type filter (--type): case-insensitive partial match - Hidden resources (--all): show Lambda::Permission - JSON output (--json): for scripting - Explain mode (--explain): detailed info for specific resource Includes comprehensive unit tests for listResources, explainResource, and CdkToolkit.resources() integration. --- .../toolkit-lib/lib/payloads/index.ts | 1 + .../lib/payloads/resource-details.ts | 60 ++ packages/aws-cdk/lib/cli/cdk-toolkit.ts | 96 +++ packages/aws-cdk/lib/cli/cli-config.ts | 32 + .../aws-cdk/lib/cli/cli-type-registry.json | 32 + packages/aws-cdk/lib/cli/cli.ts | 10 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 17 + .../aws-cdk/lib/cli/io-host/cli-io-host.ts | 1 + .../lib/cli/parse-command-line-arguments.ts | 28 + .../aws-cdk/lib/cli/user-configuration.ts | 1 + packages/aws-cdk/lib/cli/user-input.ts | 51 ++ .../aws-cdk/lib/commands/list-resources.ts | 223 +++++++ packages/aws-cdk/test/cli/cdk-toolkit.test.ts | 377 +++++++++++ .../aws-cdk/test/cli/cli-arguments.test.ts | 1 + .../test/commands/list-resources.test.ts | 600 ++++++++++++++++++ 15 files changed, 1530 insertions(+) create mode 100644 packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts create mode 100644 packages/aws-cdk/lib/commands/list-resources.ts create mode 100644 packages/aws-cdk/test/commands/list-resources.test.ts 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..0051f6207 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts @@ -0,0 +1,60 @@ +/** + * 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..781593942 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -35,6 +35,7 @@ import { mappingsByEnvironment, parseMappingGroups } from '../api/refactor'; import { type Tag } from '../api/tags'; import { StackActivityProgress } from '../commands/deploy'; import { listStacks } from '../commands/list-stacks'; +import { listResources, explainResource } from '../commands/list-resources'; import type { FromScan, GenerateTemplateOutput } from '../commands/migrate'; import { appendWarningsToReadme, @@ -1044,6 +1045,100 @@ export class CdkToolkit { return 0; // exit-code } + /** + * List all resources in a stack + */ + public async resources( + selector: string, + options: { json?: boolean; long?: boolean; all?: boolean; type?: string; explain?: string } = {}, + ): Promise { + const io = this.ioHost.asIoHelper(); + + // Handle --explain mode + if (options.explain) { + const resource = await explainResource(this, { + selector, + logicalId: options.explain, + }); + + if (!resource) { + throw new ToolkitError(`Resource '${options.explain}' not found in stack '${selector}'`); + } + + await printSerializedObject(io, resource, options.json ?? false); + return 0; + } + + // List all resources (with optional type filter) + const resources = await listResources(this, { selector, type: options.type, all: options.all }); + + if (resources.length === 0) { + if (options.type) { + await io.defaults.info(`No resources of type '${options.type}' found in stack`); + } else { + await io.defaults.info('No resources found in stack'); + } + return 0; + } + + if (options.json) { + await printSerializedObject(io, resources, 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 - just show counts by type + const lines: string[] = []; + lines.push(`${chalk.bold(selector)}: ${resources.length} resources`); + lines.push(''); + + for (const [type, typeResources] of sortedTypes) { + 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 +2270,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..6a91c6d99 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -59,6 +59,38 @@ export async function makeConfig(): Promise { 'show-dependencies': { type: 'boolean', default: false, alias: 'd', desc: 'Display stack dependency information for each stack' }, }, }, + 'resources': { + arg: { + name: 'STACK', + variadic: false, + }, + description: 'Lists all CloudFormation resources in a stack', + 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, + }, + }, + }, '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..4a608dbe1 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -160,6 +160,38 @@ } } }, + "resources": { + "arg": { + "name": "STACK", + "variadic": false + }, + "description": "Lists all CloudFormation resources in a stack", + "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 + } + } + }, "synth": { "arg": { "name": "STACKS", diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a29f56432..54e970b52 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -296,6 +296,16 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Display stack dependency information for each stack', }), ) + .command('resources [STACK]', 'Lists all CloudFormation resources in a stack', (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, + }), + ) .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..e4afb4157 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 a stack + */ + readonly resources?: ResourcesOptions; + /** * Synthesizes and prints the CloudFormation template for this stack * @@ -368,6 +373,52 @@ export interface ListOptions { readonly STACKS?: Array; } +/** + * Lists all CloudFormation resources in a stack + * + * @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; + + /** + * Positional argument for resources + */ + readonly STACK?: string; +} + /** * 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..0b5c3bd92 --- /dev/null +++ b/packages/aws-cdk/lib/commands/list-resources.ts @@ -0,0 +1,223 @@ +import type { ResourceDetails, ResourceExplainDetails } from '@aws-cdk/toolkit-lib'; +import type { CdkToolkit } from '../cli/cdk-toolkit'; +import { DefaultSelection, ExtendedStackSelection } from '../cxapp'; + +const PATH_METADATA_KEY = 'aws:cdk:path'; + +/** + * 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 selector (name or pattern) + */ + readonly selector: 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; +} + +/** + * List all resources in a stack + */ +export async function listResources( + toolkit: CdkToolkit, + options: ListResourcesOptions, +): Promise { + const assembly = await toolkit.assembly(); + + const stacks = await assembly.selectStacks( + { patterns: [options.selector] }, + { + extend: ExtendedStackSelection.None, + defaultBehavior: DefaultSelection.OnlySingle, + }, + ); + + if (stacks.stackCount === 0) { + return []; + } + + const resources: ResourceDetails[] = []; + + for (const stack of stacks.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: Array.isArray(resourceObj.DependsOn) + ? resourceObj.DependsOn + : resourceObj.DependsOn + ? [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 + */ +export async function explainResource( + toolkit: CdkToolkit, + options: ListResourcesOptions & { logicalId: string }, +): Promise { + const assembly = await toolkit.assembly(); + + const stacks = await assembly.selectStacks( + { patterns: [options.selector] }, + { + extend: ExtendedStackSelection.None, + defaultBehavior: DefaultSelection.OnlySingle, + }, + ); + + if (stacks.stackCount === 0) { + return undefined; + } + + const stack = stacks.firstStack; + 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: Array.isArray(resource.DependsOn) + ? resource.DependsOn + : resource.DependsOn + ? [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, + }; +} + +/** + * 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; + + 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); + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + walk(item); + } + } + } + + 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')) { + result = result.slice(0, -9); + } + + 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..d8b3fa295 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -193,6 +193,383 @@ 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('logicalId', 'MyBucket'); + expect(parsed[0]).toHaveProperty('type', 'AWS::S3::Bucket'); + }); + + 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].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].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(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..cd1616169 --- /dev/null +++ b/packages/aws-cdk/test/commands/list-resources.test.ts @@ -0,0 +1,600 @@ +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, { selector: '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('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, { selector: '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, { selector: '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, { selector: '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, { selector: '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, { selector: '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, { selector: 'TestStack', type: 'lambda' }); + expect(lambdaResources).toHaveLength(1); + expect(lambdaResources[0].type).toBe('AWS::Lambda::Function'); + + // Filter for S3 (uppercase) + const s3Resources = await listResources(toolkit, { selector: 'TestStack', type: 'S3' }); + expect(s3Resources).toHaveLength(1); + expect(s3Resources[0].type).toBe('AWS::S3::Bucket'); + }); + + 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, { selector: 'TestStack' }); + expect(defaultResources).toHaveLength(1); + expect(defaultResources[0].type).toBe('AWS::Lambda::Function'); + + // With --all flag + const allResources = await listResources(toolkit, { selector: '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, { selector: '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, { selector: '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, { selector: '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, { + selector: '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, { + selector: '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, { + selector: '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, { + selector: 'NonExistentStack', + logicalId: 'MyBucket', + }); + + expect(resource).toBeUndefined(); + }); +}); From fc186f48a940e11a1ae5ed628f99968281b80ef2 Mon Sep 17 00:00:00 2001 From: lousydropout Date: Wed, 26 Nov 2025 14:00:32 -0600 Subject: [PATCH 2/5] refactor(cli): group cdk resources JSON output by stack Change JSON output format from flat array with repeated stackId to nested structure: [{stackId, resources: [...]}] This reduces redundancy and provides cleaner output for scripting. --- .../lib/payloads/resource-details.ts | 51 +++++++++++++++++++ packages/aws-cdk/lib/cli/cdk-toolkit.ts | 15 +++++- packages/aws-cdk/test/cli/cdk-toolkit.test.ts | 16 ++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts index 0051f6207..1ec950e24 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/resource-details.ts @@ -1,3 +1,54 @@ +/** + * 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 */ diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 781593942..da4129ad1 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1082,7 +1082,20 @@ export class CdkToolkit { } if (options.json) { - await printSerializedObject(io, resources, true); + // 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; } diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index d8b3fa295..6f773e048 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -271,8 +271,11 @@ describe('resources', () => { const jsonOutput = resultCalls[0][0].message; const parsed = JSON.parse(jsonOutput); expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0]).toHaveProperty('logicalId', 'MyBucket'); - expect(parsed[0]).toHaveProperty('type', 'AWS::S3::Bucket'); + 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 () => { @@ -485,7 +488,8 @@ describe('resources', () => { const jsonOutput = resultCalls[0][0].message; const parsed = JSON.parse(jsonOutput); expect(parsed).toHaveLength(1); - expect(parsed[0].type).toBe('AWS::Lambda::Function'); + expect(parsed[0].resources).toHaveLength(1); + expect(parsed[0].resources[0].type).toBe('AWS::Lambda::Function'); }); test('hides Lambda::Permission by default', async () => { @@ -526,7 +530,8 @@ describe('resources', () => { const jsonOutput = resultCalls[0][0].message; const parsed = JSON.parse(jsonOutput); expect(parsed).toHaveLength(1); - expect(parsed[0].type).toBe('AWS::Lambda::Function'); + expect(parsed[0].resources).toHaveLength(1); + expect(parsed[0].resources[0].type).toBe('AWS::Lambda::Function'); }); test('shows Lambda::Permission with --all flag', async () => { @@ -566,7 +571,8 @@ describe('resources', () => { 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(2); + expect(parsed).toHaveLength(1); + expect(parsed[0].resources).toHaveLength(2); }); }); From b89c83acefa762b22297da1b36f804f33145d3c7 Mon Sep 17 00:00:00 2001 From: lousydropout Date: Wed, 26 Nov 2025 14:53:21 -0600 Subject: [PATCH 3/5] feat(cli): add multi-stack support to cdk resources command - Change STACK positional to variadic STACKS for multiple stack selection - Fix "undefined" stack name in summary output - Use DefaultSelection.AllStacks when no stacks specified --- packages/aws-cdk/lib/cli/cdk-toolkit.ts | 59 ++++++--- packages/aws-cdk/lib/cli/cli-config.ts | 6 +- .../aws-cdk/lib/cli/cli-type-registry.json | 6 +- packages/aws-cdk/lib/cli/cli.ts | 2 +- .../aws-cdk/lib/cli/convert-to-user-input.ts | 2 +- .../lib/cli/parse-command-line-arguments.ts | 2 +- packages/aws-cdk/lib/cli/user-input.ts | 6 +- .../aws-cdk/lib/commands/list-resources.ts | 13 +- packages/aws-cdk/test/cli/cdk-toolkit.test.ts | 20 +-- .../test/commands/list-resources.test.ts | 118 +++++++++++++++--- 10 files changed, 174 insertions(+), 60 deletions(-) diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index da4129ad1..55cc8bbb3 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1046,23 +1046,23 @@ export class CdkToolkit { } /** - * List all resources in a stack + * List all resources in the specified stack(s) */ public async resources( - selector: string, + selectors: string[], options: { json?: boolean; long?: boolean; all?: boolean; type?: string; explain?: string } = {}, ): Promise { const io = this.ioHost.asIoHelper(); - // Handle --explain mode + // Handle --explain mode (requires single stack) if (options.explain) { const resource = await explainResource(this, { - selector, + selectors, logicalId: options.explain, }); if (!resource) { - throw new ToolkitError(`Resource '${options.explain}' not found in stack '${selector}'`); + throw new ToolkitError(`Resource '${options.explain}' not found`); } await printSerializedObject(io, resource, options.json ?? false); @@ -1070,13 +1070,13 @@ export class CdkToolkit { } // List all resources (with optional type filter) - const resources = await listResources(this, { selector, type: options.type, all: options.all }); + const resources = await listResources(this, { selectors, type: options.type, all: options.all }); if (resources.length === 0) { if (options.type) { - await io.defaults.info(`No resources of type '${options.type}' found in stack`); + await io.defaults.info(`No resources of type '${options.type}' found`); } else { - await io.defaults.info('No resources found in stack'); + await io.defaults.info('No resources found'); } return 0; } @@ -1134,15 +1134,44 @@ export class CdkToolkit { return 0; } - // Default: Summary mode - just show counts by type + // 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[] = []; - lines.push(`${chalk.bold(selector)}: ${resources.length} resources`); - lines.push(''); - for (const [type, typeResources] of sortedTypes) { - const shortType = type.replace(/^AWS::/, ''); - const dots = '.'.repeat(Math.max(1, 45 - shortType.length)); - lines.push(`${shortType} ${chalk.gray(dots)} ${typeResources.length}`); + 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(''); diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 6a91c6d99..d1afdaccf 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -61,10 +61,10 @@ export async function makeConfig(): Promise { }, 'resources': { arg: { - name: 'STACK', - variadic: false, + name: 'STACKS', + variadic: true, }, - description: 'Lists all CloudFormation resources in a stack', + description: 'Lists all CloudFormation resources in the specified stack(s)', options: { 'long': { type: 'boolean', diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 4a608dbe1..df87d74a5 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -162,10 +162,10 @@ }, "resources": { "arg": { - "name": "STACK", - "variadic": false + "name": "STACKS", + "variadic": true }, - "description": "Lists all CloudFormation resources in a stack", + "description": "Lists all CloudFormation resources in the specified stack(s)", "options": { "long": { "type": "boolean", diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 54e970b52..53b338225 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -298,7 +298,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Display stack dependency information for each stack', }), ) - .command('resources [STACK]', 'Lists all CloudFormation resources in a stack', (yargs: Argv) => + .command('resources [STACKS..]', 'Lists all CloudFormation resources in the specified stack(s)', (yargs: Argv) => yargs .option('long', { default: false, diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index e4afb4157..21ae2117f 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -29,7 +29,7 @@ export interface UserInput { readonly list?: ListOptions; /** - * Lists all CloudFormation resources in a stack + * Lists all CloudFormation resources in the specified stack(s) */ readonly resources?: ResourcesOptions; @@ -374,7 +374,7 @@ export interface ListOptions { } /** - * Lists all CloudFormation resources in a stack + * Lists all CloudFormation resources in the specified stack(s) * * @struct */ @@ -416,7 +416,7 @@ export interface ResourcesOptions { /** * Positional argument for resources */ - readonly STACK?: string; + readonly STACKS?: Array; } /** diff --git a/packages/aws-cdk/lib/commands/list-resources.ts b/packages/aws-cdk/lib/commands/list-resources.ts index 0b5c3bd92..cc18432c8 100644 --- a/packages/aws-cdk/lib/commands/list-resources.ts +++ b/packages/aws-cdk/lib/commands/list-resources.ts @@ -16,9 +16,9 @@ const HIDDEN_RESOURCE_TYPES = [ */ export interface ListResourcesOptions { /** - * Stack selector (name or pattern) + * Stack selectors (names or patterns) */ - readonly selector: string; + readonly selectors: string[]; /** * Filter by resource type (e.g., AWS::Lambda::Function) @@ -32,7 +32,7 @@ export interface ListResourcesOptions { } /** - * List all resources in a stack + * List all resources in the specified stack(s) */ export async function listResources( toolkit: CdkToolkit, @@ -41,10 +41,10 @@ export async function listResources( const assembly = await toolkit.assembly(); const stacks = await assembly.selectStacks( - { patterns: [options.selector] }, + { patterns: options.selectors }, { extend: ExtendedStackSelection.None, - defaultBehavior: DefaultSelection.OnlySingle, + defaultBehavior: DefaultSelection.AllStacks, }, ); @@ -104,6 +104,7 @@ export async function listResources( /** * Get detailed information about a specific resource + * Note: --explain requires a single stack to be selected */ export async function explainResource( toolkit: CdkToolkit, @@ -112,7 +113,7 @@ export async function explainResource( const assembly = await toolkit.assembly(); const stacks = await assembly.selectStacks( - { patterns: [options.selector] }, + { patterns: options.selectors }, { extend: ExtendedStackSelection.None, defaultBehavior: DefaultSelection.OnlySingle, diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 6f773e048..1c4213527 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -224,7 +224,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack'); + const result = await toolkit.resources(['ResourceStack']); // THEN expect(result).toEqual(0); @@ -261,7 +261,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack', { json: true }); + const result = await toolkit.resources(['ResourceStack'], { json: true }); // THEN expect(result).toEqual(0); @@ -308,7 +308,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack', { long: true }); + const result = await toolkit.resources(['ResourceStack'], { long: true }); // THEN expect(result).toEqual(0); @@ -346,7 +346,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack', { explain: 'MyBucket' }); + const result = await toolkit.resources(['ResourceStack'], { explain: 'MyBucket' }); // THEN expect(result).toEqual(0); @@ -382,7 +382,7 @@ describe('resources', () => { }); // WHEN/THEN - await expect(toolkit.resources('ResourceStack', { explain: 'NonExistent' })) + await expect(toolkit.resources(['ResourceStack'], { explain: 'NonExistent' })) .rejects.toThrow("Resource 'NonExistent' not found"); }); @@ -405,7 +405,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('EmptyStack'); + const result = await toolkit.resources(['EmptyStack']); // THEN expect(result).toEqual(0); @@ -440,7 +440,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack', { type: 'Lambda' }); + const result = await toolkit.resources(['ResourceStack'], { type: 'Lambda' }); // THEN expect(result).toEqual(0); @@ -480,7 +480,7 @@ describe('resources', () => { }); // WHEN - const result = await toolkit.resources('ResourceStack', { type: 'Lambda', json: true }); + const result = await toolkit.resources(['ResourceStack'], { type: 'Lambda', json: true }); // THEN expect(result).toEqual(0); @@ -522,7 +522,7 @@ describe('resources', () => { }); // WHEN - without --all - const result = await toolkit.resources('ResourceStack', { json: true }); + const result = await toolkit.resources(['ResourceStack'], { json: true }); // THEN - should only have 1 resource (Lambda::Permission hidden) expect(result).toEqual(0); @@ -564,7 +564,7 @@ describe('resources', () => { }); // WHEN - with --all - const result = await toolkit.resources('ResourceStack', { json: true, all: true }); + const result = await toolkit.resources(['ResourceStack'], { json: true, all: true }); // THEN - should have both resources expect(result).toEqual(0); diff --git a/packages/aws-cdk/test/commands/list-resources.test.ts b/packages/aws-cdk/test/commands/list-resources.test.ts index cd1616169..78a6ee0d7 100644 --- a/packages/aws-cdk/test/commands/list-resources.test.ts +++ b/packages/aws-cdk/test/commands/list-resources.test.ts @@ -49,7 +49,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); expect(resources).toHaveLength(2); expect(resources).toContainEqual(expect.objectContaining({ @@ -65,6 +65,90 @@ describe('listResources', () => { })); }); + 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: [{ @@ -81,7 +165,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'EmptyStack' }); + const resources = await listResources(toolkit, { selectors: ['EmptyStack'] }); expect(resources).toHaveLength(0); }); @@ -109,7 +193,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); expect(resources).toHaveLength(1); expect(resources[0].constructPath).toBe(''); @@ -147,7 +231,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); expect(resources).toHaveLength(1); expect(resources[0].imports).toContain('SharedBucketArn'); @@ -193,7 +277,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); const retained = resources.find(r => r.logicalId === 'RetainedBucket'); const deleted = resources.find(r => r.logicalId === 'DeletedBucket'); @@ -235,7 +319,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); // Should be sorted: Lambda::Function (A then Z), then S3::Bucket expect(resources[0].logicalId).toBe('AFunction'); @@ -271,12 +355,12 @@ describe('listResources', () => { }); // Filter for lambda (lowercase) - const lambdaResources = await listResources(toolkit, { selector: 'TestStack', type: 'lambda' }); + 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, { selector: 'TestStack', type: 'S3' }); + const s3Resources = await listResources(toolkit, { selectors: ['TestStack'], type: 'S3' }); expect(s3Resources).toHaveLength(1); expect(s3Resources[0].type).toBe('AWS::S3::Bucket'); }); @@ -309,12 +393,12 @@ describe('listResources', () => { }); // Without --all flag - const defaultResources = await listResources(toolkit, { selector: 'TestStack' }); + 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, { selector: 'TestStack', all: true }); + const allResources = await listResources(toolkit, { selectors: ['TestStack'], all: true }); expect(allResources).toHaveLength(2); }); @@ -346,7 +430,7 @@ describe('listResources', () => { }); // When filtering by type, hidden types should be included - const permissionResources = await listResources(toolkit, { selector: 'TestStack', type: 'Permission' }); + const permissionResources = await listResources(toolkit, { selectors: ['TestStack'], type: 'Permission' }); expect(permissionResources).toHaveLength(1); expect(permissionResources[0].type).toBe('AWS::Lambda::Permission'); }); @@ -382,7 +466,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'MyStack' }); + const resources = await listResources(toolkit, { selectors: ['MyStack'] }); const bucket = resources.find(r => r.logicalId === 'MyBucket'); const fn = resources.find(r => r.logicalId === 'MyFunction'); @@ -426,7 +510,7 @@ describe('listResources', () => { deployments: cloudFormation, }); - const resources = await listResources(toolkit, { selector: 'TestStack' }); + const resources = await listResources(toolkit, { selectors: ['TestStack'] }); const resourceA = resources.find(r => r.logicalId === 'ResourceA'); const resourceB = resources.find(r => r.logicalId === 'ResourceB'); @@ -479,7 +563,7 @@ describe('explainResource', () => { }); const resource = await explainResource(toolkit, { - selector: 'TestStack', + selectors: ['TestStack'], logicalId: 'MyBucket', }); @@ -507,7 +591,7 @@ describe('explainResource', () => { }); const resource = await explainResource(toolkit, { - selector: 'TestStack', + selectors: ['TestStack'], logicalId: 'NonExistent', }); @@ -551,7 +635,7 @@ describe('explainResource', () => { }); const resource = await explainResource(toolkit, { - selector: 'TestStack', + selectors: ['TestStack'], logicalId: 'MyASG', }); @@ -591,7 +675,7 @@ describe('explainResource', () => { // When no stacks match the selector, explainResource returns undefined // (The CdkToolkit.resources() method handles throwing the appropriate error) const resource = await explainResource(toolkit, { - selector: 'NonExistentStack', + selectors: ['NonExistentStack'], logicalId: 'MyBucket', }); From 338aff237b9afc6a95545d461fc0f53f2fd21812 Mon Sep 17 00:00:00 2001 From: lousydropout Date: Wed, 26 Nov 2025 14:55:12 -0600 Subject: [PATCH 4/5] feat(cli): add --ignore-case option to cdk resources command Add case-insensitive matching for stack name patterns using -i/--ignore-case flag. Uses minimatch with {nocase: true} for pattern matching when enabled. --- packages/aws-cdk/lib/cli/cdk-toolkit.ts | 5 +- packages/aws-cdk/lib/cli/cli-config.ts | 6 +++ .../aws-cdk/lib/cli/cli-type-registry.json | 6 +++ packages/aws-cdk/lib/cli/cli.ts | 1 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 2 + .../lib/cli/parse-command-line-arguments.ts | 6 +++ packages/aws-cdk/lib/cli/user-input.ts | 9 ++++ .../aws-cdk/lib/commands/list-resources.ts | 51 +++++++++++++++++-- .../test/commands/list-resources.test.ts | 38 ++++++++++++++ 9 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 55cc8bbb3..714ebc5fb 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1050,7 +1050,7 @@ export class CdkToolkit { */ public async resources( selectors: string[], - options: { json?: boolean; long?: boolean; all?: boolean; type?: string; explain?: string } = {}, + options: { json?: boolean; long?: boolean; all?: boolean; type?: string; explain?: string; ignoreCase?: boolean } = {}, ): Promise { const io = this.ioHost.asIoHelper(); @@ -1059,6 +1059,7 @@ export class CdkToolkit { const resource = await explainResource(this, { selectors, logicalId: options.explain, + ignoreCase: options.ignoreCase, }); if (!resource) { @@ -1070,7 +1071,7 @@ export class CdkToolkit { } // List all resources (with optional type filter) - const resources = await listResources(this, { selectors, type: options.type, all: options.all }); + const resources = await listResources(this, { selectors, type: options.type, all: options.all, ignoreCase: options.ignoreCase }); if (resources.length === 0) { if (options.type) { diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index d1afdaccf..8c328be9a 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -89,6 +89,12 @@ export async function makeConfig(): Promise { 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': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index df87d74a5..ddb938a33 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -189,6 +189,12 @@ "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" } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 53b338225..16f76bf09 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -304,6 +304,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { 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) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 21ae2117f..f9f8eaa89 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -413,6 +413,15 @@ export interface ResourcesOptions { */ readonly explain?: string; + /** + * Use case-insensitive matching for stack name patterns + * + * aliases: i + * + * @default - false + */ + readonly ignoreCase?: boolean; + /** * Positional argument for resources */ diff --git a/packages/aws-cdk/lib/commands/list-resources.ts b/packages/aws-cdk/lib/commands/list-resources.ts index cc18432c8..21c5682eb 100644 --- a/packages/aws-cdk/lib/commands/list-resources.ts +++ b/packages/aws-cdk/lib/commands/list-resources.ts @@ -1,4 +1,5 @@ import type { ResourceDetails, ResourceExplainDetails } from '@aws-cdk/toolkit-lib'; +import { minimatch } from 'minimatch'; import type { CdkToolkit } from '../cli/cdk-toolkit'; import { DefaultSelection, ExtendedStackSelection } from '../cxapp'; @@ -29,6 +30,11 @@ export interface ListResourcesOptions { * Include all resources (including hidden types like Lambda::Permission) */ readonly all?: boolean; + + /** + * Use case-insensitive matching for stack name patterns + */ + readonly ignoreCase?: boolean; } /** @@ -40,8 +46,11 @@ export async function listResources( ): 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: options.selectors }, + { patterns: useManualFiltering ? [] : options.selectors }, { extend: ExtendedStackSelection.None, defaultBehavior: DefaultSelection.AllStacks, @@ -52,9 +61,22 @@ export async function listResources( 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 stacks.stackArtifacts) { + for (const stack of stackArtifacts) { const template = stack.template; const templateResources = template.Resources ?? {}; @@ -112,11 +134,14 @@ export async function explainResource( ): 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: options.selectors }, + { patterns: useManualFiltering ? [] : options.selectors }, { extend: ExtendedStackSelection.None, - defaultBehavior: DefaultSelection.OnlySingle, + defaultBehavior: useManualFiltering ? DefaultSelection.AllStacks : DefaultSelection.OnlySingle, }, ); @@ -124,7 +149,23 @@ export async function explainResource( return undefined; } - const stack = stacks.firstStack; + // 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 Error(`--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; diff --git a/packages/aws-cdk/test/commands/list-resources.test.ts b/packages/aws-cdk/test/commands/list-resources.test.ts index 78a6ee0d7..ce1bbaad0 100644 --- a/packages/aws-cdk/test/commands/list-resources.test.ts +++ b/packages/aws-cdk/test/commands/list-resources.test.ts @@ -365,6 +365,44 @@ describe('listResources', () => { 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: [{ From 56baf6708a7e84cd34dff56a62b805d233f09651 Mon Sep 17 00:00:00 2001 From: lousydropout Date: Wed, 26 Nov 2025 15:52:17 -0600 Subject: [PATCH 5/5] refactor(cli): polish cdk resources implementation - Fix dead code in extractImportValues (array check before object check) - Extract normalizeDependsOn helper to reduce duplication - Use RESOURCE_SUFFIX constant instead of magic number - Use ToolkitError instead of Error for consistency --- packages/aws-cdk/lib/cli/cdk-toolkit.ts | 2 +- .../aws-cdk/lib/commands/list-resources.ts | 40 ++++++++++--------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 714ebc5fb..45bb4d5a9 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -34,8 +34,8 @@ 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 { listStacks } from '../commands/list-stacks'; import { listResources, explainResource } from '../commands/list-resources'; +import { listStacks } from '../commands/list-stacks'; import type { FromScan, GenerateTemplateOutput } from '../commands/migrate'; import { appendWarningsToReadme, diff --git a/packages/aws-cdk/lib/commands/list-resources.ts b/packages/aws-cdk/lib/commands/list-resources.ts index 21c5682eb..390f7d905 100644 --- a/packages/aws-cdk/lib/commands/list-resources.ts +++ b/packages/aws-cdk/lib/commands/list-resources.ts @@ -1,9 +1,11 @@ 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) @@ -103,11 +105,7 @@ export async function listResources( logicalId, type: resourceType, constructPath, - dependsOn: Array.isArray(resourceObj.DependsOn) - ? resourceObj.DependsOn - : resourceObj.DependsOn - ? [resourceObj.DependsOn] - : [], + dependsOn: normalizeDependsOn(resourceObj.DependsOn), imports: extractImportValues(resourceObj), removalPolicy: mapDeletionPolicy(resourceObj.DeletionPolicy), }); @@ -161,7 +159,7 @@ export async function explainResource( return undefined; } if (matchingStacks.length > 1) { - throw new Error(`--explain requires exactly one stack, but found ${matchingStacks.length} matching stacks`); + throw new ToolkitError(`--explain requires exactly one stack, but found ${matchingStacks.length} matching stacks`); } stack = matchingStacks[0]; } @@ -182,11 +180,7 @@ export async function explainResource( logicalId: options.logicalId, type: resource.Type ?? '', constructPath, - dependsOn: Array.isArray(resource.DependsOn) - ? resource.DependsOn - : resource.DependsOn - ? [resource.DependsOn] - : [], + dependsOn: normalizeDependsOn(resource.DependsOn), imports: extractImportValues(resource), removalPolicy: mapDeletionPolicy(resource.DeletionPolicy), condition: resource.Condition, @@ -195,6 +189,15 @@ export async function explainResource( }; } +/** + * 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 */ @@ -204,7 +207,12 @@ function extractImportValues(resource: any): string[] { function walk(obj: any) { if (obj === null || obj === undefined) return; - if (typeof obj === 'object') { + // 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') { @@ -217,10 +225,6 @@ function extractImportValues(resource: any): string[] { for (const value of Object.values(obj)) { walk(value); } - } else if (Array.isArray(obj)) { - for (const item of obj) { - walk(item); - } } } @@ -257,8 +261,8 @@ function stripStackPrefix(path: string, stackId: string): string { } // Strip /Resource suffix (common CDK L2 pattern) - if (result.endsWith('/Resource')) { - result = result.slice(0, -9); + if (result.endsWith(RESOURCE_SUFFIX)) { + result = result.slice(0, -RESOURCE_SUFFIX.length); } return result;