Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const project = new cdk.JsiiProject({
bin: {
'pipelines-release': 'lib/release.js',
'detect-drift': 'lib/drift/detect-drift.js',
'count-resources': 'lib/awscdk/count-resources.js',
},
releaseToNpm: true,
npmTrustedPublishing: true,
Expand Down
299 changes: 299 additions & 0 deletions API.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/awscdk/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NodePackageManager } from 'projen/lib/javascript';
import { PipelineEngine } from '../engine';
import { AwsAssumeRoleStep, PipelineStep, ProjenScriptStep, SimpleCommandStep, StepSequence, PnpmSetupStep, CorepackSetupStep } from '../steps';
import { VersioningConfig, VersioningSetup } from '../versioning';
import { ResourceCountStep } from './resource-count-step';

/**
* The Environment interface is designed to hold AWS related information
Expand Down Expand Up @@ -214,6 +215,23 @@ export interface CDKPipelineOptions {
* Versioning configuration
*/
readonly versioning?: VersioningConfig;

/**
* Resource count warning threshold.
* When a stack exceeds this number of resources, a warning will be displayed.
* CloudFormation has a hard limit of 500 resources per stack.
*
* @default 450
*/
readonly resourceCountWarningThreshold?: number;

/**
* Whether to enable resource counting in the synth step.
* When enabled, counts CloudFormation resources in each stack and warns if approaching the limit.
*
* @default true
*/
readonly enableResourceCounting?: boolean;
}

