From 3c420d2841ab8bfcea16e03b2b0b37844d0447a4 Mon Sep 17 00:00:00 2001 From: kappa Date: Wed, 28 Jan 2026 10:31:14 +0900 Subject: [PATCH] feat: manage OS images in database instead of hardcoded values - Add os_images table with linode_image_id and vultr_os_id columns - Support Ubuntu (24.04, 22.04), Debian (11-13), AlmaLinux (8-9), Rocky Linux (8-9), and Fedora 42 - AlmaLinux and Rocky Linux added as CentOS migration alternatives - Default OS changed from ubuntu_22_04 to ubuntu_24_04 - Fix Vultr OS IDs (1743=22.04, 2284=24.04) - Remove hardcoded OS validation, validate against DB - Return available OS list in error message for invalid image Migration: migrations/003_os_images.sql Co-Authored-By: Claude Opus 4.5 --- migrations/003_os_images.sql | 47 ++++++++++++++++++ src/handlers/provision.ts | 10 ++-- src/repositories/ProvisioningRepository.ts | 56 +++++++++++++++++++++- src/services/provisioning-service.ts | 47 +++++++++++++++--- src/types.ts | 15 ++++++ 5 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 migrations/003_os_images.sql diff --git a/migrations/003_os_images.sql b/migrations/003_os_images.sql new file mode 100644 index 0000000..b01b262 --- /dev/null +++ b/migrations/003_os_images.sql @@ -0,0 +1,47 @@ +-- OS Images table for managing available operating systems +-- Replaces hardcoded OS_IMAGE_MAP in vps-provider.ts + +CREATE TABLE IF NOT EXISTS os_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, -- API key: 'ubuntu_24_04' + name TEXT NOT NULL, -- Display name: 'Ubuntu 24.04 LTS' + family TEXT NOT NULL, -- Family: 'ubuntu', 'debian', 'almalinux', 'rocky' + linode_image_id TEXT, -- Linode image ID: 'linode/ubuntu24.04' + vultr_os_id INTEGER, -- Vultr OS ID: 2284 + active INTEGER DEFAULT 1, -- 1=available, 0=deprecated + is_default INTEGER DEFAULT 0, -- 1=default OS for new servers + sort_order INTEGER DEFAULT 100, -- Lower = higher priority in list + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for common queries +CREATE INDEX IF NOT EXISTS idx_os_images_active ON os_images(active); +CREATE INDEX IF NOT EXISTS idx_os_images_family ON os_images(family); + +-- Insert OS images based on actual Linode/Vultr API data (2026-01) +-- Ubuntu LTS versions +INSERT INTO os_images (key, name, family, linode_image_id, vultr_os_id, active, is_default, sort_order) VALUES + ('ubuntu_24_04', 'Ubuntu 24.04 LTS', 'ubuntu', 'linode/ubuntu24.04', 2284, 1, 1, 10), + ('ubuntu_22_04', 'Ubuntu 22.04 LTS', 'ubuntu', 'linode/ubuntu22.04', 1743, 1, 0, 20), + ('ubuntu_20_04', 'Ubuntu 20.04 LTS', 'ubuntu', 'linode/ubuntu20.04', NULL, 0, 0, 30); + +-- Debian versions +INSERT INTO os_images (key, name, family, linode_image_id, vultr_os_id, active, sort_order) VALUES + ('debian_13', 'Debian 13 (Trixie)', 'debian', 'linode/debian13', 2625, 1, 40), + ('debian_12', 'Debian 12 (Bookworm)', 'debian', 'linode/debian12', 2136, 1, 50), + ('debian_11', 'Debian 11 (Bullseye)', 'debian', 'linode/debian11', 477, 1, 60); + +-- CentOS alternatives (AlmaLinux - most popular) +INSERT INTO os_images (key, name, family, linode_image_id, vultr_os_id, active, sort_order) VALUES + ('almalinux_9', 'AlmaLinux 9', 'almalinux', 'linode/almalinux9', 1868, 1, 70), + ('almalinux_8', 'AlmaLinux 8', 'almalinux', 'linode/almalinux8', 452, 1, 80); + +-- Rocky Linux (another CentOS alternative) +INSERT INTO os_images (key, name, family, linode_image_id, vultr_os_id, active, sort_order) VALUES + ('rocky_9', 'Rocky Linux 9', 'rocky', 'linode/rocky9', 1869, 1, 90), + ('rocky_8', 'Rocky Linux 8', 'rocky', 'linode/rocky8', 448, 1, 100); + +-- Fedora (for cutting-edge users) +INSERT INTO os_images (key, name, family, linode_image_id, vultr_os_id, active, sort_order) VALUES + ('fedora_42', 'Fedora 42', 'fedora', 'linode/fedora42', 2572, 1, 110); diff --git a/src/handlers/provision.ts b/src/handlers/provision.ts index 5b3c0bc..6248d11 100644 --- a/src/handlers/provision.ts +++ b/src/handlers/provision.ts @@ -47,9 +47,13 @@ function validateProvisionRequest(body: unknown): { return { valid: false, error: 'label must be 64 characters or less' }; } - const validOsImages = ['ubuntu_22_04', 'ubuntu_20_04', 'debian_11', 'debian_12']; - if (data.image !== undefined && !validOsImages.includes(data.image as string)) { - return { valid: false, error: `image must be one of: ${validOsImages.join(', ')}` }; + // Basic type validation for image - actual validation against DB done in ProvisioningService + if (data.image !== undefined && typeof data.image !== 'string') { + return { valid: false, error: 'image must be a string' }; + } + + if (data.image && (data.image as string).length > 50) { + return { valid: false, error: 'image key must be 50 characters or less' }; } if (data.dry_run !== undefined && typeof data.dry_run !== 'boolean') { diff --git a/src/repositories/ProvisioningRepository.ts b/src/repositories/ProvisioningRepository.ts index 3fd78e7..794f190 100644 --- a/src/repositories/ProvisioningRepository.ts +++ b/src/repositories/ProvisioningRepository.ts @@ -5,7 +5,7 @@ * - USER_DB (telegram-conversations): users, deposits, orders */ -import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider } from '../types'; +import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider, OsImage } from '../types'; export class ProvisioningRepository { constructor( @@ -276,4 +276,58 @@ export class ProvisioningRepository { await this.refundBalance(userId, amount); await this.updateOrderStatus(orderId, 'failed', errorMessage); } + + // ============================================ + // OS Image Operations (cloud-instances-db.os_images) + // ============================================ + + /** + * Get all active OS images + */ + async getActiveOsImages(): Promise { + const result = await this.db + .prepare( + `SELECT * FROM os_images + WHERE active = 1 + ORDER BY sort_order` + ) + .all(); + + return result.results as unknown as OsImage[]; + } + + /** + * Get OS image by key + */ + async getOsImageByKey(key: string): Promise { + const result = await this.db + .prepare('SELECT * FROM os_images WHERE key = ? AND active = 1') + .bind(key) + .first(); + + return result as unknown as OsImage | null; + } + + /** + * Get default OS image + */ + async getDefaultOsImage(): Promise { + const result = await this.db + .prepare('SELECT * FROM os_images WHERE is_default = 1 AND active = 1') + .first(); + + return result as unknown as OsImage | null; + } + + /** + * Validate OS image key exists and is active + */ + async isValidOsImage(key: string): Promise { + const result = await this.db + .prepare('SELECT 1 FROM os_images WHERE key = ? AND active = 1') + .bind(key) + .first(); + + return result !== null; + } } diff --git a/src/services/provisioning-service.ts b/src/services/provisioning-service.ts index 342c4e7..d2c0c2b 100644 --- a/src/services/provisioning-service.ts +++ b/src/services/provisioning-service.ts @@ -41,9 +41,26 @@ export class ProvisioningService { */ async provisionServer(request: ProvisionRequest): Promise { const { telegram_id, pricing_id, label, image, dry_run } = request; - const osImageKey = image || 'ubuntu_22_04'; - // Step 1: Validate user by telegram_id + // 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 { @@ -52,7 +69,7 @@ export class ProvisioningService { }; } - // Step 2: Get pricing details with provider info + // Step 4: Get pricing details with provider info const pricing = await this.repo.getPricingWithProvider(pricing_id); if (!pricing) { return { @@ -61,12 +78,12 @@ export class ProvisioningService { }; } - // Step 3: Calculate price in KRW using real-time exchange rate + // 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 4: Check balance + // Step 6: Check balance const currentBalance = await this.repo.getBalance(user.id); if (currentBalance < priceKrw) { return { @@ -206,8 +223,24 @@ export class ProvisioningService { return; } - // Get OS image ID - const osImageId = provider.getOsImageId(image); + // 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({ diff --git a/src/types.ts b/src/types.ts index e880d43..f5d3024 100644 --- a/src/types.ts +++ b/src/types.ts @@ -335,3 +335,18 @@ export interface PricingWithProvider { source_provider: VPSProvider; // linode | vultr source_region_code: string; // Provider's region code (ap-northeast, nrt) } + +// OS Images (cloud-instances-db.os_images) +export interface OsImage { + id: number; + key: string; // API key: 'ubuntu_24_04' + name: string; // Display name: 'Ubuntu 24.04 LTS' + family: string; // Family: 'ubuntu', 'debian', 'almalinux', 'rocky' + linode_image_id: string | null; // Linode image ID: 'linode/ubuntu24.04' + vultr_os_id: number | null; // Vultr OS ID: 2284 + active: number; // 1=available, 0=deprecated + is_default: number; // 1=default OS for new servers + sort_order: number; + created_at: string; + updated_at: string; +}