import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThanOrEqual, In } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import { Application } from '../applications/entities/application.entity'; import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { BillingService } from '../billing/billing.service'; import { AppLifecycleStatus, BillingCycle, DeploymentStatus } from '../common/enums'; /** * Manages the full lifecycle of applications based on their billing plan: * * ACTIVE ──(plan expires)──► SUSPENDED ──(grace period)──► PENDING_DELETION ──► DELETED * ▲ │ │ * └──────── payment ─────────────┘ │ * └──────── payment (if still within grace) ──────────────────────┘ * * Runs a periodic scan (configurable interval, default 60s) that: * 1. Finds ACTIVE apps whose planExpiresAt has passed → suspends them * 2. Finds SUSPENDED apps past their grace period → marks PENDING_DELETION * 3. Finds PENDING_DELETION apps past scheduledDeletionAt → fully deletes */ @Injectable() export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(AppLifecycleService.name); private scanTimer: NodeJS.Timeout | null = null; constructor( @InjectRepository(Application) private appRepo: Repository, @InjectRepository(PlatformSetting) private settingsRepo: Repository, private configService: ConfigService, private kubernetesService: KubernetesService, private billingService: BillingService, ) {} onModuleInit() { const interval = this.configService.get('lifecycle.scanIntervalMs') || 60_000; this.logger.log(`Lifecycle scanner starting — interval: ${interval / 1000}s`); this.scanTimer = setInterval(() => this.runScan(), interval); // Run once immediately after a short delay to catch up on startup setTimeout(() => this.runScan(), 5_000); } onModuleDestroy() { if (this.scanTimer) clearInterval(this.scanTimer); } // ─── Public API (called by billing on payment) ──────────────────── /** * Activate or reactivate an application after successful payment. * Calculates the new expiry based on billing cycle and scales pods back up. */ async activateApp(appId: string, billingCycle: BillingCycle, planId: string): Promise { const app = await this.appRepo.findOne({ where: { id: appId } }); if (!app) throw new Error(`Application ${appId} not found`); const now = new Date(); const expiresAt = this.calculateExpiry(now, billingCycle); app.planId = planId; app.billingCycle = billingCycle; app.lifecycleStatus = AppLifecycleStatus.ACTIVE; app.planExpiresAt = expiresAt; app.suspendedAt = null as any; app.scheduledDeletionAt = null as any; const saved = await this.appRepo.save(app); // If the app was suspended, scale it back up if (app.latestImageTag) { try { await this.kubernetesService.scaleDeployment(app, app.replicas || 1); this.logger.log(`Reactivated ${app.name} — scaled to ${app.replicas} replicas, expires ${expiresAt.toISOString()}`); } catch (e: any) { this.logger.warn(`Failed to scale up ${app.name} on reactivation: ${e.message}`); } } return saved; } /** * Renew an app for another billing period (used for auto-renew or manual renewal). */ async renewApp(appId: string): Promise { const app = await this.appRepo.findOne({ where: { id: appId } }); if (!app || !app.billingCycle) throw new Error('Application not found or has no billing cycle'); const baseDate = app.planExpiresAt && app.planExpiresAt > new Date() ? app.planExpiresAt // extend from current expiry : new Date(); // expired — extend from now app.planExpiresAt = this.calculateExpiry(baseDate, app.billingCycle); app.lifecycleStatus = AppLifecycleStatus.ACTIVE; app.suspendedAt = null as any; app.scheduledDeletionAt = null as any; return this.appRepo.save(app); } // ─── Periodic Scanner ───────────────────────────────────────────── private async runScan() { try { const now = new Date(); await this.suspendExpiredApps(now); await this.markForDeletion(now); await this.deleteExpiredApps(now); } catch (e: any) { this.logger.error(`Lifecycle scan error: ${e.message}`, e.stack); } } /** * Step 1: Find ACTIVE apps whose planExpiresAt has passed → SUSPEND */ private async suspendExpiredApps(now: Date) { const expiredApps = await this.appRepo.find({ where: { lifecycleStatus: AppLifecycleStatus.ACTIVE, planExpiresAt: LessThanOrEqual(now), }, }); for (const app of expiredApps) { if (!app.billingCycle) continue; // No billing plan — skip (free/unmanaged app) try { // For hourly plans: try auto-deduct from wallet for renewal if (app.billingCycle === BillingCycle.HOURLY) { const renewed = await this.tryAutoRenew(app); if (renewed) continue; } // Suspend: scale to 0 try { await this.kubernetesService.scaleDeployment(app, 0); } catch (e: any) { this.logger.warn(`Failed to scale down ${app.name}: ${e.message}`); } app.lifecycleStatus = AppLifecycleStatus.SUSPENDED; app.suspendedAt = now; // Calculate when deletion should happen const deleteAfterMs = await this.getDeleteGracePeriod(app.billingCycle); app.scheduledDeletionAt = new Date(now.getTime() + deleteAfterMs); await this.appRepo.save(app); this.logger.warn( `SUSPENDED ${app.name} (${app.billingCycle} plan expired). ` + `Scheduled deletion: ${app.scheduledDeletionAt.toISOString()}`, ); } catch (e: any) { this.logger.error(`Failed to suspend ${app.name}: ${e.message}`); } } } /** * Step 2: Find SUSPENDED apps past their scheduledDeletionAt → PENDING_DELETION */ private async markForDeletion(now: Date) { const apps = await this.appRepo.find({ where: { lifecycleStatus: AppLifecycleStatus.SUSPENDED, scheduledDeletionAt: LessThanOrEqual(now), }, }); for (const app of apps) { app.lifecycleStatus = AppLifecycleStatus.PENDING_DELETION; await this.appRepo.save(app); this.logger.warn(`PENDING_DELETION: ${app.name} — grace period ended, will be deleted`); } } /** * Step 3: Find PENDING_DELETION apps → fully delete from K8s and DB */ private async deleteExpiredApps(now: Date) { const apps = await this.appRepo.find({ where: { lifecycleStatus: AppLifecycleStatus.PENDING_DELETION, }, }); for (const app of apps) { try { // Delete all K8s resources await this.kubernetesService.deleteApplication(app); this.logger.log(`Deleted K8s resources for expired app ${app.name}`); } catch (e: any) { this.logger.warn(`K8s cleanup failed for expired ${app.name}: ${e.message}`); } try { // Mark as deleted (soft-delete approach — keep record for audit) app.lifecycleStatus = AppLifecycleStatus.DELETED; await this.appRepo.save(app); this.logger.warn(`DELETED: ${app.name} — all resources removed`); } catch (e: any) { this.logger.error(`Failed to mark ${app.name} as deleted: ${e.message}`); } } } // ─── Auto-renew for hourly plans ────────────────────────────────── private async tryAutoRenew(app: Application): Promise { if (!app.planId) return false; try { const cost = await this.billingService.calculateCostForApp(app); const hourlyAmount = cost.hourly; if (hourlyAmount <= 0) return false; // Check wallet balance const { balance } = await this.billingService.getBalance(app.userId); if (balance < hourlyAmount) return false; // Deduct and renew await this.billingService.deductWallet( app.userId, hourlyAmount, `Auto-renew hourly: ${app.name}`, app.id, ); app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY); app.lifecycleStatus = AppLifecycleStatus.ACTIVE; await this.appRepo.save(app); this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`); return true; } catch (e: any) { this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`); return false; } } // ─── Helpers ────────────────────────────────────────────────────── private calculateExpiry(from: Date, cycle: BillingCycle): Date { const expiry = new Date(from); switch (cycle) { case BillingCycle.HOURLY: expiry.setHours(expiry.getHours() + 1); break; case BillingCycle.MONTHLY: expiry.setMonth(expiry.getMonth() + 1); break; case BillingCycle.YEARLY: expiry.setFullYear(expiry.getFullYear() + 1); break; } return expiry; } /** * Get the grace period (ms) before a suspended app is deleted. * First checks PlatformSettings (admin-editable), then falls back to config file defaults. */ private async getDeleteGracePeriod(cycle: BillingCycle): Promise { const settingKey = `lifecycle.${cycle}.deleteAfterMs`; // Check admin-editable settings first try { const setting = await this.settingsRepo.findOne({ where: { key: settingKey } }); if (setting) return parseInt(setting.value, 10); } catch {} // Fall back to config file return this.configService.get(`lifecycle.${cycle}.deleteAfterMs`) || 24 * 3600 * 1000; } // ─── Admin API ──────────────────────────────────────────────────── async getLifecycleSettings(): Promise> { const settings = await this.settingsRepo.find({ where: { key: In([ 'lifecycle.hourly.deleteAfterMs', 'lifecycle.monthly.deleteAfterMs', 'lifecycle.yearly.deleteAfterMs', ]) }, }); const defaults = { 'lifecycle.hourly.deleteAfterMs': this.configService.get('lifecycle.hourly.deleteAfterMs'), 'lifecycle.monthly.deleteAfterMs': this.configService.get('lifecycle.monthly.deleteAfterMs'), 'lifecycle.yearly.deleteAfterMs': this.configService.get('lifecycle.yearly.deleteAfterMs'), }; const result: Record = { ...defaults }; for (const s of settings) { result[s.key] = parseInt(s.value, 10); } return { hourly: { deleteAfterMs: result['lifecycle.hourly.deleteAfterMs'], deleteAfterHours: result['lifecycle.hourly.deleteAfterMs'] / 3600000 }, monthly: { deleteAfterMs: result['lifecycle.monthly.deleteAfterMs'], deleteAfterDays: result['lifecycle.monthly.deleteAfterMs'] / 86400000 }, yearly: { deleteAfterMs: result['lifecycle.yearly.deleteAfterMs'], deleteAfterDays: result['lifecycle.yearly.deleteAfterMs'] / 86400000 }, }; } async updateLifecycleSetting(key: string, value: number, description?: string): Promise { let setting = await this.settingsRepo.findOne({ where: { key } }); if (setting) { setting.value = String(value); if (description) setting.description = description; } else { setting = this.settingsRepo.create({ key, value: String(value), description }); } return this.settingsRepo.save(setting); } }