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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 10:31:14 +09:00
parent 7d9edc14a3
commit 3c420d2841
5 changed files with 164 additions and 11 deletions

View File

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

View File

@@ -47,9 +47,13 @@ function validateProvisionRequest(body: unknown): {
return { valid: false, error: 'label must be 64 characters or less' }; return { valid: false, error: 'label must be 64 characters or less' };
} }
const validOsImages = ['ubuntu_22_04', 'ubuntu_20_04', 'debian_11', 'debian_12']; // Basic type validation for image - actual validation against DB done in ProvisioningService
if (data.image !== undefined && !validOsImages.includes(data.image as string)) { if (data.image !== undefined && typeof data.image !== 'string') {
return { valid: false, error: `image must be one of: ${validOsImages.join(', ')}` }; 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') { if (data.dry_run !== undefined && typeof data.dry_run !== 'boolean') {

View File

@@ -5,7 +5,7 @@
* - USER_DB (telegram-conversations): users, deposits, orders * - 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 { export class ProvisioningRepository {
constructor( constructor(
@@ -276,4 +276,58 @@ export class ProvisioningRepository {
await this.refundBalance(userId, amount); await this.refundBalance(userId, amount);
await this.updateOrderStatus(orderId, 'failed', errorMessage); await this.updateOrderStatus(orderId, 'failed', errorMessage);
} }
// ============================================
// OS Image Operations (cloud-instances-db.os_images)
// ============================================
/**
* Get all active OS images
*/
async getActiveOsImages(): Promise<OsImage[]> {
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<OsImage | null> {
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<OsImage | null> {
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<boolean> {
const result = await this.db
.prepare('SELECT 1 FROM os_images WHERE key = ? AND active = 1')
.bind(key)
.first();
return result !== null;
}
} }

View File

@@ -41,9 +41,26 @@ export class ProvisioningService {
*/ */
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> { async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
const { telegram_id, pricing_id, label, image, dry_run } = request; 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); const user = await this.repo.getUserByTelegramId(telegram_id);
if (!user) { if (!user) {
return { 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); const pricing = await this.repo.getPricingWithProvider(pricing_id);
if (!pricing) { if (!pricing) {
return { 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 // Round to nearest 500 KRW for cleaner pricing
const exchangeRate = await getExchangeRate(this.env); const exchangeRate = await getExchangeRate(this.env);
const priceKrw = Math.round((pricing.monthly_price * exchangeRate) / 500) * 500; 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); const currentBalance = await this.repo.getBalance(user.id);
if (currentBalance < priceKrw) { if (currentBalance < priceKrw) {
return { return {
@@ -206,8 +223,24 @@ export class ProvisioningService {
return; return;
} }
// Get OS image ID // Get OS image details from DB
const osImageId = provider.getOsImageId(image); 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) // Call provider API (use source_region_code for actual provider region)
const createResult = await provider.createServer({ const createResult = await provider.createServer({

View File

@@ -335,3 +335,18 @@ export interface PricingWithProvider {
source_provider: VPSProvider; // linode | vultr source_provider: VPSProvider; // linode | vultr
source_region_code: string; // Provider's region code (ap-northeast, nrt) 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;
}