cloud-cost-cli 0.1.1 ā 0.2.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/README.md +116 -122
- package/dist/bin/cloud-cost-cli.js +5 -2
- package/dist/src/analyzers/cost-estimator.d.ts +14 -0
- package/dist/src/analyzers/cost-estimator.js +74 -16
- package/dist/src/analyzers/pricing-service.d.ts +38 -0
- package/dist/src/analyzers/pricing-service.js +263 -0
- package/dist/src/commands/scan.d.ts +3 -0
- package/dist/src/commands/scan.js +173 -86
- package/dist/src/providers/azure/client.d.ts +20 -0
- package/dist/src/providers/azure/client.js +41 -0
- package/dist/src/providers/azure/disks.d.ts +4 -0
- package/dist/src/providers/azure/disks.js +87 -0
- package/dist/src/providers/azure/index.d.ts +6 -0
- package/dist/src/providers/azure/index.js +15 -0
- package/dist/src/providers/azure/public-ips.d.ts +3 -0
- package/dist/src/providers/azure/public-ips.js +47 -0
- package/dist/src/providers/azure/sql.d.ts +4 -0
- package/dist/src/providers/azure/sql.js +134 -0
- package/dist/src/providers/azure/storage.d.ts +8 -0
- package/dist/src/providers/azure/storage.js +100 -0
- package/dist/src/providers/azure/vms.d.ts +4 -0
- package/dist/src/providers/azure/vms.js +164 -0
- package/dist/src/reporters/table.js +3 -1
- package/dist/src/utils/formatter.d.ts +2 -0
- package/dist/src/utils/formatter.js +29 -1
- package/docs/RELEASE.md +14 -25
- package/package.json +12 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AZURE_DISK_PRICING = void 0;
|
|
4
|
+
exports.analyzeAzureDisks = analyzeAzureDisks;
|
|
5
|
+
// Azure Managed Disk pricing (per GB/month, East US)
|
|
6
|
+
exports.AZURE_DISK_PRICING = {
|
|
7
|
+
'Premium_LRS': 0.135, // Premium SSD
|
|
8
|
+
'StandardSSD_LRS': 0.075, // Standard SSD
|
|
9
|
+
'Standard_LRS': 0.045, // Standard HDD
|
|
10
|
+
};
|
|
11
|
+
function getDiskMonthlyCost(sizeGB, diskType) {
|
|
12
|
+
const pricePerGB = exports.AZURE_DISK_PRICING[diskType] || 0.075;
|
|
13
|
+
return sizeGB * pricePerGB;
|
|
14
|
+
}
|
|
15
|
+
async function analyzeAzureDisks(client) {
|
|
16
|
+
const computeClient = client.getComputeClient();
|
|
17
|
+
const opportunities = [];
|
|
18
|
+
try {
|
|
19
|
+
// List all managed disks
|
|
20
|
+
const disks = computeClient.disks.list();
|
|
21
|
+
for await (const disk of disks) {
|
|
22
|
+
if (!disk.id || !disk.name)
|
|
23
|
+
continue;
|
|
24
|
+
// Filter by location if specified
|
|
25
|
+
if (client.location && disk.location?.toLowerCase() !== client.location.toLowerCase()) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const sizeGB = disk.diskSizeGB || 0;
|
|
29
|
+
const diskType = disk.sku?.name || 'Standard_LRS';
|
|
30
|
+
const currentCost = getDiskMonthlyCost(sizeGB, diskType);
|
|
31
|
+
// Opportunity 1: Unattached disk
|
|
32
|
+
if (disk.diskState === 'Unattached') {
|
|
33
|
+
opportunities.push({
|
|
34
|
+
id: `azure-disk-unattached-${disk.name}`,
|
|
35
|
+
provider: 'azure',
|
|
36
|
+
resourceType: 'disk',
|
|
37
|
+
resourceId: disk.id,
|
|
38
|
+
resourceName: disk.name,
|
|
39
|
+
category: 'unused',
|
|
40
|
+
currentCost,
|
|
41
|
+
estimatedSavings: currentCost,
|
|
42
|
+
confidence: 'high',
|
|
43
|
+
recommendation: `Unattached disk (${sizeGB} GB). Delete if no longer needed.`,
|
|
44
|
+
metadata: {
|
|
45
|
+
sizeGB,
|
|
46
|
+
diskType,
|
|
47
|
+
location: disk.location,
|
|
48
|
+
diskState: disk.diskState,
|
|
49
|
+
},
|
|
50
|
+
detectedAt: new Date(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Opportunity 2: Premium disk that could be Standard SSD
|
|
54
|
+
else if (diskType === 'Premium_LRS' && sizeGB < 256) {
|
|
55
|
+
const newType = 'StandardSSD_LRS';
|
|
56
|
+
const newCost = getDiskMonthlyCost(sizeGB, newType);
|
|
57
|
+
const savings = currentCost - newCost;
|
|
58
|
+
if (savings > 5) {
|
|
59
|
+
opportunities.push({
|
|
60
|
+
id: `azure-disk-premium-${disk.name}`,
|
|
61
|
+
provider: 'azure',
|
|
62
|
+
resourceType: 'disk',
|
|
63
|
+
resourceId: disk.id,
|
|
64
|
+
resourceName: disk.name,
|
|
65
|
+
category: 'oversized',
|
|
66
|
+
currentCost,
|
|
67
|
+
estimatedSavings: savings,
|
|
68
|
+
confidence: 'medium',
|
|
69
|
+
recommendation: `Consider switching from Premium SSD to Standard SSD for non-performance-critical workloads.`,
|
|
70
|
+
metadata: {
|
|
71
|
+
sizeGB,
|
|
72
|
+
currentType: diskType,
|
|
73
|
+
suggestedType: newType,
|
|
74
|
+
location: disk.location,
|
|
75
|
+
},
|
|
76
|
+
detectedAt: new Date(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return opportunities;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error('Error analyzing Azure disks:', error);
|
|
85
|
+
return opportunities;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { AzureClient } from './client';
|
|
2
|
+
export { analyzeAzureVMs } from './vms';
|
|
3
|
+
export { analyzeAzureDisks } from './disks';
|
|
4
|
+
export { analyzeAzureStorage } from './storage';
|
|
5
|
+
export { analyzeAzureSQL } from './sql';
|
|
6
|
+
export { analyzeAzurePublicIPs } from './public-ips';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeAzurePublicIPs = exports.analyzeAzureSQL = exports.analyzeAzureStorage = exports.analyzeAzureDisks = exports.analyzeAzureVMs = exports.AzureClient = void 0;
|
|
4
|
+
var client_1 = require("./client");
|
|
5
|
+
Object.defineProperty(exports, "AzureClient", { enumerable: true, get: function () { return client_1.AzureClient; } });
|
|
6
|
+
var vms_1 = require("./vms");
|
|
7
|
+
Object.defineProperty(exports, "analyzeAzureVMs", { enumerable: true, get: function () { return vms_1.analyzeAzureVMs; } });
|
|
8
|
+
var disks_1 = require("./disks");
|
|
9
|
+
Object.defineProperty(exports, "analyzeAzureDisks", { enumerable: true, get: function () { return disks_1.analyzeAzureDisks; } });
|
|
10
|
+
var storage_1 = require("./storage");
|
|
11
|
+
Object.defineProperty(exports, "analyzeAzureStorage", { enumerable: true, get: function () { return storage_1.analyzeAzureStorage; } });
|
|
12
|
+
var sql_1 = require("./sql");
|
|
13
|
+
Object.defineProperty(exports, "analyzeAzureSQL", { enumerable: true, get: function () { return sql_1.analyzeAzureSQL; } });
|
|
14
|
+
var public_ips_1 = require("./public-ips");
|
|
15
|
+
Object.defineProperty(exports, "analyzeAzurePublicIPs", { enumerable: true, get: function () { return public_ips_1.analyzeAzurePublicIPs; } });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeAzurePublicIPs = analyzeAzurePublicIPs;
|
|
4
|
+
// Azure Public IP pricing (per month, East US)
|
|
5
|
+
const PUBLIC_IP_MONTHLY_COST = 3.65; // Static IP address
|
|
6
|
+
async function analyzeAzurePublicIPs(client) {
|
|
7
|
+
const networkClient = client.getNetworkClient();
|
|
8
|
+
const opportunities = [];
|
|
9
|
+
try {
|
|
10
|
+
// List all public IP addresses
|
|
11
|
+
const publicIPs = networkClient.publicIPAddresses.listAll();
|
|
12
|
+
for await (const ip of publicIPs) {
|
|
13
|
+
if (!ip.id || !ip.name)
|
|
14
|
+
continue;
|
|
15
|
+
// Filter by location if specified
|
|
16
|
+
if (client.location && ip.location?.toLowerCase() !== client.location.toLowerCase()) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Opportunity: Unassociated public IP
|
|
20
|
+
if (!ip.ipConfiguration) {
|
|
21
|
+
opportunities.push({
|
|
22
|
+
id: `azure-ip-unassociated-${ip.name}`,
|
|
23
|
+
provider: 'azure',
|
|
24
|
+
resourceType: 'public-ip',
|
|
25
|
+
resourceId: ip.id,
|
|
26
|
+
resourceName: ip.name,
|
|
27
|
+
category: 'unused',
|
|
28
|
+
currentCost: PUBLIC_IP_MONTHLY_COST,
|
|
29
|
+
estimatedSavings: PUBLIC_IP_MONTHLY_COST,
|
|
30
|
+
confidence: 'high',
|
|
31
|
+
recommendation: 'Unassociated public IP address. Delete if not needed.',
|
|
32
|
+
metadata: {
|
|
33
|
+
ipAddress: ip.ipAddress,
|
|
34
|
+
allocationMethod: ip.publicIPAllocationMethod,
|
|
35
|
+
location: ip.location,
|
|
36
|
+
},
|
|
37
|
+
detectedAt: new Date(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return opportunities;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error analyzing Azure public IPs:', error);
|
|
45
|
+
return opportunities;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AZURE_SQL_PRICING = void 0;
|
|
4
|
+
exports.analyzeAzureSQL = analyzeAzureSQL;
|
|
5
|
+
// Azure SQL Database pricing (per month, East US, vCore model)
|
|
6
|
+
exports.AZURE_SQL_PRICING = {
|
|
7
|
+
'GP_Gen5_2': 438.29, // General Purpose, 2 vCores
|
|
8
|
+
'GP_Gen5_4': 876.58, // General Purpose, 4 vCores
|
|
9
|
+
'GP_Gen5_8': 1753.16, // General Purpose, 8 vCores
|
|
10
|
+
'BC_Gen5_2': 876.58, // Business Critical, 2 vCores
|
|
11
|
+
'BC_Gen5_4': 1753.16, // Business Critical, 4 vCores
|
|
12
|
+
};
|
|
13
|
+
function getSQLMonthlyCost(sku) {
|
|
14
|
+
return exports.AZURE_SQL_PRICING[sku] || 500; // Fallback estimate
|
|
15
|
+
}
|
|
16
|
+
async function analyzeAzureSQL(client) {
|
|
17
|
+
const sqlClient = client.getSqlClient();
|
|
18
|
+
const monitorClient = client.getMonitorClient();
|
|
19
|
+
const opportunities = [];
|
|
20
|
+
try {
|
|
21
|
+
// List all SQL servers
|
|
22
|
+
const servers = sqlClient.servers.list();
|
|
23
|
+
for await (const server of servers) {
|
|
24
|
+
if (!server.id || !server.name)
|
|
25
|
+
continue;
|
|
26
|
+
// Filter by location if specified
|
|
27
|
+
if (client.location && server.location?.toLowerCase() !== client.location.toLowerCase()) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const resourceGroup = extractResourceGroup(server.id);
|
|
31
|
+
if (!resourceGroup)
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
// List databases on this server
|
|
35
|
+
const databases = sqlClient.databases.listByServer(resourceGroup, server.name);
|
|
36
|
+
for await (const db of databases) {
|
|
37
|
+
if (!db.id || !db.name || db.name === 'master')
|
|
38
|
+
continue;
|
|
39
|
+
const sku = db.sku?.name || 'Unknown';
|
|
40
|
+
const tier = db.sku?.tier || 'Unknown';
|
|
41
|
+
const currentCost = getSQLMonthlyCost(sku);
|
|
42
|
+
// Get DTU/CPU usage metrics
|
|
43
|
+
const avgDtu = await getAverageDTU(monitorClient, db.id, 7);
|
|
44
|
+
// Opportunity 1: Low utilization database
|
|
45
|
+
if (avgDtu < 10) {
|
|
46
|
+
opportunities.push({
|
|
47
|
+
id: `azure-sql-underutilized-${db.name}`,
|
|
48
|
+
provider: 'azure',
|
|
49
|
+
resourceType: 'sql',
|
|
50
|
+
resourceId: db.id,
|
|
51
|
+
resourceName: db.name,
|
|
52
|
+
category: 'oversized',
|
|
53
|
+
currentCost,
|
|
54
|
+
estimatedSavings: currentCost * 0.5, // Could downsize by ~50%
|
|
55
|
+
confidence: 'medium',
|
|
56
|
+
recommendation: `Database is underutilized (${avgDtu.toFixed(1)}% avg DTU). Consider downsizing tier.`,
|
|
57
|
+
metadata: {
|
|
58
|
+
sku,
|
|
59
|
+
tier,
|
|
60
|
+
avgDtu,
|
|
61
|
+
server: server.name,
|
|
62
|
+
location: db.location,
|
|
63
|
+
},
|
|
64
|
+
detectedAt: new Date(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Opportunity 2: Business Critical that could be General Purpose
|
|
68
|
+
if (tier === 'BusinessCritical' && avgDtu < 30) {
|
|
69
|
+
opportunities.push({
|
|
70
|
+
id: `azure-sql-tier-${db.name}`,
|
|
71
|
+
provider: 'azure',
|
|
72
|
+
resourceType: 'sql',
|
|
73
|
+
resourceId: db.id,
|
|
74
|
+
resourceName: db.name,
|
|
75
|
+
category: 'oversized',
|
|
76
|
+
currentCost,
|
|
77
|
+
estimatedSavings: currentCost * 0.5, // BC is ~2x GP cost
|
|
78
|
+
confidence: 'low',
|
|
79
|
+
recommendation: 'Consider switching from Business Critical to General Purpose tier if high availability is not required.',
|
|
80
|
+
metadata: {
|
|
81
|
+
sku,
|
|
82
|
+
tier,
|
|
83
|
+
avgDtu,
|
|
84
|
+
server: server.name,
|
|
85
|
+
},
|
|
86
|
+
detectedAt: new Date(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// Skip this server if we can't fetch databases
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return opportunities;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
console.error('Error analyzing Azure SQL:', error);
|
|
100
|
+
return opportunities;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function getAverageDTU(monitorClient, resourceId, days) {
|
|
104
|
+
try {
|
|
105
|
+
const endTime = new Date();
|
|
106
|
+
const startTime = new Date();
|
|
107
|
+
startTime.setDate(startTime.getDate() - days);
|
|
108
|
+
const metrics = await monitorClient.metrics.list(resourceId, {
|
|
109
|
+
timespan: `${startTime.toISOString()}/${endTime.toISOString()}`,
|
|
110
|
+
interval: 'PT1H',
|
|
111
|
+
metricnames: 'dtu_consumption_percent',
|
|
112
|
+
aggregation: 'Average',
|
|
113
|
+
});
|
|
114
|
+
const timeseries = metrics.value?.[0]?.timeseries?.[0]?.data || [];
|
|
115
|
+
if (timeseries.length === 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const values = timeseries
|
|
119
|
+
.map((d) => d.average)
|
|
120
|
+
.filter((v) => v !== null && v !== undefined);
|
|
121
|
+
if (values.length === 0) {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
125
|
+
return sum / values.length;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function extractResourceGroup(resourceId) {
|
|
132
|
+
const match = resourceId.match(/resourceGroups\/([^\/]+)/i);
|
|
133
|
+
return match ? match[1] : null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AzureClient } from './client';
|
|
2
|
+
import { SavingsOpportunity } from '../../types/opportunity';
|
|
3
|
+
export declare const AZURE_STORAGE_PRICING: {
|
|
4
|
+
hot: number;
|
|
5
|
+
cool: number;
|
|
6
|
+
archive: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function analyzeAzureStorage(client: AzureClient): Promise<SavingsOpportunity[]>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AZURE_STORAGE_PRICING = void 0;
|
|
4
|
+
exports.analyzeAzureStorage = analyzeAzureStorage;
|
|
5
|
+
// Azure Blob Storage pricing (per GB/month, East US)
|
|
6
|
+
exports.AZURE_STORAGE_PRICING = {
|
|
7
|
+
hot: 0.0184,
|
|
8
|
+
cool: 0.01,
|
|
9
|
+
archive: 0.002,
|
|
10
|
+
};
|
|
11
|
+
async function analyzeAzureStorage(client) {
|
|
12
|
+
const storageClient = client.getStorageClient();
|
|
13
|
+
const opportunities = [];
|
|
14
|
+
try {
|
|
15
|
+
// List all storage accounts
|
|
16
|
+
const accounts = storageClient.storageAccounts.list();
|
|
17
|
+
for await (const account of accounts) {
|
|
18
|
+
if (!account.id || !account.name)
|
|
19
|
+
continue;
|
|
20
|
+
// Filter by location if specified
|
|
21
|
+
if (client.location && account.location?.toLowerCase() !== client.location.toLowerCase()) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const resourceGroup = extractResourceGroup(account.id);
|
|
25
|
+
if (!resourceGroup)
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
// Get blob service properties
|
|
29
|
+
const blobServices = await storageClient.blobServices.getServiceProperties(resourceGroup, account.name);
|
|
30
|
+
// Check if lifecycle management is enabled
|
|
31
|
+
const hasLifecyclePolicy = await hasLifecycleManagement(storageClient, resourceGroup, account.name);
|
|
32
|
+
if (!hasLifecyclePolicy) {
|
|
33
|
+
// Estimate potential savings (assume 30% of data could move to cool/archive)
|
|
34
|
+
// This is a rough estimate - actual savings depend on usage patterns
|
|
35
|
+
const estimatedSavings = 50; // Conservative estimate
|
|
36
|
+
opportunities.push({
|
|
37
|
+
id: `azure-storage-lifecycle-${account.name}`,
|
|
38
|
+
provider: 'azure',
|
|
39
|
+
resourceType: 'storage',
|
|
40
|
+
resourceId: account.id,
|
|
41
|
+
resourceName: account.name,
|
|
42
|
+
category: 'misconfigured',
|
|
43
|
+
currentCost: 100, // Placeholder - hard to estimate without usage data
|
|
44
|
+
estimatedSavings,
|
|
45
|
+
confidence: 'low',
|
|
46
|
+
recommendation: 'Enable lifecycle management to automatically move old data to Cool or Archive tiers.',
|
|
47
|
+
metadata: {
|
|
48
|
+
location: account.location,
|
|
49
|
+
accountType: account.kind,
|
|
50
|
+
replication: account.sku?.name,
|
|
51
|
+
},
|
|
52
|
+
detectedAt: new Date(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Check for public access
|
|
56
|
+
if (account.allowBlobPublicAccess === true) {
|
|
57
|
+
opportunities.push({
|
|
58
|
+
id: `azure-storage-public-${account.name}`,
|
|
59
|
+
provider: 'azure',
|
|
60
|
+
resourceType: 'storage',
|
|
61
|
+
resourceId: account.id,
|
|
62
|
+
resourceName: account.name,
|
|
63
|
+
category: 'misconfigured',
|
|
64
|
+
currentCost: 0,
|
|
65
|
+
estimatedSavings: 0,
|
|
66
|
+
confidence: 'high',
|
|
67
|
+
recommendation: 'Public blob access is enabled. Review security settings.',
|
|
68
|
+
metadata: {
|
|
69
|
+
location: account.location,
|
|
70
|
+
issue: 'security',
|
|
71
|
+
},
|
|
72
|
+
detectedAt: new Date(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Skip this account if we can't fetch details
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return opportunities;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error('Error analyzing Azure storage:', error);
|
|
85
|
+
return opportunities;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function hasLifecycleManagement(storageClient, resourceGroup, accountName) {
|
|
89
|
+
try {
|
|
90
|
+
const policy = await storageClient.managementPolicies.get(resourceGroup, accountName, 'default');
|
|
91
|
+
return policy && policy.policy && policy.policy.rules && policy.policy.rules.length > 0;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function extractResourceGroup(resourceId) {
|
|
98
|
+
const match = resourceId.match(/resourceGroups\/([^\/]+)/i);
|
|
99
|
+
return match ? match[1] : null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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.AZURE_VM_PRICING = void 0;
|
|
7
|
+
exports.analyzeAzureVMs = analyzeAzureVMs;
|
|
8
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
9
|
+
// Azure VM pricing (East US, pay-as-you-go, monthly estimate)
|
|
10
|
+
// Source: Azure pricing as of 2026-01, monthly = hourly Ć 730
|
|
11
|
+
exports.AZURE_VM_PRICING = {
|
|
12
|
+
'Standard_B1s': 7.59,
|
|
13
|
+
'Standard_B1ms': 15.33,
|
|
14
|
+
'Standard_B2s': 30.37,
|
|
15
|
+
'Standard_B2ms': 60.74,
|
|
16
|
+
'Standard_D2s_v3': 70.08,
|
|
17
|
+
'Standard_D4s_v3': 140.16,
|
|
18
|
+
'Standard_D8s_v3': 280.32,
|
|
19
|
+
'Standard_E2s_v3': 109.50,
|
|
20
|
+
'Standard_E4s_v3': 219.00,
|
|
21
|
+
'Standard_F2s_v2': 62.05,
|
|
22
|
+
'Standard_F4s_v2': 124.10,
|
|
23
|
+
};
|
|
24
|
+
function getVMMonthlyCost(vmSize) {
|
|
25
|
+
return exports.AZURE_VM_PRICING[vmSize] || 100; // Fallback estimate
|
|
26
|
+
}
|
|
27
|
+
async function analyzeAzureVMs(client) {
|
|
28
|
+
const computeClient = client.getComputeClient();
|
|
29
|
+
const monitorClient = client.getMonitorClient();
|
|
30
|
+
const opportunities = [];
|
|
31
|
+
try {
|
|
32
|
+
// List all VMs in the subscription
|
|
33
|
+
const vms = computeClient.virtualMachines.listAll();
|
|
34
|
+
for await (const vm of vms) {
|
|
35
|
+
if (!vm.id || !vm.name)
|
|
36
|
+
continue;
|
|
37
|
+
// Filter by location if specified
|
|
38
|
+
if (client.location && vm.location?.toLowerCase() !== client.location.toLowerCase()) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const vmSize = vm.hardwareProfile?.vmSize || 'Unknown';
|
|
42
|
+
const powerState = await getVMPowerState(computeClient, vm);
|
|
43
|
+
// Skip stopped/deallocated VMs (no compute cost)
|
|
44
|
+
if (powerState === 'stopped' || powerState === 'deallocated') {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const currentCost = getVMMonthlyCost(vmSize);
|
|
48
|
+
// Get CPU metrics for the last 7 days
|
|
49
|
+
const avgCpu = await getAverageCPU(monitorClient, vm.id, 7);
|
|
50
|
+
// Opportunity 1: Idle VM (low CPU usage)
|
|
51
|
+
if (avgCpu < 5) {
|
|
52
|
+
opportunities.push({
|
|
53
|
+
id: `azure-vm-idle-${vm.name}`,
|
|
54
|
+
provider: 'azure',
|
|
55
|
+
resourceType: 'vm',
|
|
56
|
+
resourceId: vm.id,
|
|
57
|
+
resourceName: vm.name,
|
|
58
|
+
category: 'idle',
|
|
59
|
+
currentCost,
|
|
60
|
+
estimatedSavings: currentCost * 0.9, // Could stop or downsize
|
|
61
|
+
confidence: 'high',
|
|
62
|
+
recommendation: `VM is idle (${avgCpu.toFixed(1)}% avg CPU). Consider stopping or downsizing.`,
|
|
63
|
+
metadata: {
|
|
64
|
+
vmSize,
|
|
65
|
+
avgCpu,
|
|
66
|
+
location: vm.location,
|
|
67
|
+
powerState,
|
|
68
|
+
},
|
|
69
|
+
detectedAt: new Date(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// Opportunity 2: Underutilized VM (medium CPU usage)
|
|
73
|
+
else if (avgCpu < 20) {
|
|
74
|
+
const smallerSize = getSmallerVMSize(vmSize);
|
|
75
|
+
if (smallerSize) {
|
|
76
|
+
const newCost = getVMMonthlyCost(smallerSize);
|
|
77
|
+
const savings = currentCost - newCost;
|
|
78
|
+
if (savings > 10) { // At least $10/month savings
|
|
79
|
+
opportunities.push({
|
|
80
|
+
id: `azure-vm-underutilized-${vm.name}`,
|
|
81
|
+
provider: 'azure',
|
|
82
|
+
resourceType: 'vm',
|
|
83
|
+
resourceId: vm.id,
|
|
84
|
+
resourceName: vm.name,
|
|
85
|
+
category: 'oversized',
|
|
86
|
+
currentCost,
|
|
87
|
+
estimatedSavings: savings,
|
|
88
|
+
confidence: 'medium',
|
|
89
|
+
recommendation: `Downsize from ${vmSize} to ${smallerSize} (${avgCpu.toFixed(1)}% avg CPU).`,
|
|
90
|
+
metadata: {
|
|
91
|
+
vmSize,
|
|
92
|
+
suggestedSize: smallerSize,
|
|
93
|
+
avgCpu,
|
|
94
|
+
location: vm.location,
|
|
95
|
+
},
|
|
96
|
+
detectedAt: new Date(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return opportunities;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
console.error('Error analyzing Azure VMs:', error);
|
|
106
|
+
return opportunities;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function getVMPowerState(computeClient, vm) {
|
|
110
|
+
try {
|
|
111
|
+
const resourceGroup = extractResourceGroup(vm.id);
|
|
112
|
+
if (!resourceGroup || !vm.name)
|
|
113
|
+
return 'unknown';
|
|
114
|
+
const instanceView = await computeClient.virtualMachines.instanceView(resourceGroup, vm.name);
|
|
115
|
+
const powerState = instanceView.statuses?.find((s) => s.code?.startsWith('PowerState/'));
|
|
116
|
+
return powerState?.code?.replace('PowerState/', '').toLowerCase() || 'unknown';
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return 'unknown';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function getAverageCPU(monitorClient, resourceId, days) {
|
|
123
|
+
try {
|
|
124
|
+
const endTime = new Date();
|
|
125
|
+
const startTime = (0, dayjs_1.default)().subtract(days, 'days').toDate();
|
|
126
|
+
const metrics = await monitorClient.metrics.list(resourceId, {
|
|
127
|
+
timespan: `${startTime.toISOString()}/${endTime.toISOString()}`,
|
|
128
|
+
interval: 'PT1H',
|
|
129
|
+
metricnames: 'Percentage CPU',
|
|
130
|
+
aggregation: 'Average',
|
|
131
|
+
});
|
|
132
|
+
const timeseries = metrics.value?.[0]?.timeseries?.[0]?.data || [];
|
|
133
|
+
if (timeseries.length === 0) {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const values = timeseries
|
|
137
|
+
.map((d) => d.average)
|
|
138
|
+
.filter((v) => v !== null && v !== undefined);
|
|
139
|
+
if (values.length === 0) {
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
143
|
+
return sum / values.length;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error('Error fetching CPU metrics:', error);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function getSmallerVMSize(currentSize) {
|
|
151
|
+
const downsizeMap = {
|
|
152
|
+
'Standard_B2ms': 'Standard_B1ms',
|
|
153
|
+
'Standard_B2s': 'Standard_B1s',
|
|
154
|
+
'Standard_D4s_v3': 'Standard_D2s_v3',
|
|
155
|
+
'Standard_D8s_v3': 'Standard_D4s_v3',
|
|
156
|
+
'Standard_E4s_v3': 'Standard_E2s_v3',
|
|
157
|
+
'Standard_F4s_v2': 'Standard_F2s_v2',
|
|
158
|
+
};
|
|
159
|
+
return downsizeMap[currentSize] || null;
|
|
160
|
+
}
|
|
161
|
+
function extractResourceGroup(resourceId) {
|
|
162
|
+
const match = resourceId.match(/resourceGroups\/([^\/]+)/i);
|
|
163
|
+
return match ? match[1] : null;
|
|
164
|
+
}
|
|
@@ -37,5 +37,7 @@ function renderTable(report, topN = 5) {
|
|
|
37
37
|
});
|
|
38
38
|
console.log(table.toString());
|
|
39
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
|
|
40
|
+
console.log(`\nSummary: ${report.summary.totalResources} resources analyzed | ${report.summary.idleResources} idle | ${report.summary.oversizedResources} oversized | ${report.summary.unusedResources} unused`);
|
|
41
|
+
console.log(chalk_1.default.dim(`\nš” Note: Cost estimates based on us-east-1 pricing and may vary by region.`));
|
|
42
|
+
console.log(chalk_1.default.dim(` For more accurate estimates, actual costs depend on your usage and region.\n`));
|
|
41
43
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function formatCurrency(amount: number): string;
|
|
2
|
+
export declare function formatBytes(bytes: number, decimals?: number): string;
|
|
3
|
+
export declare function formatPercent(value: number, decimals?: number): string;
|
|
2
4
|
export declare function formatDate(date: Date): string;
|
|
3
5
|
export declare function daysSince(date: Date): number;
|
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatCurrency = formatCurrency;
|
|
4
|
+
exports.formatBytes = formatBytes;
|
|
5
|
+
exports.formatPercent = formatPercent;
|
|
4
6
|
exports.formatDate = formatDate;
|
|
5
7
|
exports.daysSince = daysSince;
|
|
6
8
|
function formatCurrency(amount) {
|
|
7
|
-
|
|
9
|
+
const formatted = new Intl.NumberFormat('en-US', {
|
|
10
|
+
style: 'currency',
|
|
11
|
+
currency: 'USD',
|
|
12
|
+
minimumFractionDigits: 2,
|
|
13
|
+
maximumFractionDigits: 2,
|
|
14
|
+
}).format(amount);
|
|
15
|
+
return formatted;
|
|
16
|
+
}
|
|
17
|
+
function formatBytes(bytes, decimals = 2) {
|
|
18
|
+
if (bytes === 0)
|
|
19
|
+
return '0 B';
|
|
20
|
+
const k = 1024;
|
|
21
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
22
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
23
|
+
// Don't use decimals for bytes
|
|
24
|
+
if (i === 0) {
|
|
25
|
+
return `${bytes} B`;
|
|
26
|
+
}
|
|
27
|
+
const value = bytes / Math.pow(k, i);
|
|
28
|
+
const formatted = decimals === 0
|
|
29
|
+
? Math.round(value).toString()
|
|
30
|
+
: value.toFixed(decimals);
|
|
31
|
+
return `${formatted} ${sizes[i]}`;
|
|
32
|
+
}
|
|
33
|
+
function formatPercent(value, decimals = 1) {
|
|
34
|
+
const percent = value * 100;
|
|
35
|
+
return `${percent.toFixed(decimals)}%`;
|
|
8
36
|
}
|
|
9
37
|
function formatDate(date) {
|
|
10
38
|
return date.toISOString().split('T')[0];
|