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 <noreply@anthropic.com>
This commit is contained in:
13
migrations/005_add_logs_table.sql
Normal file
13
migrations/005_add_logs_table.sql
Normal file
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ export class ProvisioningRepository {
|
||||
}
|
||||
|
||||
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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<ProvisionResponse> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
105
src/utils/db-logger.ts
Normal file
105
src/utils/db-logger.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* D1 기반 실시간 로깅 유틸리티
|
||||
* console.log 대신 D1에 저장하여 나중에 조회 가능
|
||||
*/
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface DbLogger {
|
||||
info: (message: string, context?: Record<string, unknown>) => void;
|
||||
warn: (message: string, context?: Record<string, unknown>) => void;
|
||||
error: (message: string, context?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* D1에 로그 저장 (fire-and-forget, non-blocking)
|
||||
*/
|
||||
async function writeLog(
|
||||
db: D1Database,
|
||||
level: LogLevel,
|
||||
service: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
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<string, unknown>) => {
|
||||
console.log(`[${service}] ${message}`, context || '');
|
||||
writeLog(db, 'info', service, message, context);
|
||||
},
|
||||
warn: (message: string, context?: Record<string, unknown>) => {
|
||||
console.warn(`[${service}] ${message}`, context || '');
|
||||
writeLog(db, 'warn', service, message, context);
|
||||
},
|
||||
error: (message: string, context?: Record<string, unknown>) => {
|
||||
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<Array<{
|
||||
id: number;
|
||||
level: string;
|
||||
service: string;
|
||||
message: string;
|
||||
context: string | null;
|
||||
created_at: string;
|
||||
}>> {
|
||||
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;
|
||||
}>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user