diff --git a/.projenrc.ts b/.projenrc.ts index 4a25831..9034824 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -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, diff --git a/API.md b/API.md index 9ba28f4..d798cd3 100644 --- a/API.md +++ b/API.md @@ -2622,6 +2622,7 @@ const bashCDKPipelineOptions: BashCDKPipelineOptions = { ... } | stages | DeploymentStage[] | This field specifies a list of stages that should be deployed using a CI/CD pipeline. | | branchName | string | the name of the branch to deploy from. | | deploySubStacks | boolean | If set to true all CDK actions will also include /* to deploy/diff/destroy sub stacks of the main stack. | +| enableResourceCounting | boolean | Whether to enable resource counting in the synth step. | | featureStages | StageOptions | This specifies details for feature stages. | | independentStages | IndependentStage[] | This specifies details for independent stages. | | paths | string[] | File path patterns that should trigger the pipeline when changed. | @@ -2634,6 +2635,7 @@ const bashCDKPipelineOptions: BashCDKPipelineOptions = { ... } | preInstallSteps | PipelineStep[] | *No description.* | | preSynthCommands | string[] | *No description.* | | preSynthSteps | PipelineStep[] | *No description.* | +| resourceCountWarningThreshold | number | Resource count warning threshold. | | stackPrefix | string | This field is used to define a prefix for the AWS Stack resources created during the pipeline's operation. | | versioning | VersioningConfig | Versioning configuration. | @@ -2691,6 +2693,21 @@ You can use this to deploy CDk applications containing multiple stacks. --- +##### `enableResourceCounting`Optional + +```typescript +public readonly enableResourceCounting: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether to enable resource counting in the synth step. + +When enabled, counts CloudFormation resources in each stack and warns if approaching the limit. + +--- + ##### `featureStages`Optional ```typescript @@ -2844,6 +2861,22 @@ public readonly preSynthSteps: PipelineStep[]; --- +##### `resourceCountWarningThreshold`Optional + +```typescript +public readonly resourceCountWarningThreshold: number; +``` + +- *Type:* number +- *Default:* 450 + +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. + +--- + ##### `stackPrefix`Optional ```typescript @@ -3082,6 +3115,7 @@ const cDKPipelineOptions: CDKPipelineOptions = { ... } | stages | DeploymentStage[] | This field specifies a list of stages that should be deployed using a CI/CD pipeline. | | branchName | string | the name of the branch to deploy from. | | deploySubStacks | boolean | If set to true all CDK actions will also include /* to deploy/diff/destroy sub stacks of the main stack. | +| enableResourceCounting | boolean | Whether to enable resource counting in the synth step. | | featureStages | StageOptions | This specifies details for feature stages. | | independentStages | IndependentStage[] | This specifies details for independent stages. | | paths | string[] | File path patterns that should trigger the pipeline when changed. | @@ -3094,6 +3128,7 @@ const cDKPipelineOptions: CDKPipelineOptions = { ... } | preInstallSteps | PipelineStep[] | *No description.* | | preSynthCommands | string[] | *No description.* | | preSynthSteps | PipelineStep[] | *No description.* | +| resourceCountWarningThreshold | number | Resource count warning threshold. | | stackPrefix | string | This field is used to define a prefix for the AWS Stack resources created during the pipeline's operation. | | versioning | VersioningConfig | Versioning configuration. | @@ -3151,6 +3186,21 @@ You can use this to deploy CDk applications containing multiple stacks. --- +##### `enableResourceCounting`Optional + +```typescript +public readonly enableResourceCounting: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether to enable resource counting in the synth step. + +When enabled, counts CloudFormation resources in each stack and warns if approaching the limit. + +--- + ##### `featureStages`Optional ```typescript @@ -3304,6 +3354,22 @@ public readonly preSynthSteps: PipelineStep[]; --- +##### `resourceCountWarningThreshold`Optional + +```typescript +public readonly resourceCountWarningThreshold: number; +``` + +- *Type:* number +- *Default:* 450 + +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. + +--- + ##### `stackPrefix`Optional ```typescript @@ -4403,6 +4469,7 @@ const githubCDKPipelineOptions: GithubCDKPipelineOptions = { ... } | stages | DeploymentStage[] | This field specifies a list of stages that should be deployed using a CI/CD pipeline. | | branchName | string | the name of the branch to deploy from. | | deploySubStacks | boolean | If set to true all CDK actions will also include /* to deploy/diff/destroy sub stacks of the main stack. | +| enableResourceCounting | boolean | Whether to enable resource counting in the synth step. | | featureStages | StageOptions | This specifies details for feature stages. | | independentStages | IndependentStage[] | This specifies details for independent stages. | | paths | string[] | File path patterns that should trigger the pipeline when changed. | @@ -4415,6 +4482,7 @@ const githubCDKPipelineOptions: GithubCDKPipelineOptions = { ... } | preInstallSteps | PipelineStep[] | *No description.* | | preSynthCommands | string[] | *No description.* | | preSynthSteps | PipelineStep[] | *No description.* | +| resourceCountWarningThreshold | number | Resource count warning threshold. | | stackPrefix | string | This field is used to define a prefix for the AWS Stack resources created during the pipeline's operation. | | versioning | VersioningConfig | Versioning configuration. | | runnerTags | string[] | runner tags to use to select runners. | @@ -4476,6 +4544,21 @@ You can use this to deploy CDk applications containing multiple stacks. --- +##### `enableResourceCounting`Optional + +```typescript +public readonly enableResourceCounting: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether to enable resource counting in the synth step. + +When enabled, counts CloudFormation resources in each stack and warns if approaching the limit. + +--- + ##### `featureStages`Optional ```typescript @@ -4629,6 +4712,22 @@ public readonly preSynthSteps: PipelineStep[]; --- +##### `resourceCountWarningThreshold`Optional + +```typescript +public readonly resourceCountWarningThreshold: number; +``` + +- *Type:* number +- *Default:* 450 + +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. + +--- + ##### `stackPrefix`Optional ```typescript @@ -5101,6 +5200,7 @@ const gitlabCDKPipelineOptions: GitlabCDKPipelineOptions = { ... } | stages | DeploymentStage[] | This field specifies a list of stages that should be deployed using a CI/CD pipeline. | | branchName | string | the name of the branch to deploy from. | | deploySubStacks | boolean | If set to true all CDK actions will also include /* to deploy/diff/destroy sub stacks of the main stack. | +| enableResourceCounting | boolean | Whether to enable resource counting in the synth step. | | featureStages | StageOptions | This specifies details for feature stages. | | independentStages | IndependentStage[] | This specifies details for independent stages. | | paths | string[] | File path patterns that should trigger the pipeline when changed. | @@ -5113,6 +5213,7 @@ const gitlabCDKPipelineOptions: GitlabCDKPipelineOptions = { ... } | preInstallSteps | PipelineStep[] | *No description.* | | preSynthCommands | string[] | *No description.* | | preSynthSteps | PipelineStep[] | *No description.* | +| resourceCountWarningThreshold | number | Resource count warning threshold. | | stackPrefix | string | This field is used to define a prefix for the AWS Stack resources created during the pipeline's operation. | | versioning | VersioningConfig | Versioning configuration. | | image | string | The Docker image to use for running the pipeline jobs. | @@ -5172,6 +5273,21 @@ You can use this to deploy CDk applications containing multiple stacks. --- +##### `enableResourceCounting`Optional + +```typescript +public readonly enableResourceCounting: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether to enable resource counting in the synth step. + +When enabled, counts CloudFormation resources in each stack and warns if approaching the limit. + +--- + ##### `featureStages`Optional ```typescript @@ -5325,6 +5441,22 @@ public readonly preSynthSteps: PipelineStep[]; --- +##### `resourceCountWarningThreshold`Optional + +```typescript +public readonly resourceCountWarningThreshold: number; +``` + +- *Type:* number +- *Default:* 450 + +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. + +--- + ##### `stackPrefix`Optional ```typescript @@ -6270,6 +6402,79 @@ If not provided, defaults to '9'. --- +### ResourceCountStepProps + +#### Initializer + +```typescript +import { ResourceCountStepProps } from 'projen-pipelines' + +const resourceCountStepProps: ResourceCountStepProps = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| cloudAssemblyDir | string | Path to the cloud assembly directory. | +| githubSummary | boolean | Whether to write results to GitHub step summary. | +| outputFile | string | Output file for results. | +| warningThreshold | number | Warning threshold for resource count. | + +--- + +##### `cloudAssemblyDir`Optional + +```typescript +public readonly cloudAssemblyDir: string; +``` + +- *Type:* string +- *Default:* 'cdk.out' + +Path to the cloud assembly directory. + +--- + +##### `githubSummary`Optional + +```typescript +public readonly githubSummary: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether to write results to GitHub step summary. + +--- + +##### `outputFile`Optional + +```typescript +public readonly outputFile: string; +``` + +- *Type:* string +- *Default:* 'resource-count-results.json' + +Output file for results. + +--- + +##### `warningThreshold`Optional + +```typescript +public readonly warningThreshold: number; +``` + +- *Type:* number +- *Default:* 450 + +Warning threshold for resource count. + +--- + ### StageOptions Options for a CDK stage like the target environment. @@ -7947,6 +8152,100 @@ Converts the step into a GitLab CI configuration. +### ResourceCountStep + +#### Initializers + +```typescript +import { ResourceCountStep } from 'projen-pipelines' + +new ResourceCountStep(project: Project, props?: ResourceCountStepProps) +``` + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| project | projen.Project | - The projen project reference. | +| props | ResourceCountStepProps | *No description.* | + +--- + +##### `project`Required + +- *Type:* projen.Project + +The projen project reference. + +--- + +##### `props`Optional + +- *Type:* ResourceCountStepProps + +--- + +#### Methods + +| **Name** | **Description** | +| --- | --- | +| toBash | Converts the sequence of steps into a Bash script configuration. | +| toGithub | Converts the sequence of steps into a GitHub Actions step configuration. | +| toGitlab | Converts the sequence of steps into a GitLab CI configuration. | +| addSteps | *No description.* | +| prependSteps | *No description.* | + +--- + +##### `toBash` + +```typescript +public toBash(): BashStepConfig +``` + +Converts the sequence of steps into a Bash script configuration. + +##### `toGithub` + +```typescript +public toGithub(): GithubStepConfig +``` + +Converts the sequence of steps into a GitHub Actions step configuration. + +##### `toGitlab` + +```typescript +public toGitlab(): GitlabStepConfig +``` + +Converts the sequence of steps into a GitLab CI configuration. + +##### `addSteps` + +```typescript +public addSteps(steps: ...PipelineStep[]): void +``` + +###### `steps`Required + +- *Type:* ...PipelineStep[] + +--- + +##### `prependSteps` + +```typescript +public prependSteps(steps: ...PipelineStep[]): void +``` + +###### `steps`Required + +- *Type:* ...PipelineStep[] + +--- + + + + ### SimpleCommandStep Concrete implementation of PipelineStep that executes simple commands. diff --git a/package-lock.json b/package-lock.json index 8dc8638..d4a07e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "commit-and-tag-version": "^12.7.1" }, "bin": { + "count-resources": "lib/awscdk/count-resources.js", "detect-drift": "lib/drift/detect-drift.js", "pipelines-release": "lib/release.js" }, diff --git a/package.json b/package.json index dc11a11..529890f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "url": "https://github.com/open-constructs/projen-pipelines.git" }, "bin": { + "count-resources": "lib/awscdk/count-resources.js", "detect-drift": "lib/drift/detect-drift.js", "pipelines-release": "lib/release.js" }, diff --git a/src/awscdk/base.ts b/src/awscdk/base.ts index 7e534eb..e5fc8e0 100644 --- a/src/awscdk/base.ts +++ b/src/awscdk/base.ts @@ -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 @@ -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; } /** @@ -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, []); diff --git a/src/awscdk/count-resources.ts b/src/awscdk/count-resources.ts new file mode 100644 index 0000000..31b10f8 --- /dev/null +++ b/src/awscdk/count-resources.ts @@ -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 Cloud assembly directory (default: cdk.out) + -t, --warning-threshold Warning threshold for resource count (default: 450) + -o, --output-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 }; diff --git a/src/awscdk/github.ts b/src/awscdk/github.ts index 857db1e..7d5d4d8 100644 --- a/src/awscdk/github.ts +++ b/src/awscdk/github.ts @@ -128,6 +128,11 @@ export class GithubCDKPipeline extends CDKPipeline { if (options.featureStages) { this.createFeatureWorkflows(); } + + // Create PR workflow for resource counting if enabled + if (this.baseOptions.enableResourceCounting !== false) { + this.createResourceCountPRWorkflow(); + } } /** the type of engine this implementation of CDKPipeline is for */ @@ -267,6 +272,18 @@ export class GithubCDKPipeline extends CDKPipeline { steps.push(this.provideInstallStep()); steps.push(this.provideSynthStep()); + // Add resource counting step if enabled + const resourceCountStep = this.provideResourceCountStep(true); + if (resourceCountStep) { + steps.push(resourceCountStep); + + // Upload resource count results as artifact + steps.push(new UploadArtifactStep(this.project, { + name: 'resource-count-results', + path: 'resource-count-results.json', + })); + } + steps.push(new UploadArtifactStep(this.project, { name: `${this.namePrefix}cloud-assembly`, path: `${this.app.cdkConfig.cdkout}/`, @@ -535,4 +552,147 @@ export class GithubCDKPipeline extends CDKPipeline { } } + + /** + * Creates a workflow for commenting resource counts on pull requests. + */ + private createResourceCountPRWorkflow(): void { + const workflow = this.app.github!.addWorkflow('resource-count-pr'); + + workflow.on({ + pullRequest: { + types: ['opened', 'synchronize', 'reopened'], + }, + }); + + const steps: PipelineStep[] = []; + steps.push(this.provideInstallStep()); + steps.push(this.provideSynthStep()); + + const resourceCountStep = this.provideResourceCountStep(true); + if (resourceCountStep) { + steps.push(resourceCountStep); + } + + const githubSteps = steps.map(s => s.toGithub()); + + workflow.addJob('resource-count', { + name: 'Count CloudFormation Resources', + runsOn: this.options.runnerTags ?? DEFAULT_RUNNER_TAGS, + permissions: mergeJobPermissions({ + contents: JobPermission.READ, + pullRequests: JobPermission.WRITE, + }, ...(githubSteps.flatMap(s => s.permissions).filter(p => p != undefined) as JobPermissions[])), + env: { + CI: 'true', + ...githubSteps.reduce((acc, step) => ({ ...acc, ...step.env }), {}), + }, + tools: { + node: { + version: this.minNodeVersion ?? '20', + }, + }, + steps: [ + { + name: 'Checkout', + uses: 'actions/checkout@v5', + }, + ...githubSteps.flatMap(s => s.steps), + { + name: 'Comment on PR', + uses: 'actions/github-script@v8', + with: { + script: this.generatePRCommentScript(), + }, + }, + ], + }); + } + + /** + * Generates the script for commenting resource counts on PRs. + */ + private generatePRCommentScript(): string { + const warningThreshold = this.baseOptions.resourceCountWarningThreshold ?? 450; + return ` +const fs = require('fs'); +const resultsFile = 'resource-count-results.json'; + +if (!fs.existsSync(resultsFile)) { + console.log('No results file found'); + return; +} + +const results = JSON.parse(fs.readFileSync(resultsFile, 'utf8')); +const stacks = results.stacks || []; +const stacksWithWarnings = stacks.filter(s => s.resourceCount >= ${warningThreshold}); + +// Create comment body +let body = '## 📊 CloudFormation Resource Count\\n\\n'; +body += '### Summary\\n'; +body += \`- **Total stacks:** \${stacks.length}\\n\`; +body += \`- **Total resources:** \${results.totalResources}\\n\`; +body += \`- **Max resources in single stack:** \${results.maxResourcesInStack}\\n\`; +body += \`- **Warning threshold:** ${warningThreshold} resources\\n\`; +body += \`- **CloudFormation limit:** 500 resources per stack\\n\\n\`; + +if (stacksWithWarnings.length > 0) { + body += '### ⚠️ Stacks Approaching Limit\\n\\n'; + body += \`\${stacksWithWarnings.length} stack(s) have crossed the warning threshold:\\n\\n\`; + + for (const stack of stacksWithWarnings) { + const percentage = Math.round((stack.resourceCount / 500) * 100); + body += \`- **\${stack.stackName}**: \${stack.resourceCount} resources (\${percentage}% of limit)\\n\`; + } + + body += '\\n**Recommendations:**\\n'; + body += '- Consider breaking large stacks into smaller, focused stacks\\n'; + body += '- Use nested stacks for reusable components\\n'; + body += '- Review resource usage and remove unnecessary resources\\n\\n'; +} + +body += '### Stack Details\\n\\n'; +body += '| Stack | Resources | % of Limit | Status |\\n'; +body += '|-------|-----------|------------|--------|\\n'; + +const sortedStacks = stacks.sort((a, b) => b.resourceCount - a.resourceCount); +for (const stack of sortedStacks) { + const percentage = Math.round((stack.resourceCount / 500) * 100); + const status = stack.resourceCount >= ${warningThreshold} ? '⚠️ Warning' : '✅ OK'; + body += \`| \${stack.stackName} | \${stack.resourceCount} | \${percentage}% | \${status} |\\n\`; +} + +// Find existing comment +const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, +}); + +const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('📊 CloudFormation Resource Count') +); + +if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body, + }); + console.log('Updated existing PR comment'); +} else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + console.log('Created new PR comment'); +} +`; + } } diff --git a/src/awscdk/gitlab.ts b/src/awscdk/gitlab.ts index b43afc3..684ff24 100644 --- a/src/awscdk/gitlab.ts +++ b/src/awscdk/gitlab.ts @@ -88,6 +88,11 @@ export class GitlabCDKPipeline extends CDKPipeline { if (options.featureStages) { this.createFeatureWorkflows(); } + + // Create MR workflow for resource counting if enabled + if (this.baseOptions.enableResourceCounting !== false) { + this.createResourceCountMRJob(); + } } /** @@ -146,6 +151,12 @@ export class GitlabCDKPipeline extends CDKPipeline { this.provideSynthStep(), ]; + // Add resource counting step if enabled + const resourceCountStep = this.provideResourceCountStep(false); + if (resourceCountStep) { + steps.push(resourceCountStep); + } + const gitlabSteps = steps.map(s => s.toGitlab()); this.config.addStages('synth'); @@ -298,5 +309,129 @@ export class GitlabCDKPipeline extends CDKPipeline { return PipelineEngine.GITLAB; } + /** + * Creates a job to count resources and comment on merge requests. + */ + private createResourceCountMRJob(): void { + const steps: PipelineStep[] = [ + this.provideInstallStep(), + this.provideSynthStep(), + ]; + + const resourceCountStep = this.provideResourceCountStep(false); + if (resourceCountStep) { + steps.push(resourceCountStep); + } + + const gitlabSteps = steps.map(s => s.toGitlab()); + + this.config.addStages('resource-count-mr'); + this.config.addJobs({ + 'resource-count-mr': { + extends: ['.aws_base', ...gitlabSteps.flatMap(s => s.extensions)], + stage: 'resource-count-mr', + tags: this.options.runnerTags?.synth ?? this.options.runnerTags?.default, + rules: [ + { + if: '$CI_PIPELINE_SOURCE == "merge_request_event"', + }, + ], + needs: gitlabSteps.flatMap(s => s.needs), + script: [ + ...gitlabSteps.flatMap(s => s.commands), + // Add script to post comment to MR + this.generateMRCommentScript(), + ], + variables: gitlabSteps.reduce((acc, step) => ({ ...acc, ...step.env }), {}), + }, + }); + } + + /** + * Generates the script for posting resource counts to GitLab MR comments. + */ + private generateMRCommentScript(): string { + const warningThreshold = this.baseOptions.resourceCountWarningThreshold ?? 450; + return ` +# Post comment to GitLab MR +if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= ${warningThreshold})] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** ${warningThreshold} resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= ${warningThreshold}) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= ${warningThreshold} then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi +else + echo "Resource count results not found" +fi +`; + } + } + diff --git a/src/awscdk/index.ts b/src/awscdk/index.ts index 6c2a1a6..0734c73 100644 --- a/src/awscdk/index.ts +++ b/src/awscdk/index.ts @@ -2,3 +2,4 @@ export * from './base'; export * from './github'; export * from './gitlab'; export * from './bash'; +export * from './resource-count-step'; diff --git a/src/awscdk/resource-count-step.ts b/src/awscdk/resource-count-step.ts new file mode 100644 index 0000000..ca730a6 --- /dev/null +++ b/src/awscdk/resource-count-step.ts @@ -0,0 +1,63 @@ +import { Project } from 'projen'; +import { StepSequence, PipelineStep, SimpleCommandStep } from '../steps'; + +export interface ResourceCountStepProps { + /** + * Path to the cloud assembly directory + * @default 'cdk.out' + */ + readonly cloudAssemblyDir?: string; + + /** + * Warning threshold for resource count + * @default 450 + */ + readonly warningThreshold?: number; + + /** + * Output file for results + * @default 'resource-count-results.json' + */ + readonly outputFile?: string; + + /** + * Whether to write results to GitHub step summary + * @default true + */ + readonly githubSummary?: boolean; +} + +export class ResourceCountStep extends StepSequence { + private static generateCommand(props: ResourceCountStepProps): string { + const args: string[] = [ + 'count-resources', + '--cloud-assembly-dir', props.cloudAssemblyDir ?? 'cdk.out', + ]; + + if (props.warningThreshold !== undefined) { + args.push('--warning-threshold', props.warningThreshold.toString()); + } + + if (props.outputFile) { + args.push('--output-file', props.outputFile); + } + + if (props.githubSummary !== false) { + args.push('--github-summary'); + } + + return args.join(' '); + } + + constructor(project: Project, props: ResourceCountStepProps = {}) { + const steps: PipelineStep[] = []; + + // Add command step to run resource counting + const command = ResourceCountStep.generateCommand(props); + steps.push(new SimpleCommandStep(project, [command], { + CLOUD_ASSEMBLY_DIR: props.cloudAssemblyDir ?? 'cdk.out', + })); + + super(project, steps); + } +} diff --git a/test/__snapshots__/amplify-deploy.test.ts.snap b/test/__snapshots__/amplify-deploy.test.ts.snap index 5c56bef..f50189a 100644 --- a/test/__snapshots__/amplify-deploy.test.ts.snap +++ b/test/__snapshots__/amplify-deploy.test.ts.snap @@ -18,6 +18,7 @@ jobs: contents: read env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -29,6 +30,12 @@ jobs: fetch-depth: 0 - run: npx projen install:ci - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -584,6 +591,7 @@ jobs: contents: read env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -595,6 +603,12 @@ jobs: fetch-depth: 0 - run: npx projen install:ci - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -752,6 +766,7 @@ jobs: contents: read env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -763,6 +778,12 @@ jobs: fetch-depth: 0 - run: npx projen install:ci - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: diff --git a/test/__snapshots__/github.test.ts.snap b/test/__snapshots__/github.test.ts.snap index b46997e..74f7d07 100644 --- a/test/__snapshots__/github.test.ts.snap +++ b/test/__snapshots__/github.test.ts.snap @@ -19,6 +19,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -36,6 +37,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -727,6 +734,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -744,6 +752,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -803,6 +817,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -820,6 +835,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -879,6 +900,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -896,6 +918,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -1160,6 +1188,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -1177,6 +1206,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -1425,6 +1460,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -1442,6 +1478,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -2564,6 +2606,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -2581,6 +2624,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -2788,6 +2837,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -2814,6 +2864,12 @@ jobs: role-chaining: true role-skip-session-tagging: true - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -3816,6 +3872,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -3834,6 +3891,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -3972,6 +4035,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -3989,6 +4053,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -4373,6 +4443,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -4390,6 +4461,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -4925,6 +5002,7 @@ jobs: env: CI: "true" FOO: bar + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -4943,6 +5021,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -5068,6 +5152,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -5085,6 +5170,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: @@ -6084,6 +6175,7 @@ jobs: id-token: write env: CI: "true" + CLOUD_ASSEMBLY_DIR: cdk.out steps: - uses: actions/setup-node@v6 with: @@ -6101,6 +6193,12 @@ jobs: role-session-name: GitHubAction aws-region: us-east-1 - run: npx projen build + - run: count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json --github-summary + - name: Upload Artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: resource-count-results + path: resource-count-results.json - name: Upload Artifact uses: actions/upload-artifact@v7 with: diff --git a/test/__snapshots__/gitlab.test.ts.snap b/test/__snapshots__/gitlab.test.ts.snap index 608e68d..396afe6 100644 --- a/test/__snapshots__/gitlab.test.ts.snap +++ b/test/__snapshots__/gitlab.test.ts.snap @@ -8,6 +8,7 @@ stages: - publish_assets - dev - prod + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -42,7 +43,9 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build - variables: {} + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + variables: + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -160,6 +163,98 @@ deploy-prod: - fi variables: AWS_REGION: eu-central-1 +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + CLOUD_ASSEMBLY_DIR: cdk.out " `; @@ -474,6 +569,7 @@ stages: - publish_assets - independent1 - independent2 + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -508,7 +604,9 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build - variables: {} + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + variables: + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -589,6 +687,98 @@ deploy-independent2: variables: AWS_REGION: eu-central-1 FOO: bar +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + CLOUD_ASSEMBLY_DIR: cdk.out " `; @@ -774,6 +964,7 @@ stages: - synth - publish_assets - prod + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -809,8 +1000,10 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json variables: FOO: bar + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -880,6 +1073,100 @@ deploy-prod: variables: FOO: bar AWS_REGION: eu-central-1 +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - echo Login + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + FOO: bar + CLOUD_ASSEMBLY_DIR: cdk.out " `; @@ -891,6 +1178,7 @@ stages: - publish_assets - dev - prod + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -927,7 +1215,9 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build - variables: {} + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + variables: + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -1053,6 +1343,100 @@ deploy-prod: - fi variables: AWS_REGION: eu-central-1 +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + tags: + - defaultTag + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + CLOUD_ASSEMBLY_DIR: cdk.out " `; @@ -1064,6 +1448,7 @@ stages: - publish_assets - dev - prod + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -1100,7 +1485,9 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build - variables: {} + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + variables: + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -1226,6 +1613,100 @@ deploy-prod: - fi variables: AWS_REGION: eu-central-1 +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + tags: + - synthTag + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + CLOUD_ASSEMBLY_DIR: cdk.out " `; @@ -1237,6 +1718,7 @@ stages: - publish_assets - dev - prod + - resource-count-mr .artifacts_cdk: artifacts: when: on_success @@ -1271,7 +1753,9 @@ synth: - npx projen install:ci - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - npx projen build - variables: {} + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + variables: + CLOUD_ASSEMBLY_DIR: cdk.out publish_assets: extends: - .aws_base @@ -1386,6 +1870,98 @@ deploy-prod: - fi variables: AWS_REGION: eu-central-1 +resource-count-mr: + extends: + - .aws_base + stage: resource-count-mr + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + needs: [] + script: + - npx projen install:ci + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "synthRole" --role-session-name "GitLabRunner-\${CI_PROJECT_ID}-\${CI_PIPELINE_ID}}" --web-identity-token \${AWS_TOKEN} --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) + - npx projen build + - count-resources --cloud-assembly-dir cdk.out --warning-threshold 450 --output-file resource-count-results.json + - | + + # Post comment to GitLab MR + if [ -f resource-count-results.json ]; then + echo "Posting resource count to merge request..." + + # Parse results + STACKS_COUNT=$(jq '.stacks | length' resource-count-results.json) + TOTAL_RESOURCES=$(jq '.totalResources' resource-count-results.json) + MAX_RESOURCES=$(jq '.maxResourcesInStack' resource-count-results.json) + STACKS_WITH_WARNINGS=$(jq '[.stacks[] | select(.resourceCount >= 450)] | length' resource-count-results.json) + + # Build comment body + COMMENT_BODY="## 📊 CloudFormation Resource Count\\n\\n" + COMMENT_BODY+="### Summary\\n" + COMMENT_BODY+="- **Total stacks:** $STACKS_COUNT\\n" + COMMENT_BODY+="- **Total resources:** $TOTAL_RESOURCES\\n" + COMMENT_BODY+="- **Max resources in single stack:** $MAX_RESOURCES\\n" + COMMENT_BODY+="- **Warning threshold:** 450 resources\\n" + COMMENT_BODY+="- **CloudFormation limit:** 500 resources per stack\\n\\n" + + # Add warnings if present + if [ "$STACKS_WITH_WARNINGS" -gt 0 ]; then + COMMENT_BODY+="### ⚠️ Stacks Approaching Limit\\n\\n" + COMMENT_BODY+="$STACKS_WITH_WARNINGS stack(s) have crossed the warning threshold:\\n\\n" + + jq -r '.stacks[] | select(.resourceCount >= 450) | "- **\\(.stackName)**: \\(.resourceCount) resources (\\((.resourceCount * 100 / 500) | floor)% of limit)"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + COMMENT_BODY+="\\n**Recommendations:**\\n" + COMMENT_BODY+="- Consider breaking large stacks into smaller, focused stacks\\n" + COMMENT_BODY+="- Use nested stacks for reusable components\\n" + COMMENT_BODY+="- Review resource usage and remove unnecessary resources\\n\\n" + fi + + # Add stack details table + COMMENT_BODY+="### Stack Details\\n\\n" + COMMENT_BODY+="| Stack | Resources | % of Limit | Status |\\n" + COMMENT_BODY+="| ----- | --------- | ---------- | ------ |\\n" + + jq -r '.stacks | sort_by(-.resourceCount)[] | "| \\(.stackName) | \\(.resourceCount) | \\((.resourceCount * 100 / 500) | floor)% | \\(if .resourceCount >= 450 then "⚠️ Warning" else "✅ OK" end) |"' resource-count-results.json | while read line; do + COMMENT_BODY+="$line\\n" + done + + # Post comment to MR using GitLab API + if [ -n "\${CI_MERGE_REQUEST_IID}" ]; then + # Check for existing comment + EXISTING_COMMENT_ID=$(curl --silent --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" \\ + | jq -r '.[] | select(.body | contains("📊 CloudFormation Resource Count")) | .id' | head -1) + + # Prepare JSON payload + JSON_PAYLOAD=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + + if [ -n "\${EXISTING_COMMENT_ID}" ]; then + # Update existing comment + curl --silent --request PUT \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes/\${EXISTING_COMMENT_ID}" + echo "Updated existing MR comment" + else + # Create new comment + curl --silent --request POST \\ + --header "PRIVATE-TOKEN: \${CI_JOB_TOKEN}" \\ + --header "Content-Type: application/json" \\ + --data "$JSON_PAYLOAD" \\ + "\${CI_API_V4_URL}/projects/\${CI_PROJECT_ID}/merge_requests/\${CI_MERGE_REQUEST_IID}/notes" + echo "Created new MR comment" + fi + else + echo "Not running in merge request context, skipping comment" + fi + else + echo "Resource count results not found" + fi + variables: + CLOUD_ASSEMBLY_DIR: cdk.out " `;