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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user