cloud-cost-cli 0.3.0 → 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 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 Azure resources to identify cost-saving opportunities — idle resources, oversized instances, unattached volumes, and more.
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.3.0:** AI-powered explanations and natural language queries!
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/EC2 instances (low CPU, stopped but still billed)
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,9 +34,10 @@ 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 Azure
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
40
+ - ✅ **GCP analyzers** - Compute Engine, Cloud Storage, Cloud SQL, Persistent Disks, Static IPs
39
41
  - ✅ **🤖 AI-powered explanations** - Get human-readable explanations for why resources are costing money
40
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
@@ -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 support (Compute Engine, Cloud Storage, Cloud SQL)
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)
@@ -110,6 +115,23 @@ export AZURE_SUBSCRIPTION_ID="your-subscription-id"
110
115
  cloud-cost-cli scan --provider azure --location eastus
111
116
  ```
112
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
+
113
135
  **How to create Azure Service Principal:**
114
136
  ```bash
115
137
  # Create service principal with Reader role
@@ -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.3.0-beta.1');
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>', 'AWS region (e.g., us-east-1)')
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
+ }
@@ -4,6 +4,7 @@ interface ScanCommandOptions {
4
4
  profile?: string;
5
5
  subscriptionId?: string;
6
6
  location?: string;
7
+ projectId?: string;
7
8
  top?: string;
8
9
  output?: string;
9
10
  days?: string;
@@ -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 --provider azure`);
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. Please run "az login" first or set up service principal credentials.\n' +
43
- 'See: https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli');
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,3 @@
1
+ import { GCPClient } from './client';
2
+ import { SavingsOpportunity } from '../../types/opportunity';
3
+ export declare function analyzeCloudSQLInstances(client: GCPClient): Promise<SavingsOpportunity[]>;
@@ -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,3 @@
1
+ import { GCPClient } from './client';
2
+ import { SavingsOpportunity } from '../../types/opportunity';
3
+ export declare function analyzeGCEInstances(client: GCPClient): Promise<SavingsOpportunity[]>;
@@ -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,3 @@
1
+ import { GCPClient } from './client';
2
+ import { SavingsOpportunity } from '../../types/opportunity';
3
+ export declare function analyzePersistentDisks(client: GCPClient): Promise<SavingsOpportunity[]>;
@@ -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,3 @@
1
+ import { GCPClient } from './client';
2
+ import { SavingsOpportunity } from '../../types/opportunity';
3
+ export declare function analyzeStaticIPs(client: GCPClient): Promise<SavingsOpportunity[]>;
@@ -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,3 @@
1
+ import { GCPClient } from './client';
2
+ import { SavingsOpportunity } from '../../types/opportunity';
3
+ export declare function analyzeGCSBuckets(client: GCPClient): Promise<SavingsOpportunity[]>;
@@ -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.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",