321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
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<Application>,
|
|
@InjectRepository(PlatformSetting)
|
|
private settingsRepo: Repository<PlatformSetting>,
|
|
private configService: ConfigService,
|
|
private kubernetesService: KubernetesService,
|
|
private billingService: BillingService,
|
|
) {}
|
|
|
|
onModuleInit() {
|
|
const interval = this.configService.get<number>('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<Application> {
|
|
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, resume it (scale app + database back up)
|
|
if (app.latestImageTag) {
|
|
try {
|
|
await this.kubernetesService.resumeApplication(app);
|
|
this.logger.log(`Reactivated ${app.name} — resumed with ${app.replicas} replicas, expires ${expiresAt.toISOString()}`);
|
|
} catch (e: any) {
|
|
this.logger.warn(`Failed to resume ${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<Application> {
|
|
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 app and database to 0
|
|
try {
|
|
await this.kubernetesService.suspendApplication(app);
|
|
} catch (e: any) {
|
|
this.logger.warn(`Failed to suspend ${app.name} in K8s: ${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<boolean> {
|
|
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<number> {
|
|
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<number>(`lifecycle.${cycle}.deleteAfterMs`) || 24 * 3600 * 1000;
|
|
}
|
|
|
|
// ─── Admin API ────────────────────────────────────────────────────
|
|
|
|
async getLifecycleSettings(): Promise<Record<string, any>> {
|
|
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<number>('lifecycle.hourly.deleteAfterMs'),
|
|
'lifecycle.monthly.deleteAfterMs': this.configService.get<number>('lifecycle.monthly.deleteAfterMs'),
|
|
'lifecycle.yearly.deleteAfterMs': this.configService.get<number>('lifecycle.yearly.deleteAfterMs'),
|
|
};
|
|
|
|
const result: Record<string, any> = { ...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<PlatformSetting> {
|
|
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);
|
|
}
|
|
}
|