cloud-cost-cli 0.3.0-beta.2 → 0.4.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 +32 -15
- package/dist/bin/cloud-cost-cli.js +4 -3
- package/dist/src/analyzers/cost-estimator.d.ts +11 -0
- package/dist/src/analyzers/cost-estimator.js +44 -1
- package/dist/src/commands/scan.d.ts +1 -0
- package/dist/src/commands/scan.js +123 -1
- package/dist/src/providers/azure/client.js +10 -2
- package/dist/src/providers/gcp/client.d.ts +29 -0
- package/dist/src/providers/gcp/client.js +98 -0
- package/dist/src/providers/gcp/cloudsql.d.ts +3 -0
- package/dist/src/providers/gcp/cloudsql.js +115 -0
- package/dist/src/providers/gcp/compute.d.ts +3 -0
- package/dist/src/providers/gcp/compute.js +108 -0
- package/dist/src/providers/gcp/disks.d.ts +3 -0
- package/dist/src/providers/gcp/disks.js +73 -0
- package/dist/src/providers/gcp/static-ips.d.ts +3 -0
- package/dist/src/providers/gcp/static-ips.js +78 -0
- package/dist/src/providers/gcp/storage.d.ts +3 -0
- package/dist/src/providers/gcp/storage.js +52 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
**Optimize your cloud spend in seconds.**
|
|
7
7
|
|
|
8
|
-
A command-line tool that analyzes your AWS and
|
|
8
|
+
A command-line tool that analyzes your AWS, Azure, and GCP resources to identify cost-saving opportunities — idle resources, oversized instances, unattached volumes, and more.
|
|
9
9
|
|
|
10
|
-
**✨ NEW in v0.
|
|
10
|
+
**✨ NEW in v0.4.0:** GCP support (Compute Engine + Cloud Storage)!
|
|
11
|
+
**✨ v0.3.0:** AI-powered explanations and natural language queries!
|
|
11
12
|
|
|
12
13
|
---
|
|
13
14
|
|
|
@@ -20,7 +21,7 @@ Cloud bills are growing faster than revenue. Engineering teams overprovision, fo
|
|
|
20
21
|
`cloud-cost-cli` connects to your cloud accounts, analyzes resource usage and billing data, and outputs a ranked list of actionable savings opportunities — all in your terminal, in under 60 seconds.
|
|
21
22
|
|
|
22
23
|
**What it finds:**
|
|
23
|
-
- Idle VMs/
|
|
24
|
+
- Idle VMs/Compute instances (low CPU, stopped but still billed)
|
|
24
25
|
- Unattached volumes, disks, and snapshots
|
|
25
26
|
- Oversized database instances (RDS, Azure SQL)
|
|
26
27
|
- Old load balancers with no traffic
|
|
@@ -33,11 +34,12 @@ Cloud bills are growing faster than revenue. Engineering teams overprovision, fo
|
|
|
33
34
|
## Features
|
|
34
35
|
|
|
35
36
|
**Current capabilities:**
|
|
36
|
-
- ✅ **Multi-cloud support** - AWS and
|
|
37
|
+
- ✅ **Multi-cloud support** - AWS, Azure, and GCP
|
|
37
38
|
- ✅ **AWS analyzers** - EC2, EBS, RDS, S3, ELB, Elastic IP
|
|
38
39
|
- ✅ **Azure analyzers** - VMs, Managed Disks, Storage, SQL, Public IPs
|
|
39
|
-
- ✅
|
|
40
|
-
- ✅
|
|
40
|
+
- ✅ **GCP analyzers** - Compute Engine, Cloud Storage, Cloud SQL, Persistent Disks, Static IPs
|
|
41
|
+
- ✅ **🤖 AI-powered explanations** - Get human-readable explanations for why resources are costing money
|
|
42
|
+
- ✅ **💬 Natural language queries** - Ask questions like "What's my biggest cost?" or "Show me idle VMs"
|
|
41
43
|
- ✅ **🔒 Privacy-first AI** - Use local Ollama or cloud OpenAI
|
|
42
44
|
- ✅ **💰 Cost tracking** - Track AI API costs (OpenAI only)
|
|
43
45
|
- ✅ **⚙️ Configuration file** - Save your preferences
|
|
@@ -46,10 +48,9 @@ Cloud bills are growing faster than revenue. Engineering teams overprovision, fo
|
|
|
46
48
|
- ✅ Output top savings opportunities with estimated monthly savings
|
|
47
49
|
- ✅ Export report as JSON or terminal table
|
|
48
50
|
- ✅ Filter by minimum savings amount
|
|
49
|
-
- ✅ Comprehensive test suite (84 tests)
|
|
50
51
|
|
|
51
52
|
**Potential future additions:**
|
|
52
|
-
- GCP
|
|
53
|
+
- More GCP services (Cloud Functions, Load Balancers, etc.)
|
|
53
54
|
- Real-time pricing API integration
|
|
54
55
|
- Additional AWS services (Lambda, DynamoDB, CloudFront, etc.)
|
|
55
56
|
- Additional Azure services (App Services, CosmosDB, etc.)
|
|
@@ -75,6 +76,10 @@ No commitment on timeline - contributions welcome!
|
|
|
75
76
|
- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az login`) OR
|
|
76
77
|
- Service Principal (env vars: `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`) OR
|
|
77
78
|
- Managed Identity (for Azure VMs)
|
|
79
|
+
- **GCP**:
|
|
80
|
+
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) (`gcloud auth application-default login`) OR
|
|
81
|
+
- Service Account JSON key (env var: `GOOGLE_APPLICATION_CREDENTIALS`) OR
|
|
82
|
+
- Compute Engine default credentials (for GCP VMs)
|
|
78
83
|
- **Optional for AI features**:
|
|
79
84
|
- OpenAI API key OR
|
|
80
85
|
- [Ollama](https://ollama.ai) installed locally (free, private, runs on your machine)
|
|
@@ -84,11 +89,6 @@ No commitment on timeline - contributions welcome!
|
|
|
84
89
|
npm install -g cloud-cost-cli
|
|
85
90
|
```
|
|
86
91
|
|
|
87
|
-
**Try the beta with AI features:**
|
|
88
|
-
```bash
|
|
89
|
-
npm install -g cloud-cost-cli@beta
|
|
90
|
-
```
|
|
91
|
-
|
|
92
92
|
---
|
|
93
93
|
|
|
94
94
|
## Usage
|
|
@@ -115,6 +115,23 @@ export AZURE_SUBSCRIPTION_ID="your-subscription-id"
|
|
|
115
115
|
cloud-cost-cli scan --provider azure --location eastus
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
**GCP scan:**
|
|
119
|
+
```bash
|
|
120
|
+
# Option 1: gcloud CLI (easiest for local use)
|
|
121
|
+
gcloud auth application-default login
|
|
122
|
+
gcloud config set project YOUR_PROJECT_ID
|
|
123
|
+
export GCP_PROJECT_ID="your-project-id"
|
|
124
|
+
cloud-cost-cli scan --provider gcp --region us-central1
|
|
125
|
+
|
|
126
|
+
# Option 2: Service Account (recommended for CI/CD and automation)
|
|
127
|
+
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/keyfile.json"
|
|
128
|
+
export GCP_PROJECT_ID="your-project-id"
|
|
129
|
+
cloud-cost-cli scan --provider gcp --region us-central1
|
|
130
|
+
|
|
131
|
+
# Option 3: CLI flag
|
|
132
|
+
cloud-cost-cli scan --provider gcp --project-id your-project-id --region us-central1
|
|
133
|
+
```
|
|
134
|
+
|
|
118
135
|
**How to create Azure Service Principal:**
|
|
119
136
|
```bash
|
|
120
137
|
# Create service principal with Reader role
|
|
@@ -128,7 +145,7 @@ az ad sp create-for-rbac --name "cloud-cost-cli" --role Reader --scopes /subscri
|
|
|
128
145
|
# }
|
|
129
146
|
```
|
|
130
147
|
|
|
131
|
-
### 🤖 AI-Powered Features
|
|
148
|
+
### 🤖 AI-Powered Features
|
|
132
149
|
|
|
133
150
|
**Get AI explanations for opportunities:**
|
|
134
151
|
```bash
|
|
@@ -424,7 +441,7 @@ A: Read-only permissions for each cloud provider:
|
|
|
424
441
|
A: Estimates are based on current pricing and usage patterns. Actual savings may vary by region and your specific pricing agreements (Reserved Instances, Savings Plans, etc.).
|
|
425
442
|
|
|
426
443
|
**Q: Is my data sent to OpenAI?**
|
|
427
|
-
A: Only if you use `--ai-provider
|
|
444
|
+
A: Only if you use OpenAI for AI features. When you use `--explain` without specifying `--ai-provider`, it defaults to OpenAI and requires an API key. Resource metadata and recommendations are sent to OpenAI's API to generate explanations. If you want complete privacy, use `--ai-provider ollama` (or set it in config) which runs 100% locally on your machine.
|
|
428
445
|
|
|
429
446
|
**Q: How much do AI features cost?**
|
|
430
447
|
A:
|
|
@@ -10,15 +10,16 @@ const program = new commander_1.Command();
|
|
|
10
10
|
program
|
|
11
11
|
.name('cloud-cost-cli')
|
|
12
12
|
.description('Optimize your cloud spend in seconds')
|
|
13
|
-
.version('0.
|
|
13
|
+
.version('0.4.0');
|
|
14
14
|
program
|
|
15
15
|
.command('scan')
|
|
16
16
|
.description('Scan cloud account for cost savings')
|
|
17
|
-
.option('--provider <aws|azure>', 'Cloud provider', 'aws')
|
|
18
|
-
.option('--region <region>', '
|
|
17
|
+
.option('--provider <aws|azure|gcp>', 'Cloud provider', 'aws')
|
|
18
|
+
.option('--region <region>', 'Cloud region (e.g., us-east-1 for AWS, us-central1 for GCP)')
|
|
19
19
|
.option('--profile <profile>', 'AWS profile name', 'default')
|
|
20
20
|
.option('--subscription-id <id>', 'Azure subscription ID')
|
|
21
21
|
.option('--location <location>', 'Azure location filter (e.g., eastus, westus2) - optional, scans all if omitted')
|
|
22
|
+
.option('--project-id <id>', 'GCP project ID')
|
|
22
23
|
.option('--top <N>', 'Show top N opportunities', '5')
|
|
23
24
|
.option('--output <table|json|markdown>', 'Output format', 'table')
|
|
24
25
|
.option('--days <N>', 'Analysis period in days', '30')
|
|
@@ -13,6 +13,14 @@ export declare const ELB_PRICING: {
|
|
|
13
13
|
clb: number;
|
|
14
14
|
};
|
|
15
15
|
export declare const EIP_PRICING_HOURLY = 0.005;
|
|
16
|
+
export declare const GCE_PRICING: Record<string, number>;
|
|
17
|
+
export declare const GCS_PRICING: {
|
|
18
|
+
standard: number;
|
|
19
|
+
nearline: number;
|
|
20
|
+
coldline: number;
|
|
21
|
+
archive: number;
|
|
22
|
+
};
|
|
23
|
+
export declare const GCP_CLOUDSQL_PRICING: Record<string, number>;
|
|
16
24
|
/**
|
|
17
25
|
* Cost estimator with support for both estimate and accurate modes
|
|
18
26
|
*/
|
|
@@ -33,3 +41,6 @@ export declare function getRDSMonthlyCost(instanceClass: string): number;
|
|
|
33
41
|
export declare function getS3MonthlyCost(sizeGB: number, storageClass?: string): number;
|
|
34
42
|
export declare function getELBMonthlyCost(type?: 'alb' | 'nlb' | 'clb'): number;
|
|
35
43
|
export declare function getEIPMonthlyCost(): number;
|
|
44
|
+
export declare function getGCEMonthlyCost(machineType: string): number;
|
|
45
|
+
export declare function getGCSMonthlyCost(sizeGB: number, storageClass?: string): number;
|
|
46
|
+
export declare function getGCPCloudSQLMonthlyCost(tier: string): number;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CostEstimator = exports.EIP_PRICING_HOURLY = exports.ELB_PRICING = exports.S3_PRICING = exports.RDS_PRICING = exports.EBS_PRICING = exports.EC2_PRICING = void 0;
|
|
3
|
+
exports.CostEstimator = exports.GCP_CLOUDSQL_PRICING = exports.GCS_PRICING = exports.GCE_PRICING = exports.EIP_PRICING_HOURLY = exports.ELB_PRICING = exports.S3_PRICING = exports.RDS_PRICING = exports.EBS_PRICING = exports.EC2_PRICING = void 0;
|
|
4
4
|
exports.getEC2MonthlyCost = getEC2MonthlyCost;
|
|
5
5
|
exports.getEBSMonthlyCost = getEBSMonthlyCost;
|
|
6
6
|
exports.getRDSMonthlyCost = getRDSMonthlyCost;
|
|
7
7
|
exports.getS3MonthlyCost = getS3MonthlyCost;
|
|
8
8
|
exports.getELBMonthlyCost = getELBMonthlyCost;
|
|
9
9
|
exports.getEIPMonthlyCost = getEIPMonthlyCost;
|
|
10
|
+
exports.getGCEMonthlyCost = getGCEMonthlyCost;
|
|
11
|
+
exports.getGCSMonthlyCost = getGCSMonthlyCost;
|
|
12
|
+
exports.getGCPCloudSQLMonthlyCost = getGCPCloudSQLMonthlyCost;
|
|
10
13
|
const pricing_service_1 = require("./pricing-service");
|
|
11
14
|
// Fallback pricing estimates (based on us-east-1, Jan 2026)
|
|
12
15
|
// Used when --accurate flag is not set or Pricing API fails
|
|
@@ -60,6 +63,35 @@ exports.ELB_PRICING = {
|
|
|
60
63
|
clb: 18.25,
|
|
61
64
|
};
|
|
62
65
|
exports.EIP_PRICING_HOURLY = 0.005;
|
|
66
|
+
// GCP Pricing (based on us-central1, Jan 2026)
|
|
67
|
+
exports.GCE_PRICING = {
|
|
68
|
+
'e2-micro': 6.11,
|
|
69
|
+
'e2-small': 12.23,
|
|
70
|
+
'e2-medium': 24.45,
|
|
71
|
+
'n1-standard-1': 24.27,
|
|
72
|
+
'n1-standard-2': 48.54,
|
|
73
|
+
'n1-standard-4': 97.09,
|
|
74
|
+
'n2-standard-2': 58.99,
|
|
75
|
+
'n2-standard-4': 117.98,
|
|
76
|
+
'c2-standard-4': 152.39,
|
|
77
|
+
'c2-standard-8': 304.78,
|
|
78
|
+
};
|
|
79
|
+
exports.GCS_PRICING = {
|
|
80
|
+
standard: 0.020,
|
|
81
|
+
nearline: 0.010,
|
|
82
|
+
coldline: 0.004,
|
|
83
|
+
archive: 0.0012,
|
|
84
|
+
};
|
|
85
|
+
exports.GCP_CLOUDSQL_PRICING = {
|
|
86
|
+
'db-n1-standard-1': 46.72,
|
|
87
|
+
'db-n1-standard-2': 93.45,
|
|
88
|
+
'db-n1-standard-4': 186.89,
|
|
89
|
+
'db-n1-standard-8': 373.79,
|
|
90
|
+
'db-n1-standard-16': 747.58,
|
|
91
|
+
'db-custom-1-4096': 35.62,
|
|
92
|
+
'db-custom-2-8192': 71.23,
|
|
93
|
+
'db-custom-4-16384': 142.47,
|
|
94
|
+
};
|
|
63
95
|
/**
|
|
64
96
|
* Cost estimator with support for both estimate and accurate modes
|
|
65
97
|
*/
|
|
@@ -142,3 +174,14 @@ function getELBMonthlyCost(type = 'alb') {
|
|
|
142
174
|
function getEIPMonthlyCost() {
|
|
143
175
|
return exports.EIP_PRICING_HOURLY * 730;
|
|
144
176
|
}
|
|
177
|
+
// GCP cost helpers
|
|
178
|
+
function getGCEMonthlyCost(machineType) {
|
|
179
|
+
return exports.GCE_PRICING[machineType] || 50; // Generic estimate if unknown
|
|
180
|
+
}
|
|
181
|
+
function getGCSMonthlyCost(sizeGB, storageClass = 'standard') {
|
|
182
|
+
const pricePerGB = exports.GCS_PRICING[storageClass] || exports.GCS_PRICING.standard;
|
|
183
|
+
return sizeGB * pricePerGB;
|
|
184
|
+
}
|
|
185
|
+
function getGCPCloudSQLMonthlyCost(tier) {
|
|
186
|
+
return exports.GCP_CLOUDSQL_PRICING[tier] || 50; // Generic estimate if unknown
|
|
187
|
+
}
|
|
@@ -14,6 +14,12 @@ const disks_1 = require("../providers/azure/disks");
|
|
|
14
14
|
const storage_1 = require("../providers/azure/storage");
|
|
15
15
|
const sql_1 = require("../providers/azure/sql");
|
|
16
16
|
const public_ips_1 = require("../providers/azure/public-ips");
|
|
17
|
+
const client_3 = require("../providers/gcp/client");
|
|
18
|
+
const compute_1 = require("../providers/gcp/compute");
|
|
19
|
+
const storage_2 = require("../providers/gcp/storage");
|
|
20
|
+
const cloudsql_1 = require("../providers/gcp/cloudsql");
|
|
21
|
+
const disks_2 = require("../providers/gcp/disks");
|
|
22
|
+
const static_ips_1 = require("../providers/gcp/static-ips");
|
|
17
23
|
const table_1 = require("../reporters/table");
|
|
18
24
|
const json_1 = require("../reporters/json");
|
|
19
25
|
const logger_1 = require("../utils/logger");
|
|
@@ -28,8 +34,11 @@ async function scanCommand(options) {
|
|
|
28
34
|
else if (options.provider === 'azure') {
|
|
29
35
|
await scanAzure(options);
|
|
30
36
|
}
|
|
37
|
+
else if (options.provider === 'gcp') {
|
|
38
|
+
await scanGCP(options);
|
|
39
|
+
}
|
|
31
40
|
else {
|
|
32
|
-
(0, logger_1.error)(`Provider "${options.provider}" not yet supported. Use --provider aws or
|
|
41
|
+
(0, logger_1.error)(`Provider "${options.provider}" not yet supported. Use --provider aws, azure, or gcp`);
|
|
33
42
|
process.exit(1);
|
|
34
43
|
}
|
|
35
44
|
}
|
|
@@ -287,3 +296,116 @@ async function scanAzure(options) {
|
|
|
287
296
|
await (0, table_1.renderTable)(report, topN, aiService);
|
|
288
297
|
}
|
|
289
298
|
}
|
|
299
|
+
async function scanGCP(options) {
|
|
300
|
+
const client = new client_3.GCPClient({
|
|
301
|
+
projectId: options.projectId,
|
|
302
|
+
region: options.region,
|
|
303
|
+
});
|
|
304
|
+
(0, logger_1.info)(`Scanning GCP project (${client.projectId}, region: ${client.region})...`);
|
|
305
|
+
// Test connection before scanning
|
|
306
|
+
(0, logger_1.info)('Testing GCP credentials...');
|
|
307
|
+
await client.testConnection();
|
|
308
|
+
(0, logger_1.success)('GCP credentials verified ✓');
|
|
309
|
+
// Run analyzers in parallel
|
|
310
|
+
(0, logger_1.info)('Analyzing Compute Engine instances...');
|
|
311
|
+
const gcePromise = (0, compute_1.analyzeGCEInstances)(client);
|
|
312
|
+
(0, logger_1.info)('Analyzing Cloud Storage buckets...');
|
|
313
|
+
const gcsPromise = (0, storage_2.analyzeGCSBuckets)(client);
|
|
314
|
+
(0, logger_1.info)('Analyzing Cloud SQL instances...');
|
|
315
|
+
const cloudsqlPromise = (0, cloudsql_1.analyzeCloudSQLInstances)(client);
|
|
316
|
+
(0, logger_1.info)('Analyzing Persistent Disks...');
|
|
317
|
+
const disksPromise = (0, disks_2.analyzePersistentDisks)(client);
|
|
318
|
+
(0, logger_1.info)('Analyzing Static IPs...');
|
|
319
|
+
const ipsPromise = (0, static_ips_1.analyzeStaticIPs)(client);
|
|
320
|
+
// Wait for all analyzers to complete
|
|
321
|
+
const [gceOpportunities, gcsOpportunities, cloudsqlOpportunities, disksOpportunities, ipsOpportunities,] = await Promise.all([
|
|
322
|
+
gcePromise,
|
|
323
|
+
gcsPromise,
|
|
324
|
+
cloudsqlPromise,
|
|
325
|
+
disksPromise,
|
|
326
|
+
ipsPromise,
|
|
327
|
+
]);
|
|
328
|
+
(0, logger_1.success)(`Found ${gceOpportunities.length} Compute Engine opportunities`);
|
|
329
|
+
(0, logger_1.success)(`Found ${gcsOpportunities.length} Cloud Storage opportunities`);
|
|
330
|
+
(0, logger_1.success)(`Found ${cloudsqlOpportunities.length} Cloud SQL opportunities`);
|
|
331
|
+
(0, logger_1.success)(`Found ${disksOpportunities.length} Persistent Disk opportunities`);
|
|
332
|
+
(0, logger_1.success)(`Found ${ipsOpportunities.length} Static IP opportunities`);
|
|
333
|
+
// Combine opportunities
|
|
334
|
+
const allOpportunities = [
|
|
335
|
+
...gceOpportunities,
|
|
336
|
+
...gcsOpportunities,
|
|
337
|
+
...cloudsqlOpportunities,
|
|
338
|
+
...disksOpportunities,
|
|
339
|
+
...ipsOpportunities,
|
|
340
|
+
];
|
|
341
|
+
// Filter by minimum savings if specified
|
|
342
|
+
const minSavings = options.minSavings ? parseFloat(options.minSavings) : 0;
|
|
343
|
+
const filteredOpportunities = allOpportunities.filter((opp) => opp.estimatedSavings >= minSavings);
|
|
344
|
+
// Calculate totals
|
|
345
|
+
const totalPotentialSavings = filteredOpportunities.reduce((sum, opp) => sum + opp.estimatedSavings, 0);
|
|
346
|
+
const summary = {
|
|
347
|
+
totalResources: filteredOpportunities.length,
|
|
348
|
+
idleResources: filteredOpportunities.filter((o) => o.category === 'idle').length,
|
|
349
|
+
oversizedResources: filteredOpportunities.filter((o) => o.category === 'oversized')
|
|
350
|
+
.length,
|
|
351
|
+
unusedResources: filteredOpportunities.filter((o) => o.category === 'unused').length,
|
|
352
|
+
};
|
|
353
|
+
const report = {
|
|
354
|
+
provider: 'gcp',
|
|
355
|
+
accountId: client.projectId,
|
|
356
|
+
region: client.region,
|
|
357
|
+
scanPeriod: {
|
|
358
|
+
start: new Date(Date.now() - parseInt(options.days || '30') * 24 * 60 * 60 * 1000),
|
|
359
|
+
end: new Date(),
|
|
360
|
+
},
|
|
361
|
+
opportunities: filteredOpportunities,
|
|
362
|
+
totalPotentialSavings,
|
|
363
|
+
summary,
|
|
364
|
+
};
|
|
365
|
+
// Render output
|
|
366
|
+
const topN = parseInt(options.top || '5');
|
|
367
|
+
let aiService;
|
|
368
|
+
if (options.explain) {
|
|
369
|
+
// Load config file to get defaults
|
|
370
|
+
const fileConfig = config_1.ConfigLoader.load();
|
|
371
|
+
// CLI flags override config file
|
|
372
|
+
const provider = options.aiProvider ||
|
|
373
|
+
fileConfig.ai?.provider ||
|
|
374
|
+
'openai';
|
|
375
|
+
const model = options.aiModel || fileConfig.ai?.model;
|
|
376
|
+
const maxExplanations = fileConfig.ai?.maxExplanations;
|
|
377
|
+
if (provider === 'openai' &&
|
|
378
|
+
!process.env.OPENAI_API_KEY &&
|
|
379
|
+
!fileConfig.ai?.apiKey) {
|
|
380
|
+
(0, logger_1.error)('--explain with OpenAI requires OPENAI_API_KEY environment variable or config file');
|
|
381
|
+
(0, logger_1.info)('Set it with: export OPENAI_API_KEY="sk-..."');
|
|
382
|
+
(0, logger_1.info)('Or use --ai-provider ollama for local AI (requires Ollama installed)');
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
aiService = new ai_1.AIService({
|
|
387
|
+
provider,
|
|
388
|
+
apiKey: provider === 'openai'
|
|
389
|
+
? process.env.OPENAI_API_KEY || fileConfig.ai?.apiKey
|
|
390
|
+
: undefined,
|
|
391
|
+
model,
|
|
392
|
+
maxExplanations,
|
|
393
|
+
});
|
|
394
|
+
if (provider === 'ollama') {
|
|
395
|
+
(0, logger_1.info)('Using local Ollama for AI explanations (privacy-first, no API costs)');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
error(`Failed to initialize AI service: ${error.message}`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Save scan cache for natural language queries
|
|
404
|
+
(0, ask_1.saveScanCache)('gcp', client.region, report);
|
|
405
|
+
if (options.output === 'json') {
|
|
406
|
+
(0, json_1.renderJSON)(report);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
await (0, table_1.renderTable)(report, topN, aiService);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -39,8 +39,16 @@ class AzureClient {
|
|
|
39
39
|
errorMsg.includes('login') ||
|
|
40
40
|
error.statusCode === 401 ||
|
|
41
41
|
error.code === 'CredentialUnavailableError') {
|
|
42
|
-
throw new Error('Azure authentication failed.
|
|
43
|
-
'
|
|
42
|
+
throw new Error('Azure authentication failed. Choose one of these options:\n\n' +
|
|
43
|
+
'Option 1 - Azure CLI (easiest):\n' +
|
|
44
|
+
' az login\n\n' +
|
|
45
|
+
'Option 2 - Service Principal (recommended for automation):\n' +
|
|
46
|
+
' export AZURE_CLIENT_ID="your-app-id"\n' +
|
|
47
|
+
' export AZURE_CLIENT_SECRET="your-secret"\n' +
|
|
48
|
+
' export AZURE_TENANT_ID="your-tenant-id"\n\n' +
|
|
49
|
+
'Option 3 - Managed Identity (for Azure VMs):\n' +
|
|
50
|
+
' Runs automatically on Azure VMs with managed identity enabled\n\n' +
|
|
51
|
+
'See: https://learn.microsoft.com/en-us/javascript/api/overview/azure/identity-readme');
|
|
44
52
|
}
|
|
45
53
|
throw error;
|
|
46
54
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { InstancesClient, DisksClient, AddressesClient, GlobalAddressesClient } from '@google-cloud/compute';
|
|
2
|
+
import { Storage } from '@google-cloud/storage';
|
|
3
|
+
import { MetricServiceClient } from '@google-cloud/monitoring';
|
|
4
|
+
import { SqlInstancesServiceClient } from '@google-cloud/sql';
|
|
5
|
+
export interface GCPClientConfig {
|
|
6
|
+
projectId?: string;
|
|
7
|
+
region?: string;
|
|
8
|
+
keyFilename?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class GCPClient {
|
|
11
|
+
projectId: string;
|
|
12
|
+
region: string;
|
|
13
|
+
private computeClient;
|
|
14
|
+
private disksClient;
|
|
15
|
+
private addressesClient;
|
|
16
|
+
private globalAddressesClient;
|
|
17
|
+
private storageClient;
|
|
18
|
+
private monitoringClient;
|
|
19
|
+
private sqlClient;
|
|
20
|
+
constructor(config?: GCPClientConfig);
|
|
21
|
+
testConnection(): Promise<void>;
|
|
22
|
+
getComputeClient(): InstancesClient;
|
|
23
|
+
getDisksClient(): DisksClient;
|
|
24
|
+
getAddressesClient(): AddressesClient;
|
|
25
|
+
getGlobalAddressesClient(): GlobalAddressesClient;
|
|
26
|
+
getStorageClient(): Storage;
|
|
27
|
+
getMonitoringClient(): MetricServiceClient;
|
|
28
|
+
getCloudSQLClient(): SqlInstancesServiceClient;
|
|
29
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GCPClient = void 0;
|
|
4
|
+
const compute_1 = require("@google-cloud/compute");
|
|
5
|
+
const storage_1 = require("@google-cloud/storage");
|
|
6
|
+
const monitoring_1 = require("@google-cloud/monitoring");
|
|
7
|
+
const sql_1 = require("@google-cloud/sql");
|
|
8
|
+
class GCPClient {
|
|
9
|
+
projectId;
|
|
10
|
+
region;
|
|
11
|
+
computeClient;
|
|
12
|
+
disksClient;
|
|
13
|
+
addressesClient;
|
|
14
|
+
globalAddressesClient;
|
|
15
|
+
storageClient;
|
|
16
|
+
monitoringClient;
|
|
17
|
+
sqlClient;
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
// Get project ID from config, env, or default credentials
|
|
20
|
+
this.projectId =
|
|
21
|
+
config.projectId ||
|
|
22
|
+
process.env.GCP_PROJECT_ID ||
|
|
23
|
+
process.env.GOOGLE_CLOUD_PROJECT ||
|
|
24
|
+
process.env.GCLOUD_PROJECT ||
|
|
25
|
+
'';
|
|
26
|
+
if (!this.projectId) {
|
|
27
|
+
throw new Error('GCP project ID not found. Set GCP_PROJECT_ID environment variable or use --project-id flag.');
|
|
28
|
+
}
|
|
29
|
+
this.region = config.region || process.env.GCP_REGION || 'us-central1';
|
|
30
|
+
// Initialize clients with optional keyFilename
|
|
31
|
+
const clientConfig = config.keyFilename
|
|
32
|
+
? { keyFilename: config.keyFilename }
|
|
33
|
+
: {};
|
|
34
|
+
this.computeClient = new compute_1.InstancesClient(clientConfig);
|
|
35
|
+
this.disksClient = new compute_1.DisksClient(clientConfig);
|
|
36
|
+
this.addressesClient = new compute_1.AddressesClient(clientConfig);
|
|
37
|
+
this.globalAddressesClient = new compute_1.GlobalAddressesClient(clientConfig);
|
|
38
|
+
this.storageClient = new storage_1.Storage(clientConfig);
|
|
39
|
+
this.monitoringClient = new monitoring_1.MetricServiceClient(clientConfig);
|
|
40
|
+
this.sqlClient = new sql_1.SqlInstancesServiceClient(clientConfig);
|
|
41
|
+
}
|
|
42
|
+
// Test GCP credentials by making a lightweight API call
|
|
43
|
+
async testConnection() {
|
|
44
|
+
try {
|
|
45
|
+
// Try to list instances in a single zone (limited scope)
|
|
46
|
+
const zone = `${this.region}-a`;
|
|
47
|
+
const request = {
|
|
48
|
+
project: this.projectId,
|
|
49
|
+
zone: zone,
|
|
50
|
+
maxResults: 1,
|
|
51
|
+
};
|
|
52
|
+
await this.computeClient.list(request);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const errorMsg = error.message || '';
|
|
56
|
+
if (errorMsg.includes('authentication') ||
|
|
57
|
+
errorMsg.includes('credentials') ||
|
|
58
|
+
errorMsg.includes('permission') ||
|
|
59
|
+
errorMsg.includes('quota') ||
|
|
60
|
+
error.code === 401 ||
|
|
61
|
+
error.code === 403) {
|
|
62
|
+
throw new Error('GCP authentication failed. Choose one of these options:\n\n' +
|
|
63
|
+
'Option 1 - gcloud CLI (easiest):\n' +
|
|
64
|
+
' gcloud auth application-default login\n' +
|
|
65
|
+
' gcloud config set project YOUR_PROJECT_ID\n\n' +
|
|
66
|
+
'Option 2 - Service Account (recommended for automation):\n' +
|
|
67
|
+
' export GOOGLE_APPLICATION_CREDENTIALS="/path/to/keyfile.json"\n' +
|
|
68
|
+
' export GCP_PROJECT_ID="your-project-id"\n\n' +
|
|
69
|
+
'Option 3 - Compute Engine (for GCP VMs):\n' +
|
|
70
|
+
' Runs automatically on GCP VMs with service account attached\n\n' +
|
|
71
|
+
'See: https://cloud.google.com/docs/authentication/getting-started');
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
getComputeClient() {
|
|
77
|
+
return this.computeClient;
|
|
78
|
+
}
|
|
79
|
+
getDisksClient() {
|
|
80
|
+
return this.disksClient;
|
|
81
|
+
}
|
|
82
|
+
getAddressesClient() {
|
|
83
|
+
return this.addressesClient;
|
|
84
|
+
}
|
|
85
|
+
getGlobalAddressesClient() {
|
|
86
|
+
return this.globalAddressesClient;
|
|
87
|
+
}
|
|
88
|
+
getStorageClient() {
|
|
89
|
+
return this.storageClient;
|
|
90
|
+
}
|
|
91
|
+
getMonitoringClient() {
|
|
92
|
+
return this.monitoringClient;
|
|
93
|
+
}
|
|
94
|
+
getCloudSQLClient() {
|
|
95
|
+
return this.sqlClient;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
exports.GCPClient = GCPClient;
|
|
@@ -0,0 +1,115 @@
|
|
|
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.analyzeCloudSQLInstances = analyzeCloudSQLInstances;
|
|
7
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
8
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
9
|
+
async function analyzeCloudSQLInstances(client) {
|
|
10
|
+
const sqlClient = client.getCloudSQLClient();
|
|
11
|
+
const monitoringClient = client.getMonitoringClient();
|
|
12
|
+
const opportunities = [];
|
|
13
|
+
try {
|
|
14
|
+
const request = {
|
|
15
|
+
project: client.projectId,
|
|
16
|
+
};
|
|
17
|
+
const response = await sqlClient.list(request);
|
|
18
|
+
const instances = response[0]?.items || [];
|
|
19
|
+
for (const instance of instances || []) {
|
|
20
|
+
if (!instance.name || !instance.settings?.tier)
|
|
21
|
+
continue;
|
|
22
|
+
// Get average CPU over last 30 days
|
|
23
|
+
const avgCpu = await getAvgCPU(monitoringClient, client.projectId, instance.name, 30);
|
|
24
|
+
// Detect oversized: low CPU (<20%)
|
|
25
|
+
if (avgCpu < 20 && avgCpu > 0 && instance.state === 'RUNNABLE') {
|
|
26
|
+
const tier = instance.settings.tier;
|
|
27
|
+
const currentCost = (0, cost_estimator_1.getGCPCloudSQLMonthlyCost)(tier);
|
|
28
|
+
// Estimate smaller tier
|
|
29
|
+
const smallerTier = getSmallerTier(tier);
|
|
30
|
+
const proposedCost = smallerTier ? (0, cost_estimator_1.getGCPCloudSQLMonthlyCost)(smallerTier) : 0;
|
|
31
|
+
const savings = currentCost - proposedCost;
|
|
32
|
+
if (savings > 0) {
|
|
33
|
+
opportunities.push({
|
|
34
|
+
id: `cloudsql-oversized-${instance.name}`,
|
|
35
|
+
provider: 'gcp',
|
|
36
|
+
resourceType: 'cloud-sql',
|
|
37
|
+
resourceId: instance.name,
|
|
38
|
+
resourceName: instance.name,
|
|
39
|
+
category: 'oversized',
|
|
40
|
+
currentCost,
|
|
41
|
+
estimatedSavings: savings,
|
|
42
|
+
confidence: 'medium',
|
|
43
|
+
recommendation: `Downsize to ${smallerTier || 'smaller tier'} (avg CPU: ${avgCpu.toFixed(1)}%)`,
|
|
44
|
+
metadata: {
|
|
45
|
+
tier,
|
|
46
|
+
avgCpu,
|
|
47
|
+
databaseVersion: instance.databaseVersion,
|
|
48
|
+
region: instance.region,
|
|
49
|
+
},
|
|
50
|
+
detectedAt: new Date(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Skip if Cloud SQL API is not enabled or permission issues
|
|
58
|
+
}
|
|
59
|
+
return opportunities;
|
|
60
|
+
}
|
|
61
|
+
async function getAvgCPU(monitoringClient, projectId, instanceName, days) {
|
|
62
|
+
const endTime = new Date();
|
|
63
|
+
const startTime = (0, dayjs_1.default)(endTime).subtract(days, 'day').toDate();
|
|
64
|
+
try {
|
|
65
|
+
const request = {
|
|
66
|
+
name: `projects/${projectId}`,
|
|
67
|
+
filter: `metric.type="cloudsql.googleapis.com/database/cpu/utilization" ` +
|
|
68
|
+
`AND resource.labels.database_id="${projectId}:${instanceName}"`,
|
|
69
|
+
interval: {
|
|
70
|
+
startTime: {
|
|
71
|
+
seconds: Math.floor(startTime.getTime() / 1000),
|
|
72
|
+
},
|
|
73
|
+
endTime: {
|
|
74
|
+
seconds: Math.floor(endTime.getTime() / 1000),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
aggregation: {
|
|
78
|
+
alignmentPeriod: { seconds: 86400 }, // 1 day
|
|
79
|
+
perSeriesAligner: 'ALIGN_MEAN',
|
|
80
|
+
crossSeriesReducer: 'REDUCE_MEAN',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const [timeSeries] = await monitoringClient.listTimeSeries(request);
|
|
84
|
+
if (!timeSeries || timeSeries.length === 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
let sum = 0;
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (const series of timeSeries) {
|
|
90
|
+
for (const point of series.points || []) {
|
|
91
|
+
if (point.value?.doubleValue !== undefined) {
|
|
92
|
+
// GCP returns CPU as a ratio (0-1), convert to percentage
|
|
93
|
+
sum += point.value.doubleValue * 100;
|
|
94
|
+
count++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return count > 0 ? sum / count : 0;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function getSmallerTier(currentTier) {
|
|
105
|
+
// GCP Cloud SQL tier mapping (simplified)
|
|
106
|
+
const tierMap = {
|
|
107
|
+
'db-n1-standard-4': 'db-n1-standard-2',
|
|
108
|
+
'db-n1-standard-2': 'db-n1-standard-1',
|
|
109
|
+
'db-n1-standard-8': 'db-n1-standard-4',
|
|
110
|
+
'db-n1-standard-16': 'db-n1-standard-8',
|
|
111
|
+
'db-custom-4-16384': 'db-custom-2-8192',
|
|
112
|
+
'db-custom-2-8192': 'db-custom-1-4096',
|
|
113
|
+
};
|
|
114
|
+
return tierMap[currentTier] || null;
|
|
115
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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.analyzeGCEInstances = analyzeGCEInstances;
|
|
7
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
8
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
9
|
+
async function analyzeGCEInstances(client) {
|
|
10
|
+
const computeClient = client.getComputeClient();
|
|
11
|
+
const monitoringClient = client.getMonitoringClient();
|
|
12
|
+
const opportunities = [];
|
|
13
|
+
// GCP instances are zone-specific, so we need to check multiple zones
|
|
14
|
+
const zones = await getZonesInRegion(client.region);
|
|
15
|
+
for (const zone of zones) {
|
|
16
|
+
const [instances] = await computeClient.list({
|
|
17
|
+
project: client.projectId,
|
|
18
|
+
zone: zone,
|
|
19
|
+
filter: 'status = "RUNNING"',
|
|
20
|
+
});
|
|
21
|
+
for await (const instance of instances) {
|
|
22
|
+
if (!instance.name || !instance.machineType)
|
|
23
|
+
continue;
|
|
24
|
+
// Extract machine type from full URL (e.g., "zones/us-central1-a/machineTypes/n1-standard-1")
|
|
25
|
+
const machineType = instance.machineType.split('/').pop() || '';
|
|
26
|
+
// Get average CPU over last 30 days
|
|
27
|
+
const avgCpu = await getAvgCPU(monitoringClient, client.projectId, instance.name, zone, 30);
|
|
28
|
+
if (avgCpu < 5) {
|
|
29
|
+
const monthlyCost = (0, cost_estimator_1.getGCEMonthlyCost)(machineType);
|
|
30
|
+
opportunities.push({
|
|
31
|
+
id: `gce-idle-${instance.name}`,
|
|
32
|
+
provider: 'gcp',
|
|
33
|
+
resourceType: 'compute-engine',
|
|
34
|
+
resourceId: instance.name,
|
|
35
|
+
resourceName: instance.labels?.name || instance.name,
|
|
36
|
+
category: 'idle',
|
|
37
|
+
currentCost: monthlyCost,
|
|
38
|
+
estimatedSavings: monthlyCost,
|
|
39
|
+
confidence: 'high',
|
|
40
|
+
recommendation: `Stop instance or downsize to e2-micro (avg CPU: ${avgCpu.toFixed(1)}%)`,
|
|
41
|
+
metadata: {
|
|
42
|
+
machineType,
|
|
43
|
+
zone,
|
|
44
|
+
avgCpu,
|
|
45
|
+
status: instance.status,
|
|
46
|
+
},
|
|
47
|
+
detectedAt: new Date(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return opportunities;
|
|
53
|
+
}
|
|
54
|
+
async function getAvgCPU(monitoringClient, projectId, instanceName, zone, days) {
|
|
55
|
+
const endTime = new Date();
|
|
56
|
+
const startTime = (0, dayjs_1.default)(endTime).subtract(days, 'day').toDate();
|
|
57
|
+
try {
|
|
58
|
+
const request = {
|
|
59
|
+
name: `projects/${projectId}`,
|
|
60
|
+
filter: `metric.type="compute.googleapis.com/instance/cpu/utilization" ` +
|
|
61
|
+
`AND resource.labels.instance_id="${instanceName}" ` +
|
|
62
|
+
`AND resource.labels.zone="${zone}"`,
|
|
63
|
+
interval: {
|
|
64
|
+
startTime: {
|
|
65
|
+
seconds: Math.floor(startTime.getTime() / 1000),
|
|
66
|
+
},
|
|
67
|
+
endTime: {
|
|
68
|
+
seconds: Math.floor(endTime.getTime() / 1000),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
aggregation: {
|
|
72
|
+
alignmentPeriod: { seconds: 86400 }, // 1 day
|
|
73
|
+
perSeriesAligner: 'ALIGN_MEAN',
|
|
74
|
+
crossSeriesReducer: 'REDUCE_MEAN',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const [timeSeries] = await monitoringClient.listTimeSeries(request);
|
|
78
|
+
if (!timeSeries || timeSeries.length === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
let sum = 0;
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const series of timeSeries) {
|
|
84
|
+
for (const point of series.points || []) {
|
|
85
|
+
if (point.value?.doubleValue !== undefined) {
|
|
86
|
+
// GCP returns CPU as a ratio (0-1), convert to percentage
|
|
87
|
+
sum += point.value.doubleValue * 100;
|
|
88
|
+
count++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return count > 0 ? sum / count : 0;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// Monitoring metrics may not be available for all instances
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function getZonesInRegion(region) {
|
|
100
|
+
// Common GCP zones per region (a, b, c, f)
|
|
101
|
+
// In a production app, you'd query this dynamically via Compute API
|
|
102
|
+
return [
|
|
103
|
+
`${region}-a`,
|
|
104
|
+
`${region}-b`,
|
|
105
|
+
`${region}-c`,
|
|
106
|
+
`${region}-f`,
|
|
107
|
+
];
|
|
108
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzePersistentDisks = analyzePersistentDisks;
|
|
4
|
+
async function analyzePersistentDisks(client) {
|
|
5
|
+
const computeClient = client.getComputeClient();
|
|
6
|
+
const opportunities = [];
|
|
7
|
+
// GCP disks are zone-specific
|
|
8
|
+
const zones = await getZonesInRegion(client.region);
|
|
9
|
+
for (const zone of zones) {
|
|
10
|
+
try {
|
|
11
|
+
const disksClient = client.getDisksClient();
|
|
12
|
+
const [disks] = await disksClient.list({
|
|
13
|
+
project: client.projectId,
|
|
14
|
+
zone: zone,
|
|
15
|
+
});
|
|
16
|
+
for await (const disk of disks) {
|
|
17
|
+
if (!disk.name || !disk.sizeGb)
|
|
18
|
+
continue;
|
|
19
|
+
// Disk is unattached if users array is empty or undefined
|
|
20
|
+
const isUnattached = !disk.users || disk.users.length === 0;
|
|
21
|
+
if (isUnattached) {
|
|
22
|
+
const sizeGB = parseInt(disk.sizeGb.toString());
|
|
23
|
+
const diskType = disk.type?.split('/').pop() || 'pd-standard';
|
|
24
|
+
const pricePerGB = getDiskPricePerGB(diskType);
|
|
25
|
+
const monthlyCost = sizeGB * pricePerGB;
|
|
26
|
+
opportunities.push({
|
|
27
|
+
id: `disk-unattached-${disk.name}`,
|
|
28
|
+
provider: 'gcp',
|
|
29
|
+
resourceType: 'persistent-disk',
|
|
30
|
+
resourceId: disk.name,
|
|
31
|
+
resourceName: disk.name,
|
|
32
|
+
category: 'unused',
|
|
33
|
+
currentCost: monthlyCost,
|
|
34
|
+
estimatedSavings: monthlyCost,
|
|
35
|
+
confidence: 'high',
|
|
36
|
+
recommendation: `Delete unattached disk or create snapshot and delete`,
|
|
37
|
+
metadata: {
|
|
38
|
+
sizeGB,
|
|
39
|
+
diskType,
|
|
40
|
+
zone,
|
|
41
|
+
creationTimestamp: disk.creationTimestamp,
|
|
42
|
+
},
|
|
43
|
+
detectedAt: new Date(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// Skip zones with permission issues or API errors
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return opportunities;
|
|
54
|
+
}
|
|
55
|
+
function getDiskPricePerGB(diskType) {
|
|
56
|
+
// GCP disk pricing per GB/month (us-central1, Jan 2026)
|
|
57
|
+
const pricing = {
|
|
58
|
+
'pd-standard': 0.040,
|
|
59
|
+
'pd-balanced': 0.100,
|
|
60
|
+
'pd-ssd': 0.170,
|
|
61
|
+
'pd-extreme': 0.125,
|
|
62
|
+
};
|
|
63
|
+
return pricing[diskType] || 0.040; // Default to standard
|
|
64
|
+
}
|
|
65
|
+
function getZonesInRegion(region) {
|
|
66
|
+
// Common GCP zones per region
|
|
67
|
+
return [
|
|
68
|
+
`${region}-a`,
|
|
69
|
+
`${region}-b`,
|
|
70
|
+
`${region}-c`,
|
|
71
|
+
`${region}-f`,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeStaticIPs = analyzeStaticIPs;
|
|
4
|
+
async function analyzeStaticIPs(client) {
|
|
5
|
+
const computeClient = client.getComputeClient();
|
|
6
|
+
const opportunities = [];
|
|
7
|
+
try {
|
|
8
|
+
const addressesClient = client.getAddressesClient();
|
|
9
|
+
// Check regional addresses
|
|
10
|
+
const [regionalAddresses] = await addressesClient.list({
|
|
11
|
+
project: client.projectId,
|
|
12
|
+
region: client.region,
|
|
13
|
+
});
|
|
14
|
+
for await (const address of regionalAddresses) {
|
|
15
|
+
if (!address.name)
|
|
16
|
+
continue;
|
|
17
|
+
// Address is unused if status is RESERVED (not IN_USE)
|
|
18
|
+
const isUnused = address.status === 'RESERVED';
|
|
19
|
+
if (isUnused) {
|
|
20
|
+
// GCP charges $0.010/hour for unused static IPs
|
|
21
|
+
const monthlyCost = 0.010 * 730; // ~$7.30/month
|
|
22
|
+
opportunities.push({
|
|
23
|
+
id: `static-ip-unused-${address.name}`,
|
|
24
|
+
provider: 'gcp',
|
|
25
|
+
resourceType: 'static-ip',
|
|
26
|
+
resourceId: address.name,
|
|
27
|
+
resourceName: address.name,
|
|
28
|
+
category: 'unused',
|
|
29
|
+
currentCost: monthlyCost,
|
|
30
|
+
estimatedSavings: monthlyCost,
|
|
31
|
+
confidence: 'high',
|
|
32
|
+
recommendation: `Release unused static IP address`,
|
|
33
|
+
metadata: {
|
|
34
|
+
ipAddress: address.address,
|
|
35
|
+
region: client.region,
|
|
36
|
+
status: address.status,
|
|
37
|
+
},
|
|
38
|
+
detectedAt: new Date(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Also check global addresses
|
|
43
|
+
const globalAddressesClient = client.getGlobalAddressesClient();
|
|
44
|
+
const [globalAddresses] = await globalAddressesClient.list({
|
|
45
|
+
project: client.projectId,
|
|
46
|
+
});
|
|
47
|
+
for await (const address of globalAddresses) {
|
|
48
|
+
if (!address.name)
|
|
49
|
+
continue;
|
|
50
|
+
const isUnused = address.status === 'RESERVED';
|
|
51
|
+
if (isUnused) {
|
|
52
|
+
const monthlyCost = 0.010 * 730;
|
|
53
|
+
opportunities.push({
|
|
54
|
+
id: `global-static-ip-unused-${address.name}`,
|
|
55
|
+
provider: 'gcp',
|
|
56
|
+
resourceType: 'static-ip',
|
|
57
|
+
resourceId: address.name,
|
|
58
|
+
resourceName: address.name,
|
|
59
|
+
category: 'unused',
|
|
60
|
+
currentCost: monthlyCost,
|
|
61
|
+
estimatedSavings: monthlyCost,
|
|
62
|
+
confidence: 'high',
|
|
63
|
+
recommendation: `Release unused global static IP address`,
|
|
64
|
+
metadata: {
|
|
65
|
+
ipAddress: address.address,
|
|
66
|
+
addressType: 'GLOBAL',
|
|
67
|
+
status: address.status,
|
|
68
|
+
},
|
|
69
|
+
detectedAt: new Date(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// Skip if Compute API errors
|
|
76
|
+
}
|
|
77
|
+
return opportunities;
|
|
78
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeGCSBuckets = analyzeGCSBuckets;
|
|
4
|
+
const cost_estimator_1 = require("../../analyzers/cost-estimator");
|
|
5
|
+
async function analyzeGCSBuckets(client) {
|
|
6
|
+
const storageClient = client.getStorageClient();
|
|
7
|
+
const [buckets] = await storageClient.getBuckets({
|
|
8
|
+
project: client.projectId,
|
|
9
|
+
});
|
|
10
|
+
const opportunities = [];
|
|
11
|
+
for (const bucket of buckets) {
|
|
12
|
+
try {
|
|
13
|
+
// Check if bucket has lifecycle management rules
|
|
14
|
+
const [metadata] = await bucket.getMetadata();
|
|
15
|
+
const lifecycleRules = metadata.lifecycle?.rule;
|
|
16
|
+
if (!lifecycleRules || lifecycleRules.length === 0) {
|
|
17
|
+
// Estimate bucket size from metadata or use placeholder
|
|
18
|
+
// In production, you'd integrate with Cloud Monitoring for accurate size
|
|
19
|
+
const estimatedSizeGB = 100; // Placeholder
|
|
20
|
+
const currentCost = (0, cost_estimator_1.getGCSMonthlyCost)(estimatedSizeGB, 'standard');
|
|
21
|
+
const nearlineCost = (0, cost_estimator_1.getGCSMonthlyCost)(estimatedSizeGB * 0.5, 'nearline'); // 50% to Nearline
|
|
22
|
+
const savings = currentCost - nearlineCost - (estimatedSizeGB * 0.5 * 0.020); // Remaining in standard
|
|
23
|
+
if (savings > 5) {
|
|
24
|
+
opportunities.push({
|
|
25
|
+
id: `gcs-no-lifecycle-${bucket.name}`,
|
|
26
|
+
provider: 'gcp',
|
|
27
|
+
resourceType: 'cloud-storage',
|
|
28
|
+
resourceId: bucket.name,
|
|
29
|
+
resourceName: bucket.name,
|
|
30
|
+
category: 'misconfigured',
|
|
31
|
+
currentCost,
|
|
32
|
+
estimatedSavings: savings,
|
|
33
|
+
confidence: 'low',
|
|
34
|
+
recommendation: `Enable lifecycle policy (Nearline or Coldline transition)`,
|
|
35
|
+
metadata: {
|
|
36
|
+
bucketName: bucket.name,
|
|
37
|
+
storageClass: metadata.storageClass,
|
|
38
|
+
location: metadata.location,
|
|
39
|
+
estimatedSizeGB,
|
|
40
|
+
},
|
|
41
|
+
detectedAt: new Date(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Skip buckets with permission issues
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return opportunities;
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloud-cost-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Optimize your cloud spend in seconds",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"keywords": [
|
|
19
19
|
"cloud",
|
|
20
20
|
"aws",
|
|
21
|
+
"azure",
|
|
22
|
+
"gcp",
|
|
21
23
|
"cost",
|
|
22
24
|
"optimization",
|
|
23
25
|
"cli",
|
|
@@ -25,7 +27,8 @@
|
|
|
25
27
|
"cost-optimization",
|
|
26
28
|
"aws-cli",
|
|
27
29
|
"devops",
|
|
28
|
-
"cloud-cost"
|
|
30
|
+
"cloud-cost",
|
|
31
|
+
"google-cloud"
|
|
29
32
|
],
|
|
30
33
|
"author": "Phuong Vu <vuhuuphuong@gmail.com>",
|
|
31
34
|
"repository": {
|
|
@@ -58,6 +61,10 @@
|
|
|
58
61
|
"@azure/arm-sql": "^10.0.0",
|
|
59
62
|
"@azure/arm-storage": "^19.1.0",
|
|
60
63
|
"@azure/identity": "^4.13.0",
|
|
64
|
+
"@google-cloud/compute": "^6.7.0",
|
|
65
|
+
"@google-cloud/monitoring": "^5.3.1",
|
|
66
|
+
"@google-cloud/sql": "^0.24.0",
|
|
67
|
+
"@google-cloud/storage": "^7.18.0",
|
|
61
68
|
"chalk": "^5.3.0",
|
|
62
69
|
"cli-table3": "^0.6.5",
|
|
63
70
|
"commander": "^12.1.0",
|