import type { RegionInput, InstanceTypeInput, InstanceFamily } from '../types'; import { VaultClient, VaultError } from './vault'; import { RateLimiter } from './base'; import { TIMEOUTS, HTTP_STATUS } from '../constants'; /** * AWS connector error class */ export class AWSError extends Error { constructor( message: string, public statusCode?: number, public details?: unknown ) { super(message); this.name = 'AWSError'; } } /** * AWS region data structure */ interface AWSRegion { code: string; name: string; } /** * AWS instance type data from ec2.shop API */ interface AWSInstanceType { InstanceType: string; Memory: string; // e.g., "8 GiB" VCPUS: number; Storage: string; Network: string; Cost: number; MonthlyPrice: number; GPU: number | null; SpotPrice: string | null; } /** * ec2.shop API response structure */ interface EC2ShopResponse { Prices: AWSInstanceType[]; } /** * AWS EC2 Connector * * Features: * - Uses public ec2.shop API for instance type data * - No authentication required for basic data * - Rate limiting: 20 requests/second * - Hardcoded region list (relatively static) * - Comprehensive error handling * * @example * const vault = new VaultClient(vaultUrl, vaultToken); * const connector = new AWSConnector(vault); * await connector.initialize(); * const regions = await connector.fetchRegions(); */ export class AWSConnector { readonly provider = 'aws'; private readonly instanceDataUrl = 'https://ec2.shop/?json'; private readonly rateLimiter: RateLimiter; private readonly requestTimeout = TIMEOUTS.AWS_REQUEST; /** * AWS regions list (relatively static data) * Based on AWS public region information */ private readonly awsRegions: AWSRegion[] = [ { code: 'us-east-1', name: 'US East (N. Virginia)' }, { code: 'us-east-2', name: 'US East (Ohio)' }, { code: 'us-west-1', name: 'US West (N. California)' }, { code: 'us-west-2', name: 'US West (Oregon)' }, { code: 'eu-west-1', name: 'EU (Ireland)' }, { code: 'eu-west-2', name: 'EU (London)' }, { code: 'eu-west-3', name: 'EU (Paris)' }, { code: 'eu-central-1', name: 'EU (Frankfurt)' }, { code: 'eu-central-2', name: 'EU (Zurich)' }, { code: 'eu-north-1', name: 'EU (Stockholm)' }, { code: 'eu-south-1', name: 'EU (Milan)' }, { code: 'eu-south-2', name: 'EU (Spain)' }, { code: 'ap-northeast-1', name: 'Asia Pacific (Tokyo)' }, { code: 'ap-northeast-2', name: 'Asia Pacific (Seoul)' }, { code: 'ap-northeast-3', name: 'Asia Pacific (Osaka)' }, { code: 'ap-southeast-1', name: 'Asia Pacific (Singapore)' }, { code: 'ap-southeast-2', name: 'Asia Pacific (Sydney)' }, { code: 'ap-southeast-3', name: 'Asia Pacific (Jakarta)' }, { code: 'ap-southeast-4', name: 'Asia Pacific (Melbourne)' }, { code: 'ap-south-1', name: 'Asia Pacific (Mumbai)' }, { code: 'ap-south-2', name: 'Asia Pacific (Hyderabad)' }, { code: 'ap-east-1', name: 'Asia Pacific (Hong Kong)' }, { code: 'ca-central-1', name: 'Canada (Central)' }, { code: 'ca-west-1', name: 'Canada (Calgary)' }, { code: 'sa-east-1', name: 'South America (São Paulo)' }, { code: 'af-south-1', name: 'Africa (Cape Town)' }, { code: 'me-south-1', name: 'Middle East (Bahrain)' }, { code: 'me-central-1', name: 'Middle East (UAE)' }, { code: 'il-central-1', name: 'Israel (Tel Aviv)' }, ]; constructor(private vaultClient: VaultClient) { // Rate limit: 20 requests/second per region // Use 10 tokens with 10/second refill to be conservative this.rateLimiter = new RateLimiter(20, 10); console.log('[AWSConnector] Initialized'); } /** * Initialize connector by fetching credentials from Vault * Note: Currently not required for public API access, * but included for future AWS API integration */ async initialize(): Promise { console.log('[AWSConnector] Fetching credentials from Vault'); try { const credentials = await this.vaultClient.getCredentials(this.provider); // AWS uses different credential keys const awsCreds = credentials as unknown as { aws_access_key_id?: string; aws_secret_access_key?: string; }; // Credentials loaded for future AWS API direct access console.log('[AWSConnector] Credentials loaded successfully', { hasAccessKey: !!awsCreds.aws_access_key_id, hasSecretKey: !!awsCreds.aws_secret_access_key, }); } catch (error) { if (error instanceof VaultError) { console.warn('[AWSConnector] Vault credentials not available, using public API only'); // Not critical for public API access } else { throw error; } } } /** * Fetch all regions * Returns hardcoded region list as AWS regions are relatively static * * @returns Array of AWS regions */ async fetchRegions(): Promise { console.log('[AWSConnector] Fetching regions', { count: this.awsRegions.length }); return this.awsRegions; } /** * Fetch all instance types from ec2.shop API * * @returns Array of AWS instance types * @throws AWSError on API failures */ async fetchInstanceTypes(): Promise { console.log('[AWSConnector] Fetching instance types from ec2.shop'); await this.rateLimiter.waitForToken(); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); const response = await fetch(this.instanceDataUrl, { method: 'GET', headers: { 'Accept': 'application/json', }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new AWSError( `Failed to fetch instance types: ${response.statusText}`, response.status ); } const data = await response.json() as EC2ShopResponse; const instances = data.Prices || []; console.log('[AWSConnector] Instance types fetched', { count: instances.length }); return instances; } catch (error) { // Handle timeout if (error instanceof Error && error.name === 'AbortError') { console.error('[AWSConnector] Request timeout', { timeout: this.requestTimeout }); throw new AWSError( `Request to ec2.shop API timed out after ${this.requestTimeout}ms`, 504 ); } // Re-throw AWSError if (error instanceof AWSError) { throw error; } // Handle unexpected errors console.error('[AWSConnector] Unexpected error', { error }); throw new AWSError( `Failed to fetch instance types: ${error instanceof Error ? error.message : 'Unknown error'}`, HTTP_STATUS.INTERNAL_ERROR, error ); } } /** * Normalize AWS region data for database storage * * @param raw - Raw AWS region data * @param providerId - Database provider ID * @returns Normalized region data ready for insertion */ normalizeRegion(raw: AWSRegion, providerId: number): RegionInput { return { provider_id: providerId, region_code: raw.code, region_name: raw.name, country_code: this.extractCountryCode(raw.code), latitude: null, // AWS doesn't provide coordinates in basic data longitude: null, available: 1, // All listed regions are available }; } /** * Normalize AWS instance type data for database storage * * @param raw - Raw AWS instance type data * @param providerId - Database provider ID * @returns Normalized instance type data ready for insertion */ normalizeInstance(raw: AWSInstanceType, providerId: number): InstanceTypeInput { // Parse memory from string like "8 GiB" to MB const memoryGib = parseFloat(raw.Memory); const memoryMb = Number.isNaN(memoryGib) ? 0 : Math.round(memoryGib * 1024); // Parse storage information const storageGb = this.parseStorage(raw.Storage); // Parse GPU information from instance type name const { gpuCount, gpuType } = this.parseGpuInfo(raw.InstanceType); // Validate GPU count - ensure it's a valid number const rawGpuCount = typeof raw.GPU === 'number' ? raw.GPU : 0; const finalGpuCount = Number.isNaN(rawGpuCount) ? gpuCount : rawGpuCount; // Validate VCPU - ensure it's a valid number const vcpu = raw.VCPUS && !Number.isNaN(raw.VCPUS) ? raw.VCPUS : 0; // Convert all metadata values to primitives before JSON.stringify const storageType = typeof raw.Storage === 'string' ? raw.Storage : String(raw.Storage ?? ''); const network = typeof raw.Network === 'string' ? raw.Network : String(raw.Network ?? ''); const hourlyPrice = typeof raw.Cost === 'number' ? raw.Cost : 0; const monthlyPrice = typeof raw.MonthlyPrice === 'number' ? raw.MonthlyPrice : 0; const spotPrice = typeof raw.SpotPrice === 'string' ? raw.SpotPrice : String(raw.SpotPrice ?? ''); return { provider_id: providerId, instance_id: raw.InstanceType, instance_name: raw.InstanceType, vcpu: vcpu, memory_mb: memoryMb, storage_gb: storageGb, transfer_tb: null, // ec2.shop doesn't provide transfer limits network_speed_gbps: this.parseNetworkSpeed(raw.Network), gpu_count: finalGpuCount, gpu_type: gpuType, instance_family: this.mapInstanceFamily(raw.InstanceType), metadata: JSON.stringify({ storage_type: storageType, network: network, hourly_price: hourlyPrice, monthly_price: monthlyPrice, spot_price: spotPrice, }), }; } /** * Extract country code from AWS region code * * @param regionCode - AWS region code (e.g., 'us-east-1') * @returns Lowercase ISO alpha-2 country code or null */ private extractCountryCode(regionCode: string): string | null { const countryMap: Record = { 'us': 'us', 'eu': 'eu', 'ap': 'ap', 'ca': 'ca', 'sa': 'br', 'af': 'za', 'me': 'ae', 'il': 'il', }; const prefix = regionCode.split('-')[0]; return countryMap[prefix] || null; } /** * Parse storage information from AWS storage string * * @param storage - AWS storage string (e.g., "EBS only", "1 x 900 NVMe SSD", "2400 GB") * @returns Storage size in GB or 0 if EBS only or parsing fails */ private parseStorage(storage: string): number { if (!storage || storage.toLowerCase().includes('ebs only')) { return 0; // EBS only instances have no instance storage } // Parse format like "1 x 900 NVMe SSD" or "2 x 1900 NVMe SSD" const multiDiskMatch = storage.match(/(\d+)\s*x\s*(\d+)/); if (multiDiskMatch) { const count = parseInt(multiDiskMatch[1], 10); const sizePerDisk = parseInt(multiDiskMatch[2], 10); const totalStorage = count * sizePerDisk; return Number.isNaN(totalStorage) ? 0 : totalStorage; } // Parse format like "2400 GB" or "500GB" const singleSizeMatch = storage.match(/(\d+)\s*GB/i); if (singleSizeMatch) { const size = parseInt(singleSizeMatch[1], 10); return Number.isNaN(size) ? 0 : size; } return 0; } /** * Parse network speed from AWS network string * * @param network - AWS network string (e.g., "Up to 5 Gigabit", "25 Gigabit") * @returns Network speed in Gbps or null */ private parseNetworkSpeed(network: string): number | null { if (!network) { return null; } const match = network.match(/(\d+)\s*Gigabit/i); if (match) { return parseInt(match[1], 10); } return null; } /** * Parse GPU information from instance type name * * @param instanceType - AWS instance type name * @returns GPU count and type */ private parseGpuInfo(instanceType: string): { gpuCount: number; gpuType: string | null } { const typeLower = instanceType.toLowerCase(); // GPU instance families if (typeLower.startsWith('p2.')) { return { gpuCount: this.getGpuCount(instanceType, 'p2'), gpuType: 'NVIDIA K80' }; } if (typeLower.startsWith('p3.')) { return { gpuCount: this.getGpuCount(instanceType, 'p3'), gpuType: 'NVIDIA V100' }; } if (typeLower.startsWith('p4.')) { return { gpuCount: this.getGpuCount(instanceType, 'p4'), gpuType: 'NVIDIA A100' }; } if (typeLower.startsWith('p5.')) { return { gpuCount: this.getGpuCount(instanceType, 'p5'), gpuType: 'NVIDIA H100' }; } if (typeLower.startsWith('g3.')) { return { gpuCount: this.getGpuCount(instanceType, 'g3'), gpuType: 'NVIDIA M60' }; } if (typeLower.startsWith('g4.')) { return { gpuCount: this.getGpuCount(instanceType, 'g4'), gpuType: 'NVIDIA T4' }; } if (typeLower.startsWith('g5.')) { return { gpuCount: this.getGpuCount(instanceType, 'g5'), gpuType: 'NVIDIA A10G' }; } if (typeLower.startsWith('inf')) { return { gpuCount: this.getInferentiaCount(instanceType), gpuType: 'AWS Inferentia' }; } if (typeLower.startsWith('trn')) { return { gpuCount: this.getTrainiumCount(instanceType), gpuType: 'AWS Trainium' }; } return { gpuCount: 0, gpuType: null }; } /** * Get GPU count based on instance size * * @param instanceType - Full instance type name * @param family - Instance family prefix * @returns Number of GPUs (always returns a valid number, defaults to 0) */ private getGpuCount(instanceType: string, _family: string): number { const size = instanceType.split('.')[1]; if (!size) { return 0; } // Common GPU counts by size const gpuMap: Record = { 'xlarge': 1, '2xlarge': 1, '4xlarge': 2, '8xlarge': 4, '16xlarge': 8, '24xlarge': 8, '48xlarge': 8, }; const gpuCount = gpuMap[size]; return gpuCount !== undefined ? gpuCount : 0; } /** * Get Inferentia accelerator count * * @param instanceType - Full instance type name * @returns Number of Inferentia chips */ private getInferentiaCount(instanceType: string): number { const size = instanceType.split('.')[1]; const infMap: Record = { 'xlarge': 1, '2xlarge': 1, '6xlarge': 4, '24xlarge': 16, }; return infMap[size] || 1; } /** * Get Trainium accelerator count * * @param instanceType - Full instance type name * @returns Number of Trainium chips */ private getTrainiumCount(instanceType: string): number { const size = instanceType.split('.')[1]; const trnMap: Record = { '2xlarge': 1, '32xlarge': 16, }; return trnMap[size] || 1; } /** * Map AWS instance type to standard instance family * * @param instanceType - AWS instance type name * @returns Standard instance family type */ private mapInstanceFamily(instanceType: string): InstanceFamily { const family = instanceType.split('.')[0].toLowerCase(); // General purpose if (family.match(/^[tm]\d+[a-z]?$/)) { return 'general'; } if (family.match(/^a\d+$/)) { return 'general'; } // Compute optimized if (family.match(/^c\d+[a-z]?$/)) { return 'compute'; } // Memory optimized if (family.match(/^[rx]\d+[a-z]?$/)) { return 'memory'; } if (family.match(/^u-\d+/)) { return 'memory'; } if (family.match(/^z\d+[a-z]?$/)) { return 'memory'; } // Storage optimized if (family.match(/^[dhi]\d+[a-z]?$/)) { return 'storage'; } // GPU/accelerated computing if (family.match(/^[pg]\d+[a-z]?$/)) { return 'gpu'; } if (family.match(/^(inf|trn|dl)\d*/)) { return 'gpu'; } // Default to general for unknown types console.warn('[AWSConnector] Unknown instance family, defaulting to general', { type: instanceType }); return 'general'; } }