Files
cloud-orchestrator/src/services/provisioning-service.ts
kappa 1c65c02045 feat: add GET /api/provision/images endpoint
- 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>
2026-01-28 10:35:16 +09:00

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