feat(lifecycle): add billing lifecycle with auto-suspend/delete
- New AppLifecycleService with cron-based scanner (60s interval) - State machine: ACTIVE → SUSPENDED → PENDING_DELETION → DELETED - Auto-renew hourly plans from wallet - Admin-configurable grace periods via PlatformSettings table - New LifecycleController (GET/PATCH /lifecycle/settings) - PlatformSetting entity for runtime admin config - BillingService: calculateCostForApp, deductWallet - Application entity: planId, billingCycle, lifecycleStatus, planExpiresAt - New enums: AppLifecycleStatus, BillingCycle
This commit is contained in:
@@ -12,6 +12,7 @@ import { BuildModule } from './build/build.module';
|
|||||||
import { TicketsModule } from './tickets/tickets.module';
|
import { TicketsModule } from './tickets/tickets.module';
|
||||||
import { BillingModule } from './billing/billing.module';
|
import { BillingModule } from './billing/billing.module';
|
||||||
import { SnapshotsModule } from './snapshots/snapshots.module';
|
import { SnapshotsModule } from './snapshots/snapshots.module';
|
||||||
|
import { LifecycleModule } from './lifecycle/lifecycle.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -62,6 +63,7 @@ import configuration from './config/configuration';
|
|||||||
TicketsModule,
|
TicketsModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
SnapshotsModule,
|
SnapshotsModule,
|
||||||
|
LifecycleModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
OneToMany,
|
OneToMany,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AppRuntime, DatabaseType } from '../../common/enums';
|
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus } from '../../common/enums';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Deployment } from '../../deployments/entities/deployment.entity';
|
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
codePath: string; // Path to uploaded zip
|
codePath: string; // Path to uploaded zip
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
dbDumpPath: string; // Path to uploaded SQL dump file
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
envVars: Record<string, string>;
|
envVars: Record<string, string>;
|
||||||
|
|
||||||
@@ -105,6 +108,25 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
subdomain: string; // <subdomain>.apps.cloudhost.local
|
subdomain: string; // <subdomain>.apps.cloudhost.local
|
||||||
|
|
||||||
|
// ── Billing & Lifecycle ─────────────────────────────
|
||||||
|
@Column({ nullable: true })
|
||||||
|
planId: string; // FK to ServicePlan
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: BillingCycle, nullable: true })
|
||||||
|
billingCycle: BillingCycle; // The cycle this app was purchased under
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: AppLifecycleStatus, default: AppLifecycleStatus.ACTIVE })
|
||||||
|
lifecycleStatus: AppLifecycleStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
planExpiresAt: Date; // When the current billing period ends
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
suspendedAt: Date; // When the app was suspended (pods scaled to 0)
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
scheduledDeletionAt: Date; // When the app will be permanently deleted
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
import {
|
import {
|
||||||
CreateServicePlanDto,
|
CreateServicePlanDto,
|
||||||
UpdateServicePlanDto,
|
UpdateServicePlanDto,
|
||||||
@@ -21,14 +25,18 @@ import {
|
|||||||
} from './dto/billing.dto';
|
} from './dto/billing.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole, BillingCycle, AppLifecycleStatus } from '../common/enums';
|
||||||
|
|
||||||
@ApiTags('Billing')
|
@ApiTags('Billing')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('billing')
|
@Controller('billing')
|
||||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
@Inject(forwardRef(() => AppLifecycleService))
|
||||||
|
private readonly lifecycleService: AppLifecycleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
// ─── Service Plans (Admin) ────────────────────────────────────────
|
// ─── Service Plans (Admin) ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -98,18 +106,49 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('wallet/pay/:applicationId')
|
@Post('wallet/pay/:applicationId')
|
||||||
@ApiOperation({ summary: 'Pay for an application from wallet' })
|
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||||
async payForApplication(
|
async payForApplication(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() body: { amount: number; cycle: string },
|
@Body() body: { planId: string; cycle: string },
|
||||||
) {
|
) {
|
||||||
return this.billingService.deductWallet(
|
const cycle = body.cycle as BillingCycle;
|
||||||
|
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||||
|
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cost
|
||||||
|
const plan = await this.billingService.findPlan(body.planId);
|
||||||
|
const costs = await this.billingService.calculateCost({
|
||||||
|
runtime: plan.runtime,
|
||||||
|
databaseType: 'none', // Will be refined per-app later
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
replicas: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||||
|
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
|
: costs.yearly;
|
||||||
|
|
||||||
|
// Deduct from wallet
|
||||||
|
const tx = await this.billingService.deductWallet(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
body.amount,
|
amount,
|
||||||
`Payment for application (${body.cycle})`,
|
`Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`,
|
||||||
applicationId,
|
applicationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Activate/reactivate the application
|
||||||
|
const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transaction: tx,
|
||||||
|
application: { id: app.id, name: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt },
|
||||||
|
message: app.lifecycleStatus === AppLifecycleStatus.ACTIVE
|
||||||
|
? `Application "${app.name}" activated until ${app.planExpiresAt?.toISOString()}`
|
||||||
|
: `Payment processed — application status: ${app.lifecycleStatus}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Payment Gateway ─────────────────────────────────────────────
|
// ─── Payment Gateway ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { BillingController } from './billing.controller';
|
import { BillingController } from './billing.controller';
|
||||||
@@ -6,10 +6,13 @@ import { ServicePlan } from './entities/service-plan.entity';
|
|||||||
import { PricingRule } from './entities/pricing-rule.entity';
|
import { PricingRule } from './entities/pricing-rule.entity';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
|
import { LifecycleModule } from '../lifecycle/lifecycle.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction]),
|
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
|
||||||
|
forwardRef(() => LifecycleModule),
|
||||||
],
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [BillingService],
|
providers: [BillingService],
|
||||||
|
|||||||
@@ -288,4 +288,27 @@ export class BillingService {
|
|||||||
async getAllWallets(): Promise<Wallet[]> {
|
async getAllWallets(): Promise<Wallet[]> {
|
||||||
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost for an existing Application entity.
|
||||||
|
* Used by lifecycle service for auto-renew.
|
||||||
|
*/
|
||||||
|
async calculateCostForApp(app: {
|
||||||
|
runtime: string;
|
||||||
|
databaseType: string;
|
||||||
|
cpuLimit: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
replicas: number;
|
||||||
|
dbStorageSize?: string;
|
||||||
|
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
|
||||||
|
const result = await this.calculateCost({
|
||||||
|
runtime: app.runtime,
|
||||||
|
databaseType: app.databaseType,
|
||||||
|
cpuLimit: app.cpuLimit,
|
||||||
|
memoryLimit: app.memoryLimit,
|
||||||
|
replicas: app.replicas,
|
||||||
|
dbStorageSize: app.dbStorageSize,
|
||||||
|
});
|
||||||
|
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-value settings table editable by super-admin at runtime.
|
||||||
|
* Used for lifecycle retention periods, feature flags, etc.
|
||||||
|
*/
|
||||||
|
@Entity('platform_settings')
|
||||||
|
export class PlatformSetting {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -80,3 +80,12 @@ export enum TransactionType {
|
|||||||
DEDUCTION = 'deduction', // Payment for service
|
DEDUCTION = 'deduction', // Payment for service
|
||||||
REFUND = 'refund', // Refund
|
REFUND = 'refund', // Refund
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Application Lifecycle ─────────────────────────────
|
||||||
|
|
||||||
|
export enum AppLifecycleStatus {
|
||||||
|
ACTIVE = 'active', // Paid & running
|
||||||
|
SUSPENDED = 'suspended', // Plan expired, pods scaled to 0, data retained
|
||||||
|
PENDING_DELETION = 'pending_deletion', // Grace period — will be deleted if no payment
|
||||||
|
DELETED = 'deleted', // Fully removed from K8s and DB
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
registry: {
|
registry: {
|
||||||
url: process.env.REGISTRY_URL || 'registry.example.com',
|
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
|
||||||
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500',
|
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500',
|
||||||
username: process.env.REGISTRY_USERNAME || 'admin',
|
username: process.env.REGISTRY_USERNAME || 'admin',
|
||||||
password: process.env.REGISTRY_PASSWORD || '',
|
password: process.env.REGISTRY_PASSWORD || '',
|
||||||
@@ -38,4 +38,23 @@ export default () => ({
|
|||||||
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
||||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Lifecycle defaults (can be overridden via PlatformSettings entity by admin)
|
||||||
|
lifecycle: {
|
||||||
|
// How long after plan expires to suspend the app (scale to 0)
|
||||||
|
hourly: {
|
||||||
|
suspendAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_SUSPEND_MS || String(0), 10), // immediately on expiry
|
||||||
|
deleteAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_DELETE_MS || String(24 * 3600 * 1000), 10), // 24h after suspend
|
||||||
|
},
|
||||||
|
monthly: {
|
||||||
|
suspendAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_SUSPEND_MS || String(0), 10),
|
||||||
|
deleteAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_DELETE_MS || String(3 * 24 * 3600 * 1000), 10), // 3 days
|
||||||
|
},
|
||||||
|
yearly: {
|
||||||
|
suspendAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_SUSPEND_MS || String(0), 10),
|
||||||
|
deleteAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_DELETE_MS || String(7 * 24 * 3600 * 1000), 10), // 7 days
|
||||||
|
},
|
||||||
|
// Cron interval for the lifecycle scanner
|
||||||
|
scanIntervalMs: parseInt(process.env.LIFECYCLE_SCAN_INTERVAL_MS || String(60_000), 10), // 1 min
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
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, 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<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 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
import { AppLifecycleService } from './app-lifecycle.service';
|
||||||
|
|
||||||
|
@ApiTags('Lifecycle')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('lifecycle')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class LifecycleController {
|
||||||
|
constructor(private readonly lifecycleService: AppLifecycleService) {}
|
||||||
|
|
||||||
|
@Get('settings')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Get lifecycle retention settings (Admin)' })
|
||||||
|
async getSettings() {
|
||||||
|
return this.lifecycleService.getLifecycleSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Update lifecycle retention settings (Admin)' })
|
||||||
|
async updateSettings(
|
||||||
|
@Body() body: {
|
||||||
|
hourlyDeleteAfterMs?: number;
|
||||||
|
monthlyDeleteAfterMs?: number;
|
||||||
|
yearlyDeleteAfterMs?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const results: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (body.hourlyDeleteAfterMs !== undefined) {
|
||||||
|
results.hourly = await this.lifecycleService.updateLifecycleSetting(
|
||||||
|
'lifecycle.hourly.deleteAfterMs', body.hourlyDeleteAfterMs,
|
||||||
|
'Grace period before deleting hourly apps (ms)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.monthlyDeleteAfterMs !== undefined) {
|
||||||
|
results.monthly = await this.lifecycleService.updateLifecycleSetting(
|
||||||
|
'lifecycle.monthly.deleteAfterMs', body.monthlyDeleteAfterMs,
|
||||||
|
'Grace period before deleting monthly apps (ms)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.yearlyDeleteAfterMs !== undefined) {
|
||||||
|
results.yearly = await this.lifecycleService.updateLifecycleSetting(
|
||||||
|
'lifecycle.yearly.deleteAfterMs', body.yearlyDeleteAfterMs,
|
||||||
|
'Grace period before deleting yearly apps (ms)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updated: results, current: await this.lifecycleService.getLifecycleSettings() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||||
|
import { AppLifecycleService } from './app-lifecycle.service';
|
||||||
|
import { LifecycleController } from './lifecycle.controller';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Application, PlatformSetting]),
|
||||||
|
KubernetesModule,
|
||||||
|
forwardRef(() => BillingModule),
|
||||||
|
],
|
||||||
|
controllers: [LifecycleController],
|
||||||
|
providers: [AppLifecycleService],
|
||||||
|
exports: [AppLifecycleService],
|
||||||
|
})
|
||||||
|
export class LifecycleModule {}
|
||||||
Reference in New Issue
Block a user