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:
kappa
2026-01-30 08:27:34 +09:00
parent f62712af37
commit 6385b5cab6
13 changed files with 767 additions and 7 deletions

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,