import type { Env, RegionInput, InstanceTypeInput, InstanceFamily, GpuInstanceInput } from '../types'; import { RateLimiter } from './base'; import { createLogger } from '../utils/logger'; import { HTTP_STATUS } from '../constants'; /** * Vultr API error class */ export class VultrError extends Error { constructor( message: string, public statusCode?: number, public details?: unknown ) { super(message); this.name = 'VultrError'; } } /** * Vultr API response types */ interface VultrRegion { id: string; city: string; country: string; continent: string; options: string[]; } interface VultrPlan { id: string; vcpu_count: number; ram: number; // in MB disk: number; // in GB disk_count: number; bandwidth: number; // in GB monthly_cost: number; type: string; locations: string[]; } interface VultrApiResponse { [key: string]: T[]; } /** * Vultr API Connector * * Features: * - Fetches regions and plans from Vultr API via relay server * - Rate limiting: 3000 requests/hour * - Data normalization for database storage * - Comprehensive error handling * - Credentials from environment variables * * @example * const connector = new VultrConnector(env); * await connector.initialize(); * const regions = await connector.fetchRegions(); * * @example * // Using custom relay URL * const connector = new VultrConnector(env, 'https://custom-relay.example.com'); * * @param env - Environment with credentials * @param relayUrl - Optional relay server URL (defaults to 'https://vultr-relay.anvil.it.com') */ export class VultrConnector { readonly provider = 'vultr'; private readonly baseUrl: string; private readonly rateLimiter: RateLimiter; private readonly requestTimeout = 10000; // 10 seconds private readonly logger: ReturnType; private apiKey: string | null = null; constructor( private env: Env, relayUrl?: string ) { // Use relay server by default, allow override via parameter or environment variable // Relay server mirrors Vultr API structure: /v2/regions, /v2/plans this.baseUrl = relayUrl || 'https://vultr-relay.anvil.it.com/v2'; // Rate limit: 3000 requests/hour = ~0.83 requests/second // Use 0.8 to be conservative this.rateLimiter = new RateLimiter(10, 0.8); this.logger = createLogger('[VultrConnector]', env); this.logger.info('Initialized', { baseUrl: this.baseUrl }); } /** * Initialize connector by loading credentials from environment * Must be called before making API requests */ async initialize(): Promise { this.logger.info('Loading credentials from environment'); if (!this.env.VULTR_API_KEY) { throw new VultrError( 'VULTR_API_KEY not found in environment', HTTP_STATUS.INTERNAL_ERROR ); } this.apiKey = this.env.VULTR_API_KEY; this.logger.info('Credentials loaded successfully'); } /** * Fetch all regions from Vultr API * * @returns Array of raw Vultr region data * @throws VultrError on API failures */ async fetchRegions(): Promise { this.logger.info('Fetching regions'); const response = await this.makeRequest>( '/regions' ); this.logger.info('Regions fetched', { count: response.regions.length }); return response.regions; } /** * Fetch all plans from Vultr API * * @returns Array of raw Vultr plan data * @throws VultrError on API failures */ async fetchPlans(): Promise { this.logger.info('Fetching plans'); const response = await this.makeRequest>( '/plans' ); this.logger.info('Plans fetched', { count: response.plans.length }); return response.plans; } /** * Normalize Vultr region data for database storage * * @param raw - Raw Vultr region data * @param providerId - Database provider ID * @returns Normalized region data ready for insertion */ normalizeRegion(raw: VultrRegion, providerId: number): RegionInput { return { provider_id: providerId, region_code: raw.id, region_name: `${raw.city}, ${raw.country}`, country_code: this.getCountryCode(raw.country), latitude: null, // Vultr doesn't provide coordinates longitude: null, available: 1, // Vultr only returns available regions }; } /** * Normalize Vultr plan data for database storage * * @param raw - Raw Vultr plan data * @param providerId - Database provider ID * @returns Normalized instance type data ready for insertion */ normalizeInstance(raw: VultrPlan, providerId: number): InstanceTypeInput { // Calculate hourly price: monthly_cost / 730 hours const hourlyPrice = raw.monthly_cost / 730; return { provider_id: providerId, instance_id: raw.id, instance_name: raw.id, vcpu: raw.vcpu_count, memory_mb: raw.ram, // Already in MB storage_gb: raw.disk, // Already in GB transfer_tb: raw.bandwidth / 1000, // Convert GB to TB network_speed_gbps: null, // Vultr doesn't provide network speed gpu_count: 0, // Vultr doesn't expose GPU in plans API gpu_type: null, instance_family: this.mapInstanceFamily(raw.type), metadata: JSON.stringify({ type: raw.type, disk_count: raw.disk_count, locations: raw.locations, hourly_price: hourlyPrice, monthly_price: raw.monthly_cost, }), }; } /** * Normalize Vultr GPU plan data for database storage * * @param raw - Raw Vultr plan data for GPU instance (vcg type) * @param providerId - Database provider ID * @returns Normalized GPU instance data ready for insertion */ normalizeGpuInstance(raw: VultrPlan, providerId: number): GpuInstanceInput { const hourlyPrice = raw.monthly_cost / 730; // Extract GPU type from vcg prefix // vcg-* instances are NVIDIA-based GPU instances const gpuType = 'NVIDIA'; return { provider_id: providerId, instance_id: raw.id, instance_name: raw.id, vcpu: raw.vcpu_count, memory_mb: raw.ram, storage_gb: raw.disk, transfer_tb: raw.bandwidth / 1000, network_speed_gbps: null, gpu_count: 1, // Vultr vcg instances have 1 GPU gpu_type: gpuType, gpu_memory_gb: null, // Vultr doesn't expose GPU memory in plans API metadata: JSON.stringify({ type: raw.type, disk_count: raw.disk_count, locations: raw.locations, hourly_price: hourlyPrice, monthly_price: raw.monthly_cost, }), }; } /** * Map Vultr instance type to standard instance family * * @param vultrType - Vultr instance type * @returns Standard instance family type */ private mapInstanceFamily(vultrType: string): InstanceFamily { const typeLower = vultrType.toLowerCase(); if (typeLower === 'vc2' || typeLower === 'vhf') { return 'general'; } if (typeLower === 'vhp') { return 'compute'; } if (typeLower === 'vdc') { return 'compute'; // dedicated CPU → compute family } if (typeLower === 'vcg') { return 'gpu'; } // Default to general for unknown types this.logger.warn('Unknown instance type, defaulting to general', { type: vultrType }); return 'general'; } /** * Map country name to ISO 3166-1 alpha-2 country code * * @param countryName - Full country name * @returns Lowercase ISO alpha-2 country code or null if not found */ private getCountryCode(countryName: string): string | null { const countryMap: Record = { 'US': 'us', 'United States': 'us', 'Canada': 'ca', 'UK': 'gb', 'United Kingdom': 'gb', 'Germany': 'de', 'France': 'fr', 'Netherlands': 'nl', 'Australia': 'au', 'Japan': 'jp', 'Singapore': 'sg', 'South Korea': 'kr', 'India': 'in', 'Spain': 'es', 'Poland': 'pl', 'Sweden': 'se', 'Israel': 'il', 'Mexico': 'mx', 'Brazil': 'br', }; return countryMap[countryName] || null; } /** * Make authenticated request to Vultr API with rate limiting * * @param endpoint - API endpoint (e.g., '/regions') * @returns Parsed API response * @throws VultrError on API failures */ private async makeRequest(endpoint: string): Promise { if (!this.apiKey) { throw new VultrError( 'Connector not initialized. Call initialize() first.', 500 ); } // Apply rate limiting await this.rateLimiter.waitForToken(); const url = `${this.baseUrl}${endpoint}`; this.logger.debug('Making request', { endpoint }); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); const response = await fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'Mozilla/5.0 (compatible; CloudInstancesAPI/1.0)', }, signal: controller.signal, }); clearTimeout(timeoutId); // Handle HTTP errors if (!response.ok) { await this.handleHttpError(response); } const data = await response.json() as T; return data; } catch (error) { // Handle timeout if (error instanceof Error && error.name === 'AbortError') { this.logger.error('Request timeout', { endpoint, timeout_ms: this.requestTimeout }); throw new VultrError( `Request to Vultr API timed out after ${this.requestTimeout}ms`, 504 ); } // Re-throw VultrError if (error instanceof VultrError) { throw error; } // Handle unexpected errors this.logger.error('Unexpected error', { endpoint, error: error instanceof Error ? error.message : String(error) }); throw new VultrError( `Failed to fetch from Vultr API: ${error instanceof Error ? error.message : 'Unknown error'}`, HTTP_STATUS.INTERNAL_ERROR, error ); } } /** * Handle HTTP error responses from Vultr API * This method always throws a VultrError */ private async handleHttpError(response: Response): Promise { const statusCode = response.status; let errorMessage: string; let errorDetails: unknown; try { const errorData = await response.json() as { error?: string; message?: string }; errorMessage = errorData.error || errorData.message || response.statusText; errorDetails = errorData; } catch { errorMessage = response.statusText; errorDetails = null; } this.logger.error('HTTP error', { statusCode, errorMessage }); if (statusCode === 401) { throw new VultrError( 'Vultr authentication failed: Invalid or expired API key', 401, errorDetails ); } if (statusCode === 403) { throw new VultrError( 'Vultr authorization failed: Insufficient permissions', 403, errorDetails ); } if (statusCode === HTTP_STATUS.TOO_MANY_REQUESTS) { // Check for Retry-After header const retryAfter = response.headers.get('Retry-After'); const retryMessage = retryAfter ? ` Retry after ${retryAfter} seconds.` : ''; throw new VultrError( `Vultr rate limit exceeded: Too many requests.${retryMessage}`, HTTP_STATUS.TOO_MANY_REQUESTS, errorDetails ); } if (statusCode >= 500 && statusCode < 600) { throw new VultrError( `Vultr server error: ${errorMessage}`, statusCode, errorDetails ); } throw new VultrError( `Vultr API request failed: ${errorMessage}`, statusCode, errorDetails ); } }