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 { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils';
|
||||
import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils';
|
||||
import { handleHealth } from './handlers/health';
|
||||
import { handleGetServers } from './handlers/servers';
|
||||
import { handleRecommend } from './handlers/recommend';
|
||||
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
|
||||
@@ -44,7 +53,7 @@ export default {
|
||||
const origin = getAllowedOrigin(request);
|
||||
const corsHeaders = {
|
||||
'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',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
@@ -71,6 +80,52 @@ export default {
|
||||
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(
|
||||
{ error: 'Not found', request_id: requestId },
|
||||
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 {
|
||||
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;
|
||||
OPENAI_API_KEY: string;
|
||||
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 {
|
||||
@@ -203,3 +222,110 @@ export interface AIRecommendationResponse {
|
||||
}>;
|
||||
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
|
||||
*/
|
||||
@@ -61,3 +91,34 @@ export function getAllowedOrigin(request: Request): string {
|
||||
// Browser will block the response due to CORS mismatch
|
||||
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 {
|
||||
escapeHtml,
|
||||
jsonResponse,
|
||||
getAllowedOrigin
|
||||
getAllowedOrigin,
|
||||
isAllowedProvisionOrigin
|
||||
} from './http';
|
||||
|
||||
// Validation utilities (type guards, request validation)
|
||||
|
||||
Reference in New Issue
Block a user