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:
kappa
2026-01-27 17:19:19 +09:00
parent 8c543eeaa5
commit 9b51b8d427
12 changed files with 1796 additions and 5 deletions

354
src/handlers/provision.ts Normal file
View 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;
}
}