feat: add Queue-based async server provisioning

- Add Cloudflare Queue for async server provisioning workflow
- Implement VPS provider abstraction (Linode, Vultr)
- Add provisioning API endpoints with API key authentication
- Fix race condition in balance deduction (atomic query)
- Remove root_password from Queue for security (fetch from DB)
- Add IP assignment wait logic after server creation
- Add rollback/refund on all failure cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-27 17:19:19 +09:00
parent 8c543eeaa5
commit 9b51b8d427
12 changed files with 1796 additions and 5 deletions

View File

@@ -0,0 +1,272 @@
/**
* Repository for provisioning-related database operations
* Uses two databases:
* - DB (cloud-instances-db): server specs, pricing, providers
* - USER_DB (telegram-conversations): users, deposits, orders
*/
import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider } from '../types';
export class ProvisioningRepository {
constructor(
private db: D1Database, // cloud-instances-db
private userDb: D1Database // telegram-conversations
) {}
// ============================================
// User Operations (telegram-conversations.users)
// ============================================
async getUserByTelegramId(telegramId: string): Promise<TelegramUser | null> {
const result = await this.userDb
.prepare('SELECT * FROM users WHERE telegram_id = ?')
.bind(telegramId)
.first();
return result as unknown as TelegramUser | null;
}
async getUserById(userId: number): Promise<TelegramUser | null> {
const result = await this.userDb
.prepare('SELECT * FROM users WHERE id = ?')
.bind(userId)
.first();
return result as unknown as TelegramUser | null;
}
// ============================================
// Deposit Operations (telegram-conversations.user_deposits)
// Balance is in KRW (원), INTEGER type
// ============================================
async getUserDeposit(userId: number): Promise<UserDeposit | null> {
const result = await this.userDb
.prepare('SELECT * FROM user_deposits WHERE user_id = ?')
.bind(userId)
.first();
return result as unknown as UserDeposit | null;
}
async getBalance(userId: number): Promise<number> {
const deposit = await this.getUserDeposit(userId);
return deposit?.balance ?? 0;
}
async deductBalance(userId: number, amount: number): Promise<boolean> {
// Atomic balance deduction - prevents race condition
const result = await this.userDb
.prepare(
`UPDATE user_deposits
SET balance = balance - ?, updated_at = datetime('now')
WHERE user_id = ? AND balance >= ?
RETURNING balance`
)
.bind(amount, userId, amount)
.first();
// If no row returned, balance was insufficient
return result !== null;
}
async refundBalance(userId: number, amount: number): Promise<void> {
await this.userDb
.prepare(
`UPDATE user_deposits
SET balance = balance + ?, updated_at = datetime('now')
WHERE user_id = ?`
)
.bind(amount, userId)
.run();
}
// ============================================
// Server Order Operations (telegram-conversations.server_orders)
// ============================================
async createServerOrder(
userId: number,
specId: number,
region: string,
pricePaid: number,
label: string | null,
image: string | null
): Promise<ServerOrder> {
const result = await this.userDb
.prepare(
`INSERT INTO server_orders
(user_id, spec_id, status, region, price_paid, label, image, billing_type)
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly')
RETURNING *`
)
.bind(userId, specId, region, pricePaid, label, image)
.first();
return result as unknown as ServerOrder;
}
async updateOrderStatus(
orderId: number,
status: ServerOrder['status'],
errorMessage?: string
): Promise<void> {
if (errorMessage) {
await this.userDb
.prepare(
`UPDATE server_orders
SET status = ?, error_message = ?, updated_at = datetime('now')
WHERE id = ?`
)
.bind(status, errorMessage, orderId)
.run();
} else {
await this.userDb
.prepare(
`UPDATE server_orders
SET status = ?, updated_at = datetime('now')
WHERE id = ?`
)
.bind(status, orderId)
.run();
}
}
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
await this.userDb
.prepare(
`UPDATE server_orders
SET root_password = ?, updated_at = datetime('now')
WHERE id = ?`
)
.bind(rootPassword, orderId)
.run();
}
async updateOrderProviderInfo(
orderId: number,
providerInstanceId: string,
ipAddress: string | null,
rootPassword: string
): Promise<void> {
await this.userDb
.prepare(
`UPDATE server_orders
SET provider_instance_id = ?,
ip_address = ?,
root_password = ?,
status = 'active',
provisioned_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ?`
)
.bind(providerInstanceId, ipAddress, rootPassword, orderId)
.run();
}
async getOrderById(orderId: number): Promise<ServerOrder | null> {
const result = await this.userDb
.prepare('SELECT * FROM server_orders WHERE id = ?')
.bind(orderId)
.first();
return result as unknown as ServerOrder | null;
}
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
const result = await this.userDb
.prepare(
'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'
)
.bind(userId, limit)
.all();
return result.results as unknown as ServerOrder[];
}
// ============================================
// Pricing Lookup (cloud-instances-db)
// Uses anvil_pricing, anvil_instances, anvil_regions
// ============================================
async getPricingWithProvider(pricingId: number): Promise<PricingWithProvider | null> {
const result = await this.db
.prepare(
`SELECT
ap.id as pricing_id,
0 as provider_id,
'Anvil' as provider_name,
'https://api.anvil.cloud' as api_base_url,
ai.name as instance_id,
ai.display_name as instance_name,
ar.name as region_code,
ar.display_name as region_name,
ap.monthly_price,
ai.vcpus as vcpu,
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
ai.disk_gb as storage_gb
FROM anvil_pricing ap
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
WHERE ap.id = ? AND ai.active = 1 AND ar.active = 1`
)
.bind(pricingId)
.first();
return result as unknown as PricingWithProvider | null;
}
// ============================================
// Atomic Operations (balance deduction + order creation)
// ============================================
/**
* Deduct balance and create order atomically
* Returns order ID on success, null on insufficient balance
*/
async createOrderWithPayment(
userId: number,
specId: number,
region: string,
priceKrw: number,
label: string | null,
image: string | null
): Promise<{ orderId: number | null; error?: string }> {
try {
// Step 1: Check and deduct balance
const deducted = await this.deductBalance(userId, priceKrw);
if (!deducted) {
return { orderId: null, error: 'INSUFFICIENT_BALANCE' };
}
// Step 2: Create order
const order = await this.createServerOrder(
userId,
specId,
region,
priceKrw,
label,
image
);
return { orderId: order.id };
} catch (error) {
// Attempt to refund on error
try {
await this.refundBalance(userId, priceKrw);
} catch {
// Log but don't throw - manual intervention needed
console.error('[ProvisioningRepository] Failed to refund on error:', error);
}
return { orderId: null, error: 'ORDER_CREATION_FAILED' };
}
}
/**
* Refund balance and update order status on provisioning failure
*/
async rollbackOrder(orderId: number, userId: number, amount: number, errorMessage: string): Promise<void> {
await this.refundBalance(userId, amount);
await this.updateOrderStatus(orderId, 'failed', errorMessage);
}
}