Skip to content
Draft
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
113 changes: 111 additions & 2 deletions packages/devtools/frigg-cli/deploy-command/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');

// Import doctor command for post-deployment health check
const { doctorCommand } = require('../doctor-command');

const RunPreDeploymentHealthCheckUseCase = require('@friggframework/devtools/infrastructure/domains/health/application/use-cases/run-pre-deployment-health-check-use-case');
const AWSResourceDetector = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector');
const PreDeploymentCategorizer = require('@friggframework/devtools/infrastructure/domains/health/domain/services/pre-deployment-categorizer');
const { TemplateParser } = require('@friggframework/devtools/infrastructure/domains/health/domain/services/template-parser');
const StackIdentifier = require('@friggframework/devtools/infrastructure/domains/health/domain/value-objects/stack-identifier');

// Configuration constants
const PATHS = {
APP_DEFINITION: 'index.js',
Expand Down Expand Up @@ -211,6 +216,98 @@ function getStackName(appDefinition, options) {
return null;
}

/**
* Run pre-deployment health check
* @param {string} stackName - CloudFormation stack name
* @param {Object} options - Deploy options
* @returns {Promise<boolean>} True if deployment can proceed, false if blocked
*/
async function runPreDeploymentHealthCheck(stackName, options) {
console.log('\n' + '═'.repeat(80));
console.log('Running pre-deployment health check...');
console.log('═'.repeat(80));

try {
const region = options.region || process.env.AWS_REGION || 'us-east-1';
const stackIdentifier = new StackIdentifier({ stackName, region });

const templatePath = TemplateParser.getBuildTemplatePath(process.cwd());

if (!TemplateParser.buildTemplateExists(process.cwd())) {
console.log('\n⚠️ Build template not found - run build first');
console.log(' Skipping pre-deployment health check');
return true;
}

const AWSStackRepository = require('@friggframework/devtools/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository');

const stackRepository = new AWSStackRepository({ region });
const resourceDetector = new AWSResourceDetector({ region });
const categorizer = new PreDeploymentCategorizer();
const templateParser = new TemplateParser();

const useCase = new RunPreDeploymentHealthCheckUseCase({
stackRepository,
resourceDetector,
preDeploymentCategorizer: categorizer,
templateParser,
});

const result = await useCase.execute({
stackIdentifier,
templatePath,
onProgress: (step, message) => console.log(step, message),
});

console.log('\n📊 Pre-Deployment Health Check Results:');
console.log(` Total issues: ${result.summary.total}`);
console.log(` 🚫 Blocking: ${result.summary.blocking}`);
console.log(` ⚠️ Warnings: ${result.summary.warnings}`);

if (result.blockingIssues.length > 0) {
console.log('\n🚫 BLOCKING ISSUES (deployment will fail):');
result.blockingIssues.forEach((item, index) => {
console.log(`\n ${index + 1}. ${item.issue.description}`);
console.log(` Type: ${item.issue.resourceType}`);
if (item.issue.resolution) {
console.log(` Resolution: ${item.issue.resolution}`);
}
if (item.issue.canAutoFix) {
console.log(` ✓ Can be auto-fixed with: frigg repair --import`);
}
});

console.log('\n✗ Deployment blocked due to critical issues');
console.log(' Fix these issues and run deploy again');
return false;
}

if (result.warningIssues.length > 0) {
console.log('\n⚠️ WARNINGS (non-blocking):');
result.warningIssues.forEach((item, index) => {
console.log(`\n ${index + 1}. ${item.issue.description}`);
console.log(` Type: ${item.issue.resourceType}`);
});

console.log('\n⚠️ Warnings detected but deployment can proceed');
console.log(' Run "frigg doctor" after deployment to address warnings');
} else {
console.log('\n✓ No issues detected - deployment can proceed');
}

return true;

} catch (error) {
console.log(`\n⚠️ Pre-deployment health check failed: ${error.message}`);
if (options.verbose) {
console.error(error.stack);
}

console.log(' Proceeding with deployment...');
return true;
}
}

/**
* Run post-deployment health check
* @param {string} stackName - CloudFormation stack name
Expand Down Expand Up @@ -271,9 +368,21 @@ async function deployCommand(options) {
console.log('Deploying the serverless application...');

const appDefinition = loadAppDefinition();
const stackName = getStackName(appDefinition, options);

if (!options.skipPreCheck && stackName) {
const canDeploy = await runPreDeploymentHealthCheck(stackName, options);

if (!canDeploy) {
console.error('\n✗ Deployment aborted due to blocking issues');
process.exit(1);
}
} else if (options.skipPreCheck) {
console.log('\n⏭️ Skipping pre-deployment health check (--skip-pre-check)');
}

const environment = validateAndBuildEnvironment(appDefinition, options);

// Execute deployment
const exitCode = await executeServerlessDeployment(environment, options);

// Check if deployment was successful
Expand Down
1 change: 1 addition & 0 deletions packages/devtools/frigg-cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ program
.option('-s, --stage <stage>', 'deployment stage', 'dev')
.option('-v, --verbose', 'enable verbose output')
.option('-f, --force', 'force deployment (bypasses caching for layers and functions)')
.option('--skip-pre-check', 'skip pre-deployment health check')
.option('--skip-doctor', 'skip post-deployment health check')
.action(deployCommand);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ class IResourceDetector {
* Find orphaned resources (exist in cloud but not in any stack)
*
* @param {Object} params
* @param {string} params.region - AWS region
* @param {string[]} [params.resourceTypes] - Optional: limit to specific resource types
* @param {string[]} [params.excludePhysicalIds=[]] - Physical IDs to exclude from orphan check
* @param {StackIdentifier} params.stackIdentifier - Target stack
* @param {Object} [params.expectedResources] - Resources from template (logical ID -> resource def)
* @param {Array} [params.stackResources] - Resources currently in stack (with physicalIds)
* @returns {Promise<Array<Object>>} Array of orphaned resources
* @returns {Promise<Array<Object>>} Resources with properties:
* - physicalId: string
Expand All @@ -119,11 +119,25 @@ class IResourceDetector {
* - isOrphaned: boolean (always true)
* - reason: string (explanation of why it's orphaned)
*/
async findOrphanedResources({ region, resourceTypes = [], excludePhysicalIds = [] }) {
async findOrphanedResources({ stackIdentifier, expectedResources, stackResources }) {
throw new Error(
'IResourceDetector.findOrphanedResources() must be implemented by adapter'
);
}

/**
* Check service quotas for resources in template
*
* @param {Object} params
* @param {StackIdentifier} params.stackIdentifier - Target stack
* @param {Object} params.expectedResources - Resources from template (logical ID -> resource def)
* @returns {Promise<Array<Object>>} Array of quota-related issues
*/
async checkServiceQuotas({ stackIdentifier, expectedResources }) {
throw new Error(
'IResourceDetector.checkServiceQuotas() must be implemented by adapter'
);
}
}

module.exports = IResourceDetector;
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
const RunPreDeploymentHealthCheckUseCase = require('../run-pre-deployment-health-check-use-case');
const PreDeploymentCategorizer = require('../../../domain/services/pre-deployment-categorizer');
const StackIdentifier = require('../../../domain/value-objects/stack-identifier');
const { TemplateParser } = require('../../../domain/services/template-parser');

describe('Pre-Deployment Health Check Integration', () => {
let useCase;
let mockStackRepository;
let mockResourceDetector;
let categorizer;
let templateParser;

beforeEach(() => {
mockStackRepository = {
getStack: jest.fn(),
};

mockResourceDetector = {
findOrphanedResources: jest.fn().mockResolvedValue([]),
checkServiceQuotas: jest.fn().mockResolvedValue([]),
};

categorizer = new PreDeploymentCategorizer();
templateParser = new TemplateParser();

Check warning on line 24 in packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/pre-deployment-health-check-integration.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "templateParser".

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZozsfyG8L8hrhXfpvGR&open=AZozsfyG8L8hrhXfpvGR&pullRequest=478

useCase = new RunPreDeploymentHealthCheckUseCase({
stackRepository: mockStackRepository,
resourceDetector: mockResourceDetector,
preDeploymentCategorizer: categorizer,
templateParser: { parseTemplate: jest.fn() },
});
});

describe('end-to-end scenarios', () => {
it('should detect and block orphaned KMS alias before deployment', async () => {
mockStackRepository.getStack.mockResolvedValue({
stackName: 'my-app-dev',
stackStatus: 'UPDATE_COMPLETE',
});

const template = {
resources: {
MyKmsAlias: {
Type: 'AWS::KMS::Alias',
Properties: {
AliasName: 'alias/my-app-dev-kms',
},
},
},
};

useCase.templateParser.parseTemplate.mockResolvedValue(template);

mockResourceDetector.findOrphanedResources.mockResolvedValue([
{
physicalId: 'alias/my-app-dev-kms',
resourceType: 'AWS::KMS::Alias',
properties: { AliasName: 'alias/my-app-dev-kms' },
},
]);

const stackIdentifier = new StackIdentifier({
stackName: 'my-app-dev',
region: 'us-east-1',
});

const result = await useCase.execute({
stackIdentifier,
templatePath: '/fake/path/template.json',
});

expect(result.canDeploy).toBe(false);
expect(result.blockingIssues).toHaveLength(1);
expect(result.blockingIssues[0].issue.resourceType).toBe('AWS::KMS::Alias');
expect(result.blockingIssues[0].category.isBlocking()).toBe(true);
});

it('should allow deployment when no issues found', async () => {
mockStackRepository.getStack.mockResolvedValue({
stackName: 'my-app-prod',
stackStatus: 'UPDATE_COMPLETE',
});

const template = {
resources: {
MyLambda: {
Type: 'AWS::Lambda::Function',
Properties: {
FunctionName: 'my-function',
},
},
},
};

useCase.templateParser.parseTemplate.mockResolvedValue(template);

const stackIdentifier = new StackIdentifier({
stackName: 'my-app-prod',
region: 'us-east-1',
});

const result = await useCase.execute({
stackIdentifier,
templatePath: '/fake/path/template.json',
});

expect(result.canDeploy).toBe(true);
expect(result.blockingIssues).toHaveLength(0);
expect(result.summary).toEqual({
total: 0,
blocking: 0,
warnings: 0,
});
});

it('should block deployment when stack in ROLLBACK_COMPLETE', async () => {
mockStackRepository.getStack.mockResolvedValue({
stackName: 'my-app-broken',
stackStatus: 'ROLLBACK_COMPLETE',
});

const template = { resources: {} };
useCase.templateParser.parseTemplate.mockResolvedValue(template);

const stackIdentifier = new StackIdentifier({
stackName: 'my-app-broken',
region: 'us-east-1',
});

const result = await useCase.execute({
stackIdentifier,
templatePath: '/fake/path/template.json',
});

expect(result.canDeploy).toBe(false);
expect(result.blockingIssues).toHaveLength(1);
expect(result.blockingIssues[0].issue.stackStatus).toBe('ROLLBACK_COMPLETE');
});

it('should handle first-time deployment (stack does not exist)', async () => {
mockStackRepository.getStack.mockRejectedValue({
code: 'ValidationError',
message: 'Stack does not exist',
});

const template = {
resources: {
MyBucket: {
Type: 'AWS::S3::Bucket',
Properties: { BucketName: 'my-new-bucket' },
},
},
};

useCase.templateParser.parseTemplate.mockResolvedValue(template);

const stackIdentifier = new StackIdentifier({
stackName: 'my-new-stack',
region: 'us-east-1',
});

const result = await useCase.execute({
stackIdentifier,
templatePath: '/fake/path/template.json',
});

expect(result.canDeploy).toBe(true);
expect(result.stackExists).toBe(false);
expect(result.blockingIssues).toHaveLength(0);
});
});
});
Loading
Loading