add optinal apps

This commit is contained in:
keyhan
2026-04-23 15:26:49 +03:30
parent f481a57d8f
commit 38748b0827
16 changed files with 3091 additions and 144 deletions
@@ -136,6 +136,50 @@ export class ApplicationsController {
return result;
}
@Get(':id/storage')
@ApiOperation({ summary: 'Get comprehensive storage usage (database + app storage)' })
async getStorageUsage(@Param('id') id: string, @Request() req: any) {
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
const usage = await this.kubernetesService.getStorageUsage(app);
return {
applicationId: app.id,
applicationName: app.name,
...usage,
// Configured sizes from entity
configured: {
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
},
};
}
@Patch(':id/app-storage')
@ApiOperation({ summary: 'Resize (expand) app storage PVC' })
async resizeAppStorage(
@Param('id') id: string,
@Request() req: any,
@Body() body: { size: string },
) {
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
if (!body.size || !/^\d+Gi$/.test(body.size)) {
throw new BadRequestException('Size must be in format like "2Gi", "5Gi", "10Gi"');
}
const result = await this.kubernetesService.resizeAppStoragePvc(app, body.size);
if (result.success) {
// Update the saved size in the DB
await this.applicationsService.update(id, app.userId, { appStorageSize: body.size } as any);
}
return result;
}
@Get()
@ApiOperation({ summary: 'List my applications' })
async findAll(@Request() req: any) {
@@ -4,6 +4,8 @@ import {
IsOptional,
IsNumber,
IsObject,
IsBoolean,
IsArray,
Min,
Max,
Matches,
@@ -62,6 +64,33 @@ export class CreateApplicationDto {
@IsString()
dbStorageSize?: string;
@ApiPropertyOptional({ example: '2Gi', description: 'App PVC storage size for uploads/wp-content (e.g. 2Gi, 5Gi). Default: 2Gi' })
@IsOptional()
@IsString()
appStorageSize?: string;
// ── Optional Services ─────────────────────────────
@ApiPropertyOptional({ example: false, description: 'Enable Redis for caching' })
@IsOptional()
@IsBoolean()
enableRedis?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' })
@IsOptional()
@IsBoolean()
enableRabbitmq?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' })
@IsOptional()
@IsBoolean()
enableElasticsearch?: boolean;
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' })
@IsOptional()
@IsArray()
@IsString({ each: true })
logPaths?: string[];
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
@IsOptional()
@IsString()
@@ -177,6 +206,38 @@ export class UpdateApplicationDto {
@Min(1)
@Max(10)
replicas?: number;
@ApiPropertyOptional({ example: '5Gi', description: 'Database PVC storage size' })
@IsOptional()
@IsString()
dbStorageSize?: string;
@ApiPropertyOptional({ example: '5Gi', description: 'App PVC storage size for uploads/wp-content' })
@IsOptional()
@IsString()
appStorageSize?: string;
// ── Optional Services ─────────────────────────────
@ApiPropertyOptional({ example: false, description: 'Enable Redis' })
@IsOptional()
@IsBoolean()
enableRedis?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ' })
@IsOptional()
@IsBoolean()
enableRabbitmq?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch logging' })
@IsOptional()
@IsBoolean()
enableElasticsearch?: boolean;
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths' })
@IsOptional()
@IsArray()
@IsString({ each: true })
logPaths?: string[];
}
export class ScaleResourcesDto {
@@ -30,10 +30,10 @@ export class Application {
databaseType: DatabaseType;
@Column({ nullable: true })
runtimeVersion: string; // e.g. node: '20', '18', '16' | laravel php: '8.3', '8.2' | wordpress: '6.7', '6.6'
runtimeVersion: string; // node: '20','18','16' | laravel/wordpress php: '8.3','8.2' | go: '1.22','1.21' | python/django: '3.12','3.11' | dotnet: '8.0','7.0'
@Column({ nullable: true })
phpVersion: string; // PHP version for Laravel/WordPress (e.g. '8.3', '8.2', '8.1')
phpVersion: string; // PHP version for Laravel/WordPress/PHP (e.g. '8.3', '8.2', '8.1')
@Column({ nullable: true })
dbVersion: string; // e.g. postgres: '17', '16', '15' | mysql: '9.0', '8.4', '8.0'
@@ -47,6 +47,22 @@ export class Application {
@Column({ nullable: true, default: '1Gi' })
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
@Column({ nullable: true, default: '2Gi' })
appStorageSize: string; // PVC storage size for app files (wp-content, uploads)
// ── Optional Services ─────────────────────────────
@Column({ default: false })
enableRedis: boolean;
@Column({ default: false })
enableRabbitmq: boolean;
@Column({ default: false })
enableElasticsearch: boolean; // For application logging
@Column({ type: 'jsonb', nullable: true })
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
@Column({ nullable: true })
gitUrl: string;
+273
View File
@@ -12,16 +12,22 @@ import {
BadRequestException,
Inject,
forwardRef,
ForbiddenException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import {
CreateServicePlanDto,
UpdateServicePlanDto,
ChargeWalletDto,
CalculateCostDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
} from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@@ -36,6 +42,10 @@ export class BillingController {
private readonly billingService: BillingService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
@Inject(forwardRef(() => KubernetesService))
private readonly kubernetesService: KubernetesService,
) {}
// ─── Service Plans (Admin) ────────────────────────────────────────
@@ -208,4 +218,267 @@ export class BillingController {
) {
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
}
// ─── Application Renewal ──────────────────────────────────────────
@Get('applications/:applicationId/renewal-cost')
@ApiOperation({ summary: 'Get renewal cost for an application' })
async getRenewalCost(
@Request() req: any,
@Param('applicationId') applicationId: string,
) {
// User can only view their own app, admin/sales can view any
const app = await this.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
return {
applicationId: app.id,
applicationName: app.name,
lifecycleStatus: app.lifecycleStatus,
planExpiresAt: app.planExpiresAt,
currentCycle: app.billingCycle,
costs,
};
}
@Post('applications/:applicationId/renew')
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
async renewApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
// User can only renew their own app, admin/sales can renew any
const app = await this.getAppWithAccess(req.user, applicationId);
// Calculate cost for the selected cycle
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
if (amount <= 0) {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
// Deduct from wallet (user's wallet for user, app owner's wallet for admin action)
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
: req.user.id;
const tx = await this.billingService.deductWallet(
walletUserId,
amount,
`Renewal for ${app.name} (${dto.cycle})`,
app.id,
);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle, app.planId || '');
return {
success: true,
transaction: tx,
application: {
id: renewedApp.id,
name: renewedApp.name,
lifecycleStatus: renewedApp.lifecycleStatus,
planExpiresAt: renewedApp.planExpiresAt,
billingCycle: renewedApp.billingCycle,
},
message: `Application "${renewedApp.name}" renewed until ${renewedApp.planExpiresAt?.toISOString()}`,
};
}
@Post('admin/applications/:applicationId/renew')
@Roles(UserRole.ADMIN, UserRole.SALES)
@ApiOperation({ summary: 'Admin/Sales: Renew an application (can bypass wallet if needed)' })
async adminRenewApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { cycle: string; bypassPayment?: boolean; reason?: string },
) {
const app = await this.applicationsService.findOne(applicationId);
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
}
if (body.bypassPayment) {
// Direct activation without payment (for special cases, support, etc.)
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
return {
success: true,
bypassedPayment: true,
reason: body.reason || 'Admin action',
application: {
id: renewedApp.id,
name: renewedApp.name,
lifecycleStatus: renewedApp.lifecycleStatus,
planExpiresAt: renewedApp.planExpiresAt,
},
message: `Application "${renewedApp.name}" renewed by admin (payment bypassed)`,
};
}
// Normal renewal - deduct from app owner's wallet
const costs = await this.billingService.calculateRenewalCost(app);
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
const tx = await this.billingService.deductWallet(
app.userId,
amount,
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
app.id,
);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
return {
success: true,
transaction: tx,
application: {
id: renewedApp.id,
name: renewedApp.name,
lifecycleStatus: renewedApp.lifecycleStatus,
planExpiresAt: renewedApp.planExpiresAt,
},
message: `Application "${renewedApp.name}" renewed by ${req.user.role}`,
};
}
// ─── Resource Upgrade ─────────────────────────────────────────────
@Post('applications/:applicationId/upgrade/calculate')
@ApiOperation({ summary: 'Calculate cost for resource upgrade' })
async calculateUpgradeCost(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: CalculateUpgradeCostDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const result = await this.billingService.calculateUpgradeCost(app, dto);
return {
applicationId: app.id,
applicationName: app.name,
...result,
currentResources: {
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
},
newResources: {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
},
};
}
@Post('applications/:applicationId/upgrade')
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
async upgradeResources(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
// Application must be active to upgrade
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
);
}
// Calculate upgrade cost
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
: req.user.id;
await this.billingService.deductWallet(
walletUserId,
costResult.proratedAmount,
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
app.id,
);
}
// Apply the resource changes
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
});
// Update Kubernetes resources
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
// Resize app storage PVC if changed
if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize);
}
} catch (e: any) {
// Log error but don't fail - DB is updated, K8s will sync on next deploy
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
return {
success: true,
paidAmount: costResult.proratedAmount,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
dbStorageSize: updatedApp.dbStorageSize,
appStorageSize: updatedApp.appStorageSize,
},
message: costResult.proratedAmount > 0
? `Resources upgraded. Paid ${costResult.proratedAmount} Toman for remaining ${costResult.remainingHours} hours.`
: 'Resources updated (downgrade or no cost change).',
};
}
// ─── Helper Methods ───────────────────────────────────────────────
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
if (isAdminOrSales) {
return this.applicationsService.findOne(applicationId);
}
// Regular user - must own the app
return this.applicationsService.findOne(applicationId, user.id);
}
}
+4
View File
@@ -8,11 +8,15 @@ import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({
imports: [
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController],
providers: [BillingService],
+116 -3
View File
@@ -10,7 +10,9 @@ import {
CreateServicePlanDto,
UpdateServicePlanDto,
CalculateCostDto,
UpgradeResourcesDto,
} from './dto/billing.dto';
import { Application } from '../applications/entities/application.entity';
@Injectable()
export class BillingService {
@@ -133,9 +135,14 @@ export class BillingService {
// Parse resource values
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
const dbStorageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
const appStorageGb = dto.appStorageSize ? parseFloat(dto.appStorageSize.replace('Gi', '')) || 0 : 0;
const totalStorageGb = dbStorageGb + appStorageGb;
const hasDatabase = dto.databaseType !== 'none';
const replicas = dto.replicas || 1;
const hasRedis = dto.enableRedis || false;
const hasRabbitmq = dto.enableRabbitmq || false;
const hasElasticsearch = dto.enableElasticsearch || false;
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
let totalBase = 0;
@@ -158,13 +165,25 @@ export class BillingService {
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
break;
case PricingResourceType.STORAGE_PER_GB:
cost = storageGb * Number(rule.unitPrice);
label = `Storage (${storageGb} GB)`;
cost = totalStorageGb * Number(rule.unitPrice);
label = `Storage (${totalStorageGb} GB)`;
break;
case PricingResourceType.DATABASE_ADDON:
cost = hasDatabase ? Number(rule.unitPrice) : 0;
label = 'Database addon';
break;
case PricingResourceType.REDIS_ADDON:
cost = hasRedis ? Number(rule.unitPrice) : 0;
label = 'Redis addon';
break;
case PricingResourceType.RABBITMQ_ADDON:
cost = hasRabbitmq ? Number(rule.unitPrice) : 0;
label = 'RabbitMQ addon';
break;
case PricingResourceType.ELASTICSEARCH_ADDON:
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
label = 'Elasticsearch addon';
break;
}
if (cost > 0) {
@@ -300,6 +319,7 @@ export class BillingService {
memoryLimit: string;
replicas: number;
dbStorageSize?: string;
appStorageSize?: string;
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost({
runtime: app.runtime,
@@ -308,7 +328,100 @@ export class BillingService {
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
});
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
// ─── Renewal Cost Calculation ─────────────────────────────────────
/**
* Calculate renewal cost for an application.
* Returns cost for each billing cycle based on current app config.
*/
async calculateRenewalCost(app: Application): Promise<{
hourly: number;
monthly: number;
yearly: number;
currentCycle?: BillingCycle;
currentCycleCost?: number;
}> {
const costs = await this.calculateCostForApp(app);
let currentCycleCost: number | undefined;
if (app.billingCycle) {
currentCycleCost = app.billingCycle === BillingCycle.HOURLY ? costs.hourly
: app.billingCycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
}
return {
...costs,
currentCycle: app.billingCycle,
currentCycleCost,
};
}
// ─── Resource Upgrade Cost Calculation ────────────────────────────
/**
* Calculate the cost difference for a resource upgrade.
* Returns the additional cost per billing cycle.
*/
async calculateUpgradeCost(
app: Application,
newResources: UpgradeResourcesDto,
): Promise<{
currentCost: { hourly: number; monthly: number; yearly: number };
newCost: { hourly: number; monthly: number; yearly: number };
difference: { hourly: number; monthly: number; yearly: number };
proratedAmount: number;
remainingHours: number;
billingCycle: BillingCycle | null;
}> {
// Current cost
const currentCost = await this.calculateCostForApp(app);
// New cost with upgraded resources
const newCost = await this.calculateCost({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: newResources.cpuLimit || app.cpuLimit,
memoryLimit: newResources.memoryLimit || app.memoryLimit,
replicas: newResources.replicas ?? app.replicas,
dbStorageSize: newResources.dbStorageSize || app.dbStorageSize,
appStorageSize: newResources.appStorageSize || app.appStorageSize,
});
// Difference
const difference = {
hourly: newCost.hourly - currentCost.hourly,
monthly: newCost.monthly - currentCost.monthly,
yearly: newCost.yearly - currentCost.yearly,
};
// Calculate prorated amount based on remaining time in billing period
let proratedAmount = 0;
let remainingHours = 0;
if (app.planExpiresAt && app.billingCycle) {
const now = new Date();
const expiresAt = new Date(app.planExpiresAt);
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
// Only charge difference if upgrading (not downgrading)
if (difference.hourly > 0) {
proratedAmount = Math.ceil(difference.hourly * remainingHours);
}
}
return {
currentCost,
newCost,
difference,
proratedAmount,
remainingHours: Math.round(remainingHours),
billingCycle: app.billingCycle,
};
}
}
+69
View File
@@ -116,4 +116,73 @@ export class CalculateCostDto {
@IsOptional()
@IsString()
dbStorageSize?: string;
@ApiProperty({ example: '2Gi', description: 'App storage size for uploads/wp-content' })
@IsOptional()
@IsString()
appStorageSize?: string;
@ApiPropertyOptional({ example: false, description: 'Enable Redis caching' })
@IsOptional()
@IsBoolean()
enableRedis?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ message broker' })
@IsOptional()
@IsBoolean()
enableRabbitmq?: boolean;
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch logging' })
@IsOptional()
@IsBoolean()
enableElasticsearch?: boolean;
}
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
export class RenewApplicationDto {
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
@IsEnum(BillingCycle)
cycle: BillingCycle;
}
export class UpgradeResourcesDto {
@ApiPropertyOptional({ example: '500m', description: 'New CPU request' })
@IsOptional()
@IsString()
cpuRequest?: string;
@ApiPropertyOptional({ example: '1000m', description: 'New CPU limit' })
@IsOptional()
@IsString()
cpuLimit?: string;
@ApiPropertyOptional({ example: '256Mi', description: 'New memory request' })
@IsOptional()
@IsString()
memoryRequest?: string;
@ApiPropertyOptional({ example: '1Gi', description: 'New memory limit' })
@IsOptional()
@IsString()
memoryLimit?: string;
@ApiPropertyOptional({ example: 2, description: 'New replica count' })
@IsOptional()
@IsNumber()
@Min(1)
replicas?: number;
@ApiPropertyOptional({ example: '5Gi', description: 'New database storage size' })
@IsOptional()
@IsString()
dbStorageSize?: string;
@ApiPropertyOptional({ example: '5Gi', description: 'New app storage size for uploads/wp-content' })
@IsOptional()
@IsString()
appStorageSize?: string;
}
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
+293
View File
@@ -1,5 +1,298 @@
import { AppRuntime } from '../common/enums';
/**
* Tests for build service — Dockerfile generation for all runtimes
*/
// ─────────────────────────────────────────────────────────────────────────────
// Go Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Go Dockerfile generation', () => {
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const goVersion = app.runtimeVersion || '1.22';
const port = app.port || 8080;
return `FROM golang:${goVersion}-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum* ./
RUN go mod download || true
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
FROM alpine:3.19
WORKDIR /app
RUN apk --no-cache add ca-certificates tzdata
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
COPY --from=builder /app/main .
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
CMD ["./main"]
`;
}
it('should use correct Go version', () => {
const df = goDockerfile({ runtimeVersion: '1.21' });
expect(df).toContain('FROM golang:1.21-alpine');
});
it('should default to Go 1.22', () => {
const df = goDockerfile({});
expect(df).toContain('FROM golang:1.22-alpine');
});
it('should build static binary with CGO_ENABLED=0', () => {
const df = goDockerfile({});
expect(df).toContain('CGO_ENABLED=0');
});
it('should use multi-stage build for smaller image', () => {
const df = goDockerfile({});
expect(df).toContain('AS builder');
expect(df).toContain('FROM alpine:3.19');
});
it('should include health check', () => {
const df = goDockerfile({ port: 8080 });
expect(df).toContain('HEALTHCHECK');
expect(df).toContain('http://localhost:8080/health');
});
it('should create data directory for persistent storage', () => {
const df = goDockerfile({});
expect(df).toContain('mkdir -p /app/data');
});
it('should run as non-root user', () => {
const df = goDockerfile({});
expect(df).toContain('USER appuser');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Python Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Python Dockerfile generation', () => {
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
FROM python:${pythonVersion}-slim
WORKDIR /app
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
`;
}
it('should use correct Python version', () => {
const df = pythonDockerfile({ runtimeVersion: '3.11' });
expect(df).toContain('FROM python:3.11-slim');
});
it('should default to Python 3.12', () => {
const df = pythonDockerfile({});
expect(df).toContain('FROM python:3.12-slim');
});
it('should use multi-stage build', () => {
const df = pythonDockerfile({});
expect(df).toContain('AS builder');
});
it('should install from requirements.txt', () => {
const df = pythonDockerfile({});
expect(df).toContain('requirements.txt');
});
it('should include health check', () => {
const df = pythonDockerfile({ port: 8000 });
expect(df).toContain('HEALTHCHECK');
expect(df).toContain('http://localhost:8000/health');
});
it('should run as non-root user', () => {
const df = pythonDockerfile({});
expect(df).toContain('USER appuser');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Django Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Django Dockerfile generation', () => {
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
FROM python:${pythonVersion}-slim
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
RUN mkdir -p /app/staticfiles /app/media /app/data
USER appuser
ENV PORT=${port}
ENV DJANGO_SETTINGS_MODULE=config.settings
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
`;
}
it('should use correct Python version', () => {
const df = djangoDockerfile({ runtimeVersion: '3.10' });
expect(df).toContain('FROM python:3.10-slim');
});
it('should set DJANGO_SETTINGS_MODULE', () => {
const df = djangoDockerfile({});
expect(df).toContain('DJANGO_SETTINGS_MODULE');
});
it('should create staticfiles and media directories', () => {
const df = djangoDockerfile({});
expect(df).toContain('/app/staticfiles');
expect(df).toContain('/app/media');
});
it('should run migrations on startup', () => {
const df = djangoDockerfile({});
expect(df).toContain('migrate');
});
it('should use gunicorn for production', () => {
const df = djangoDockerfile({});
expect(df).toContain('gunicorn');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// .NET Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('.NET Dockerfile generation', () => {
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const dotnetVersion = app.runtimeVersion || '8.0';
const port = app.port || 5000;
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore || true
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
WORKDIR /app
COPY --from=build /app/publish .
RUN mkdir -p /app/data
USER appuser
ENV ASPNETCORE_URLS=http://+:${port}
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
CMD ["dotnet", "app.dll"]
`;
}
it('should use correct .NET version', () => {
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
expect(df).toContain('dotnet/sdk:7.0');
expect(df).toContain('dotnet/aspnet:7.0');
});
it('should default to .NET 8.0', () => {
const df = dotnetDockerfile({});
expect(df).toContain('dotnet/sdk:8.0');
});
it('should use multi-stage build', () => {
const df = dotnetDockerfile({});
expect(df).toContain('AS build');
expect(df).toContain('dotnet/aspnet');
});
it('should publish in Release mode', () => {
const df = dotnetDockerfile({});
expect(df).toContain('-c Release');
});
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
const df = dotnetDockerfile({});
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
});
it('should configure ASPNETCORE_URLS for correct port', () => {
const df = dotnetDockerfile({ port: 8080 });
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// PHP Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('PHP Dockerfile generation', () => {
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `FROM php:${phpVersion}-fpm-alpine
RUN apk add --no-cache nginx supervisor curl
RUN docker-php-ext-install pdo pdo_mysql opcache
WORKDIR /var/www/html
COPY . .
RUN mkdir -p /var/www/html/uploads /var/www/html/data
RUN chown -R www-data:www-data /var/www/html
EXPOSE ${port}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
it('should use correct PHP version', () => {
const df = phpDockerfile({ phpVersion: '8.2' });
expect(df).toContain('FROM php:8.2-fpm-alpine');
});
it('should default to PHP 8.3', () => {
const df = phpDockerfile({});
expect(df).toContain('FROM php:8.3-fpm-alpine');
});
it('should use FPM with nginx via supervisord', () => {
const df = phpDockerfile({});
expect(df).toContain('supervisord');
expect(df).toContain('nginx');
});
it('should install common PHP extensions', () => {
const df = phpDockerfile({});
expect(df).toContain('pdo');
expect(df).toContain('opcache');
});
it('should create upload and data directories', () => {
const df = phpDockerfile({});
expect(df).toContain('/var/www/html/uploads');
expect(df).toContain('/var/www/html/data');
});
});
/**
* Tests for the WordPress build flow — specifically:
* 1. Helper pod PVC race condition (must wait for termination)
+290
View File
@@ -622,6 +622,16 @@ export class BuildService {
return this.laravelDockerfile(app);
case AppRuntime.WORDPRESS:
return this.wordpressDockerfile(app);
case AppRuntime.GO:
return this.goDockerfile(app);
case AppRuntime.PHP:
return this.phpDockerfile(app);
case AppRuntime.PYTHON:
return this.pythonDockerfile(app);
case AppRuntime.DJANGO:
return this.djangoDockerfile(app);
case AppRuntime.DOTNET:
return this.dotnetDockerfile(app);
default:
throw new Error(`Unsupported runtime: ${runtime}`);
}
@@ -874,6 +884,286 @@ CMD []` : `CMD ["apache2-foreground"]`}
`;
}
// ─── Go Dockerfile ─────────────────────────────────────────────────
private goDockerfile(app: Application): string {
const goVersion = app.runtimeVersion || '1.22';
const port = app.port || 8080;
return `# --- Build stage ---
FROM golang:${goVersion}-alpine AS builder
WORKDIR /app
# Install git for fetching dependencies
RUN apk add --no-cache git
# Copy go mod files first for better caching
COPY go.mod go.sum* ./
RUN go mod download || true
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
# --- Production stage ---
FROM alpine:3.19
WORKDIR /app
# Add CA certificates for HTTPS requests
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
# Copy the binary from builder
COPY --from=builder /app/main .
COPY --from=builder /app/static ./static 2>/dev/null || true
COPY --from=builder /app/templates ./templates 2>/dev/null || true
COPY --from=builder /app/public ./public 2>/dev/null || true
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
CMD ["./main"]
`;
}
// ─── PHP (Plain) Dockerfile ────────────────────────────────────────
private phpDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `FROM php:${phpVersion}-fpm-alpine
RUN apk add --no-cache nginx supervisor curl \\
&& docker-php-ext-install pdo pdo_mysql opcache \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
# Install common PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\
&& docker-php-ext-install gd
WORKDIR /var/www/html
COPY . .
# Generate nginx config
RUN mkdir -p /etc/nginx/http.d && \\
echo 'server {' > /etc/nginx/http.d/default.conf && \\
echo ' listen ${port};' >> /etc/nginx/http.d/default.conf && \\
echo ' root /var/www/html;' >> /etc/nginx/http.d/default.conf && \\
echo ' index index.php index.html;' >> /etc/nginx/http.d/default.conf && \\
echo ' client_max_body_size 64M;' >> /etc/nginx/http.d/default.conf && \\
echo ' location / { try_files \\$uri \\$uri/ /index.php?\\$query_string; }' >> /etc/nginx/http.d/default.conf && \\
echo ' location ~ \\.php\\$ {' >> /etc/nginx/http.d/default.conf && \\
echo ' fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \\
echo ' fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \\
echo ' include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \\
echo ' }' >> /etc/nginx/http.d/default.conf && \\
echo ' location ~ /\\.ht { deny all; }' >> /etc/nginx/http.d/default.conf && \\
echo '}' >> /etc/nginx/http.d/default.conf
# Generate supervisord config
RUN echo '[supervisord]' > /etc/supervisord.conf && \\
echo 'nodaemon=true' >> /etc/supervisord.conf && \\
echo 'logfile=/dev/stdout' >> /etc/supervisord.conf && \\
echo 'logfile_maxbytes=0' >> /etc/supervisord.conf && \\
echo '[program:php-fpm]' >> /etc/supervisord.conf && \\
echo 'command=php-fpm -F' >> /etc/supervisord.conf && \\
echo 'autostart=true' >> /etc/supervisord.conf && \\
echo 'autorestart=true' >> /etc/supervisord.conf && \\
echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\
echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\
echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
echo '[program:nginx]' >> /etc/supervisord.conf && \\
echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \\
echo 'autostart=true' >> /etc/supervisord.conf && \\
echo 'autorestart=true' >> /etc/supervisord.conf && \\
echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\
echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\
echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf
# Use custom configs if provided
RUN [ -f docker/nginx.conf ] && cp docker/nginx.conf /etc/nginx/http.d/default.conf || true
RUN [ -f docker/supervisord.conf ] && cp docker/supervisord.conf /etc/supervisord.conf || true
# Create upload and data directories
RUN mkdir -p /var/www/html/uploads /var/www/html/data \\
&& chown -R www-data:www-data /var/www/html
EXPOSE ${port}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
// ─── Python Dockerfile ─────────────────────────────────────────────
private pythonDockerfile(app: Application): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user flask gunicorn
# --- Production stage ---
FROM python:${pythonVersion}-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
libpq5 curl \\
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy installed packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY . .
# Create data directory
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
ENV PYTHONUNBUFFERED=1
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
CMD curl -f http://localhost:${port}/health || exit 1
# Auto-detect: Flask, FastAPI, or plain Python
CMD ["sh", "-c", "if [ -f main.py ]; then if grep -q 'FastAPI\\|fastapi' main.py; then uvicorn main:app --host 0.0.0.0 --port ${port}; elif grep -q 'Flask\\|flask' main.py; then gunicorn -w 4 -b 0.0.0.0:${port} main:app; else python main.py; fi; elif [ -f app.py ]; then if grep -q 'FastAPI\\|fastapi' app.py; then uvicorn app:app --host 0.0.0.0 --port ${port}; elif grep -q 'Flask\\|flask' app.py; then gunicorn -w 4 -b 0.0.0.0:${port} app:app; else python app.py; fi; else gunicorn -w 4 -b 0.0.0.0:${port} app:app; fi"]
`;
}
// ─── Django Dockerfile ─────────────────────────────────────────────
private djangoDockerfile(app: Application): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient
# --- Production stage ---
FROM python:${pythonVersion}-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
libpq5 default-libmysqlclient-dev curl \\
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy installed packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY . .
# Create directories for static files and media
RUN mkdir -p /app/staticfiles /app/media /app/data \\
&& chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=config.settings
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD curl -f http://localhost:${port}/health/ || curl -f http://localhost:${port}/ || exit 1
# Auto-detect project structure and run migrations + collectstatic
CMD ["sh", "-c", "\\
PROJECT_NAME=$(find . -maxdepth 2 -name 'wsgi.py' | head -1 | cut -d'/' -f2) && \\
if [ -z \\\"$PROJECT_NAME\\\" ]; then PROJECT_NAME='config'; fi && \\
echo \\\"Django project: $PROJECT_NAME\\\" && \\
python manage.py migrate --noinput 2>/dev/null || true && \\
python manage.py collectstatic --noinput 2>/dev/null || true && \\
gunicorn $PROJECT_NAME.wsgi:application --bind 0.0.0.0:${port} --workers 4 --threads 2 \\
"]
`;
}
// ─── .NET Dockerfile ───────────────────────────────────────────────
private dotnetDockerfile(app: Application): string {
const dotnetVersion = app.runtimeVersion || '8.0';
const port = app.port || 5000;
return `# --- Build stage ---
FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
WORKDIR /src
# Copy csproj and restore dependencies
COPY *.csproj ./
RUN dotnet restore || true
# Copy everything else and build
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore 2>/dev/null || \\
dotnet publish -c Release -o /app/publish
# --- Production stage ---
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
WORKDIR /app
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy published app
COPY --from=build /app/publish .
# Create data directory
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV ASPNETCORE_URLS=http://+:${port}
ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD curl -f http://localhost:${port}/health || curl -f http://localhost:${port}/ || exit 1
# Auto-detect entry point DLL
CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet $DLL"]
`;
}
private async waitForJobCompletion(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
+17
View File
@@ -29,14 +29,28 @@ export enum AppRuntime {
NODEJS = 'nodejs',
LARAVEL = 'laravel',
WORDPRESS = 'wordpress',
GO = 'go',
PHP = 'php',
PYTHON = 'python',
DJANGO = 'django',
DOTNET = 'dotnet',
}
export enum DatabaseType {
MYSQL = 'mysql',
POSTGRESQL = 'postgresql',
MONGODB = 'mongodb',
MARIADB = 'mariadb',
NONE = 'none',
}
// Optional services that can be attached to an application
export enum OptionalService {
REDIS = 'redis',
RABBITMQ = 'rabbitmq',
ELASTICSEARCH = 'elasticsearch',
}
export enum DeploymentStatus {
PENDING = 'pending',
BUILDING = 'building',
@@ -73,6 +87,9 @@ export enum PricingResourceType {
MEMORY_PER_GB = 'memory_per_gb', // Price per GB RAM
STORAGE_PER_GB = 'storage_per_gb', // Price per GB disk
DATABASE_ADDON = 'database_addon', // Price for database addon
REDIS_ADDON = 'redis_addon', // Price for Redis addon
RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon
ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon
}
export enum TransactionType {
File diff suppressed because it is too large Load Diff
@@ -70,13 +70,13 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
const saved = await this.appRepo.save(app);
// If the app was suspended, scale it back up
// If the app was suspended, resume it (scale app + database 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()}`);
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 scale up ${app.name} on reactivation: ${e.message}`);
this.logger.warn(`Failed to resume ${app.name} on reactivation: ${e.message}`);
}
}
@@ -136,11 +136,11 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
if (renewed) continue;
}
// Suspend: scale to 0
// Suspend: scale app and database to 0
try {
await this.kubernetesService.scaleDeployment(app, 0);
await this.kubernetesService.suspendApplication(app);
} catch (e: any) {
this.logger.warn(`Failed to scale down ${app.name}: ${e.message}`);
this.logger.warn(`Failed to suspend ${app.name} in K8s: ${e.message}`);
}
app.lifecycleStatus = AppLifecycleStatus.SUSPENDED;