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:
kappa
2026-01-21 20:17:07 +09:00
commit 95043049b4
32 changed files with 10151 additions and 0 deletions

363
src/connectors/linode.ts Normal file
View 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
);
}
}