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:
363
src/connectors/linode.ts
Normal file
363
src/connectors/linode.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import type { RegionInput, InstanceTypeInput, InstanceFamily } from '../types';
|
||||
import { VaultClient, VaultError } from './vault';
|
||||
|
||||
/**
|
||||
* Rate limiter for Linode API
|
||||
* Linode rate limit: 1600 requests/hour = ~0.44 requests/second
|
||||
*/
|
||||
class RateLimiter {
|
||||
private lastRequestTime = 0;
|
||||
private readonly minInterval: number;
|
||||
|
||||
constructor(requestsPerSecond: number) {
|
||||
this.minInterval = 1000 / requestsPerSecond; // milliseconds between requests
|
||||
}
|
||||
|
||||
async throttle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - this.lastRequestTime;
|
||||
|
||||
if (timeSinceLastRequest < this.minInterval) {
|
||||
const waitTime = this.minInterval - timeSinceLastRequest;
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
|
||||
this.lastRequestTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
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 apiToken: string | null = null;
|
||||
|
||||
constructor(private vaultClient: VaultClient) {
|
||||
// Rate limit: 1600 requests/hour = ~0.44 requests/second
|
||||
// Use 0.4 to be conservative
|
||||
this.rateLimiter = new RateLimiter(0.4);
|
||||
console.log('[LinodeConnector] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connector by fetching credentials from Vault
|
||||
* Must be called before making API requests
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[LinodeConnector] Fetching credentials from Vault');
|
||||
|
||||
try {
|
||||
const credentials = await this.vaultClient.getCredentials(this.provider);
|
||||
this.apiToken = credentials.api_token;
|
||||
console.log('[LinodeConnector] 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<LinodeRegion[]> {
|
||||
console.log('[LinodeConnector] Fetching regions');
|
||||
|
||||
const response = await this.makeRequest<LinodeApiResponse<LinodeRegion>>(
|
||||
'/regions'
|
||||
);
|
||||
|
||||
console.log('[LinodeConnector] 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<LinodeInstanceType[]> {
|
||||
console.log('[LinodeConnector] Fetching instance types');
|
||||
|
||||
const response = await this.makeRequest<LinodeApiResponse<LinodeInstanceType>>(
|
||||
'/linode/types'
|
||||
);
|
||||
|
||||
console.log('[LinodeConnector] 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
|
||||
console.warn('[LinodeConnector] 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<T>(endpoint: string): Promise<T> {
|
||||
if (!this.apiToken) {
|
||||
throw new LinodeError(
|
||||
'Connector not initialized. Call initialize() first.',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
// Apply rate limiting
|
||||
await this.rateLimiter.throttle();
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
console.log('[LinodeConnector] 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') {
|
||||
console.error('[LinodeConnector] Request timeout', { endpoint, timeout: 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
|
||||
console.error('[LinodeConnector] Unexpected error', { endpoint, error });
|
||||
throw new LinodeError(
|
||||
`Failed to fetch from Linode API: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
500,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP error responses from Linode API
|
||||
* This method always throws a LinodeError
|
||||
*/
|
||||
private async handleHttpError(response: Response): Promise<never> {
|
||||
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;
|
||||
}
|
||||
|
||||
console.error('[LinodeConnector] 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 === 429) {
|
||||
throw new LinodeError(
|
||||
'Linode rate limit exceeded: Too many requests',
|
||||
429,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user