- Add start/stop/reboot endpoints for server power management - Add D1-based logging system (logs table + db-logger utility) - Add idempotency_key validation for order deduplication - Extend VPS provider interface with lifecycle methods Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
/**
|
|
* 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, OsImage } 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,
|
|
telegramUserId: string,
|
|
specId: number,
|
|
region: string,
|
|
pricePaid: number,
|
|
label: string | null,
|
|
image: string | null,
|
|
idempotencyKey: string | null
|
|
): Promise<ServerOrder> {
|
|
const result = await this.userDb
|
|
.prepare(
|
|
`INSERT INTO server_orders
|
|
(user_id, telegram_user_id, spec_id, status, region, price_paid, label, image, billing_type, idempotency_key, created_at, expires_at)
|
|
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, 'monthly', ?, CURRENT_TIMESTAMP, datetime(CURRENT_TIMESTAMP, '+720 hours'))
|
|
RETURNING *`
|
|
)
|
|
.bind(userId, telegramUserId, specId, region, pricePaid, label, image, idempotencyKey)
|
|
.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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update order with root password
|
|
*
|
|
* SECURITY WARNING: Password is currently stored in plaintext.
|
|
* TODO: Implement encryption using WebCrypto API (AES-GCM) before production use.
|
|
* The encryption key should be stored in env.ENCRYPTION_KEY secret.
|
|
*/
|
|
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
|
|
// TODO: Encrypt password before storage
|
|
// const encryptedPassword = await this.encryptPassword(rootPassword, env.ENCRYPTION_KEY);
|
|
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[]> {
|
|
// Query uses two databases:
|
|
// - this.userDb (telegram-conversations): server_orders
|
|
// - this.db (cloud-instances-db): anvil_pricing, anvil_instances
|
|
|
|
// Step 1: Get orders from user DB
|
|
const ordersResult = await this.userDb
|
|
.prepare(
|
|
`SELECT * FROM server_orders
|
|
WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled')
|
|
ORDER BY created_at DESC LIMIT ?`
|
|
)
|
|
.bind(userId, limit)
|
|
.all();
|
|
|
|
const orders = ordersResult.results as unknown as ServerOrder[];
|
|
|
|
if (orders.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Step 2: Get spec details from cloud-instances-db
|
|
const specIds = orders.map(o => o.spec_id);
|
|
const placeholders = specIds.map(() => '?').join(',');
|
|
|
|
const specsResult = await this.db
|
|
.prepare(
|
|
`SELECT
|
|
ap.id as spec_id,
|
|
ai.vcpus as vcpu,
|
|
ai.memory_gb,
|
|
ai.disk_gb,
|
|
ai.transfer_tb as bandwidth_tb,
|
|
ai.display_name as spec_name
|
|
FROM anvil_pricing ap
|
|
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
|
|
WHERE ap.id IN (${placeholders})`
|
|
)
|
|
.bind(...specIds)
|
|
.all();
|
|
|
|
// Step 3: Create a map for efficient lookup
|
|
const specsMap = new Map(
|
|
(specsResult.results as unknown as any[]).map(s => [s.spec_id, s])
|
|
);
|
|
|
|
// Step 4: Merge spec details into orders
|
|
return orders.map(order => {
|
|
const spec = specsMap.get(order.spec_id);
|
|
return {
|
|
...order,
|
|
vcpu: spec?.vcpu,
|
|
memory_gb: spec?.memory_gb,
|
|
disk_gb: spec?.disk_gb,
|
|
bandwidth_tb: spec?.bandwidth_tb,
|
|
spec_name: spec?.spec_name
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find order by idempotency key (for duplicate prevention)
|
|
*/
|
|
async findOrderByIdempotencyKey(idempotencyKey: string): Promise<ServerOrder | null> {
|
|
const result = await this.userDb
|
|
.prepare('SELECT * FROM server_orders WHERE idempotency_key = ?')
|
|
.bind(idempotencyKey)
|
|
.first();
|
|
|
|
return result as unknown as ServerOrder | null;
|
|
}
|
|
|
|
// ============================================
|
|
// 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,
|
|
CASE ar.source_provider
|
|
WHEN 'linode' THEN 'https://api.linode.com/v4'
|
|
WHEN 'vultr' THEN 'https://api.vultr.com/v2'
|
|
ELSE 'https://api.anvil.cloud'
|
|
END as api_base_url,
|
|
it.instance_id 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,
|
|
ar.source_provider,
|
|
ar.source_region_code
|
|
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
|
|
JOIN instance_types it ON ap.source_instance_id = it.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,
|
|
telegramUserId: string,
|
|
specId: number,
|
|
region: string,
|
|
priceKrw: number,
|
|
label: string | null,
|
|
image: string | null,
|
|
idempotencyKey: 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,
|
|
telegramUserId,
|
|
specId,
|
|
region,
|
|
priceKrw,
|
|
label,
|
|
image,
|
|
idempotencyKey
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
// ============================================
|
|
// 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;
|
|
}
|
|
}
|