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;
}
}

41
src/handlers/queue.ts Normal file
View 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();
}
}
}

View File

@@ -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);
},
};

View 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);
}
}

View 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);
}
}

View 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 };
}
}

View 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;

View 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 };
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -7,7 +7,8 @@
export {
escapeHtml,
jsonResponse,
getAllowedOrigin
getAllowedOrigin,
isAllowedProvisionOrigin
} from './http';
// Validation utilities (type guards, request validation)