feat: add Queue-based async server provisioning
- Add Cloudflare Queue for async server provisioning workflow - Implement VPS provider abstraction (Linode, Vultr) - Add provisioning API endpoints with API key authentication - Fix race condition in balance deduction (atomic query) - Remove root_password from Queue for security (fetch from DB) - Add IP assignment wait logic after server creation - Add rollback/refund on all failure cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
354
src/handlers/provision.ts
Normal file
354
src/handlers/provision.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* Server Provisioning Handler
|
||||||
|
* POST /api/provision - Create a new server
|
||||||
|
* GET /api/provision/orders - Get user's orders
|
||||||
|
* GET /api/provision/orders/:id - Get specific order
|
||||||
|
* DELETE /api/provision/orders/:id - Delete/terminate server
|
||||||
|
* GET /api/provision/balance - Get user's balance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env, ProvisionRequest } from '../types';
|
||||||
|
import { ProvisioningService } from '../services/provisioning-service';
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '../utils/http';
|
||||||
|
import { LIMITS } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate provision request body
|
||||||
|
* Note: user_id in API is the Telegram ID
|
||||||
|
*/
|
||||||
|
function validateProvisionRequest(body: unknown): {
|
||||||
|
valid: true;
|
||||||
|
data: ProvisionRequest;
|
||||||
|
} | {
|
||||||
|
valid: false;
|
||||||
|
error: string;
|
||||||
|
} {
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return { valid: false, error: 'Request body must be a JSON object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Required fields - user_id is the Telegram ID
|
||||||
|
if (!data.user_id || typeof data.user_id !== 'string') {
|
||||||
|
return { valid: false, error: 'user_id is required and must be a string (Telegram ID)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.pricing_id || typeof data.pricing_id !== 'number' || data.pricing_id <= 0) {
|
||||||
|
return { valid: false, error: 'pricing_id is required and must be a positive number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields validation
|
||||||
|
if (data.label !== undefined && typeof data.label !== 'string') {
|
||||||
|
return { valid: false, error: 'label must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.label && (data.label as string).length > 64) {
|
||||||
|
return { valid: false, error: 'label must be 64 characters or less' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validOsImages = ['ubuntu_22_04', 'ubuntu_20_04', 'debian_11', 'debian_12'];
|
||||||
|
if (data.image !== undefined && !validOsImages.includes(data.image as string)) {
|
||||||
|
return { valid: false, error: `image must be one of: ${validOsImages.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dry_run !== undefined && typeof data.dry_run !== 'boolean') {
|
||||||
|
return { valid: false, error: 'dry_run must be a boolean' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
data: {
|
||||||
|
telegram_id: data.user_id as string, // API uses user_id, internally it's telegram_id
|
||||||
|
pricing_id: data.pricing_id as number,
|
||||||
|
label: data.label as string | undefined,
|
||||||
|
image: data.image as string | undefined,
|
||||||
|
dry_run: data.dry_run as boolean | undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/provision
|
||||||
|
* Create a new server
|
||||||
|
*/
|
||||||
|
export async function handleProvision(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check content length
|
||||||
|
const contentLength = request.headers.get('content-length');
|
||||||
|
if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) {
|
||||||
|
return createErrorResponse('Request body too large', 413, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
const validation = validateProvisionRequest(body);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check API keys (skip for dry_run)
|
||||||
|
if (!validation.data.dry_run && !env.LINODE_API_KEY && !env.VULTR_API_KEY) {
|
||||||
|
return createErrorResponse(
|
||||||
|
'No VPS provider API keys configured',
|
||||||
|
503,
|
||||||
|
'SERVICE_UNAVAILABLE',
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create provisioning service with both DBs
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env, // Full env for exchange rate
|
||||||
|
env.DB, // cloud-instances-db
|
||||||
|
env.USER_DB, // telegram-conversations
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Provision server
|
||||||
|
const result = await provisioningService.provisionServer(validation.data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Map error codes to HTTP status codes
|
||||||
|
const statusCode = getStatusCodeForError(result.error!.code);
|
||||||
|
return createErrorResponse(
|
||||||
|
result.error!.message,
|
||||||
|
statusCode,
|
||||||
|
result.error!.code,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response (hide root password in response)
|
||||||
|
const sanitizedOrder = {
|
||||||
|
...result.order!,
|
||||||
|
root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include dry_run_info if present
|
||||||
|
const response: Record<string, unknown> = {
|
||||||
|
message: validation.data.dry_run
|
||||||
|
? 'Dry run successful. Validation passed.'
|
||||||
|
: 'Server provisioned successfully',
|
||||||
|
order: sanitizedOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add dry_run details from service
|
||||||
|
if ('dry_run_info' in result) {
|
||||||
|
response.dry_run_info = (result as Record<string, unknown>).dry_run_info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleProvision] Error:', error);
|
||||||
|
return createErrorResponse(
|
||||||
|
'Internal server error during provisioning',
|
||||||
|
500,
|
||||||
|
'INTERNAL_ERROR',
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/provision/orders
|
||||||
|
* Get user's orders
|
||||||
|
*/
|
||||||
|
export async function handleGetOrders(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env,
|
||||||
|
env.DB,
|
||||||
|
env.USER_DB,
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const orders = await provisioningService.getUserOrders(userId);
|
||||||
|
|
||||||
|
// Sanitize root passwords
|
||||||
|
const sanitizedOrders = orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
root_password: order.root_password ? '***REDACTED***' : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleGetOrders] Error:', error);
|
||||||
|
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/provision/orders/:id
|
||||||
|
* Get specific order with root password (one-time view concept)
|
||||||
|
*/
|
||||||
|
export async function handleGetOrder(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
orderId: string,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env,
|
||||||
|
env.DB,
|
||||||
|
env.USER_DB,
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = await provisioningService.getOrder(orderId);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user to verify ownership
|
||||||
|
const balance = await provisioningService.getUserBalance(userId);
|
||||||
|
if (!balance || order.user_id !== balance.user_id) {
|
||||||
|
return createErrorResponse('Unauthorized', 403, 'UNAUTHORIZED', corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include root password for order owner
|
||||||
|
return createSuccessResponse({ order }, 200, corsHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleGetOrder] Error:', error);
|
||||||
|
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/provision/orders/:id
|
||||||
|
* Delete/terminate a server
|
||||||
|
*/
|
||||||
|
export async function handleDeleteOrder(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
orderId: string,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env,
|
||||||
|
env.DB,
|
||||||
|
env.USER_DB,
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await provisioningService.deleteServer(orderId, userId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return createErrorResponse(result.error!, 400, 'DELETE_FAILED', corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleDeleteOrder] Error:', error);
|
||||||
|
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/provision/balance
|
||||||
|
* Get user's balance (in KRW)
|
||||||
|
*/
|
||||||
|
export async function handleGetBalance(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env,
|
||||||
|
env.DB,
|
||||||
|
env.USER_DB,
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const balance = await provisioningService.getUserBalance(userId);
|
||||||
|
|
||||||
|
if (!balance) {
|
||||||
|
return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse(
|
||||||
|
{
|
||||||
|
balance_krw: balance.balance_krw,
|
||||||
|
currency: 'KRW',
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleGetBalance] Error:', error);
|
||||||
|
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map error codes to HTTP status codes
|
||||||
|
*/
|
||||||
|
function getStatusCodeForError(code: string): number {
|
||||||
|
switch (code) {
|
||||||
|
case 'USER_NOT_FOUND':
|
||||||
|
case 'PRICING_NOT_FOUND':
|
||||||
|
return 404;
|
||||||
|
case 'INSUFFICIENT_BALANCE':
|
||||||
|
return 402; // Payment Required
|
||||||
|
case 'PROVIDER_NOT_CONFIGURED':
|
||||||
|
return 503;
|
||||||
|
case 'VALIDATION_ERROR':
|
||||||
|
return 400;
|
||||||
|
case 'UNAUTHORIZED':
|
||||||
|
return 403;
|
||||||
|
default:
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/handlers/queue.ts
Normal file
41
src/handlers/queue.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Queue Handler for async server provisioning
|
||||||
|
* Processes messages from PROVISION_QUEUE
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env, ProvisionQueueMessage } from '../types';
|
||||||
|
import { ProvisioningService } from '../services/provisioning-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue message handler
|
||||||
|
* Called by Cloudflare Workers Queue consumer
|
||||||
|
*/
|
||||||
|
export async function handleProvisionQueue(
|
||||||
|
batch: MessageBatch<ProvisionQueueMessage>,
|
||||||
|
env: Env
|
||||||
|
): Promise<void> {
|
||||||
|
const provisioningService = new ProvisioningService(
|
||||||
|
env,
|
||||||
|
env.DB,
|
||||||
|
env.USER_DB,
|
||||||
|
env.LINODE_API_KEY,
|
||||||
|
env.VULTR_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
try {
|
||||||
|
console.log(`[Queue] Processing message for order ${message.body.order_id}`);
|
||||||
|
|
||||||
|
await provisioningService.processQueueMessage(message.body);
|
||||||
|
|
||||||
|
// Acknowledge successful processing
|
||||||
|
message.ack();
|
||||||
|
console.log(`[Queue] Order ${message.body.order_id} processed successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Queue] Error processing order ${message.body.order_id}:`, error);
|
||||||
|
|
||||||
|
// Retry the message (will go to DLQ after max_retries)
|
||||||
|
message.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/index.ts
66
src/index.ts
@@ -5,11 +5,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils';
|
import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils';
|
||||||
import { handleHealth } from './handlers/health';
|
import { handleHealth } from './handlers/health';
|
||||||
import { handleGetServers } from './handlers/servers';
|
import { handleGetServers } from './handlers/servers';
|
||||||
import { handleRecommend } from './handlers/recommend';
|
import { handleRecommend } from './handlers/recommend';
|
||||||
import { handleReport } from './handlers/report';
|
import { handleReport } from './handlers/report';
|
||||||
|
import {
|
||||||
|
handleProvision,
|
||||||
|
handleGetOrders,
|
||||||
|
handleGetOrder,
|
||||||
|
handleDeleteOrder,
|
||||||
|
handleGetBalance,
|
||||||
|
} from './handlers/provision';
|
||||||
|
import { handleProvisionQueue } from './handlers/queue';
|
||||||
|
import type { ProvisionQueueMessage } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main request handler
|
* Main request handler
|
||||||
@@ -44,7 +53,7 @@ export default {
|
|||||||
const origin = getAllowedOrigin(request);
|
const origin = getAllowedOrigin(request);
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': origin,
|
'Access-Control-Allow-Origin': origin,
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
'Vary': 'Origin',
|
'Vary': 'Origin',
|
||||||
};
|
};
|
||||||
@@ -71,6 +80,52 @@ export default {
|
|||||||
return handleReport(request, env, corsHeaders);
|
return handleReport(request, env, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provisioning endpoints (restricted to *.kappa-d8e.workers.dev + API key)
|
||||||
|
if (path.startsWith('/api/provision')) {
|
||||||
|
// Check API key (required for all provision requests)
|
||||||
|
const apiKey = request.headers.get('X-API-Key');
|
||||||
|
if (!env.PROVISION_API_KEY || apiKey !== env.PROVISION_API_KEY) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED' },
|
||||||
|
401,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check origin for browser requests
|
||||||
|
if (!isAllowedProvisionOrigin(request)) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Forbidden: Invalid origin', code: 'FORBIDDEN' },
|
||||||
|
403,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/provision' && request.method === 'POST') {
|
||||||
|
return handleProvision(request, env, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/provision/orders' && request.method === 'GET') {
|
||||||
|
return handleGetOrders(request, env, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/provision/balance' && request.method === 'GET') {
|
||||||
|
return handleGetBalance(request, env, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic route: /api/provision/orders/:id
|
||||||
|
const orderMatch = path.match(/^\/api\/provision\/orders\/([a-zA-Z0-9-]+)$/);
|
||||||
|
if (orderMatch) {
|
||||||
|
const orderId = orderMatch[1];
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return handleGetOrder(request, env, orderId, corsHeaders);
|
||||||
|
}
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
return handleDeleteOrder(request, env, orderId, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{ error: 'Not found', request_id: requestId },
|
{ error: 'Not found', request_id: requestId },
|
||||||
404,
|
404,
|
||||||
@@ -92,4 +147,11 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue handler for async server provisioning
|
||||||
|
*/
|
||||||
|
async queue(batch: MessageBatch<ProvisionQueueMessage>, env: Env): Promise<void> {
|
||||||
|
await handleProvisionQueue(batch, env);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
272
src/repositories/ProvisioningRepository.ts
Normal file
272
src/repositories/ProvisioningRepository.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Repository for provisioning-related database operations
|
||||||
|
* Uses two databases:
|
||||||
|
* - DB (cloud-instances-db): server specs, pricing, providers
|
||||||
|
* - USER_DB (telegram-conversations): users, deposits, orders
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider } from '../types';
|
||||||
|
|
||||||
|
export class ProvisioningRepository {
|
||||||
|
constructor(
|
||||||
|
private db: D1Database, // cloud-instances-db
|
||||||
|
private userDb: D1Database // telegram-conversations
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// User Operations (telegram-conversations.users)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getUserByTelegramId(telegramId: string): Promise<TelegramUser | null> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare('SELECT * FROM users WHERE telegram_id = ?')
|
||||||
|
.bind(telegramId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as TelegramUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(userId: number): Promise<TelegramUser | null> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare('SELECT * FROM users WHERE id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as TelegramUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Deposit Operations (telegram-conversations.user_deposits)
|
||||||
|
// Balance is in KRW (원), INTEGER type
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getUserDeposit(userId: number): Promise<UserDeposit | null> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare('SELECT * FROM user_deposits WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as UserDeposit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(userId: number): Promise<number> {
|
||||||
|
const deposit = await this.getUserDeposit(userId);
|
||||||
|
return deposit?.balance ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deductBalance(userId: number, amount: number): Promise<boolean> {
|
||||||
|
// Atomic balance deduction - prevents race condition
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE user_deposits
|
||||||
|
SET balance = balance - ?, updated_at = datetime('now')
|
||||||
|
WHERE user_id = ? AND balance >= ?
|
||||||
|
RETURNING balance`
|
||||||
|
)
|
||||||
|
.bind(amount, userId, amount)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// If no row returned, balance was insufficient
|
||||||
|
return result !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refundBalance(userId: number, amount: number): Promise<void> {
|
||||||
|
await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE user_deposits
|
||||||
|
SET balance = balance + ?, updated_at = datetime('now')
|
||||||
|
WHERE user_id = ?`
|
||||||
|
)
|
||||||
|
.bind(amount, userId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Server Order Operations (telegram-conversations.server_orders)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createServerOrder(
|
||||||
|
userId: number,
|
||||||
|
specId: number,
|
||||||
|
region: string,
|
||||||
|
pricePaid: number,
|
||||||
|
label: string | null,
|
||||||
|
image: string | null
|
||||||
|
): Promise<ServerOrder> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO server_orders
|
||||||
|
(user_id, spec_id, status, region, price_paid, label, image, billing_type)
|
||||||
|
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly')
|
||||||
|
RETURNING *`
|
||||||
|
)
|
||||||
|
.bind(userId, specId, region, pricePaid, label, image)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as ServerOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderStatus(
|
||||||
|
orderId: number,
|
||||||
|
status: ServerOrder['status'],
|
||||||
|
errorMessage?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (errorMessage) {
|
||||||
|
await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE server_orders
|
||||||
|
SET status = ?, error_message = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(status, errorMessage, orderId)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE server_orders
|
||||||
|
SET status = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(status, orderId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
|
||||||
|
await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE server_orders
|
||||||
|
SET root_password = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(rootPassword, orderId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderProviderInfo(
|
||||||
|
orderId: number,
|
||||||
|
providerInstanceId: string,
|
||||||
|
ipAddress: string | null,
|
||||||
|
rootPassword: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.userDb
|
||||||
|
.prepare(
|
||||||
|
`UPDATE server_orders
|
||||||
|
SET provider_instance_id = ?,
|
||||||
|
ip_address = ?,
|
||||||
|
root_password = ?,
|
||||||
|
status = 'active',
|
||||||
|
provisioned_at = datetime('now'),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(providerInstanceId, ipAddress, rootPassword, orderId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrderById(orderId: number): Promise<ServerOrder | null> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare('SELECT * FROM server_orders WHERE id = ?')
|
||||||
|
.bind(orderId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as ServerOrder | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
|
||||||
|
const result = await this.userDb
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'
|
||||||
|
)
|
||||||
|
.bind(userId, limit)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return result.results as unknown as ServerOrder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Pricing Lookup (cloud-instances-db)
|
||||||
|
// Uses anvil_pricing, anvil_instances, anvil_regions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getPricingWithProvider(pricingId: number): Promise<PricingWithProvider | null> {
|
||||||
|
const result = await this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
ap.id as pricing_id,
|
||||||
|
0 as provider_id,
|
||||||
|
'Anvil' as provider_name,
|
||||||
|
'https://api.anvil.cloud' as api_base_url,
|
||||||
|
ai.name as instance_id,
|
||||||
|
ai.display_name as instance_name,
|
||||||
|
ar.name as region_code,
|
||||||
|
ar.display_name as region_name,
|
||||||
|
ap.monthly_price,
|
||||||
|
ai.vcpus as vcpu,
|
||||||
|
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
|
||||||
|
ai.disk_gb as storage_gb
|
||||||
|
FROM anvil_pricing ap
|
||||||
|
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
|
||||||
|
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||||
|
WHERE ap.id = ? AND ai.active = 1 AND ar.active = 1`
|
||||||
|
)
|
||||||
|
.bind(pricingId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result as unknown as PricingWithProvider | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Atomic Operations (balance deduction + order creation)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduct balance and create order atomically
|
||||||
|
* Returns order ID on success, null on insufficient balance
|
||||||
|
*/
|
||||||
|
async createOrderWithPayment(
|
||||||
|
userId: number,
|
||||||
|
specId: number,
|
||||||
|
region: string,
|
||||||
|
priceKrw: number,
|
||||||
|
label: string | null,
|
||||||
|
image: string | null
|
||||||
|
): Promise<{ orderId: number | null; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Step 1: Check and deduct balance
|
||||||
|
const deducted = await this.deductBalance(userId, priceKrw);
|
||||||
|
if (!deducted) {
|
||||||
|
return { orderId: null, error: 'INSUFFICIENT_BALANCE' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create order
|
||||||
|
const order = await this.createServerOrder(
|
||||||
|
userId,
|
||||||
|
specId,
|
||||||
|
region,
|
||||||
|
priceKrw,
|
||||||
|
label,
|
||||||
|
image
|
||||||
|
);
|
||||||
|
|
||||||
|
return { orderId: order.id };
|
||||||
|
} catch (error) {
|
||||||
|
// Attempt to refund on error
|
||||||
|
try {
|
||||||
|
await this.refundBalance(userId, priceKrw);
|
||||||
|
} catch {
|
||||||
|
// Log but don't throw - manual intervention needed
|
||||||
|
console.error('[ProvisioningRepository] Failed to refund on error:', error);
|
||||||
|
}
|
||||||
|
return { orderId: null, error: 'ORDER_CREATION_FAILED' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund balance and update order status on provisioning failure
|
||||||
|
*/
|
||||||
|
async rollbackOrder(orderId: number, userId: number, amount: number, errorMessage: string): Promise<void> {
|
||||||
|
await this.refundBalance(userId, amount);
|
||||||
|
await this.updateOrderStatus(orderId, 'failed', errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/services/linode-provider.ts
Normal file
159
src/services/linode-provider.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Linode VPS Provider Implementation
|
||||||
|
* API Docs: https://www.linode.com/docs/api/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||||
|
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
||||||
|
|
||||||
|
interface LinodeInstance {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string;
|
||||||
|
region: string;
|
||||||
|
type: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinodeError {
|
||||||
|
errors: Array<{
|
||||||
|
field?: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LinodeProvider extends VPSProviderBase {
|
||||||
|
constructor(apiKey: string, timeout: number = 30000) {
|
||||||
|
super({
|
||||||
|
apiKey,
|
||||||
|
baseUrl: 'https://api.linode.com/v4',
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createServer(request: CreateServerRequest): Promise<CreateServerResponse> {
|
||||||
|
const url = `${this.config.baseUrl}/linode/instances`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
type: request.plan,
|
||||||
|
region: request.region,
|
||||||
|
image: request.osImage,
|
||||||
|
root_pass: request.rootPassword,
|
||||||
|
label: request.label || `server-${Date.now()}`,
|
||||||
|
tags: request.tags || [],
|
||||||
|
authorized_keys: request.sshKeys || [],
|
||||||
|
booted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (await response.json()) as LinodeError;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: `LINODE_${response.status}`,
|
||||||
|
message: error.errors?.[0]?.reason || 'Unknown error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as LinodeInstance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
instanceId: String(data.id),
|
||||||
|
ipv4: data.ipv4?.[0],
|
||||||
|
ipv6: data.ipv6?.split('/')[0], // Remove CIDR notation
|
||||||
|
status: data.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'LINODE_NETWORK_ERROR',
|
||||||
|
message: error instanceof Error ? error.message : 'Network error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const url = `${this.config.baseUrl}/linode/instances/${instanceId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (await response.json()) as LinodeError;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.errors?.[0]?.reason || 'Failed to delete instance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerStatus(
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; ipv4?: string; ipv6?: string }> {
|
||||||
|
const url = `${this.config.baseUrl}/linode/instances/${instanceId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { status: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as LinodeInstance;
|
||||||
|
return {
|
||||||
|
status: data.status,
|
||||||
|
ipv4: data.ipv4?.[0],
|
||||||
|
ipv6: data.ipv6?.split('/')[0],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { status: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOsImageId(osImage: string): string {
|
||||||
|
return OS_IMAGE_MAP.linode[osImage as keyof typeof OS_IMAGE_MAP.linode] || 'linode/ubuntu22.04';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate secure root password for Linode
|
||||||
|
* Linode requires: 6-128 chars, uppercase, lowercase, numeric
|
||||||
|
*/
|
||||||
|
generateRootPassword(): string {
|
||||||
|
return this.generateSecurePassword(32);
|
||||||
|
}
|
||||||
|
}
|
||||||
392
src/services/provisioning-service.ts
Normal file
392
src/services/provisioning-service.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* Provisioning Service
|
||||||
|
* Orchestrates the server provisioning workflow with Queue-based async processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env, ProvisionRequest, ProvisionResponse, ProvisionQueueMessage, ServerOrder, VPSProvider } from '../types';
|
||||||
|
import { ProvisioningRepository } from '../repositories/ProvisioningRepository';
|
||||||
|
import { LinodeProvider } from './linode-provider';
|
||||||
|
import { VultrProvider } from './vultr-provider';
|
||||||
|
import { getExchangeRate } from '../utils/exchange-rate';
|
||||||
|
|
||||||
|
export class ProvisioningService {
|
||||||
|
private repo: ProvisioningRepository;
|
||||||
|
private linodeProvider: LinodeProvider | null;
|
||||||
|
private vultrProvider: VultrProvider | null;
|
||||||
|
private env: Env;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
env: Env, // Full env for exchange rate API + cache + queue
|
||||||
|
db: D1Database, // cloud-instances-db: pricing, providers
|
||||||
|
userDb: D1Database, // telegram-conversations: users, deposits, orders
|
||||||
|
linodeApiKey?: string,
|
||||||
|
vultrApiKey?: string
|
||||||
|
) {
|
||||||
|
this.env = env;
|
||||||
|
this.repo = new ProvisioningRepository(db, userDb);
|
||||||
|
this.linodeProvider = linodeApiKey ? new LinodeProvider(linodeApiKey) : null;
|
||||||
|
this.vultrProvider = vultrApiKey ? new VultrProvider(vultrApiKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main provisioning workflow (async via Queue)
|
||||||
|
* 1. Validate user by telegram_id
|
||||||
|
* 2. Get pricing details
|
||||||
|
* 3. Check and deduct balance (KRW)
|
||||||
|
* 4. Create order with status 'queued'
|
||||||
|
* 5. Send message to Queue
|
||||||
|
* 6. Return immediately with order info
|
||||||
|
*/
|
||||||
|
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
|
||||||
|
const { telegram_id, pricing_id, label, image, dry_run } = request;
|
||||||
|
const osImageKey = image || 'ubuntu_22_04';
|
||||||
|
|
||||||
|
// Step 1: Validate user by telegram_id
|
||||||
|
const user = await this.repo.getUserByTelegramId(telegram_id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: { code: 'USER_NOT_FOUND', message: 'User not found' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get pricing details with provider info
|
||||||
|
const pricing = await this.repo.getPricingWithProvider(pricing_id);
|
||||||
|
if (!pricing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: { code: 'PRICING_NOT_FOUND', message: 'Invalid pricing_id or unavailable' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Calculate price in KRW using real-time exchange rate
|
||||||
|
// Round to nearest 500 KRW for cleaner pricing
|
||||||
|
const exchangeRate = await getExchangeRate(this.env);
|
||||||
|
const priceKrw = Math.round((pricing.monthly_price * exchangeRate) / 500) * 500;
|
||||||
|
|
||||||
|
// Step 4: Check balance
|
||||||
|
const currentBalance = await this.repo.getBalance(user.id);
|
||||||
|
if (currentBalance < priceKrw) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INSUFFICIENT_BALANCE',
|
||||||
|
message: `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}, Available: ₩${currentBalance.toLocaleString()}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dry run mode: return validation result without creating order
|
||||||
|
if (dry_run) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
order: {
|
||||||
|
id: 0,
|
||||||
|
user_id: user.id,
|
||||||
|
spec_id: pricing_id,
|
||||||
|
status: 'pending',
|
||||||
|
region: pricing.region_code,
|
||||||
|
provider_instance_id: null,
|
||||||
|
ip_address: null,
|
||||||
|
root_password: null,
|
||||||
|
price_paid: priceKrw,
|
||||||
|
error_message: null,
|
||||||
|
provisioned_at: null,
|
||||||
|
terminated_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
label: label || null,
|
||||||
|
image: osImageKey,
|
||||||
|
billing_type: 'monthly',
|
||||||
|
},
|
||||||
|
dry_run_info: {
|
||||||
|
message: 'Dry run successful. No server created, no balance deducted.',
|
||||||
|
user_id: user.id,
|
||||||
|
telegram_id,
|
||||||
|
current_balance_krw: currentBalance,
|
||||||
|
price_krw: priceKrw,
|
||||||
|
remaining_balance_krw: currentBalance - priceKrw,
|
||||||
|
provider: pricing.provider_name,
|
||||||
|
instance: pricing.instance_name,
|
||||||
|
region: pricing.region_name,
|
||||||
|
},
|
||||||
|
} as ProvisionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Generate root password before creating order
|
||||||
|
const rootPassword = this.generateSecurePassword();
|
||||||
|
|
||||||
|
// Step 6: Create order with payment (atomic balance deduction + order creation)
|
||||||
|
const orderResult = await this.repo.createOrderWithPayment(
|
||||||
|
user.id,
|
||||||
|
pricing_id,
|
||||||
|
pricing.region_code,
|
||||||
|
priceKrw,
|
||||||
|
label || null,
|
||||||
|
osImageKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!orderResult.orderId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: orderResult.error || 'ORDER_CREATION_FAILED',
|
||||||
|
message: orderResult.error === 'INSUFFICIENT_BALANCE'
|
||||||
|
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
|
||||||
|
: 'Failed to create order',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = orderResult.orderId;
|
||||||
|
|
||||||
|
// Step 7: Store root password in order (encrypted in production)
|
||||||
|
await this.repo.updateOrderRootPassword(orderId, rootPassword);
|
||||||
|
|
||||||
|
// Step 8: Update order status to 'queued' and send to Queue
|
||||||
|
await this.repo.updateOrderStatus(orderId, 'provisioning');
|
||||||
|
|
||||||
|
// Send message to provision queue (root_password stored in DB, not in queue for security)
|
||||||
|
const queueMessage: ProvisionQueueMessage = {
|
||||||
|
order_id: orderId,
|
||||||
|
user_id: user.id,
|
||||||
|
pricing_id,
|
||||||
|
label: label || null,
|
||||||
|
image: osImageKey,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.env.PROVISION_QUEUE.send(queueMessage);
|
||||||
|
console.log(`[ProvisioningService] Order ${orderId} queued for provisioning`);
|
||||||
|
|
||||||
|
// Step 9: Return immediately with order info
|
||||||
|
const order = await this.repo.getOrderById(orderId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
order: order!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process provision queue message (called by Queue consumer)
|
||||||
|
* 1. Get order and pricing details
|
||||||
|
* 2. Call provider API
|
||||||
|
* 3. On success: update order to active
|
||||||
|
* 4. On failure: refund balance, update order to failed
|
||||||
|
*/
|
||||||
|
async processQueueMessage(message: ProvisionQueueMessage): Promise<void> {
|
||||||
|
const { order_id, user_id, pricing_id, label, image } = message;
|
||||||
|
console.log(`[ProvisioningService] Processing order ${order_id}`);
|
||||||
|
|
||||||
|
// Fetch order to get root_password (stored in DB for security)
|
||||||
|
const order = await this.repo.getOrderById(order_id);
|
||||||
|
if (!order || !order.root_password) {
|
||||||
|
console.error(`[ProvisioningService] Order or root_password not found for order ${order_id}`);
|
||||||
|
await this.repo.updateOrderStatus(order_id, 'failed', 'Order not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root_password = order.root_password;
|
||||||
|
|
||||||
|
// Get pricing details
|
||||||
|
const pricing = await this.repo.getPricingWithProvider(pricing_id);
|
||||||
|
if (!pricing) {
|
||||||
|
console.error(`[ProvisioningService] Pricing not found for order ${order_id}`);
|
||||||
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider
|
||||||
|
const provider = this.getProvider(pricing.provider_name.toLowerCase() as VPSProvider);
|
||||||
|
if (!provider) {
|
||||||
|
console.error(`[ProvisioningService] Provider not configured for order ${order_id}`);
|
||||||
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Provider not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get OS image ID
|
||||||
|
const osImageId = provider.getOsImageId(image);
|
||||||
|
|
||||||
|
// Call provider API
|
||||||
|
const createResult = await provider.createServer({
|
||||||
|
plan: pricing.instance_id,
|
||||||
|
region: pricing.region_code,
|
||||||
|
osImage: osImageId,
|
||||||
|
label: label || `order-${order_id}`,
|
||||||
|
rootPassword: root_password,
|
||||||
|
tags: [`user:${user_id}`, `order:${order_id}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success) {
|
||||||
|
console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error);
|
||||||
|
await this.repo.rollbackOrder(
|
||||||
|
order_id,
|
||||||
|
user_id,
|
||||||
|
order.price_paid,
|
||||||
|
createResult.error?.message || 'Provider API error'
|
||||||
|
);
|
||||||
|
// Throw error to trigger retry
|
||||||
|
throw new Error(createResult.error?.message || 'Provider API error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for IP assignment if not immediately available
|
||||||
|
let ipv4 = createResult.ipv4;
|
||||||
|
if (!ipv4 && createResult.instanceId) {
|
||||||
|
console.log(`[ProvisioningService] Waiting for IP assignment for order ${order_id}...`);
|
||||||
|
const readyResult = await this.waitForServerReady(provider, createResult.instanceId);
|
||||||
|
ipv4 = readyResult.ipv4;
|
||||||
|
if (!ipv4) {
|
||||||
|
console.warn(`[ProvisioningService] IP not assigned within timeout for order ${order_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - update order with provider info
|
||||||
|
await this.repo.updateOrderProviderInfo(
|
||||||
|
order_id,
|
||||||
|
createResult.instanceId!,
|
||||||
|
ipv4 || null,
|
||||||
|
root_password
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider instance by name
|
||||||
|
*/
|
||||||
|
private getProvider(providerName: VPSProvider): LinodeProvider | VultrProvider | null {
|
||||||
|
switch (providerName) {
|
||||||
|
case 'linode':
|
||||||
|
return this.linodeProvider;
|
||||||
|
case 'vultr':
|
||||||
|
return this.vultrProvider;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for server to be ready with IP assigned
|
||||||
|
* Polls getServerStatus every 5 seconds for up to 2 minutes
|
||||||
|
*/
|
||||||
|
private async waitForServerReady(
|
||||||
|
provider: LinodeProvider | VultrProvider,
|
||||||
|
instanceId: string,
|
||||||
|
maxWaitMs: number = 120000
|
||||||
|
): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const pollInterval = 5000;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
const status = await provider.getServerStatus(instanceId);
|
||||||
|
|
||||||
|
if (status.ipv4) {
|
||||||
|
return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ready: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cryptographically secure password
|
||||||
|
*/
|
||||||
|
private generateSecurePassword(length: number = 32): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars[array[i] % chars.length];
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Additional Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's server orders by telegram_id
|
||||||
|
*/
|
||||||
|
async getUserOrders(telegramId: string, limit: number = 20): Promise<ServerOrder[]> {
|
||||||
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
||||||
|
if (!user) return [];
|
||||||
|
return this.repo.getOrdersByUserId(user.id, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID
|
||||||
|
*/
|
||||||
|
async getOrder(orderId: string): Promise<ServerOrder | null> {
|
||||||
|
const numericId = parseInt(orderId, 10);
|
||||||
|
if (isNaN(numericId)) return null;
|
||||||
|
return this.repo.getOrderById(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user balance by telegram_id (in KRW)
|
||||||
|
*/
|
||||||
|
async getUserBalance(telegramId: string): Promise<{ balance_krw: number; user_id: number } | null> {
|
||||||
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
||||||
|
if (!user) return null;
|
||||||
|
const balance = await this.repo.getBalance(user.id);
|
||||||
|
return { balance_krw: balance, user_id: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a server (terminate) - requires telegram_id for authorization
|
||||||
|
*/
|
||||||
|
async deleteServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'User not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericOrderId = parseInt(orderId, 10);
|
||||||
|
if (isNaN(numericOrderId)) {
|
||||||
|
return { success: false, error: 'Invalid order ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await this.repo.getOrderById(numericOrderId);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: 'Order not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.user_id !== user.id) {
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'active') {
|
||||||
|
return { success: false, error: 'Server is not active' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.provider_instance_id) {
|
||||||
|
return { success: false, error: 'No provider instance ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider from pricing info
|
||||||
|
const pricing = await this.repo.getPricingWithProvider(order.spec_id);
|
||||||
|
if (!pricing) {
|
||||||
|
return { success: false, error: 'Pricing info not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = this.getProvider(pricing.provider_name.toLowerCase() as VPSProvider);
|
||||||
|
if (!provider) {
|
||||||
|
return { success: false, error: 'Provider not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteResult = await provider.deleteServer(order.provider_instance_id);
|
||||||
|
|
||||||
|
if (!deleteResult.success) {
|
||||||
|
return { success: false, error: deleteResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repo.updateOrderStatus(numericOrderId, 'terminated');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/services/vps-provider.ts
Normal file
117
src/services/vps-provider.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* VPS Provider Abstract Base Class
|
||||||
|
* Common interface for Linode, Vultr, and other VPS providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||||
|
|
||||||
|
export abstract class VPSProviderBase {
|
||||||
|
protected config: VPSProviderConfig;
|
||||||
|
|
||||||
|
constructor(config: VPSProviderConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new server instance
|
||||||
|
*/
|
||||||
|
abstract createServer(request: CreateServerRequest): Promise<CreateServerResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a server instance
|
||||||
|
*/
|
||||||
|
abstract deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server status
|
||||||
|
*/
|
||||||
|
abstract getServerStatus(instanceId: string): Promise<{ status: string; ipv4?: string; ipv6?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map OS image key to provider-specific identifier
|
||||||
|
*/
|
||||||
|
abstract getOsImageId(osImage: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with retry and exponential backoff
|
||||||
|
*/
|
||||||
|
protected async fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
retries: number = 3
|
||||||
|
): Promise<Response> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Don't retry on client errors (4xx), only server errors (5xx)
|
||||||
|
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on 5xx errors
|
||||||
|
if (response.status >= 500 && i < retries - 1) {
|
||||||
|
await this.sleep(Math.pow(2, i) * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
if (i === retries - 1) break;
|
||||||
|
await this.sleep(Math.pow(2, i) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for specified milliseconds
|
||||||
|
*/
|
||||||
|
protected sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cryptographically secure password
|
||||||
|
*/
|
||||||
|
protected generateSecurePassword(length: number = 32): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars[array[i] % chars.length];
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Image mapping for providers
|
||||||
|
*/
|
||||||
|
export const OS_IMAGE_MAP = {
|
||||||
|
linode: {
|
||||||
|
ubuntu_22_04: 'linode/ubuntu22.04',
|
||||||
|
ubuntu_20_04: 'linode/ubuntu20.04',
|
||||||
|
debian_11: 'linode/debian11',
|
||||||
|
debian_12: 'linode/debian12',
|
||||||
|
},
|
||||||
|
vultr: {
|
||||||
|
ubuntu_22_04: '2284', // Ubuntu 22.04 x64
|
||||||
|
ubuntu_20_04: '1743', // Ubuntu 20.04 x64
|
||||||
|
debian_11: '477', // Debian 11 x64
|
||||||
|
debian_12: '2136', // Debian 12 x64
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
189
src/services/vultr-provider.ts
Normal file
189
src/services/vultr-provider.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Vultr VPS Provider Implementation
|
||||||
|
* API Docs: https://www.vultr.com/api/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||||
|
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
||||||
|
|
||||||
|
interface VultrInstance {
|
||||||
|
id: string;
|
||||||
|
os: string;
|
||||||
|
ram: number;
|
||||||
|
disk: number;
|
||||||
|
main_ip: string;
|
||||||
|
vcpu_count: number;
|
||||||
|
region: string;
|
||||||
|
plan: string;
|
||||||
|
date_created: string;
|
||||||
|
status: string;
|
||||||
|
power_status: string;
|
||||||
|
server_status: string;
|
||||||
|
v6_main_ip: string;
|
||||||
|
v6_network: string;
|
||||||
|
hostname: string;
|
||||||
|
label: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VultrCreateResponse {
|
||||||
|
instance: VultrInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VultrError {
|
||||||
|
error: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VultrProvider extends VPSProviderBase {
|
||||||
|
constructor(apiKey: string, timeout: number = 30000) {
|
||||||
|
super({
|
||||||
|
apiKey,
|
||||||
|
baseUrl: 'https://api.vultr.com/v2',
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createServer(request: CreateServerRequest): Promise<CreateServerResponse> {
|
||||||
|
const url = `${this.config.baseUrl}/instances`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
plan: request.plan,
|
||||||
|
region: request.region,
|
||||||
|
os_id: parseInt(request.osImage, 10),
|
||||||
|
label: request.label || `server-${Date.now()}`,
|
||||||
|
hostname: request.label || `server-${Date.now()}`,
|
||||||
|
tag: request.tags?.[0] || '',
|
||||||
|
sshkey_id: request.sshKeys || [],
|
||||||
|
enable_ipv6: true,
|
||||||
|
backups: 'disabled',
|
||||||
|
ddos_protection: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (await response.json()) as VultrError;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: `VULTR_${response.status}`,
|
||||||
|
message: error.error || 'Unknown error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as VultrCreateResponse;
|
||||||
|
const instance = data.instance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
instanceId: instance.id,
|
||||||
|
ipv4: instance.main_ip !== '0.0.0.0' ? instance.main_ip : undefined,
|
||||||
|
ipv6: instance.v6_main_ip || undefined,
|
||||||
|
status: instance.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VULTR_NETWORK_ERROR',
|
||||||
|
message: error instanceof Error ? error.message : 'Network error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const url = `${this.config.baseUrl}/instances/${instanceId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vultr returns 204 No Content on success
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = (await response.json()) as VultrError;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.error || 'Failed to delete instance',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerStatus(
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; ipv4?: string; ipv6?: string }> {
|
||||||
|
const url = `${this.config.baseUrl}/instances/${instanceId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { status: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { instance: VultrInstance };
|
||||||
|
return {
|
||||||
|
status: data.instance.status,
|
||||||
|
ipv4: data.instance.main_ip !== '0.0.0.0' ? data.instance.main_ip : undefined,
|
||||||
|
ipv6: data.instance.v6_main_ip || undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { status: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOsImageId(osImage: string): string {
|
||||||
|
return OS_IMAGE_MAP.vultr[osImage as keyof typeof OS_IMAGE_MAP.vultr] || '2284'; // Default Ubuntu 22.04
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for server to be ready (IP assigned)
|
||||||
|
* Vultr servers may take a minute to get IP addresses
|
||||||
|
*/
|
||||||
|
async waitForReady(
|
||||||
|
instanceId: string,
|
||||||
|
maxWaitMs: number = 120000
|
||||||
|
): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const pollInterval = 5000;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
const status = await this.getServerStatus(instanceId);
|
||||||
|
|
||||||
|
if (status.status === 'active' && status.ipv4) {
|
||||||
|
return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ready: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/types.ts
128
src/types.ts
@@ -4,10 +4,29 @@
|
|||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
AI: Ai; // Legacy - kept for fallback
|
AI: Ai; // Legacy - kept for fallback
|
||||||
DB: D1Database;
|
DB: D1Database; // cloud-instances-db: server specs, pricing
|
||||||
|
USER_DB: D1Database; // telegram-conversations: users, deposits, orders
|
||||||
CACHE: KVNamespace;
|
CACHE: KVNamespace;
|
||||||
OPENAI_API_KEY: string;
|
OPENAI_API_KEY: string;
|
||||||
AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions
|
AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions
|
||||||
|
// VPS Provider API Keys
|
||||||
|
LINODE_API_KEY?: string;
|
||||||
|
VULTR_API_KEY?: string;
|
||||||
|
// Provision API security
|
||||||
|
PROVISION_API_KEY?: string; // Required for /api/provision/* endpoints
|
||||||
|
// Queue for async provisioning
|
||||||
|
PROVISION_QUEUE: Queue<ProvisionQueueMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue message for async server provisioning
|
||||||
|
// Note: root_password is stored in DB, not in queue message for security
|
||||||
|
export interface ProvisionQueueMessage {
|
||||||
|
order_id: number;
|
||||||
|
user_id: number;
|
||||||
|
pricing_id: number;
|
||||||
|
label: string | null;
|
||||||
|
image: string;
|
||||||
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
@@ -203,3 +222,110 @@ export interface AIRecommendationResponse {
|
|||||||
}>;
|
}>;
|
||||||
infrastructure_tips?: string[];
|
infrastructure_tips?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Provisioning System Types (telegram-conversations DB)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// users table
|
||||||
|
export interface TelegramUser {
|
||||||
|
id: number; // AUTO INCREMENT
|
||||||
|
telegram_id: string; // Telegram user ID (used as external identifier)
|
||||||
|
username: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_deposits table (balance in KRW, INTEGER)
|
||||||
|
export interface UserDeposit {
|
||||||
|
id: number;
|
||||||
|
user_id: number; // References users.id
|
||||||
|
balance: number; // Balance in KRW (원)
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// server_orders table (existing schema)
|
||||||
|
export interface ServerOrder {
|
||||||
|
id: number; // AUTO INCREMENT
|
||||||
|
user_id: number; // References users.id
|
||||||
|
spec_id: number; // Server spec ID
|
||||||
|
status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated';
|
||||||
|
region: string;
|
||||||
|
provider_instance_id: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
root_password: string | null;
|
||||||
|
price_paid: number; // Price in KRW (원)
|
||||||
|
error_message: string | null;
|
||||||
|
provisioned_at: string | null;
|
||||||
|
terminated_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
label: string | null;
|
||||||
|
image: string | null;
|
||||||
|
billing_type: string; // 'monthly' default
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VPSProvider = 'linode' | 'vultr';
|
||||||
|
|
||||||
|
export interface ProvisionRequest {
|
||||||
|
telegram_id: string; // Telegram user ID
|
||||||
|
pricing_id: number; // From cloud-instances-db.pricing
|
||||||
|
label?: string;
|
||||||
|
image?: string; // OS image (e.g., 'ubuntu_22_04')
|
||||||
|
dry_run?: boolean; // Test mode: validate only, don't create server
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionResponse {
|
||||||
|
success: boolean;
|
||||||
|
order?: ServerOrder;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider API types
|
||||||
|
export interface VPSProviderConfig {
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServerRequest {
|
||||||
|
plan: string; // Provider-specific plan ID (instance_id)
|
||||||
|
region: string; // Provider-specific region code
|
||||||
|
osImage: string; // OS identifier
|
||||||
|
label?: string;
|
||||||
|
rootPassword: string;
|
||||||
|
sshKeys?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServerResponse {
|
||||||
|
success: boolean;
|
||||||
|
instanceId?: string;
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
status?: string;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingWithProvider {
|
||||||
|
pricing_id: number;
|
||||||
|
provider_id: number;
|
||||||
|
provider_name: string;
|
||||||
|
api_base_url: string;
|
||||||
|
instance_id: string; // Provider's plan ID (e.g., g6-nanode-1)
|
||||||
|
instance_name: string;
|
||||||
|
region_code: string; // Provider's region code
|
||||||
|
region_name: string;
|
||||||
|
monthly_price: number;
|
||||||
|
vcpu: number;
|
||||||
|
memory_mb: number;
|
||||||
|
storage_gb: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,36 @@ export function jsonResponse<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create error response
|
||||||
|
*/
|
||||||
|
export function createErrorResponse(
|
||||||
|
message: string,
|
||||||
|
status: number,
|
||||||
|
code?: string,
|
||||||
|
corsHeaders: Record<string, string> = {}
|
||||||
|
): Response {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
code: code || 'ERROR',
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create success response
|
||||||
|
*/
|
||||||
|
export function createSuccessResponse<T>(
|
||||||
|
data: T,
|
||||||
|
status: number = 200,
|
||||||
|
corsHeaders: Record<string, string> = {}
|
||||||
|
): Response {
|
||||||
|
return jsonResponse(data, status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get allowed CORS origin
|
* Helper function to get allowed CORS origin
|
||||||
*/
|
*/
|
||||||
@@ -61,3 +91,34 @@ export function getAllowedOrigin(request: Request): string {
|
|||||||
// Browser will block the response due to CORS mismatch
|
// Browser will block the response due to CORS mismatch
|
||||||
return allowedOrigins[0];
|
return allowedOrigins[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate request origin for sensitive endpoints (e.g., provision)
|
||||||
|
* Only allows requests from *.kappa-d8e.workers.dev or worker-to-worker calls
|
||||||
|
*/
|
||||||
|
export function isAllowedProvisionOrigin(request: Request): boolean {
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
const referer = request.headers.get('Referer');
|
||||||
|
|
||||||
|
// Allow worker-to-worker calls (Cloudflare adds this header)
|
||||||
|
// Note: This header indicates the request came from another Cloudflare Worker
|
||||||
|
const cfWorker = request.headers.get('CF-Worker');
|
||||||
|
if (cfWorker) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no Origin header (server-to-server, curl, etc.) - check Referer or allow
|
||||||
|
// For strict mode, you might want to reject these too
|
||||||
|
if (!origin) {
|
||||||
|
// Check Referer as fallback
|
||||||
|
if (referer && referer.includes('.kappa-d8e.workers.dev')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Allow server-to-server for now (can be made stricter with API key)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Origin is from allowed domain
|
||||||
|
const allowedPattern = /^https:\/\/[a-zA-Z0-9-]+\.kappa-d8e\.workers\.dev$/;
|
||||||
|
return allowedPattern.test(origin);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
export {
|
export {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
getAllowedOrigin
|
getAllowedOrigin,
|
||||||
|
isAllowedProvisionOrigin
|
||||||
} from './http';
|
} from './http';
|
||||||
|
|
||||||
// Validation utilities (type guards, request validation)
|
// Validation utilities (type guards, request validation)
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ workers_dev = true
|
|||||||
[ai]
|
[ai]
|
||||||
binding = "AI"
|
binding = "AI"
|
||||||
|
|
||||||
# D1 Database binding (cloud-instances-db: 1,119 servers)
|
# D1 Database binding (cloud-instances-db: server specs, pricing)
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "cloud-instances-db"
|
database_name = "cloud-instances-db"
|
||||||
database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8"
|
database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8"
|
||||||
|
|
||||||
|
# D1 Database binding (telegram-conversations: users, deposits, orders)
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "USER_DB"
|
||||||
|
database_name = "telegram-conversations"
|
||||||
|
database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e"
|
||||||
|
|
||||||
# KV Cache binding for rate limiting and response caching
|
# KV Cache binding for rate limiting and response caching
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
binding = "CACHE"
|
binding = "CACHE"
|
||||||
@@ -22,3 +28,14 @@ id = "c68cdb477022424cbe4594f491390c8a"
|
|||||||
# Observability
|
# Observability
|
||||||
[observability]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
|
# Queue for async server provisioning
|
||||||
|
[[queues.producers]]
|
||||||
|
queue = "provision-queue"
|
||||||
|
binding = "PROVISION_QUEUE"
|
||||||
|
|
||||||
|
[[queues.consumers]]
|
||||||
|
queue = "provision-queue"
|
||||||
|
max_batch_size = 1
|
||||||
|
max_retries = 3
|
||||||
|
dead_letter_queue = "provision-queue-dlq"
|
||||||
|
|||||||
Reference in New Issue
Block a user