- Add handleGetOsImages handler in provision.ts - Add getOsImages method in ProvisioningService - Add route in index.ts - Returns key, name, family, is_default for each OS image Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
/**
|
|
* Provisioning Service
|
|
* Orchestrates the server provisioning workflow with Queue-based async processing
|
|
*/
|
|
|
|
import type { Env, ProvisionRequest, ProvisionResponse, ProvisionQueueMessage, ServerOrder, VPSProvider } from '../types';
|
|
import { ProvisioningRepository } from '../repositories/ProvisioningRepository';
|
|
import { LinodeProvider } from './linode-provider';
|
|
import { VultrProvider } from './vultr-provider';
|
|
import { getExchangeRate } from '../utils/exchange-rate';
|
|
|
|
export class ProvisioningService {
|
|
private repo: ProvisioningRepository;
|
|
private linodeProvider: LinodeProvider | null;
|
|
private vultrProvider: VultrProvider | null;
|
|
private env: Env;
|
|
|
|
constructor(
|
|
env: Env, // Full env for exchange rate API + cache + queue
|
|
db: D1Database, // cloud-instances-db: pricing, providers
|
|
userDb: D1Database, // telegram-conversations: users, deposits, orders
|
|
linodeApiKey?: string,
|
|
vultrApiKey?: string,
|
|
linodeApiUrl?: string, // Optional: for testing with emulator
|
|
vultrApiUrl?: string // Optional: for testing with emulator
|
|
) {
|
|
this.env = env;
|
|
this.repo = new ProvisioningRepository(db, userDb);
|
|
this.linodeProvider = linodeApiKey ? new LinodeProvider(linodeApiKey, linodeApiUrl) : null;
|
|
this.vultrProvider = vultrApiKey ? new VultrProvider(vultrApiKey, vultrApiUrl) : null;
|
|
}
|
|
|
|
/**
|
|
* Main provisioning workflow (async via Queue)
|
|
* 1. Validate user by telegram_id
|
|
* 2. Get pricing details
|
|
* 3. Check and deduct balance (KRW)
|
|
* 4. Create order with status 'queued'
|
|
* 5. Send message to Queue
|
|
* 6. Return immediately with order info
|
|
*/
|
|
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
|
|
const { telegram_id, pricing_id, label, image, dry_run } = request;
|
|
|
|
// Step 1: Validate OS image from DB (or get default)
|
|
let osImage = image ? await this.repo.getOsImageByKey(image) : await this.repo.getDefaultOsImage();
|
|
if (!osImage) {
|
|
// If specified image not found, try to get list for error message
|
|
const availableImages = await this.repo.getActiveOsImages();
|
|
const availableKeys = availableImages.map(img => img.key).join(', ');
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: 'INVALID_OS_IMAGE',
|
|
message: image
|
|
? `Invalid OS image '${image}'. Available: ${availableKeys}`
|
|
: 'No default OS image configured',
|
|
},
|
|
};
|
|
}
|
|
const osImageKey = osImage.key;
|
|
|
|
// Step 2: Validate user by telegram_id
|
|
const user = await this.repo.getUserByTelegramId(telegram_id);
|
|
if (!user) {
|
|
return {
|
|
success: false,
|
|
error: { code: 'USER_NOT_FOUND', message: 'User not found' },
|
|
};
|
|
}
|
|
|
|
// Step 4: Get pricing details with provider info
|
|
const pricing = await this.repo.getPricingWithProvider(pricing_id);
|
|
if (!pricing) {
|
|
return {
|
|
success: false,
|
|
error: { code: 'PRICING_NOT_FOUND', message: 'Invalid pricing_id or unavailable' },
|
|
};
|
|
}
|
|
|
|
// Step 5: Calculate price in KRW using real-time exchange rate
|
|
// Round to nearest 500 KRW for cleaner pricing
|
|
const exchangeRate = await getExchangeRate(this.env);
|
|
const priceKrw = Math.round((pricing.monthly_price * exchangeRate) / 500) * 500;
|
|
|
|
// Step 6: Check balance
|
|
const currentBalance = await this.repo.getBalance(user.id);
|
|
if (currentBalance < priceKrw) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: 'INSUFFICIENT_BALANCE',
|
|
message: `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}, Available: ₩${currentBalance.toLocaleString()}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Dry run mode: return validation result without creating order
|
|
if (dry_run) {
|
|
return {
|
|
success: true,
|
|
order: {
|
|
id: 0,
|
|
user_id: user.id,
|
|
spec_id: pricing_id,
|
|
status: 'pending',
|
|
region: pricing.region_code,
|
|
provider_instance_id: null,
|
|
ip_address: null,
|
|
root_password: null,
|
|
price_paid: priceKrw,
|
|
error_message: null,
|
|
provisioned_at: null,
|
|
terminated_at: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
label: label || null,
|
|
image: osImageKey,
|
|
billing_type: 'monthly',
|
|
},
|
|
dry_run_info: {
|
|
message: 'Dry run successful. No server created, no balance deducted.',
|
|
user_id: user.id,
|
|
telegram_id,
|
|
current_balance_krw: currentBalance,
|
|
price_krw: priceKrw,
|
|
remaining_balance_krw: currentBalance - priceKrw,
|
|
provider: pricing.provider_name,
|
|
instance: pricing.instance_name,
|
|
region: pricing.region_name,
|
|
},
|
|
} as ProvisionResponse;
|
|
}
|
|
|
|
// Step 5: Generate root password before creating order
|
|
const rootPassword = this.generateSecurePassword();
|
|
|
|
// Step 6: Create order with payment (atomic balance deduction + order creation)
|
|
const orderResult = await this.repo.createOrderWithPayment(
|
|
user.id,
|
|
pricing_id,
|
|
pricing.region_code,
|
|
priceKrw,
|
|
label || null,
|
|
osImageKey
|
|
);
|
|
|
|
if (!orderResult.orderId) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: orderResult.error || 'ORDER_CREATION_FAILED',
|
|
message: orderResult.error === 'INSUFFICIENT_BALANCE'
|
|
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
|
|
: 'Failed to create order',
|
|
},
|
|
};
|
|
}
|
|
|
|
const orderId = orderResult.orderId;
|
|
|
|
// Step 7: Store root password in order (encrypted in production)
|
|
await this.repo.updateOrderRootPassword(orderId, rootPassword);
|
|
|
|
// Step 8: Update order status to 'queued' and send to Queue
|
|
await this.repo.updateOrderStatus(orderId, 'provisioning');
|
|
|
|
// Send message to provision queue (root_password stored in DB, not in queue for security)
|
|
const queueMessage: ProvisionQueueMessage = {
|
|
order_id: orderId,
|
|
user_id: user.id,
|
|
pricing_id,
|
|
label: label || null,
|
|
image: osImageKey,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
await this.env.PROVISION_QUEUE.send(queueMessage);
|
|
console.log(`[ProvisioningService] Order ${orderId} queued for provisioning`);
|
|
|
|
// Step 9: Return immediately with order info
|
|
const order = await this.repo.getOrderById(orderId);
|
|
|
|
return {
|
|
success: true,
|
|
order: order!,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process provision queue message (called by Queue consumer)
|
|
* 1. Get order and pricing details
|
|
* 2. Call provider API
|
|
* 3. On success: update order to active
|
|
* 4. On failure: refund balance, update order to failed
|
|
*/
|
|
async processQueueMessage(message: ProvisionQueueMessage): Promise<void> {
|
|
const { order_id, user_id, pricing_id, label, image } = message;
|
|
console.log(`[ProvisioningService] Processing order ${order_id}`);
|
|
|
|
// Fetch order to get root_password (stored in DB for security)
|
|
const order = await this.repo.getOrderById(order_id);
|
|
if (!order || !order.root_password) {
|
|
console.error(`[ProvisioningService] Order or root_password not found for order ${order_id}`);
|
|
await this.repo.updateOrderStatus(order_id, 'failed', 'Order not found');
|
|
return;
|
|
}
|
|
const root_password = order.root_password;
|
|
|
|
// Get pricing details
|
|
const pricing = await this.repo.getPricingWithProvider(pricing_id);
|
|
if (!pricing) {
|
|
console.error(`[ProvisioningService] Pricing not found for order ${order_id}`);
|
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found');
|
|
return;
|
|
}
|
|
|
|
// Get provider (use source_provider: linode/vultr)
|
|
const provider = this.getProvider(pricing.source_provider);
|
|
if (!provider) {
|
|
console.error(`[ProvisioningService] Provider ${pricing.source_provider} not configured for order ${order_id}`);
|
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `Provider ${pricing.source_provider} not configured`);
|
|
return;
|
|
}
|
|
|
|
// Get OS image details from DB
|
|
const osImage = await this.repo.getOsImageByKey(image);
|
|
if (!osImage) {
|
|
console.error(`[ProvisioningService] OS image '${image}' not found for order ${order_id}`);
|
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image '${image}' not found`);
|
|
return;
|
|
}
|
|
|
|
// Get provider-specific OS image ID
|
|
const osImageId = pricing.source_provider === 'linode'
|
|
? osImage.linode_image_id
|
|
: String(osImage.vultr_os_id);
|
|
|
|
if (!osImageId) {
|
|
console.error(`[ProvisioningService] OS image '${image}' not available for ${pricing.source_provider}`);
|
|
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image not available for ${pricing.source_provider}`);
|
|
return;
|
|
}
|
|
|
|
// Call provider API (use source_region_code for actual provider region)
|
|
const createResult = await provider.createServer({
|
|
plan: pricing.instance_id,
|
|
region: pricing.source_region_code,
|
|
osImage: osImageId,
|
|
label: label || `order-${order_id}`,
|
|
rootPassword: root_password,
|
|
tags: [`user:${user_id}`, `order:${order_id}`],
|
|
});
|
|
|
|
if (!createResult.success) {
|
|
console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error);
|
|
await this.repo.rollbackOrder(
|
|
order_id,
|
|
user_id,
|
|
order.price_paid,
|
|
createResult.error?.message || 'Provider API error'
|
|
);
|
|
// Throw error to trigger retry
|
|
throw new Error(createResult.error?.message || 'Provider API error');
|
|
}
|
|
|
|
// Wait for IP assignment if not immediately available
|
|
let ipv4 = createResult.ipv4;
|
|
if (!ipv4 && createResult.instanceId) {
|
|
console.log(`[ProvisioningService] Waiting for IP assignment for order ${order_id}...`);
|
|
const readyResult = await this.waitForServerReady(provider, createResult.instanceId);
|
|
ipv4 = readyResult.ipv4;
|
|
if (!ipv4) {
|
|
console.warn(`[ProvisioningService] IP not assigned within timeout for order ${order_id}`);
|
|
}
|
|
}
|
|
|
|
// Success - update order with provider info
|
|
await this.repo.updateOrderProviderInfo(
|
|
order_id,
|
|
createResult.instanceId!,
|
|
ipv4 || null,
|
|
root_password
|
|
);
|
|
|
|
console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`);
|
|
}
|
|
|
|
/**
|
|
* Get provider instance by name
|
|
*/
|
|
private getProvider(providerName: VPSProvider): LinodeProvider | VultrProvider | null {
|
|
switch (providerName) {
|
|
case 'linode':
|
|
return this.linodeProvider;
|
|
case 'vultr':
|
|
return this.vultrProvider;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for server to be ready with IP assigned
|
|
* Polls getServerStatus every 5 seconds for up to 2 minutes
|
|
*/
|
|
private async waitForServerReady(
|
|
provider: LinodeProvider | VultrProvider,
|
|
instanceId: string,
|
|
maxWaitMs: number = 120000
|
|
): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> {
|
|
const startTime = Date.now();
|
|
const pollInterval = 5000;
|
|
|
|
while (Date.now() - startTime < maxWaitMs) {
|
|
const status = await provider.getServerStatus(instanceId);
|
|
|
|
if (status.ipv4) {
|
|
return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 };
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
}
|
|
|
|
return { ready: false };
|
|
}
|
|
|
|
/**
|
|
* Generate cryptographically secure password
|
|
*/
|
|
private generateSecurePassword(length: number = 32): string {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
|
const array = new Uint8Array(length);
|
|
crypto.getRandomValues(array);
|
|
let password = '';
|
|
for (let i = 0; i < length; i++) {
|
|
password += chars[array[i] % chars.length];
|
|
}
|
|
return password;
|
|
}
|
|
|
|
// ============================================
|
|
// Additional Operations
|
|
// ============================================
|
|
|
|
/**
|
|
* Get user's server orders by telegram_id
|
|
*/
|
|
async getUserOrders(telegramId: string, limit: number = 20): Promise<ServerOrder[]> {
|
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
|
if (!user) return [];
|
|
return this.repo.getOrdersByUserId(user.id, limit);
|
|
}
|
|
|
|
/**
|
|
* Get order by ID
|
|
*/
|
|
async getOrder(orderId: string): Promise<ServerOrder | null> {
|
|
const numericId = parseInt(orderId, 10);
|
|
if (isNaN(numericId)) return null;
|
|
return this.repo.getOrderById(numericId);
|
|
}
|
|
|
|
/**
|
|
* Get user balance by telegram_id (in KRW)
|
|
*/
|
|
async getUserBalance(telegramId: string): Promise<{ balance_krw: number; user_id: number } | null> {
|
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
|
if (!user) return null;
|
|
const balance = await this.repo.getBalance(user.id);
|
|
return { balance_krw: balance, user_id: user.id };
|
|
}
|
|
|
|
/**
|
|
* Get available OS images
|
|
*/
|
|
async getOsImages() {
|
|
return this.repo.getActiveOsImages();
|
|
}
|
|
|
|
/**
|
|
* Delete a server (terminate) - requires telegram_id for authorization
|
|
*/
|
|
async deleteServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> {
|
|
const user = await this.repo.getUserByTelegramId(telegramId);
|
|
if (!user) {
|
|
return { success: false, error: 'User not found' };
|
|
}
|
|
|
|
const numericOrderId = parseInt(orderId, 10);
|
|
if (isNaN(numericOrderId)) {
|
|
return { success: false, error: 'Invalid order ID' };
|
|
}
|
|
|
|
const order = await this.repo.getOrderById(numericOrderId);
|
|
|
|
if (!order) {
|
|
return { success: false, error: 'Order not found' };
|
|
}
|
|
|
|
if (order.user_id !== user.id) {
|
|
return { success: false, error: 'Unauthorized' };
|
|
}
|
|
|
|
if (order.status !== 'active') {
|
|
return { success: false, error: 'Server is not active' };
|
|
}
|
|
|
|
if (!order.provider_instance_id) {
|
|
return { success: false, error: 'No provider instance ID' };
|
|
}
|
|
|
|
// Get provider from pricing info
|
|
const pricing = await this.repo.getPricingWithProvider(order.spec_id);
|
|
if (!pricing) {
|
|
return { success: false, error: 'Pricing info not found' };
|
|
}
|
|
|
|
const provider = this.getProvider(pricing.provider_name.toLowerCase() as VPSProvider);
|
|
if (!provider) {
|
|
return { success: false, error: 'Provider not configured' };
|
|
}
|
|
|
|
const deleteResult = await provider.deleteServer(order.provider_instance_id);
|
|
|
|
if (!deleteResult.success) {
|
|
return { success: false, error: deleteResult.error };
|
|
}
|
|
|
|
await this.repo.updateOrderStatus(numericOrderId, 'terminated');
|
|
|
|
return { success: true };
|
|
}
|
|
}
|