cloud-cost-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/bin/cloud-cost-cli.d.ts +2 -0
- package/dist/bin/cloud-cost-cli.js +23 -0
- package/dist/src/analyzers/cost-estimator.d.ts +21 -0
- package/dist/src/analyzers/cost-estimator.js +86 -0
- package/dist/src/commands/scan.d.ts +12 -0
- package/dist/src/commands/scan.js +111 -0
- package/dist/src/providers/aws/client.d.ts +27 -0
- package/dist/src/providers/aws/client.js +53 -0
- package/dist/src/providers/aws/ebs.d.ts +3 -0
- package/dist/src/providers/aws/ebs.js +41 -0
- package/dist/src/providers/aws/ec2.d.ts +3 -0
- package/dist/src/providers/aws/ec2.js +75 -0
- package/dist/src/providers/aws/eip.d.ts +3 -0
- package/dist/src/providers/aws/eip.js +38 -0
- package/dist/src/providers/aws/elb.d.ts +3 -0
- package/dist/src/providers/aws/elb.js +66 -0
- package/dist/src/providers/aws/rds.d.ts +3 -0
- package/dist/src/providers/aws/rds.js +92 -0
- package/dist/src/providers/aws/s3.d.ts +3 -0
- package/dist/src/providers/aws/s3.js +54 -0
- package/dist/src/reporters/json.d.ts +2 -0
- package/dist/src/reporters/json.js +6 -0
- package/dist/src/reporters/table.d.ts +2 -0
- package/dist/src/reporters/table.js +41 -0
- package/dist/src/types/index.d.ts +2 -0
- package/dist/src/types/index.js +18 -0
- package/dist/src/types/opportunity.d.ts +31 -0
- package/dist/src/types/opportunity.js +2 -0
- package/dist/src/types/provider.d.ts +15 -0
- package/dist/src/types/provider.js +2 -0
- package/dist/src/utils/formatter.d.ts +3 -0
- package/dist/src/utils/formatter.js +16 -0
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.js +18 -0
- package/dist/src/utils/logger.d.ts +6 -0
- package/dist/src/utils/logger.js +30 -0
- package/docs/RELEASE.md +143 -0
- package/docs/contributing.md +201 -0
- package/docs/iam-policy.json +25 -0
- package/docs/installation.md +208 -0
- package/package.json +69 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyzeEC2Instances = analyzeEC2Instances;
|
|
7
|
+
const client_ec2_1 = require("@aws-sdk/client-ec2");
|
|
8
|
+
const client_cloudwatch_1 = require("@aws-sdk/client-cloudwatch");
|
|
9
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
10
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
11
|
+
async function analyzeEC2Instances(client) {
|
|
12
|
+
const ec2Client = client.getEC2Client();
|
|
13
|
+
const cloudwatchClient = client.getCloudWatchClient();
|
|
14
|
+
const result = await ec2Client.send(new client_ec2_1.DescribeInstancesCommand({
|
|
15
|
+
Filters: [{ Name: 'instance-state-name', Values: ['running'] }],
|
|
16
|
+
}));
|
|
17
|
+
const instances = [];
|
|
18
|
+
for (const reservation of result.Reservations || []) {
|
|
19
|
+
instances.push(...(reservation.Instances || []));
|
|
20
|
+
}
|
|
21
|
+
const opportunities = [];
|
|
22
|
+
for (const instance of instances) {
|
|
23
|
+
if (!instance.InstanceId || !instance.InstanceType)
|
|
24
|
+
continue;
|
|
25
|
+
// Get average CPU over last 30 days
|
|
26
|
+
const avgCpu = await getAvgCPU(cloudwatchClient, instance.InstanceId, 30);
|
|
27
|
+
if (avgCpu < 5) {
|
|
28
|
+
const monthlyCost = (0, cost_estimator_1.getEC2MonthlyCost)(instance.InstanceType);
|
|
29
|
+
opportunities.push({
|
|
30
|
+
id: `ec2-idle-${instance.InstanceId}`,
|
|
31
|
+
provider: 'aws',
|
|
32
|
+
resourceType: 'ec2',
|
|
33
|
+
resourceId: instance.InstanceId,
|
|
34
|
+
resourceName: instance.Tags?.find((t) => t.Key === 'Name')?.Value,
|
|
35
|
+
category: 'idle',
|
|
36
|
+
currentCost: monthlyCost,
|
|
37
|
+
estimatedSavings: monthlyCost,
|
|
38
|
+
confidence: 'high',
|
|
39
|
+
recommendation: `Stop instance or downsize to t3.small (avg CPU: ${avgCpu.toFixed(1)}%)`,
|
|
40
|
+
metadata: {
|
|
41
|
+
instanceType: instance.InstanceType,
|
|
42
|
+
avgCpu,
|
|
43
|
+
state: instance.State?.Name,
|
|
44
|
+
},
|
|
45
|
+
detectedAt: new Date(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return opportunities;
|
|
50
|
+
}
|
|
51
|
+
async function getAvgCPU(cloudwatchClient, instanceId, days) {
|
|
52
|
+
const endTime = new Date();
|
|
53
|
+
const startTime = (0, dayjs_1.default)(endTime).subtract(days, 'day').toDate();
|
|
54
|
+
try {
|
|
55
|
+
const result = await cloudwatchClient.send(new client_cloudwatch_1.GetMetricStatisticsCommand({
|
|
56
|
+
Namespace: 'AWS/EC2',
|
|
57
|
+
MetricName: 'CPUUtilization',
|
|
58
|
+
Dimensions: [{ Name: 'InstanceId', Value: instanceId }],
|
|
59
|
+
StartTime: startTime,
|
|
60
|
+
EndTime: endTime,
|
|
61
|
+
Period: 86400, // 1 day
|
|
62
|
+
Statistics: [client_cloudwatch_1.Statistic.Average],
|
|
63
|
+
}));
|
|
64
|
+
if (!result.Datapoints || result.Datapoints.length === 0) {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const avg = result.Datapoints.reduce((sum, dp) => sum + (dp.Average || 0), 0) /
|
|
68
|
+
result.Datapoints.length;
|
|
69
|
+
return avg;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// CloudWatch metrics may not be available for all instances
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeElasticIPs = analyzeElasticIPs;
|
|
4
|
+
const client_ec2_1 = require("@aws-sdk/client-ec2");
|
|
5
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
6
|
+
async function analyzeElasticIPs(client) {
|
|
7
|
+
const ec2Client = client.getEC2Client();
|
|
8
|
+
const result = await ec2Client.send(new client_ec2_1.DescribeAddressesCommand({}));
|
|
9
|
+
const addresses = result.Addresses || [];
|
|
10
|
+
const opportunities = [];
|
|
11
|
+
for (const address of addresses) {
|
|
12
|
+
if (!address.PublicIp)
|
|
13
|
+
continue;
|
|
14
|
+
// Check if EIP is not associated with a running instance
|
|
15
|
+
if (!address.InstanceId || !address.AssociationId) {
|
|
16
|
+
const monthlyCost = (0, cost_estimator_1.getEIPMonthlyCost)();
|
|
17
|
+
opportunities.push({
|
|
18
|
+
id: `eip-unattached-${address.AllocationId || address.PublicIp}`,
|
|
19
|
+
provider: 'aws',
|
|
20
|
+
resourceType: 'eip',
|
|
21
|
+
resourceId: address.AllocationId || address.PublicIp,
|
|
22
|
+
resourceName: address.PublicIp,
|
|
23
|
+
category: 'unused',
|
|
24
|
+
currentCost: monthlyCost,
|
|
25
|
+
estimatedSavings: monthlyCost,
|
|
26
|
+
confidence: 'high',
|
|
27
|
+
recommendation: `Release unattached Elastic IP`,
|
|
28
|
+
metadata: {
|
|
29
|
+
publicIp: address.PublicIp,
|
|
30
|
+
allocationId: address.AllocationId,
|
|
31
|
+
domain: address.Domain,
|
|
32
|
+
},
|
|
33
|
+
detectedAt: new Date(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return opportunities;
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeELBs = analyzeELBs;
|
|
4
|
+
const client_elastic_load_balancing_v2_1 = require("@aws-sdk/client-elastic-load-balancing-v2");
|
|
5
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
6
|
+
async function analyzeELBs(client) {
|
|
7
|
+
const elbClient = client.getELBClient();
|
|
8
|
+
const result = await elbClient.send(new client_elastic_load_balancing_v2_1.DescribeLoadBalancersCommand({}));
|
|
9
|
+
const loadBalancers = result.LoadBalancers || [];
|
|
10
|
+
const opportunities = [];
|
|
11
|
+
for (const lb of loadBalancers) {
|
|
12
|
+
if (!lb.LoadBalancerArn || !lb.LoadBalancerName)
|
|
13
|
+
continue;
|
|
14
|
+
// Check if load balancer has active targets
|
|
15
|
+
const hasActiveTargets = await checkActiveTargets(elbClient, lb.LoadBalancerArn);
|
|
16
|
+
if (!hasActiveTargets) {
|
|
17
|
+
const lbType = lb.Type === 'application' ? 'alb' : lb.Type === 'network' ? 'nlb' : 'alb';
|
|
18
|
+
const monthlyCost = (0, cost_estimator_1.getELBMonthlyCost)(lbType);
|
|
19
|
+
opportunities.push({
|
|
20
|
+
id: `elb-unused-${lb.LoadBalancerName}`,
|
|
21
|
+
provider: 'aws',
|
|
22
|
+
resourceType: 'elb',
|
|
23
|
+
resourceId: lb.LoadBalancerArn,
|
|
24
|
+
resourceName: lb.LoadBalancerName,
|
|
25
|
+
category: 'unused',
|
|
26
|
+
currentCost: monthlyCost,
|
|
27
|
+
estimatedSavings: monthlyCost,
|
|
28
|
+
confidence: 'high',
|
|
29
|
+
recommendation: `Delete unused load balancer (no active targets)`,
|
|
30
|
+
metadata: {
|
|
31
|
+
loadBalancerName: lb.LoadBalancerName,
|
|
32
|
+
type: lb.Type,
|
|
33
|
+
scheme: lb.Scheme,
|
|
34
|
+
createdTime: lb.CreatedTime,
|
|
35
|
+
},
|
|
36
|
+
detectedAt: new Date(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return opportunities;
|
|
41
|
+
}
|
|
42
|
+
async function checkActiveTargets(elbClient, loadBalancerArn) {
|
|
43
|
+
try {
|
|
44
|
+
// Get target groups for this load balancer
|
|
45
|
+
const tgResult = await elbClient.send(new client_elastic_load_balancing_v2_1.DescribeTargetGroupsCommand({ LoadBalancerArn: loadBalancerArn }));
|
|
46
|
+
const targetGroups = tgResult.TargetGroups || [];
|
|
47
|
+
if (targetGroups.length === 0) {
|
|
48
|
+
return false; // No target groups = unused
|
|
49
|
+
}
|
|
50
|
+
// Check if any target group has healthy targets
|
|
51
|
+
for (const tg of targetGroups) {
|
|
52
|
+
if (!tg.TargetGroupArn)
|
|
53
|
+
continue;
|
|
54
|
+
const healthResult = await elbClient.send(new client_elastic_load_balancing_v2_1.DescribeTargetHealthCommand({ TargetGroupArn: tg.TargetGroupArn }));
|
|
55
|
+
const healthyTargets = (healthResult.TargetHealthDescriptions || []).filter((t) => t.TargetHealth?.State === 'healthy');
|
|
56
|
+
if (healthyTargets.length > 0) {
|
|
57
|
+
return true; // Has at least one healthy target
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false; // No healthy targets found
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// If we can't determine, assume it's in use (conservative)
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyzeRDSInstances = analyzeRDSInstances;
|
|
7
|
+
const client_rds_1 = require("@aws-sdk/client-rds");
|
|
8
|
+
const client_cloudwatch_1 = require("@aws-sdk/client-cloudwatch");
|
|
9
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
10
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
11
|
+
async function analyzeRDSInstances(client) {
|
|
12
|
+
const rdsClient = client.getRDSClient();
|
|
13
|
+
const cloudwatchClient = client.getCloudWatchClient();
|
|
14
|
+
const result = await rdsClient.send(new client_rds_1.DescribeDBInstancesCommand({}));
|
|
15
|
+
const instances = result.DBInstances || [];
|
|
16
|
+
const opportunities = [];
|
|
17
|
+
for (const instance of instances) {
|
|
18
|
+
if (!instance.DBInstanceIdentifier || !instance.DBInstanceClass)
|
|
19
|
+
continue;
|
|
20
|
+
// Get average CPU and connections over last 30 days
|
|
21
|
+
const avgCpu = await getAvgMetric(cloudwatchClient, instance.DBInstanceIdentifier, 'CPUUtilization', 30);
|
|
22
|
+
const avgConnections = await getAvgMetric(cloudwatchClient, instance.DBInstanceIdentifier, 'DatabaseConnections', 30);
|
|
23
|
+
// Detect oversized: low CPU (<20%) or low connections
|
|
24
|
+
if (avgCpu < 20 && avgCpu > 0) {
|
|
25
|
+
const currentCost = (0, cost_estimator_1.getRDSMonthlyCost)(instance.DBInstanceClass);
|
|
26
|
+
// Estimate smaller instance class (rough heuristic)
|
|
27
|
+
const smallerClass = getSmallerInstanceClass(instance.DBInstanceClass);
|
|
28
|
+
const proposedCost = smallerClass ? (0, cost_estimator_1.getRDSMonthlyCost)(smallerClass) : 0;
|
|
29
|
+
const savings = currentCost - proposedCost;
|
|
30
|
+
if (savings > 0) {
|
|
31
|
+
opportunities.push({
|
|
32
|
+
id: `rds-oversized-${instance.DBInstanceIdentifier}`,
|
|
33
|
+
provider: 'aws',
|
|
34
|
+
resourceType: 'rds',
|
|
35
|
+
resourceId: instance.DBInstanceIdentifier,
|
|
36
|
+
resourceName: instance.DBInstanceIdentifier,
|
|
37
|
+
category: 'oversized',
|
|
38
|
+
currentCost,
|
|
39
|
+
estimatedSavings: savings,
|
|
40
|
+
confidence: 'medium',
|
|
41
|
+
recommendation: `Downsize to ${smallerClass || 'smaller instance'} (avg CPU: ${avgCpu.toFixed(1)}%, avg connections: ${avgConnections.toFixed(0)})`,
|
|
42
|
+
metadata: {
|
|
43
|
+
instanceClass: instance.DBInstanceClass,
|
|
44
|
+
avgCpu,
|
|
45
|
+
avgConnections,
|
|
46
|
+
engine: instance.Engine,
|
|
47
|
+
},
|
|
48
|
+
detectedAt: new Date(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return opportunities;
|
|
54
|
+
}
|
|
55
|
+
async function getAvgMetric(cloudwatchClient, dbInstanceId, metricName, days) {
|
|
56
|
+
const endTime = new Date();
|
|
57
|
+
const startTime = (0, dayjs_1.default)(endTime).subtract(days, 'day').toDate();
|
|
58
|
+
try {
|
|
59
|
+
const result = await cloudwatchClient.send(new client_cloudwatch_1.GetMetricStatisticsCommand({
|
|
60
|
+
Namespace: 'AWS/RDS',
|
|
61
|
+
MetricName: metricName,
|
|
62
|
+
Dimensions: [{ Name: 'DBInstanceIdentifier', Value: dbInstanceId }],
|
|
63
|
+
StartTime: startTime,
|
|
64
|
+
EndTime: endTime,
|
|
65
|
+
Period: 86400, // 1 day
|
|
66
|
+
Statistics: [client_cloudwatch_1.Statistic.Average],
|
|
67
|
+
}));
|
|
68
|
+
if (!result.Datapoints || result.Datapoints.length === 0) {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
const avg = result.Datapoints.reduce((sum, dp) => sum + (dp.Average || 0), 0) /
|
|
72
|
+
result.Datapoints.length;
|
|
73
|
+
return avg;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function getSmallerInstanceClass(currentClass) {
|
|
80
|
+
// Simple downsize heuristic: xlarge -> large, large -> medium, etc.
|
|
81
|
+
const downsizeMap = {
|
|
82
|
+
'db.t3.large': 'db.t3.medium',
|
|
83
|
+
'db.t3.xlarge': 'db.t3.large',
|
|
84
|
+
'db.m5.large': 'db.t3.large',
|
|
85
|
+
'db.m5.xlarge': 'db.m5.large',
|
|
86
|
+
'db.m5.2xlarge': 'db.m5.xlarge',
|
|
87
|
+
'db.r5.large': 'db.t3.large',
|
|
88
|
+
'db.r5.xlarge': 'db.r5.large',
|
|
89
|
+
'db.r5.2xlarge': 'db.r5.xlarge',
|
|
90
|
+
};
|
|
91
|
+
return downsizeMap[currentClass] || null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeS3Buckets = analyzeS3Buckets;
|
|
4
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
5
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
6
|
+
async function analyzeS3Buckets(client) {
|
|
7
|
+
const s3Client = client.getS3Client();
|
|
8
|
+
const result = await s3Client.send(new client_s3_1.ListBucketsCommand({}));
|
|
9
|
+
const buckets = result.Buckets || [];
|
|
10
|
+
const opportunities = [];
|
|
11
|
+
for (const bucket of buckets) {
|
|
12
|
+
if (!bucket.Name)
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
// Check if bucket has lifecycle policy
|
|
16
|
+
await s3Client.send(new client_s3_1.GetBucketLifecycleConfigurationCommand({ Bucket: bucket.Name }));
|
|
17
|
+
// If we get here, lifecycle exists — skip this bucket
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
// NoSuchLifecycleConfiguration means no lifecycle policy
|
|
21
|
+
if (error.name === 'NoSuchLifecycleConfiguration') {
|
|
22
|
+
// Estimate bucket size (we can't get exact size without CloudWatch or S3 Storage Lens)
|
|
23
|
+
// For MVP, assume buckets without lifecycle are worth flagging
|
|
24
|
+
// In production, integrate with CloudWatch GetMetricStatistics for BucketSizeBytes
|
|
25
|
+
const estimatedSizeGB = 100; // Placeholder: assume 100 GB for flagged buckets
|
|
26
|
+
const currentCost = (0, cost_estimator_1.getS3MonthlyCost)(estimatedSizeGB, 'standard');
|
|
27
|
+
const glacierCost = (0, cost_estimator_1.getS3MonthlyCost)(estimatedSizeGB * 0.5, 'glacier'); // Assume 50% can move to Glacier
|
|
28
|
+
const savings = currentCost - glacierCost - (estimatedSizeGB * 0.5 * 0.023); // Remaining in standard
|
|
29
|
+
if (savings > 5) {
|
|
30
|
+
// Only flag if savings > $5/month
|
|
31
|
+
opportunities.push({
|
|
32
|
+
id: `s3-no-lifecycle-${bucket.Name}`,
|
|
33
|
+
provider: 'aws',
|
|
34
|
+
resourceType: 's3',
|
|
35
|
+
resourceId: bucket.Name,
|
|
36
|
+
resourceName: bucket.Name,
|
|
37
|
+
category: 'misconfigured',
|
|
38
|
+
currentCost,
|
|
39
|
+
estimatedSavings: savings,
|
|
40
|
+
confidence: 'low',
|
|
41
|
+
recommendation: `Enable lifecycle policy (Intelligent-Tiering or Glacier transition)`,
|
|
42
|
+
metadata: {
|
|
43
|
+
bucketName: bucket.Name,
|
|
44
|
+
estimatedSizeGB,
|
|
45
|
+
creationDate: bucket.CreationDate,
|
|
46
|
+
},
|
|
47
|
+
detectedAt: new Date(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return opportunities;
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderTable = renderTable;
|
|
7
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const utils_1 = require("../utils");
|
|
10
|
+
function renderTable(report, topN = 5) {
|
|
11
|
+
console.log(chalk_1.default.bold('\nCloud Cost Optimization Report'));
|
|
12
|
+
console.log(`Provider: ${report.provider} | Region: ${report.region} | Account: ${report.accountId}`);
|
|
13
|
+
console.log(`Analyzed: ${report.scanPeriod.start.toISOString().split('T')[0]} to ${report.scanPeriod.end.toISOString().split('T')[0]}\n`);
|
|
14
|
+
const opportunities = report.opportunities
|
|
15
|
+
.sort((a, b) => b.estimatedSavings - a.estimatedSavings)
|
|
16
|
+
.slice(0, topN);
|
|
17
|
+
if (opportunities.length === 0) {
|
|
18
|
+
console.log(chalk_1.default.green('✓ No cost optimization opportunities found!'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(chalk_1.default.bold(`Top ${opportunities.length} Savings Opportunities (est. ${(0, utils_1.formatCurrency)(report.totalPotentialSavings)}/month):\n`));
|
|
22
|
+
const table = new cli_table3_1.default({
|
|
23
|
+
head: ['#', 'Type', 'Resource ID', 'Recommendation', 'Savings/mo'],
|
|
24
|
+
colWidths: [5, 10, 25, 50, 15],
|
|
25
|
+
style: {
|
|
26
|
+
head: ['cyan'],
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
opportunities.forEach((opp, index) => {
|
|
30
|
+
table.push([
|
|
31
|
+
(index + 1).toString(),
|
|
32
|
+
opp.resourceType.toUpperCase(),
|
|
33
|
+
opp.resourceId,
|
|
34
|
+
opp.recommendation,
|
|
35
|
+
chalk_1.default.green((0, utils_1.formatCurrency)(opp.estimatedSavings)),
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
console.log(table.toString());
|
|
39
|
+
console.log(chalk_1.default.bold(`\nTotal potential savings: ${chalk_1.default.green((0, utils_1.formatCurrency)(report.totalPotentialSavings))}/month (${chalk_1.default.green((0, utils_1.formatCurrency)(report.totalPotentialSavings * 12))}/year)`));
|
|
40
|
+
console.log(`\nSummary: ${report.summary.totalResources} resources analyzed | ${report.summary.idleResources} idle | ${report.summary.oversizedResources} oversized | ${report.summary.unusedResources} unused\n`);
|
|
41
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./opportunity"), exports);
|
|
18
|
+
__exportStar(require("./provider"), exports);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SavingsOpportunity {
|
|
2
|
+
id: string;
|
|
3
|
+
provider: 'aws' | 'gcp' | 'azure';
|
|
4
|
+
resourceType: string;
|
|
5
|
+
resourceId: string;
|
|
6
|
+
resourceName?: string;
|
|
7
|
+
category: 'idle' | 'oversized' | 'unused' | 'misconfigured';
|
|
8
|
+
currentCost: number;
|
|
9
|
+
estimatedSavings: number;
|
|
10
|
+
confidence: 'high' | 'medium' | 'low';
|
|
11
|
+
recommendation: string;
|
|
12
|
+
metadata: Record<string, any>;
|
|
13
|
+
detectedAt: Date;
|
|
14
|
+
}
|
|
15
|
+
export interface ScanReport {
|
|
16
|
+
provider: string;
|
|
17
|
+
accountId: string;
|
|
18
|
+
region: string;
|
|
19
|
+
scanPeriod: {
|
|
20
|
+
start: Date;
|
|
21
|
+
end: Date;
|
|
22
|
+
};
|
|
23
|
+
opportunities: SavingsOpportunity[];
|
|
24
|
+
totalPotentialSavings: number;
|
|
25
|
+
summary: {
|
|
26
|
+
totalResources: number;
|
|
27
|
+
idleResources: number;
|
|
28
|
+
oversizedResources: number;
|
|
29
|
+
unusedResources: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ScanReport } from './opportunity';
|
|
2
|
+
export interface CloudProvider {
|
|
3
|
+
name: string;
|
|
4
|
+
scan(options: ScanOptions): Promise<ScanReport>;
|
|
5
|
+
}
|
|
6
|
+
export interface ScanOptions {
|
|
7
|
+
region?: string;
|
|
8
|
+
profile?: string;
|
|
9
|
+
startDate?: Date;
|
|
10
|
+
endDate?: Date;
|
|
11
|
+
thresholds?: {
|
|
12
|
+
idleCpuPercent?: number;
|
|
13
|
+
minAgeDays?: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatCurrency = formatCurrency;
|
|
4
|
+
exports.formatDate = formatDate;
|
|
5
|
+
exports.daysSince = daysSince;
|
|
6
|
+
function formatCurrency(amount) {
|
|
7
|
+
return `$${amount.toFixed(2)}`;
|
|
8
|
+
}
|
|
9
|
+
function formatDate(date) {
|
|
10
|
+
return date.toISOString().split('T')[0];
|
|
11
|
+
}
|
|
12
|
+
function daysSince(date) {
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const diff = now.getTime() - date.getTime();
|
|
15
|
+
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./logger"), exports);
|
|
18
|
+
__exportStar(require("./formatter"), exports);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function log(message: string): void;
|
|
2
|
+
export declare function success(message: string): void;
|
|
3
|
+
export declare function error(message: string): void;
|
|
4
|
+
export declare function warn(message: string): void;
|
|
5
|
+
export declare function info(message: string): void;
|
|
6
|
+
export declare function progress(message: string): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.log = log;
|
|
7
|
+
exports.success = success;
|
|
8
|
+
exports.error = error;
|
|
9
|
+
exports.warn = warn;
|
|
10
|
+
exports.info = info;
|
|
11
|
+
exports.progress = progress;
|
|
12
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
13
|
+
function log(message) {
|
|
14
|
+
console.log(message);
|
|
15
|
+
}
|
|
16
|
+
function success(message) {
|
|
17
|
+
console.log(chalk_1.default.green('✓'), message);
|
|
18
|
+
}
|
|
19
|
+
function error(message) {
|
|
20
|
+
console.error(chalk_1.default.red('✗'), message);
|
|
21
|
+
}
|
|
22
|
+
function warn(message) {
|
|
23
|
+
console.warn(chalk_1.default.yellow('⚠'), message);
|
|
24
|
+
}
|
|
25
|
+
function info(message) {
|
|
26
|
+
console.log(chalk_1.default.blue('ℹ'), message);
|
|
27
|
+
}
|
|
28
|
+
function progress(message) {
|
|
29
|
+
process.stdout.write(chalk_1.default.gray(message));
|
|
30
|
+
}
|