/**
Expand Down Expand Up @@ -329,6 +347,19 @@ export abstract class CDKPipeline extends Component {
return seq;
}

protected provideResourceCountStep(githubSummary: boolean = false): PipelineStep | undefined {
if (this.baseOptions.enableResourceCounting === false) {
return undefined;
}

return new ResourceCountStep(this.project, {
cloudAssemblyDir: this.app.cdkConfig.cdkout,
warningThreshold: this.baseOptions.resourceCountWarningThreshold ?? 450,
outputFile: 'resource-count-results.json',
githubSummary,
});
}

protected provideAssetUploadStep(stageName?: string): PipelineStep {
const seq = new StepSequence(this.project, []);

Expand Down
272 changes: 272 additions & 0 deletions src/awscdk/count-resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
#!/usr/bin/env node

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';

interface ResourceCountOptions {
cloudAssemblyDir: string;
warningThreshold?: number;
outputFile?: string;
githubSummary?: boolean;
}

interface StackResourceCount {
stackName: string;
resourceCount: number;
templateFile: string;
warning?: string;
}

interface ResourceCountResult {
stacks: StackResourceCount[];
totalResources: number;
maxResourcesInStack: number;
timestamp: string;
}

class ResourceCounter {
private readonly options: ResourceCountOptions;
private readonly warningThreshold: number;
private readonly results: StackResourceCount[] = [];

constructor(options: ResourceCountOptions) {
this.options = options;
this.warningThreshold = options.warningThreshold ?? 450;
}

public run(): void {
console.log(`Counting resources in cloud assembly: ${this.options.cloudAssemblyDir}`);
console.log(`Warning threshold: ${this.warningThreshold} resources`);

try {
this.countResources();
this.printSummary();
this.saveResults();

if (this.options.githubSummary) {
this.writeGitHubSummary();
}
} catch (error) {
console.error('Error counting resources:', error);
process.exit(1);
}
}

private countResources(): void {
const manifestPath = join(this.options.cloudAssemblyDir, 'manifest.json');

if (!existsSync(manifestPath)) {
throw new Error(`Cloud assembly manifest not found at ${manifestPath}`);
}

const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));

// Find all CloudFormation stack artifacts
const artifacts = manifest.artifacts || {};

for (const [artifactId, artifact] of Object.entries(artifacts)) {
if ((artifact as any).type === 'aws:cloudformation:stack') {
const stackArtifact = artifact as any;
const templateFile = stackArtifact.properties?.templateFile;

if (templateFile) {
const templatePath = join(this.options.cloudAssemblyDir, templateFile);
const resourceCount = this.countResourcesInTemplate(templatePath);

const stackResult: StackResourceCount = {
stackName: artifactId,
resourceCount,
templateFile,
};

// Add warning if threshold is crossed
if (resourceCount >= this.warningThreshold) {
const percentOfLimit = Math.round((resourceCount / 500) * 100);
stackResult.warning = `Stack is at ${percentOfLimit}% of CloudFormation's 500 resource limit`;
}

this.results.push(stackResult);
}
}
}
}

private countResourcesInTemplate(templatePath: string): number {
try {
const template = JSON.parse(readFileSync(templatePath, 'utf8'));
const resources = template.Resources || {};
return Object.keys(resources).length;
} catch (error) {
console.warn(`Warning: Could not read template ${templatePath}:`, error);
return 0;
}
}

private printSummary(): void {
console.log('\n' + '='.repeat(80));
console.log('Resource Count Summary');
console.log('='.repeat(80));

if (this.results.length === 0) {
console.log('No stacks found in cloud assembly');
return;
}

const totalResources = this.results.reduce((sum, r) => sum + r.resourceCount, 0);
const maxResources = Math.max(...this.results.map(r => r.resourceCount));
const stacksWithWarnings = this.results.filter(r => r.warning);

console.log(`\nTotal stacks: ${this.results.length}`);
console.log(`Total resources: ${totalResources}`);
console.log(`Max resources in a single stack: ${maxResources}`);

console.log('\nPer-stack breakdown:');
for (const stack of this.results) {
const percentage = Math.round((stack.resourceCount / 500) * 100);
const statusIcon = stack.warning ? '⚠️ ' : '✓ ';
console.log(` ${statusIcon} ${stack.stackName}: ${stack.resourceCount} resources (${percentage}% of limit)`);

if (stack.warning) {
console.log(` ⚠️ ${stack.warning}`);
}
}

if (stacksWithWarnings.length > 0) {
console.log('\n' + '!'.repeat(80));
console.log(`WARNING: ${stacksWithWarnings.length} stack(s) approaching CloudFormation resource limit!`);
console.log('!'.repeat(80));
console.log('\nConsider:');
console.log(' - Breaking large stacks into smaller, focused stacks');
console.log(' - Using nested stacks for reusable components');
console.log(' - Reviewing resource usage and removing unnecessary resources');
console.log(' - CloudFormation hard limit is 500 resources per stack');
}

console.log('\n' + '='.repeat(80) + '\n');
}

private writeGitHubSummary(): void {
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (!summaryFile) {
console.log('GITHUB_STEP_SUMMARY not set, skipping GitHub summary');
return;
}

const totalResources = this.results.reduce((sum, r) => sum + r.resourceCount, 0);
const maxResources = Math.max(...this.results.map(r => r.resourceCount));
const stacksWithWarnings = this.results.filter(r => r.warning);

let summary = '## 📊 CloudFormation Resource Count\n\n';
summary += '### Summary\n';
summary += `- **Total stacks:** ${this.results.length}\n`;
summary += `- **Total resources:** ${totalResources}\n`;
summary += `- **Max resources in single stack:** ${maxResources}\n`;
summary += `- **Warning threshold:** ${this.warningThreshold} resources\n`;
summary += '- **CloudFormation limit:** 500 resources per stack\n\n';

if (stacksWithWarnings.length > 0) {
summary += '### ⚠️ Stacks Approaching Limit\n\n';
summary += `${stacksWithWarnings.length} stack(s) have crossed the warning threshold:\n\n`;

for (const stack of stacksWithWarnings) {
const percentage = Math.round((stack.resourceCount / 500) * 100);
summary += `- **${stack.stackName}**: ${stack.resourceCount} resources (${percentage}% of limit)\n`;
}

summary += '\n**Recommendations:**\n';
summary += '- Consider breaking large stacks into smaller, focused stacks\n';
summary += '- Use nested stacks for reusable components\n';
summary += '- Review resource usage and remove unnecessary resources\n\n';
}

summary += '### Stack Details\n\n';
summary += '| Stack | Resources | % of Limit | Status |\n';
summary += '|-------|-----------|------------|--------|\n';

for (const stack of this.results.sort((a, b) => b.resourceCount - a.resourceCount)) {
const percentage = Math.round((stack.resourceCount / 500) * 100);
const status = stack.warning ? '⚠️ Warning' : '✅ OK';
summary += `| ${stack.stackName} | ${stack.resourceCount} | ${percentage}% | ${status} |\n`;
}

try {
writeFileSync(summaryFile, summary, { flag: 'a' });
console.log('GitHub summary written successfully');
} catch (error) {
console.error('Failed to write GitHub summary:', error);
}
}

private saveResults(): void {
const outputFile = this.options.outputFile || 'resource-count-results.json';

const result: ResourceCountResult = {
stacks: this.results,
totalResources: this.results.reduce((sum, r) => sum + r.resourceCount, 0),
maxResourcesInStack: Math.max(...this.results.map(r => r.resourceCount), 0),
timestamp: new Date().toISOString(),
};

writeFileSync(outputFile, JSON.stringify(result, null, 2));
console.log(`Results saved to ${outputFile}`);
}
}

// Parse command line arguments
function parseArgs(): ResourceCountOptions {
const args = process.argv.slice(2);
const options: ResourceCountOptions = {
cloudAssemblyDir: 'cdk.out',
warningThreshold: 450,
githubSummary: false,
};

for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--cloud-assembly-dir':
case '-d':
options.cloudAssemblyDir = args[++i];
break;
case '--warning-threshold':
case '-t':
options.warningThreshold = parseInt(args[++i], 10);
break;
case '--output-file':
case '-o':
options.outputFile = args[++i];
break;
case '--github-summary':
case '-g':
options.githubSummary = true;
break;
case '--help':
case '-h':
console.log(`
Usage: count-resources.ts [options]

Options:
-d, --cloud-assembly-dir <dir> Cloud assembly directory (default: cdk.out)
-t, --warning-threshold <number> Warning threshold for resource count (default: 450)
-o, --output-file <file> Output file for results (default: resource-count-results.json)
-g, --github-summary Write results to GitHub step summary
-h, --help Show this help message
`);
process.exit(0);
break;
default:
console.error(`Unknown option: ${args[i]}`);
process.exit(1);
}
}

return options;
}

// Main execution
if (require.main === module) {
const options = parseArgs();
const counter = new ResourceCounter(options);
counter.run();
}

export { ResourceCounter, ResourceCountOptions, ResourceCountResult, StackResourceCount };
Loading
Loading