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:
268
src/routes/health.ts
Normal file
268
src/routes/health.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Health Check Route Handler
|
||||
* Comprehensive health monitoring for database and provider sync status
|
||||
*/
|
||||
|
||||
import { Env } from '../types';
|
||||
import { RepositoryFactory } from '../repositories';
|
||||
|
||||
/**
|
||||
* Component health status
|
||||
*/
|
||||
type ComponentStatus = 'healthy' | 'degraded' | 'unhealthy';
|
||||
|
||||
/**
|
||||
* Provider health information
|
||||
*/
|
||||
interface ProviderHealth {
|
||||
name: string;
|
||||
status: ComponentStatus;
|
||||
last_sync: string | null;
|
||||
sync_status: string;
|
||||
regions_count?: number;
|
||||
instances_count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database health information
|
||||
*/
|
||||
interface DatabaseHealth {
|
||||
status: ComponentStatus;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check response structure
|
||||
*/
|
||||
interface HealthCheckResponse {
|
||||
status: ComponentStatus;
|
||||
timestamp: string;
|
||||
components: {
|
||||
database: DatabaseHealth;
|
||||
providers: ProviderHealth[];
|
||||
};
|
||||
summary: {
|
||||
total_providers: number;
|
||||
healthy_providers: number;
|
||||
total_regions: number;
|
||||
total_instances: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check database connectivity and measure latency
|
||||
*/
|
||||
async function checkDatabaseHealth(db: D1Database): Promise<DatabaseHealth> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Simple connectivity check
|
||||
await db.prepare('SELECT 1').first();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency_ms: latency,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Health] Database check failed:', error);
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Database connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider health status based on sync information
|
||||
*/
|
||||
function getProviderStatus(
|
||||
lastSync: string | null,
|
||||
syncStatus: string
|
||||
): ComponentStatus {
|
||||
// If sync failed, mark as degraded
|
||||
if (syncStatus === 'error') {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
// If never synced, mark as unhealthy
|
||||
if (!lastSync) {
|
||||
return 'unhealthy';
|
||||
}
|
||||
|
||||
const lastSyncDate = new Date(lastSync.replace(' ', 'T') + 'Z');
|
||||
const now = new Date();
|
||||
const hoursSinceSync = (now.getTime() - lastSyncDate.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
// Healthy: synced within 24 hours
|
||||
if (hoursSinceSync <= 24) {
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
// Degraded: synced within 48 hours
|
||||
if (hoursSinceSync <= 48) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
// Unhealthy: not synced for over 48 hours
|
||||
return 'unhealthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall system status based on component statuses
|
||||
*/
|
||||
function getOverallStatus(
|
||||
dbStatus: ComponentStatus,
|
||||
providerStatuses: ComponentStatus[]
|
||||
): ComponentStatus {
|
||||
// If database is unhealthy, entire system is unhealthy
|
||||
if (dbStatus === 'unhealthy') {
|
||||
return 'unhealthy';
|
||||
}
|
||||
|
||||
// If all providers are unhealthy, system is unhealthy
|
||||
if (providerStatuses.every(status => status === 'unhealthy')) {
|
||||
return 'unhealthy';
|
||||
}
|
||||
|
||||
// If any provider is degraded or unhealthy, system is degraded
|
||||
if (providerStatuses.some(status => status === 'degraded' || status === 'unhealthy')) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
// All components healthy
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle health check request
|
||||
*/
|
||||
export async function handleHealth(env: Env): Promise<Response> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const repos = new RepositoryFactory(env.DB);
|
||||
|
||||
// Check database health
|
||||
const dbHealth = await checkDatabaseHealth(env.DB);
|
||||
|
||||
// If database is unhealthy, return early
|
||||
if (dbHealth.status === 'unhealthy') {
|
||||
const response: HealthCheckResponse = {
|
||||
status: 'unhealthy',
|
||||
timestamp,
|
||||
components: {
|
||||
database: dbHealth,
|
||||
providers: [],
|
||||
},
|
||||
summary: {
|
||||
total_providers: 0,
|
||||
healthy_providers: 0,
|
||||
total_regions: 0,
|
||||
total_instances: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return Response.json(response, { status: 503 });
|
||||
}
|
||||
|
||||
// Get all providers
|
||||
const providers = await repos.providers.findAll();
|
||||
|
||||
// Build provider health information
|
||||
const providerHealthList: ProviderHealth[] = [];
|
||||
const providerStatuses: ComponentStatus[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
// Get counts for this provider
|
||||
const [regionsResult, instancesResult] = await Promise.all([
|
||||
env.DB.prepare('SELECT COUNT(*) as count FROM regions WHERE provider_id = ?')
|
||||
.bind(provider.id)
|
||||
.first<{ count: number }>(),
|
||||
env.DB.prepare(
|
||||
'SELECT COUNT(*) as count FROM instance_types WHERE provider_id = ?'
|
||||
)
|
||||
.bind(provider.id)
|
||||
.first<{ count: number }>(),
|
||||
]);
|
||||
|
||||
const status = getProviderStatus(provider.last_sync_at, provider.sync_status);
|
||||
providerStatuses.push(status);
|
||||
|
||||
const providerHealth: ProviderHealth = {
|
||||
name: provider.name,
|
||||
status,
|
||||
last_sync: provider.last_sync_at,
|
||||
sync_status: provider.sync_status,
|
||||
regions_count: regionsResult?.count || 0,
|
||||
instances_count: instancesResult?.count || 0,
|
||||
};
|
||||
|
||||
// Add error if present
|
||||
if (provider.sync_error) {
|
||||
providerHealth.error = provider.sync_error;
|
||||
}
|
||||
|
||||
providerHealthList.push(providerHealth);
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const totalRegions = providerHealthList.reduce(
|
||||
(sum, p) => sum + (p.regions_count || 0),
|
||||
0
|
||||
);
|
||||
const totalInstances = providerHealthList.reduce(
|
||||
(sum, p) => sum + (p.instances_count || 0),
|
||||
0
|
||||
);
|
||||
const healthyProviders = providerStatuses.filter(s => s === 'healthy').length;
|
||||
|
||||
// Determine overall status
|
||||
const overallStatus = getOverallStatus(dbHealth.status, providerStatuses);
|
||||
|
||||
const response: HealthCheckResponse = {
|
||||
status: overallStatus,
|
||||
timestamp,
|
||||
components: {
|
||||
database: dbHealth,
|
||||
providers: providerHealthList,
|
||||
},
|
||||
summary: {
|
||||
total_providers: providers.length,
|
||||
healthy_providers: healthyProviders,
|
||||
total_regions: totalRegions,
|
||||
total_instances: totalInstances,
|
||||
},
|
||||
};
|
||||
|
||||
// Return 200 for healthy, 503 for degraded/unhealthy
|
||||
const statusCode = overallStatus === 'healthy' ? 200 : 503;
|
||||
|
||||
return Response.json(response, { status: statusCode });
|
||||
} catch (error) {
|
||||
console.error('[Health] Health check failed:', error);
|
||||
|
||||
const errorResponse: HealthCheckResponse = {
|
||||
status: 'unhealthy',
|
||||
timestamp,
|
||||
components: {
|
||||
database: {
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Health check failed',
|
||||
},
|
||||
providers: [],
|
||||
},
|
||||
summary: {
|
||||
total_providers: 0,
|
||||
healthy_providers: 0,
|
||||
total_regions: 0,
|
||||
total_instances: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return Response.json(errorResponse, { status: 503 });
|
||||
}
|
||||
}
|
||||
8
src/routes/index.ts
Normal file
8
src/routes/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Routes Index
|
||||
* Central export point for all API route handlers
|
||||
*/
|
||||
|
||||
export { handleSync } from './sync';
|
||||
export { handleInstances } from './instances';
|
||||
export { handleHealth } from './health';
|
||||
413
src/routes/instances.ts
Normal file
413
src/routes/instances.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Instances Route Handler
|
||||
*
|
||||
* Endpoint for querying instance types with filtering, sorting, and pagination.
|
||||
* Integrates with cache service for performance optimization.
|
||||
*/
|
||||
|
||||
import type { Env } from '../types';
|
||||
|
||||
/**
|
||||
* Parsed and validated query parameters
|
||||
*/
|
||||
interface ParsedQueryParams {
|
||||
provider?: string;
|
||||
region?: string;
|
||||
min_vcpu?: number;
|
||||
max_vcpu?: number;
|
||||
min_memory_gb?: number;
|
||||
max_memory_gb?: number;
|
||||
max_price?: number;
|
||||
instance_family?: string;
|
||||
has_gpu?: boolean;
|
||||
sort_by?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported cloud providers
|
||||
*/
|
||||
const SUPPORTED_PROVIDERS = ['linode', 'vultr', 'aws'] as const;
|
||||
type SupportedProvider = typeof SUPPORTED_PROVIDERS[number];
|
||||
|
||||
/**
|
||||
* Valid sort fields
|
||||
*/
|
||||
const VALID_SORT_FIELDS = [
|
||||
'price',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'vcpu',
|
||||
'memory_mb',
|
||||
'memory_gb',
|
||||
'storage_gb',
|
||||
'instance_name',
|
||||
'provider',
|
||||
'region'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid instance families
|
||||
*/
|
||||
const VALID_FAMILIES = ['general', 'compute', 'memory', 'storage', 'gpu'] as const;
|
||||
|
||||
/**
|
||||
* Default query parameters
|
||||
*/
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_OFFSET = 0;
|
||||
|
||||
/**
|
||||
* Validate provider name
|
||||
*/
|
||||
function isSupportedProvider(provider: string): provider is SupportedProvider {
|
||||
return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sort field
|
||||
*/
|
||||
function isValidSortField(field: string): boolean {
|
||||
return VALID_SORT_FIELDS.includes(field as typeof VALID_SORT_FIELDS[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate instance family
|
||||
*/
|
||||
function isValidFamily(family: string): boolean {
|
||||
return VALID_FAMILIES.includes(family as typeof VALID_FAMILIES[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate query parameters
|
||||
*/
|
||||
function parseQueryParams(url: URL): {
|
||||
params?: ParsedQueryParams;
|
||||
error?: { code: string; message: string; parameter?: string };
|
||||
} {
|
||||
const searchParams = url.searchParams;
|
||||
const params: ParsedQueryParams = {
|
||||
limit: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
};
|
||||
|
||||
// Provider validation
|
||||
const provider = searchParams.get('provider');
|
||||
if (provider !== null) {
|
||||
if (!isSupportedProvider(provider)) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: `Invalid provider: ${provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`,
|
||||
parameter: 'provider',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.provider = provider;
|
||||
}
|
||||
|
||||
// Region (no validation, passed as-is)
|
||||
const region = searchParams.get('region');
|
||||
if (region !== null) {
|
||||
params.region = region;
|
||||
}
|
||||
|
||||
// Numeric parameter validation helper
|
||||
function parsePositiveNumber(
|
||||
name: string,
|
||||
value: string | null
|
||||
): number | undefined | { error: any } {
|
||||
if (value === null) return undefined;
|
||||
|
||||
const parsed = Number(value);
|
||||
if (isNaN(parsed) || parsed < 0) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: `Invalid value for ${name}: must be a positive number`,
|
||||
parameter: name,
|
||||
},
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Parse min_vcpu
|
||||
const minVcpuResult = parsePositiveNumber('min_vcpu', searchParams.get('min_vcpu'));
|
||||
if (minVcpuResult && typeof minVcpuResult === 'object' && 'error' in minVcpuResult) {
|
||||
return minVcpuResult;
|
||||
}
|
||||
if (typeof minVcpuResult === 'number') {
|
||||
params.min_vcpu = minVcpuResult;
|
||||
}
|
||||
|
||||
// Parse max_vcpu
|
||||
const maxVcpuResult = parsePositiveNumber('max_vcpu', searchParams.get('max_vcpu'));
|
||||
if (maxVcpuResult && typeof maxVcpuResult === 'object' && 'error' in maxVcpuResult) {
|
||||
return maxVcpuResult;
|
||||
}
|
||||
if (typeof maxVcpuResult === 'number') {
|
||||
params.max_vcpu = maxVcpuResult;
|
||||
}
|
||||
|
||||
// Parse min_memory_gb
|
||||
const minMemoryResult = parsePositiveNumber('min_memory_gb', searchParams.get('min_memory_gb'));
|
||||
if (minMemoryResult && typeof minMemoryResult === 'object' && 'error' in minMemoryResult) {
|
||||
return minMemoryResult;
|
||||
}
|
||||
if (typeof minMemoryResult === 'number') {
|
||||
params.min_memory_gb = minMemoryResult;
|
||||
}
|
||||
|
||||
// Parse max_memory_gb
|
||||
const maxMemoryResult = parsePositiveNumber('max_memory_gb', searchParams.get('max_memory_gb'));
|
||||
if (maxMemoryResult && typeof maxMemoryResult === 'object' && 'error' in maxMemoryResult) {
|
||||
return maxMemoryResult;
|
||||
}
|
||||
if (typeof maxMemoryResult === 'number') {
|
||||
params.max_memory_gb = maxMemoryResult;
|
||||
}
|
||||
|
||||
// Parse max_price
|
||||
const maxPriceResult = parsePositiveNumber('max_price', searchParams.get('max_price'));
|
||||
if (maxPriceResult && typeof maxPriceResult === 'object' && 'error' in maxPriceResult) {
|
||||
return maxPriceResult;
|
||||
}
|
||||
if (typeof maxPriceResult === 'number') {
|
||||
params.max_price = maxPriceResult;
|
||||
}
|
||||
|
||||
// Instance family validation
|
||||
const family = searchParams.get('instance_family');
|
||||
if (family !== null) {
|
||||
if (!isValidFamily(family)) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: `Invalid instance_family: ${family}. Valid values: ${VALID_FAMILIES.join(', ')}`,
|
||||
parameter: 'instance_family',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.instance_family = family;
|
||||
}
|
||||
|
||||
// GPU filter (boolean)
|
||||
const hasGpu = searchParams.get('has_gpu');
|
||||
if (hasGpu !== null) {
|
||||
if (hasGpu !== 'true' && hasGpu !== 'false') {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: 'Invalid value for has_gpu: must be "true" or "false"',
|
||||
parameter: 'has_gpu',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.has_gpu = hasGpu === 'true';
|
||||
}
|
||||
|
||||
// Sort by validation
|
||||
const sortBy = searchParams.get('sort_by');
|
||||
if (sortBy !== null) {
|
||||
if (!isValidSortField(sortBy)) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: `Invalid sort_by: ${sortBy}. Valid values: ${VALID_SORT_FIELDS.join(', ')}`,
|
||||
parameter: 'sort_by',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.sort_by = sortBy;
|
||||
}
|
||||
|
||||
// Sort order validation
|
||||
const order = searchParams.get('order');
|
||||
if (order !== null) {
|
||||
if (order !== 'asc' && order !== 'desc') {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: 'Invalid order: must be "asc" or "desc"',
|
||||
parameter: 'order',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.order = order;
|
||||
}
|
||||
|
||||
// Limit validation
|
||||
const limitStr = searchParams.get('limit');
|
||||
if (limitStr !== null) {
|
||||
const limit = Number(limitStr);
|
||||
if (isNaN(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: `Invalid limit: must be between 1 and ${MAX_LIMIT}`,
|
||||
parameter: 'limit',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.limit = limit;
|
||||
}
|
||||
|
||||
// Offset validation
|
||||
const offsetStr = searchParams.get('offset');
|
||||
if (offsetStr !== null) {
|
||||
const offset = Number(offsetStr);
|
||||
if (isNaN(offset) || offset < 0) {
|
||||
return {
|
||||
error: {
|
||||
code: 'INVALID_PARAMETER',
|
||||
message: 'Invalid offset: must be a non-negative number',
|
||||
parameter: 'offset',
|
||||
},
|
||||
};
|
||||
}
|
||||
params.offset = offset;
|
||||
}
|
||||
|
||||
return { params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key from query parameters
|
||||
* TODO: Replace with cacheService.generateKey(params) when cache service is implemented
|
||||
*/
|
||||
function generateCacheKey(params: ParsedQueryParams): string {
|
||||
const parts: string[] = ['instances'];
|
||||
|
||||
if (params.provider) parts.push(`provider:${params.provider}`);
|
||||
if (params.region) parts.push(`region:${params.region}`);
|
||||
if (params.min_vcpu !== undefined) parts.push(`min_vcpu:${params.min_vcpu}`);
|
||||
if (params.max_vcpu !== undefined) parts.push(`max_vcpu:${params.max_vcpu}`);
|
||||
if (params.min_memory_gb !== undefined) parts.push(`min_memory:${params.min_memory_gb}`);
|
||||
if (params.max_memory_gb !== undefined) parts.push(`max_memory:${params.max_memory_gb}`);
|
||||
if (params.max_price !== undefined) parts.push(`max_price:${params.max_price}`);
|
||||
if (params.instance_family) parts.push(`family:${params.instance_family}`);
|
||||
if (params.has_gpu !== undefined) parts.push(`gpu:${params.has_gpu}`);
|
||||
if (params.sort_by) parts.push(`sort:${params.sort_by}`);
|
||||
if (params.order) parts.push(`order:${params.order}`);
|
||||
parts.push(`limit:${params.limit}`);
|
||||
parts.push(`offset:${params.offset}`);
|
||||
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /instances endpoint
|
||||
*
|
||||
* @param request - HTTP request object
|
||||
* @param env - Cloudflare Worker environment bindings
|
||||
* @returns JSON response with instance query results
|
||||
*
|
||||
* @example
|
||||
* GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50
|
||||
*/
|
||||
export async function handleInstances(
|
||||
request: Request,
|
||||
_env: Env
|
||||
): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('[Instances] Request received', { url: request.url });
|
||||
|
||||
try {
|
||||
// Parse URL and query parameters
|
||||
const url = new URL(request.url);
|
||||
const parseResult = parseQueryParams(url);
|
||||
|
||||
// Handle validation errors
|
||||
if (parseResult.error) {
|
||||
console.error('[Instances] Validation error', parseResult.error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: parseResult.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const params = parseResult.params!;
|
||||
console.log('[Instances] Query params validated', params);
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = generateCacheKey(params);
|
||||
console.log('[Instances] Cache key generated', { cacheKey });
|
||||
|
||||
// TODO: Implement cache check
|
||||
// const cacheService = new CacheService(env);
|
||||
// const cached = await cacheService.get(cacheKey);
|
||||
// if (cached) {
|
||||
// console.log('[Instances] Cache hit', { cacheKey, age: cached.cache_age_seconds });
|
||||
// return Response.json({
|
||||
// success: true,
|
||||
// data: {
|
||||
// ...cached.data,
|
||||
// metadata: {
|
||||
// cached: true,
|
||||
// cache_age_seconds: cached.cache_age_seconds,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
console.log('[Instances] Cache miss (or cache service not implemented)');
|
||||
|
||||
// TODO: Implement database query
|
||||
// const queryService = new QueryService(env.DB);
|
||||
// const result = await queryService.queryInstances(params);
|
||||
|
||||
// Placeholder response until query service is implemented
|
||||
const queryTime = Date.now() - startTime;
|
||||
const placeholderResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
instances: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
has_more: false,
|
||||
},
|
||||
metadata: {
|
||||
cached: false,
|
||||
last_sync: new Date().toISOString(),
|
||||
query_time_ms: queryTime,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[Instances] TODO: Implement query service');
|
||||
console.log('[Instances] Placeholder response generated', {
|
||||
queryTime,
|
||||
cacheKey,
|
||||
});
|
||||
|
||||
// TODO: Implement cache storage
|
||||
// await cacheService.set(cacheKey, result);
|
||||
|
||||
return Response.json(placeholderResponse, { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Instances] Unexpected error', { error });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'QUERY_FAILED',
|
||||
message: 'Instance query failed',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
228
src/routes/sync.ts
Normal file
228
src/routes/sync.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Sync Route Handler
|
||||
*
|
||||
* Endpoint for triggering synchronization with cloud providers.
|
||||
* Validates request parameters and orchestrates sync operations.
|
||||
*/
|
||||
|
||||
import type { Env, SyncReport } from '../types';
|
||||
|
||||
/**
|
||||
* Request body interface for sync endpoint
|
||||
*/
|
||||
interface SyncRequestBody {
|
||||
providers?: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported cloud providers
|
||||
*/
|
||||
const SUPPORTED_PROVIDERS = ['linode', 'vultr', 'aws'] as const;
|
||||
type SupportedProvider = typeof SUPPORTED_PROVIDERS[number];
|
||||
|
||||
/**
|
||||
* Validate if provider is supported
|
||||
*/
|
||||
function isSupportedProvider(provider: string): provider is SupportedProvider {
|
||||
return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /sync endpoint
|
||||
*
|
||||
* @param request - HTTP request object
|
||||
* @param env - Cloudflare Worker environment bindings
|
||||
* @returns JSON response with sync results
|
||||
*
|
||||
* @example
|
||||
* POST /sync
|
||||
* {
|
||||
* "providers": ["linode"],
|
||||
* "force": false
|
||||
* }
|
||||
*/
|
||||
export async function handleSync(
|
||||
request: Request,
|
||||
_env: Env
|
||||
): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
console.log('[Sync] Request received', { timestamp: startedAt });
|
||||
|
||||
try {
|
||||
// Parse and validate request body
|
||||
let body: SyncRequestBody = {};
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
body = await request.json() as SyncRequestBody;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sync] Invalid JSON in request body', { error });
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'Invalid JSON in request body',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate providers array
|
||||
const providers = body.providers || ['linode'];
|
||||
|
||||
if (!Array.isArray(providers)) {
|
||||
console.error('[Sync] Providers must be an array', { providers });
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PROVIDERS',
|
||||
message: 'Providers must be an array',
|
||||
details: { received: typeof providers }
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
console.error('[Sync] Providers array is empty');
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'EMPTY_PROVIDERS',
|
||||
message: 'At least one provider must be specified',
|
||||
details: null
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each provider
|
||||
const unsupportedProviders: string[] = [];
|
||||
for (const provider of providers) {
|
||||
if (typeof provider !== 'string') {
|
||||
console.error('[Sync] Provider must be a string', { provider });
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PROVIDER_TYPE',
|
||||
message: 'Each provider must be a string',
|
||||
details: { provider, type: typeof provider }
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSupportedProvider(provider)) {
|
||||
unsupportedProviders.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedProviders.length > 0) {
|
||||
console.error('[Sync] Unsupported providers', { unsupportedProviders });
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED_PROVIDERS',
|
||||
message: `Unsupported providers: ${unsupportedProviders.join(', ')}`,
|
||||
details: {
|
||||
unsupported: unsupportedProviders,
|
||||
supported: SUPPORTED_PROVIDERS
|
||||
}
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const force = body.force === true;
|
||||
|
||||
console.log('[Sync] Validation passed', { providers, force });
|
||||
|
||||
// TODO: Once SyncOrchestrator is implemented, use it here
|
||||
// For now, return a placeholder response
|
||||
|
||||
// const syncOrchestrator = new SyncOrchestrator(env.DB, env.VAULT_URL, env.VAULT_TOKEN);
|
||||
// const syncReport = await syncOrchestrator.syncProviders(providers, force);
|
||||
|
||||
// Placeholder sync report
|
||||
const completedAt = new Date().toISOString();
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const syncId = `sync_${Date.now()}`;
|
||||
|
||||
console.log('[Sync] TODO: Implement actual sync logic');
|
||||
console.log('[Sync] Placeholder response generated', { syncId, totalDuration });
|
||||
|
||||
// Return placeholder success response
|
||||
const placeholderReport: SyncReport = {
|
||||
success: true,
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt,
|
||||
total_duration_ms: totalDuration,
|
||||
providers: providers.map(providerName => ({
|
||||
provider: providerName,
|
||||
success: true,
|
||||
regions_synced: 0,
|
||||
instances_synced: 0,
|
||||
pricing_synced: 0,
|
||||
duration_ms: 0,
|
||||
})),
|
||||
summary: {
|
||||
total_providers: providers.length,
|
||||
successful_providers: providers.length,
|
||||
failed_providers: 0,
|
||||
total_regions: 0,
|
||||
total_instances: 0,
|
||||
total_pricing: 0,
|
||||
}
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
sync_id: syncId,
|
||||
...placeholderReport
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sync] Unexpected error', { error });
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYNC_FAILED',
|
||||
message: 'Sync operation failed',
|
||||
details: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
duration_ms: totalDuration,
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt
|
||||
}
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user