From 6385b5cab63cf1d1420cd7c04a2f989d45005acd Mon Sep 17 00:00:00 2001 From: kappa Date: Fri, 30 Jan 2026 08:27:34 +0900 Subject: [PATCH] feat: add server lifecycle management and D1 logging - Add start/stop/reboot endpoints for server power management - Add D1-based logging system (logs table + db-logger utility) - Add idempotency_key validation for order deduplication - Extend VPS provider interface with lifecycle methods Co-Authored-By: Claude Opus 4.5 --- migrations/005_add_logs_table.sql | 13 ++ src/handlers/provision.ts | 115 ++++++++++ src/index.ts | 6 + src/middleware/auth.ts | 12 +- src/middleware/origin.ts | 9 + src/repositories/ProvisioningRepository.ts | 51 ++++- src/services/linode-provider.ts | 94 +++++++- src/services/provisioning-service.ts | 247 +++++++++++++++++++++ src/services/vps-provider.ts | 17 +- src/services/vultr-provider.ts | 90 +++++++- src/types.ts | 8 +- src/utils/db-logger.ts | 105 +++++++++ src/utils/index.ts | 7 + 13 files changed, 767 insertions(+), 7 deletions(-) create mode 100644 migrations/005_add_logs_table.sql create mode 100644 src/utils/db-logger.ts diff --git a/migrations/005_add_logs_table.sql b/migrations/005_add_logs_table.sql new file mode 100644 index 0000000..492c183 --- /dev/null +++ b/migrations/005_add_logs_table.sql @@ -0,0 +1,13 @@ +-- D1 로깅 테이블 +CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + service TEXT NOT NULL, + message TEXT NOT NULL, + context TEXT, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); +CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service); diff --git a/src/handlers/provision.ts b/src/handlers/provision.ts index b5a7ee2..8b4fdfa 100644 --- a/src/handlers/provision.ts +++ b/src/handlers/provision.ts @@ -61,6 +61,15 @@ function validateProvisionRequest(body: unknown): { return { valid: false, error: 'dry_run must be a boolean' }; } + if (data.idempotency_key !== undefined) { + if (typeof data.idempotency_key !== 'string') { + return { valid: false, error: 'idempotency_key must be a string' }; + } + if ((data.idempotency_key as string).length > 255) { + return { valid: false, error: 'idempotency_key must be 255 characters or less' }; + } + } + return { valid: true, data: { @@ -69,6 +78,7 @@ function validateProvisionRequest(body: unknown): { label: data.label as string | undefined, image: data.image as string | undefined, dry_run: data.dry_run as boolean | undefined, + idempotency_key: data.idempotency_key as string | undefined, }, }; } @@ -257,6 +267,111 @@ function getDeleteErrorStatusCode(error: string): 400 | 403 | 404 { return 400; // Default for validation/business logic errors } +/** + * POST /api/provision/orders/:id/start + * Start a server + */ +export async function handleStartServer(c: Context<{ Bindings: Env }>) { + try { + const orderId = c.req.param('id'); + const userId = c.get('userId'); // Set by validateUserId middleware + + const provisioningService = createProvisioningService(c.env); + + // Verify user exists first + const balance = await provisioningService.getUserBalance(userId); + if (!balance) { + return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); + } + + const result = await provisioningService.startServer(orderId, userId); + + if (!result.success) { + const statusCode = getStartStopErrorStatusCode(result.error!); + return c.json({ error: result.error!, code: 'START_FAILED', request_id: c.get('requestId') }, statusCode); + } + + return c.json({ message: 'Server started successfully' }); + } catch (error) { + console.error('[handleStartServer] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); + } +} + +/** + * POST /api/provision/orders/:id/stop + * Stop a server + */ +export async function handleStopServer(c: Context<{ Bindings: Env }>) { + try { + const orderId = c.req.param('id'); + const userId = c.get('userId'); // Set by validateUserId middleware + + const provisioningService = createProvisioningService(c.env); + + // Verify user exists first + const balance = await provisioningService.getUserBalance(userId); + if (!balance) { + return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); + } + + const result = await provisioningService.stopServer(orderId, userId); + + if (!result.success) { + const statusCode = getStartStopErrorStatusCode(result.error!); + return c.json({ error: result.error!, code: 'STOP_FAILED', request_id: c.get('requestId') }, statusCode); + } + + return c.json({ message: 'Server stopped successfully' }); + } catch (error) { + console.error('[handleStopServer] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); + } +} + +/** + * POST /api/provision/orders/:id/reboot + * Reboot a server + */ +export async function handleRebootServer(c: Context<{ Bindings: Env }>) { + try { + const orderId = c.req.param('id'); + const userId = c.get('userId'); // Set by validateUserId middleware + + const provisioningService = createProvisioningService(c.env); + + // Verify user exists first + const balance = await provisioningService.getUserBalance(userId); + if (!balance) { + return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); + } + + const result = await provisioningService.rebootServer(orderId, userId); + + if (!result.success) { + const statusCode = getStartStopErrorStatusCode(result.error!); + return c.json({ error: result.error!, code: 'REBOOT_FAILED', request_id: c.get('requestId') }, statusCode); + } + + return c.json({ message: 'Server rebooted successfully' }); + } catch (error) { + console.error('[handleRebootServer] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); + } +} + +/** + * Map start/stop error messages to HTTP status codes + */ +function getStartStopErrorStatusCode(error: string): 400 | 403 | 404 { + if (error === 'Order not found') return 404; + if (error === 'Unauthorized') return 403; + if (error === 'User not found') return 404; + if (error === 'Server is not stopped' || error === 'Server is not active') return 400; + if (error.includes('Server must be running to reboot')) return 400; + return 400; // Default for validation/business logic errors +} + /** * GET /api/provision/balance * Get user's balance (in KRW) diff --git a/src/index.ts b/src/index.ts index b9249a4..4f9d8dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,9 @@ import { handleGetOrders, handleGetOrder, handleDeleteOrder, + handleStartServer, + handleStopServer, + handleRebootServer, handleGetBalance, handleGetOsImages, } from './handlers/provision'; @@ -60,6 +63,9 @@ provision.post('/', handleProvision); provision.get('/orders', handleGetOrders); provision.get('/orders/:id', handleGetOrder); provision.delete('/orders/:id', handleDeleteOrder); +provision.post('/orders/:id/start', handleStartServer); +provision.post('/orders/:id/stop', handleStopServer); +provision.post('/orders/:id/reboot', handleRebootServer); provision.get('/balance', handleGetBalance); provision.get('/images', handleGetOsImages); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 45f76b2..47e5f99 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -4,12 +4,22 @@ import { Context, Next } from 'hono'; import type { Env } from '../types'; -import { isAllowedOrigin } from './origin'; +import { isAllowedOrigin, isServiceBinding } from './origin'; /** * Middleware to check API key for provisioning endpoints + * Service Binding requests (internal Worker-to-Worker) bypass authentication */ export async function provisionAuth(c: Context<{ Bindings: Env }>, next: Next) { + const url = c.req.url; + + // Service Binding detection: URL starts with "https://internal" + if (isServiceBinding(url)) { + console.log('[Auth] Service Binding request detected, bypassing authentication:', url); + return next(); + } + + // External request: require API key authentication const apiKey = c.req.header('X-API-Key'); if (!c.env.PROVISION_API_KEY || apiKey !== c.env.PROVISION_API_KEY) { diff --git a/src/middleware/origin.ts b/src/middleware/origin.ts index 28ad221..bf02ba1 100644 --- a/src/middleware/origin.ts +++ b/src/middleware/origin.ts @@ -31,3 +31,12 @@ export function validateOrigin(origin: string | null | undefined): string | null export function isAllowedOrigin(origin: string | null | undefined): boolean { return validateOrigin(origin) !== null; } + +/** + * Check if request is a Service Binding (internal Worker-to-Worker call) + * Service Binding URLs start with "https://internal" or "http://internal" + * These requests are made within Cloudflare's internal network and are secure by default + */ +export function isServiceBinding(url: string): boolean { + return url.startsWith('https://internal') || url.startsWith('http://internal'); +} diff --git a/src/repositories/ProvisioningRepository.ts b/src/repositories/ProvisioningRepository.ts index 8a249e0..f4ae758 100644 --- a/src/repositories/ProvisioningRepository.ts +++ b/src/repositories/ProvisioningRepository.ts @@ -185,7 +185,12 @@ export class ProvisioningRepository { } async getOrdersByUserId(userId: number, limit: number = 20): Promise { - const result = await this.userDb + // Query uses two databases: + // - this.userDb (telegram-conversations): server_orders + // - this.db (cloud-instances-db): anvil_pricing, anvil_instances + + // Step 1: Get orders from user DB + const ordersResult = await this.userDb .prepare( `SELECT * FROM server_orders WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled') @@ -194,7 +199,49 @@ export class ProvisioningRepository { .bind(userId, limit) .all(); - return result.results as unknown as ServerOrder[]; + const orders = ordersResult.results as unknown as ServerOrder[]; + + if (orders.length === 0) { + return []; + } + + // Step 2: Get spec details from cloud-instances-db + const specIds = orders.map(o => o.spec_id); + const placeholders = specIds.map(() => '?').join(','); + + const specsResult = await this.db + .prepare( + `SELECT + ap.id as spec_id, + ai.vcpus as vcpu, + ai.memory_gb, + ai.disk_gb, + ai.transfer_tb as bandwidth_tb, + ai.display_name as spec_name + FROM anvil_pricing ap + JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id + WHERE ap.id IN (${placeholders})` + ) + .bind(...specIds) + .all(); + + // Step 3: Create a map for efficient lookup + const specsMap = new Map( + (specsResult.results as unknown as any[]).map(s => [s.spec_id, s]) + ); + + // Step 4: Merge spec details into orders + return orders.map(order => { + const spec = specsMap.get(order.spec_id); + return { + ...order, + vcpu: spec?.vcpu, + memory_gb: spec?.memory_gb, + disk_gb: spec?.disk_gb, + bandwidth_tb: spec?.bandwidth_tb, + spec_name: spec?.spec_name + }; + }); } /** diff --git a/src/services/linode-provider.ts b/src/services/linode-provider.ts index 5d10641..b6c93e1 100644 --- a/src/services/linode-provider.ts +++ b/src/services/linode-provider.ts @@ -134,9 +134,99 @@ export class LinodeProvider extends VPSProviderBase { } } + async startServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/linode/instances/${instanceId}/boot`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const error = (await response.json()) as LinodeError; + return { + success: false, + error: error.errors?.[0]?.reason || 'Failed to start instance', + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async stopServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/linode/instances/${instanceId}/shutdown`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const error = (await response.json()) as LinodeError; + return { + success: false, + error: error.errors?.[0]?.reason || 'Failed to stop instance', + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async rebootServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/linode/instances/${instanceId}/reboot`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const error = (await response.json()) as LinodeError; + return { + success: false, + error: error.errors?.[0]?.reason || 'Failed to reboot 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 }> { + ): Promise<{ status: string; power_status?: string; ipv4?: string; ipv6?: string }> { const url = `${this.config.baseUrl}/linode/instances/${instanceId}`; try { @@ -152,8 +242,10 @@ export class LinodeProvider extends VPSProviderBase { } const data = (await response.json()) as LinodeInstance; + // Linode: status is the power state (running, offline, etc.) return { status: data.status, + power_status: data.status, // For compatibility with Vultr logic ipv4: data.ipv4?.[0], ipv6: data.ipv6?.split('/')[0], }; diff --git a/src/services/provisioning-service.ts b/src/services/provisioning-service.ts index 70e75e2..0c03d39 100644 --- a/src/services/provisioning-service.ts +++ b/src/services/provisioning-service.ts @@ -9,6 +9,7 @@ import { LinodeProvider } from './linode-provider'; import { VultrProvider } from './vultr-provider'; import { getExchangeRate } from '../utils/exchange-rate'; import { TIMEOUTS } from '../config'; +import { createDbLogger, DbLogger } from '../utils'; const TELEGRAM_TIMEOUT_MS = 10000; // 10 seconds for Telegram API calls @@ -17,6 +18,7 @@ export class ProvisioningService { private linodeProvider: LinodeProvider | null; private vultrProvider: VultrProvider | null; private env: Env; + private logger: DbLogger; constructor( env: Env, // Full env for exchange rate API + cache + queue @@ -31,6 +33,7 @@ export class ProvisioningService { this.repo = new ProvisioningRepository(db, userDb); this.linodeProvider = linodeApiKey ? new LinodeProvider(linodeApiKey, linodeApiUrl) : null; this.vultrProvider = vultrApiKey ? new VultrProvider(vultrApiKey, vultrApiUrl) : null; + this.logger = createDbLogger(userDb, 'provisioning'); } /** @@ -45,6 +48,8 @@ export class ProvisioningService { async provisionServer(request: ProvisionRequest): Promise { const { telegram_id, pricing_id, label, image, dry_run, idempotency_key } = request; + this.logger.info('provisionServer started', { telegram_id, pricing_id, label, image, dry_run }); + // Step 0: Check idempotency - if key exists, handle based on order status let existingPendingOrder: ServerOrder | null = null; @@ -202,6 +207,8 @@ export class ProvisioningService { orderId = orderResult.orderId; + this.logger.info('Order created', { orderId, pricing_id, telegram_id }); + // Step 7: Store root password in order (encrypted in production) await this.repo.updateOrderRootPassword(orderId, rootPassword); } @@ -221,6 +228,7 @@ export class ProvisioningService { await this.env.PROVISION_QUEUE.send(queueMessage); console.log(`[ProvisioningService] Order ${orderId} queued for provisioning`); + this.logger.info('Order queued', { orderId, telegram_id }); // Step 9: Return immediately with order info const order = await this.repo.getOrderById(orderId); @@ -241,6 +249,7 @@ export class ProvisioningService { async processQueueMessage(message: ProvisionQueueMessage): Promise { const { order_id, user_id, pricing_id, label, image } = message; console.log(`[ProvisioningService] Processing order ${order_id}`); + this.logger.info('Processing queue message', { order_id, user_id, pricing_id }); // Fetch order to get root_password (stored in DB for security) const order = await this.repo.getOrderById(order_id); @@ -307,6 +316,13 @@ export class ProvisioningService { } // Call provider API (use source_region_code for actual provider region) + this.logger.info('Calling provider API', { + provider: pricing.source_provider, + order_id, + region: pricing.source_region_code, + plan: pricing.instance_id + }); + const createResult = await provider.createServer({ plan: pricing.instance_id, region: pricing.source_region_code, @@ -319,6 +335,7 @@ export class ProvisioningService { if (!createResult.success) { console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error); + this.logger.error('Provider API failed', { order_id, error: createResult.error }); const errorMsg = createResult.error?.message || 'Provider API error'; await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); @@ -346,6 +363,11 @@ export class ProvisioningService { ); console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`); + this.logger.info('Order provisioned', { + order_id, + instanceId: createResult.instanceId, + ipv4: ipv4 || 'pending' + }); // Send Telegram notification to user (now that we have real IP and password) await this.sendProvisioningSuccessNotification( @@ -602,6 +624,8 @@ IP 주소: ${ipAddress || 'IP 할당 대기 중'} * Delete a server (terminate) - requires telegram_id for authorization */ async deleteServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> { + this.logger.info('deleteServer started', { orderId, telegramId }); + const user = await this.repo.getUserByTelegramId(telegramId); if (!user) { return { success: false, error: 'User not found' }; @@ -644,10 +668,233 @@ IP 주소: ${ipAddress || 'IP 할당 대기 중'} const deleteResult = await provider.deleteServer(order.provider_instance_id); if (!deleteResult.success) { + this.logger.error('deleteServer failed', { + orderId: numericOrderId, + telegramId, + error: deleteResult.error + }); return { success: false, error: deleteResult.error }; } await this.repo.updateOrderStatus(numericOrderId, 'terminated'); + this.logger.info('deleteServer success', { orderId: numericOrderId, telegramId }); + + return { success: true }; + } + + /** + * Start a server - requires telegram_id for authorization + */ + async startServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> { + this.logger.info('startServer started', { orderId, telegramId }); + + 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.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.source_provider as VPSProvider); + if (!provider) { + return { success: false, error: 'Provider not configured' }; + } + + // Check actual server status from provider API + const currentStatus = await provider.getServerStatus(order.provider_instance_id); + + // Check if server is already running + const isRunning = currentStatus.status === 'running' || currentStatus.power_status === 'running'; + if (isRunning) { + return { success: false, error: 'Server is already running' }; + } + + // Check if server is stopped (ready to start) + const isStopped = currentStatus.status === 'offline' || currentStatus.status === 'stopped' || + currentStatus.power_status === 'stopped'; + if (!isStopped) { + return { success: false, error: `Server is not in stopped state (current: ${currentStatus.status || currentStatus.power_status})` }; + } + + const startResult = await provider.startServer(order.provider_instance_id); + + if (!startResult.success) { + this.logger.error('startServer failed', { + orderId: numericOrderId, + telegramId, + error: startResult.error + }); + return { success: false, error: startResult.error }; + } + + // Update DB status to 'active' for display purposes + await this.repo.updateOrderStatus(numericOrderId, 'active'); + this.logger.info('startServer success', { orderId: numericOrderId, telegramId }); + + return { success: true }; + } + + /** + * Stop a server - requires telegram_id for authorization + */ + async stopServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> { + this.logger.info('stopServer started', { orderId, telegramId }); + + 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.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.source_provider as VPSProvider); + if (!provider) { + return { success: false, error: 'Provider not configured' }; + } + + // Check actual server status from provider API + const currentStatus = await provider.getServerStatus(order.provider_instance_id); + + // Check if server is already stopped + const isStopped = currentStatus.status === 'offline' || currentStatus.status === 'stopped' || + currentStatus.power_status === 'stopped'; + if (isStopped) { + return { success: false, error: 'Server is already stopped' }; + } + + // Check if server is running (ready to stop) + const isRunning = currentStatus.status === 'running' || currentStatus.power_status === 'running'; + if (!isRunning) { + return { success: false, error: `Server is not in running state (current: ${currentStatus.status || currentStatus.power_status})` }; + } + + const stopResult = await provider.stopServer(order.provider_instance_id); + + if (!stopResult.success) { + this.logger.error('stopServer failed', { + orderId: numericOrderId, + telegramId, + error: stopResult.error + }); + return { success: false, error: stopResult.error }; + } + + // Update DB status to 'stopped' for display purposes + await this.repo.updateOrderStatus(numericOrderId, 'stopped'); + this.logger.info('stopServer success', { orderId: numericOrderId, telegramId }); + + return { success: true }; + } + + /** + * Reboot a server - requires telegram_id for authorization + */ + async rebootServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> { + this.logger.info('rebootServer started', { orderId, telegramId }); + + 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.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.source_provider as VPSProvider); + if (!provider) { + return { success: false, error: 'Provider not configured' }; + } + + // Check actual server status from provider API + const currentStatus = await provider.getServerStatus(order.provider_instance_id); + + // Check if server is running (ready to reboot) + const isRunning = currentStatus.status === 'running' || currentStatus.power_status === 'running'; + if (!isRunning) { + return { success: false, error: `Server must be running to reboot (current: ${currentStatus.status || currentStatus.power_status})` }; + } + + const rebootResult = await provider.rebootServer(order.provider_instance_id); + + if (!rebootResult.success) { + this.logger.error('rebootServer failed', { + orderId: numericOrderId, + telegramId, + error: rebootResult.error + }); + return { success: false, error: rebootResult.error }; + } + + // No DB status update needed - server remains 'active' + this.logger.info('rebootServer success', { orderId: numericOrderId, telegramId }); return { success: true }; } diff --git a/src/services/vps-provider.ts b/src/services/vps-provider.ts index f35f3a2..823b984 100644 --- a/src/services/vps-provider.ts +++ b/src/services/vps-provider.ts @@ -25,7 +25,22 @@ export abstract class VPSProviderBase { /** * Get server status */ - abstract getServerStatus(instanceId: string): Promise<{ status: string; ipv4?: string; ipv6?: string }>; + abstract getServerStatus(instanceId: string): Promise<{ status: string; power_status?: string; ipv4?: string; ipv6?: string }>; + + /** + * Start a server instance + */ + abstract startServer(instanceId: string): Promise<{ success: boolean; error?: string }>; + + /** + * Stop a server instance + */ + abstract stopServer(instanceId: string): Promise<{ success: boolean; error?: string }>; + + /** + * Reboot a server instance + */ + abstract rebootServer(instanceId: string): Promise<{ success: boolean; error?: string }>; /** * Map OS image key to provider-specific identifier diff --git a/src/services/vultr-provider.ts b/src/services/vultr-provider.ts index 8bb02e2..b77bdf8 100644 --- a/src/services/vultr-provider.ts +++ b/src/services/vultr-provider.ts @@ -134,9 +134,96 @@ export class VultrProvider extends VPSProviderBase { } } + async startServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/instances/${instanceId}/start`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + 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 start instance', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async stopServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/instances/${instanceId}/halt`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + 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 stop instance', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async rebootServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/instances/${instanceId}/reboot`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + 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 reboot 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 }> { + ): Promise<{ status: string; power_status?: string; ipv4?: string; ipv6?: string }> { const url = `${this.config.baseUrl}/instances/${instanceId}`; try { @@ -154,6 +241,7 @@ export class VultrProvider extends VPSProviderBase { const data = (await response.json()) as { instance: VultrInstance }; return { status: data.instance.status, + power_status: data.instance.power_status, ipv4: data.instance.main_ip !== '0.0.0.0' ? data.instance.main_ip : undefined, ipv6: data.instance.v6_main_ip || undefined, }; diff --git a/src/types.ts b/src/types.ts index 3119815..3879f78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -260,7 +260,7 @@ export interface ServerOrder { user_id: number; // References users.id telegram_user_id: string; // Telegram user ID (for direct reference) spec_id: number; // Server spec ID - status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated'; + status: 'pending' | 'provisioning' | 'active' | 'stopped' | 'failed' | 'cancelled' | 'terminated'; region: string; provider_instance_id: string | null; ip_address: string | null; @@ -275,6 +275,12 @@ export interface ServerOrder { image: string | null; billing_type: string; // 'monthly' default idempotency_key: string | null; // Idempotency key for duplicate prevention + // Server spec details (optional, populated via JOIN with anvil_instances) + vcpu?: number; // CPU cores + memory_gb?: number; // Memory in GB + disk_gb?: number; // Disk in GB + bandwidth_tb?: number; // Bandwidth in TB/month + spec_name?: string; // Display name (e.g., "Standard 8GB") } export type VPSProvider = 'linode' | 'vultr'; diff --git a/src/utils/db-logger.ts b/src/utils/db-logger.ts new file mode 100644 index 0000000..5533137 --- /dev/null +++ b/src/utils/db-logger.ts @@ -0,0 +1,105 @@ +/** + * D1 기반 실시간 로깅 유틸리티 + * console.log 대신 D1에 저장하여 나중에 조회 가능 + */ + +type LogLevel = 'info' | 'warn' | 'error'; + +export interface DbLogger { + info: (message: string, context?: Record) => void; + warn: (message: string, context?: Record) => void; + error: (message: string, context?: Record) => void; +} + +/** + * D1에 로그 저장 (fire-and-forget, non-blocking) + */ +async function writeLog( + db: D1Database, + level: LogLevel, + service: string, + message: string, + context?: Record +): Promise { + try { + await db.prepare( + 'INSERT INTO logs (level, service, message, context) VALUES (?, ?, ?, ?)' + ).bind( + level, + service, + message, + context ? JSON.stringify(context) : null + ).run(); + } catch (e) { + // DB 저장 실패 시 console로 fallback + console.error('[db-logger] Failed to write log:', e, { level, service, message, context }); + } +} + +/** + * 서비스별 로거 생성 + */ +export function createDbLogger(db: D1Database, service: string): DbLogger { + return { + info: (message: string, context?: Record) => { + console.log(`[${service}] ${message}`, context || ''); + writeLog(db, 'info', service, message, context); + }, + warn: (message: string, context?: Record) => { + console.warn(`[${service}] ${message}`, context || ''); + writeLog(db, 'warn', service, message, context); + }, + error: (message: string, context?: Record) => { + console.error(`[${service}] ${message}`, context || ''); + writeLog(db, 'error', service, message, context); + }, + }; +} + +/** + * 최근 로그 조회 + */ +export async function getRecentLogs( + db: D1Database, + options?: { + level?: LogLevel; + service?: string; + limit?: number; + } +): Promise> { + const { level, service, limit = 100 } = options || {}; + + let query = 'SELECT * FROM logs WHERE 1=1'; + const params: string[] = []; + + if (level) { + query += ' AND level = ?'; + params.push(level); + } + if (service) { + query += ' AND service = ?'; + params.push(service); + } + + query += ' ORDER BY created_at DESC LIMIT ?'; + params.push(String(limit)); + + const stmt = db.prepare(query); + const result = await stmt.bind(...params).all(); + + return result.results as Array<{ + id: number; + level: string; + service: string; + message: string; + context: string | null; + created_at: string; + }>; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index f866452..a9fdd09 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -52,6 +52,13 @@ export { EXCHANGE_RATE_FALLBACK } from './exchange-rate'; +// D1 logging utilities +export { + createDbLogger, + getRecentLogs +} from './db-logger'; +export type { DbLogger } from './db-logger'; + // Re-export region utilities from region-utils.ts for backward compatibility export { DEFAULT_ANVIL_REGION_FILTER_SQL,