Initial commit: Cloud Instances API
Multi-cloud VM instance database with Cloudflare Workers - Linode, Vultr, AWS connector integration - D1 database with regions, instances, pricing - Query API with filtering, caching, pagination - Cron-based auto-sync (daily + 6-hourly) - Health monitoring endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
375
src/connectors/vultr.ts
Normal file
375
src/connectors/vultr.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type { RegionInput, InstanceTypeInput, InstanceFamily } from '../types';
|
||||
import { VaultClient, VaultError } from './vault';
|
||||
import { RateLimiter } from './base';
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
[key: string]: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vultr API Connector
|
||||
*
|
||||
* Features:
|
||||
* - Fetches regions and plans from Vultr API
|
||||
* - Rate limiting: 3000 requests/hour
|
||||
* - Data normalization for database storage
|
||||
* - Comprehensive error handling
|
||||
* - Vault integration for credentials
|
||||
*
|
||||
* @example
|
||||
* const vault = new VaultClient(vaultUrl, vaultToken);
|
||||
* const connector = new VultrConnector(vault);
|
||||
* const regions = await connector.fetchRegions();
|
||||
*/
|
||||
export class VultrConnector {
|
||||
readonly provider = 'vultr';
|
||||
private readonly baseUrl = 'https://api.vultr.com/v2';
|
||||
private readonly rateLimiter: RateLimiter;
|
||||
private readonly requestTimeout = 10000; // 10 seconds
|
||||
private apiKey: string | null = null;
|
||||
|
||||
constructor(private vaultClient: VaultClient) {
|
||||
// Rate limit: 3000 requests/hour = ~0.83 requests/second
|
||||
// Use 0.8 to be conservative
|
||||
this.rateLimiter = new RateLimiter(10, 0.8);
|
||||
console.log('[VultrConnector] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connector by fetching credentials from Vault
|
||||
* Must be called before making API requests
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[VultrConnector] Fetching credentials from Vault');
|
||||
|
||||
try {
|
||||
const credentials = await this.vaultClient.getCredentials(this.provider);
|
||||
this.apiKey = credentials.api_token;
|
||||
console.log('[VultrConnector] Credentials loaded successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
throw new VultrError(
|
||||
`Failed to load Vultr credentials: ${error.message}`,
|
||||
error.statusCode
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all regions from Vultr API
|
||||
*
|
||||
* @returns Array of raw Vultr region data
|
||||
* @throws VultrError on API failures
|
||||
*/
|
||||
async fetchRegions(): Promise<VultrRegion[]> {
|
||||
console.log('[VultrConnector] Fetching regions');
|
||||
|
||||
const response = await this.makeRequest<VultrApiResponse<VultrRegion>>(
|
||||
'/regions'
|
||||
);
|
||||
|
||||
console.log('[VultrConnector] 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<VultrPlan[]> {
|
||||
console.log('[VultrConnector] Fetching plans');
|
||||
|
||||
const response = await this.makeRequest<VultrApiResponse<VultrPlan>>(
|
||||
'/plans'
|
||||
);
|
||||
|
||||
console.log('[VultrConnector] 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
console.warn('[VultrConnector] 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<string, string> = {
|
||||
'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<T>(endpoint: string): Promise<T> {
|
||||
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}`;
|
||||
console.log('[VultrConnector] 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',
|
||||
},
|
||||
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') {
|
||||
console.error('[VultrConnector] Request timeout', { endpoint, timeout: 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
|
||||
console.error('[VultrConnector] Unexpected error', { endpoint, error });
|
||||
throw new VultrError(
|
||||
`Failed to fetch from Vultr API: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
500,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP error responses from Vultr API
|
||||
* This method always throws a VultrError
|
||||
*/
|
||||
private async handleHttpError(response: Response): Promise<never> {
|
||||
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;
|
||||
}
|
||||
|
||||
console.error('[VultrConnector] 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 === 429) {
|
||||
// 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}`,
|
||||
429,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user