import type { Env, RegionInput, InstanceTypeInput, InstanceFamily } from '../types'; import { VaultClient, VaultError } from './vault'; import { RateLimiter } from './base'; import { createLogger } from '../utils/logger'; import { HTTP_STATUS } from '../constants'; /** * Linode API error class */ export class LinodeError extends Error { constructor( message: string, public statusCode?: number, public details?: unknown ) { super(message); this.name = 'LinodeError'; } } /** * Linode API response types */ interface LinodeRegion { id: string; label: string; country: string; capabilities: string[]; status: string; } interface LinodeInstanceType { id: string; label: string; price: { hourly: number; monthly: number; }; memory: number; vcpus: number; disk: number; transfer: number; network_out: number; gpus: number; class: string; } interface LinodeApiResponse { data: T[]; page?: number; pages?: number; results?: number; } /** * Linode API Connector * * Features: * - Fetches regions and instance types from Linode API * - Rate limiting: 1600 requests/hour * - Data normalization for database storage * - Comprehensive error handling * - Vault integration for credentials * * @example * const vault = new VaultClient(vaultUrl, vaultToken); * const connector = new LinodeConnector(vault); * const regions = await connector.fetchRegions(); */ export class LinodeConnector { readonly provider = 'linode'; private readonly baseUrl = 'https://api.linode.com/v4'; private readonly rateLimiter: RateLimiter; private readonly requestTimeout = 10000; // 10 seconds private readonly logger: ReturnType; private apiToken: string | null = null; constructor(private vaultClient: VaultClient, env?: Env) { // Rate limit: 1600 requests/hour = ~0.44 requests/second // Token bucket: maxTokens=5 (allow burst), refillRate=0.5 (conservative) this.rateLimiter = new RateLimiter(5, 0.5); this.logger = createLogger('[LinodeConnector]', env); this.logger.info('Initialized'); } /** * Initialize connector by fetching credentials from Vault * Must be called before making API requests */ async initialize(): Promise { this.logger.info('Fetching credentials from Vault'); try { const credentials = await this.vaultClient.getCredentials(this.provider); this.apiToken = credentials.api_token || null; this.logger.info('Credentials loaded successfully'); } catch (error) { if (error instanceof VaultError) { throw new LinodeError( `Failed to load Linode credentials: ${error.message}`, error.statusCode ); } throw error; } } /** * Fetch all regions from Linode API * * @returns Array of raw Linode region data * @throws LinodeError on API failures */ async fetchRegions(): Promise { this.logger.info('Fetching regions'); const response = await this.makeRequest>( '/regions' ); this.logger.info('Regions fetched', { count: response.data.length }); return response.data; } /** * Fetch all instance types from Linode API * * @returns Array of raw Linode instance type data * @throws LinodeError on API failures */ async fetchInstanceTypes(): Promise { this.logger.info('Fetching instance types'); const response = await this.makeRequest>( '/linode/types' ); this.logger.info('Instance types fetched', { count: response.data.length }); return response.data; } /** * Normalize Linode region data for database storage * * @param raw - Raw Linode region data * @param providerId - Database provider ID * @returns Normalized region data ready for insertion */ normalizeRegion(raw: LinodeRegion, providerId: number): RegionInput { return { provider_id: providerId, region_code: raw.id, region_name: raw.label, country_code: raw.country.toLowerCase(), latitude: null, // Linode doesn't provide coordinates longitude: null, available: raw.status === 'ok' ? 1 : 0, }; } /** * Normalize Linode instance type data for database storage * * @param raw - Raw Linode instance type data * @param providerId - Database provider ID * @returns Normalized instance type data ready for insertion */ normalizeInstance(raw: LinodeInstanceType, providerId: number): InstanceTypeInput { return { provider_id: providerId, instance_id: raw.id, instance_name: raw.label, vcpu: raw.vcpus, memory_mb: raw.memory, // Already in MB storage_gb: Math.round(raw.disk / 1024), // Convert MB to GB transfer_tb: raw.transfer / 1000, // Convert GB to TB network_speed_gbps: raw.network_out / 1000, // Convert Mbps to Gbps gpu_count: raw.gpus, gpu_type: raw.gpus > 0 ? 'nvidia' : null, // Linode uses NVIDIA GPUs instance_family: this.mapInstanceFamily(raw.class), metadata: JSON.stringify({ class: raw.class, hourly_price: raw.price.hourly, monthly_price: raw.price.monthly, }), }; } /** * Map Linode instance class to standard instance family * * @param linodeClass - Linode instance class * @returns Standard instance family type */ private mapInstanceFamily(linodeClass: string): InstanceFamily { const classLower = linodeClass.toLowerCase(); if (classLower === 'nanode' || classLower === 'standard') { return 'general'; } if (classLower === 'highmem') { return 'memory'; } if (classLower === 'dedicated') { return 'compute'; } if (classLower === 'gpu') { return 'gpu'; } // Default to general for unknown classes this.logger.warn('Unknown instance class, defaulting to general', { class: linodeClass }); return 'general'; } /** * Make authenticated request to Linode API with rate limiting * * @param endpoint - API endpoint (e.g., '/regions') * @returns Parsed API response * @throws LinodeError on API failures */ private async makeRequest(endpoint: string): Promise { if (!this.apiToken) { throw new LinodeError( 'Connector not initialized. Call initialize() first.', HTTP_STATUS.INTERNAL_ERROR ); } // 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.apiToken}`, 'Content-Type': 'application/json', }, 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 LinodeError( `Request to Linode API timed out after ${this.requestTimeout}ms`, 504 ); } // Re-throw LinodeError if (error instanceof LinodeError) { throw error; } // Handle unexpected errors this.logger.error('Unexpected error', { endpoint, error: error instanceof Error ? error.message : String(error) }); throw new LinodeError( `Failed to fetch from Linode API: ${error instanceof Error ? error.message : 'Unknown error'}`, HTTP_STATUS.INTERNAL_ERROR, error ); } } /** * Handle HTTP error responses from Linode API * This method always throws a LinodeError */ private async handleHttpError(response: Response): Promise { const statusCode = response.status; let errorMessage: string; let errorDetails: unknown; try { const errorData = await response.json() as { errors?: Array<{ reason?: string }> }; errorMessage = errorData.errors?.[0]?.reason || response.statusText; errorDetails = errorData; } catch { errorMessage = response.statusText; errorDetails = null; } this.logger.error('HTTP error', { statusCode, errorMessage }); if (statusCode === 401) { throw new LinodeError( 'Linode authentication failed: Invalid or expired API token', 401, errorDetails ); } if (statusCode === 403) { throw new LinodeError( 'Linode authorization failed: Insufficient permissions', 403, errorDetails ); } if (statusCode === HTTP_STATUS.TOO_MANY_REQUESTS) { throw new LinodeError( 'Linode rate limit exceeded: Too many requests', HTTP_STATUS.TOO_MANY_REQUESTS, errorDetails ); } if (statusCode >= 500 && statusCode < 600) { throw new LinodeError( `Linode server error: ${errorMessage}`, statusCode, errorDetails ); } throw new LinodeError( `Linode API request failed: ${errorMessage}`, statusCode, errorDetails ); } }