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:
@@ -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') {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,26 @@ export class ProvisioningService {
|
||||
*/
|
||||
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
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({
|
||||
|
||||
15
src/types.ts
15
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user