Files
cloud-orchestrator/src/repositories/ProvisioningRepository.ts
kappa 6385b5cab6 feat: add server lifecycle management and D1 logging
- 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>
2026-01-30 08:27:34 +09:00

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;
}
}