Harden platform security, reliability, and CI after full audit.

Close deployment IDOR and gate stub payment endpoints, add production
secret validation, health probes, Redis-backed build progress, GitHub
Actions CI, expanded tests, billing/k8s refactors, and ops runbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-06-29 20:59:49 +03:30
parent a87bc49393
commit 837f0fa63f
83 changed files with 3953 additions and 1308 deletions
+7
View File
@@ -20,6 +20,13 @@ JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=localhost
REDIS_PORT=6379
# Multi-cluster: AES-256-GCM key for encrypting kubeconfigs at rest (required in production).
# Generate with: openssl rand -hex 32
CLUSTER_KUBECONFIG_KEY=
# Stub payment gateway (dev/staging only — disabled in production unless explicitly enabled)
# PAYMENT_GATEWAY_STUB_ENABLED=true
# ─── OTP SMS ────────────────────────────────────────────────────────────────
# Pick the provider. Without valid credentials, OTP codes are logged to the API
# console in development only; in production a missing config makes OTP send fail
+28
View File
@@ -0,0 +1,28 @@
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['src/**/*.ts', 'test/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
];
@@ -0,0 +1,33 @@
-- Mobile-first auth: phone is the login identifier, email becomes an optional
-- contact field, plus a table of short-lived one-time SMS codes for verifying
-- a phone (registration/login completion and number changes).
-- Email becomes optional (login no longer uses it). Postgres treats NULLs as
-- distinct, so the existing UNIQUE constraint keeps working for users without one.
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS "phoneVerified" BOOLEAN NOT NULL DEFAULT FALSE;
-- Unique per non-null phone (NULLs allowed for legacy email-only staff accounts).
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique ON users (phone) WHERE phone IS NOT NULL;
-- One-time SMS verification codes (hashed).
DO $$ BEGIN
CREATE TYPE verification_codes_purpose_enum AS ENUM ('login', 'change_phone');
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS verification_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose verification_codes_purpose_enum NOT NULL,
destination VARCHAR NOT NULL,
"codeHash" VARCHAR NOT NULL,
"expiresAt" TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
"consumedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS verification_codes_user_purpose_idx
ON verification_codes ("userId", purpose);
@@ -93,14 +93,14 @@ spec:
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/docs
path: /api/v1/health
port: 4000
initialDelaySeconds: 60
periodSeconds: 15
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /api/docs
path: /api/v1/ready
port: 4000
initialDelaySeconds: 20
periodSeconds: 10
@@ -0,0 +1,35 @@
{{- if .Values.monitoring.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "cloudhost-platform.fullname" . }}-backend-metrics
namespace: {{ include "cloudhost-platform.namespace" . }}
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
spec:
type: ClusterIP
ports:
- name: http
port: 4000
targetPort: 4000
selector:
app: {{ include "cloudhost-platform.backend.fullname" . }}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "cloudhost-platform.fullname" . }}-backend
namespace: {{ include "cloudhost-platform.namespace" . }}
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
release: prometheus
spec:
selector:
matchLabels:
app: {{ include "cloudhost-platform.backend.fullname" . }}
endpoints:
- port: http
path: /api/v1/health
interval: 30s
{{- end }}
@@ -0,0 +1,62 @@
{{- if .Values.backups.postgres.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
namespace: {{ include "cloudhost-platform.namespace" . }}
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.backups.postgres.schedule | quote }}
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: {{ .Values.images.postgres | quote }}
env:
- name: PGHOST
value: {{ include "cloudhost-platform.postgres.fullname" . }}
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: {{ .Values.postgres.database | quote }}
- name: PGUSER
value: {{ .Values.postgres.username | quote }}
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: postgres-password
command:
- sh
- -c
- |
set -e
STAMP=$(date +%Y%m%d-%H%M%S)
FILE="/backup/cloudhost-${STAMP}.sql.gz"
pg_dump | gzip > "$FILE"
echo "Backup written to $FILE"
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: backup
persistentVolumeClaim:
claimName: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
namespace: {{ include "cloudhost-platform.namespace" . }}
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: {{ .Values.backups.postgres.storageSize }}
{{- end }}
@@ -97,3 +97,12 @@ ingress:
migrations:
enabled: true
image: postgres:16-alpine
monitoring:
enabled: false
backups:
postgres:
enabled: false
schedule: "0 3 * * *"
storageSize: 10Gi
@@ -0,0 +1,77 @@
apiVersion: v1
kind: Namespace
metadata:
name: cloudhost-builds
labels:
app.kubernetes.io/part-of: cloudhost
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kaniko-builder
namespace: cloudhost-builds
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kaniko-builder
namespace: cloudhost-builds
rules:
- apiGroups: ['']
resources: ['pods', 'pods/log', 'secrets', 'configmaps', 'persistentvolumeclaims']
verbs: ['create', 'get', 'list', 'watch', 'delete', 'patch', 'update']
- apiGroups: ['batch']
resources: ['jobs']
verbs: ['create', 'get', 'list', 'watch', 'delete']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kaniko-builder
namespace: cloudhost-builds
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: kaniko-builder
subjects:
- kind: ServiceAccount
name: kaniko-builder
namespace: cloudhost-builds
---
# In-cluster registry for Kaniko push + app image pull (HTTP — add TLS in production).
apiVersion: v1
kind: Service
metadata:
name: registry
namespace: cloudhost-builds
spec:
type: ClusterIP
ports:
- port: 5000
targetPort: 5000
selector:
app: registry
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: cloudhost-builds
spec:
replicas: 1
selector:
matchLabels:
app: registry
template:
metadata:
labels:
app: registry
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
env:
- name: REGISTRY_HTTP_ADDR
value: ':5000'
+29 -30
View File
@@ -17,6 +17,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.26",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"bcrypt": "^6.0.0",
"bull": "^4.12.0",
@@ -24,6 +25,7 @@
"class-validator": "^0.15.1",
"handlebars": "^4.7.8",
"helmet": "^8.2.0",
"ioredis": "^5.11.1",
"js-yaml": "^4.2.0",
"multer": "^2.1.1",
"passport": "^0.7.0",
@@ -1347,9 +1349,9 @@
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
@@ -2574,6 +2576,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/typeorm": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
@@ -4896,9 +4909,9 @@
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
@@ -6661,20 +6674,18 @@
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
"@ioredis/commands": "1.10.0",
"cluster-key-slot": "1.1.1",
"debug": "4.4.3",
"denque": "2.1.0",
"redis-errors": "1.2.0",
"redis-parser": "3.0.0",
"standard-as-callback": "2.1.0"
},
"engines": {
"node": ">=12.22.0"
@@ -7868,24 +7879,12 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+19 -5
View File
@@ -9,7 +9,9 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"src/**/*.ts\" --fix",
"lint:check": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
@@ -18,7 +20,8 @@
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
"seed": "ts-node -r tsconfig-paths/register src/seed.ts",
"sync:migrations": "node scripts/sync-helm-migrations.mjs"
},
"dependencies": {
"@kubernetes/client-node": "^1.4.0",
@@ -30,6 +33,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.26",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"bcrypt": "^6.0.0",
"bull": "^4.12.0",
@@ -37,6 +41,7 @@
"class-validator": "^0.15.1",
"handlebars": "^4.7.8",
"helmet": "^8.2.0",
"ioredis": "^5.11.1",
"js-yaml": "^4.2.0",
"multer": "^2.1.1",
"passport": "^0.7.0",
@@ -69,14 +74,23 @@
"typescript": "^6.0.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/test-setup.ts"
]
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Copy SQL migrations from backend/migrations/ into the Helm chart ConfigMap source.
* Run after adding or editing migration files: npm run sync:migrations
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sourceDir = path.resolve(__dirname, '../migrations');
const targetDir = path.resolve(__dirname, '../helm/cloudhost-platform/migrations');
if (!fs.existsSync(sourceDir)) {
console.error(`Source not found: ${sourceDir}`);
process.exit(1);
}
fs.mkdirSync(targetDir, { recursive: true });
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
fs.copyFileSync(path.join(sourceDir, file), path.join(targetDir, file));
}
// Remove stale SQL files no longer in source
for (const existing of fs.readdirSync(targetDir)) {
if (existing.endsWith('.sql') && !files.includes(existing)) {
fs.unlinkSync(path.join(targetDir, existing));
}
}
console.log(`Synced ${files.length} migration(s) to ${targetDir}`);
+18
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ApplicationsModule } from './applications/applications.module';
@@ -15,6 +17,7 @@ import { SnapshotsModule } from './snapshots/snapshots.module';
import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from './health/health.module';
import configuration from './config/configuration';
@Module({
@@ -54,6 +57,14 @@ import configuration from './config/configuration';
inject: [ConfigService],
}),
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000,
limit: 120,
},
]),
// Feature modules
AuthModule,
UsersModule,
@@ -68,6 +79,13 @@ import configuration from './config/configuration';
LifecycleModule,
ApplicationMigrationsModule,
AdminModule,
HealthModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
@@ -19,6 +19,7 @@ import {
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApplicationsService } from './applications.service';
import { DomainService } from './domain.service';
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
@@ -67,6 +68,7 @@ export class ApplicationsController {
}
@Post(':id/upload')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'Upload application code (zip file)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', {
+2
View File
@@ -1,5 +1,6 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -7,6 +8,7 @@ import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@Throttle({ default: { limit: 20, ttl: 60_000 } })
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@@ -0,0 +1,149 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { BillingOpsService } from './billing-ops.service';
import { assertStubGatewayAllowed } from './payment-gateway.util';
import {
ChargeWalletDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
} from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, InvoiceStatus, PaymentMethod } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@Controller('billing')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class BillingInvoicesController {
constructor(
private readonly billingService: BillingService,
private readonly billingOpsService: BillingOpsService,
) {}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
assertStubGatewayAllowed();
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all wallets (Admin)' })
async getAllWallets() {
return this.billingService.getAllWallets();
}
@Post('admin/wallets/:userId/charge')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
async adminChargeWallet(
@Param('userId') userId: string,
@Body() dto: ChargeWalletDto,
) {
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
}
}
+258
View File
@@ -0,0 +1,258 @@
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
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 { UpgradeResourcesDto } from './dto/billing.dto';
import {
BillingCycle,
InvoiceStatus,
ProductType,
DatabaseType,
UserRole,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
@Injectable()
export class BillingOpsService {
constructor(
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,
) {}
async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, resources),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
const pt = app.productType ?? ProductType.APPLICATION;
if (dto.redisResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
redis: {
...app.optionalServiceResources?.redis,
...dto.redisResources,
storageGi:
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
},
};
}
if (dto.rabbitmqResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
rabbitmq: {
...app.optionalServiceResources?.rabbitmq,
...dto.rabbitmqResources,
storageGi:
dto.rabbitmqResources.storageGi ??
app.optionalServiceResources?.rabbitmq?.storageGi ??
2,
},
},
};
}
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
return {};
}
return {
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,
};
}
async applyUpgradeToKubernetes(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
): Promise<void> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_DATABASE) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
},
'database',
);
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
return;
}
if (pt === ProductType.MANAGED_REDIS) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
return;
}
if (pt === ProductType.MANAGED_RABBITMQ) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
return;
}
if (dto.redisResources && app.enableRedis) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
}
if (dto.rabbitmqResources && app.enableRabbitmq) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
}
const touchesAppWorkload =
dto.cpuRequest !== undefined ||
dto.cpuLimit !== undefined ||
dto.memoryRequest !== undefined ||
dto.memoryLimit !== undefined ||
dto.replicas !== undefined;
if (touchesAppWorkload) {
await this.kubernetesService.updateResources(app, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
}
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
}
if (
dto.dbStorageSize &&
dto.dbStorageSize !== previous.dbStorageSize &&
previous.databaseType &&
previous.databaseType !== DatabaseType.NONE
) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
async applyOptionalServiceUpgrade(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
service: 'redis' | 'rabbitmq',
): Promise<void> {
const res = app.optionalServiceResources?.[service];
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
service,
);
}
const prevGi =
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
const nextGi = dtoRes?.storageGi;
if (nextGi != null && nextGi > prevGi) {
const resize =
service === 'redis'
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
if (isAdminOrSales) {
return this.applicationsService.findOne(applicationId);
}
return this.applicationsService.findOne(applicationId, user.id);
}
}
@@ -0,0 +1,190 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Request,
BadRequestException,
Inject,
forwardRef,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { assertStubGatewayAllowed } from './payment-gateway.util';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { BillingCycle, InvoiceReason } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@Controller('billing')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class BillingWalletController {
constructor(
private readonly billingService: BillingService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
) {}
// ─── Wallet (User) ───────────────────────────────────────────────
@Get('wallet')
@ApiOperation({ summary: 'Get my wallet balance' })
async getBalance(@Request() req: any) {
return this.billingService.getBalance(req.user.id);
}
@Post('wallet/charge')
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
}
@Get('wallet/transactions')
@ApiOperation({ summary: 'Get my wallet transactions' })
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
}
@Get('resource-credits')
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
async getResourceCredits(@Request() req: any) {
const credits = await this.billingService.getActiveCredits(req.user.id);
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: PayApplicationDto,
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
}
const app = await this.applicationsService.findOne(applicationId, req.user.id);
const payment = await this.billingService.resolveAppPayment(
req.user.id,
app,
cycle,
);
const coupon = await this.billingService.resolveCoupon(
req.user.id,
body.couponCode,
await this.billingService.getAppChargeBreakdown(app),
cycle,
payment.amountDue,
);
let invoice = null;
if (payment.amountDue > 0) {
invoice = await this.billingService.createInvoice({
userId: req.user.id,
applicationId: app.id,
reason: InvoiceReason.DEPLOY,
lines: [
{
label: `Application payment: ${app.name}`,
description: `Billing cycle: ${cycle}`,
amount: payment.amountDue,
metadata: {
cycle,
waivedAmount: payment.waivedAmount,
creditApplied: payment.creditId || null,
},
},
],
metadata: {
action: 'activate',
cycle,
},
discount: coupon ?? undefined,
});
}
let tx = null;
if (invoice) {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
tx = paid.transaction;
invoice = paid.invoice;
}
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
);
return {
transaction: tx,
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
discountAmount: coupon?.amount ?? 0,
discountCode: coupon?.code ?? null,
paidAmount: invoice ? Number(invoice.total) : 0,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
},
message: payment.waivedAmount > 0
? payment.amountDue > 0
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
: payment.amountDue > 0
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
: `Application "${activated.name}" activated`,
};
}
// ─── Payment Gateway ─────────────────────────────────────────────
@Post('gateway/initiate')
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
async initiateGateway(
@Request() req: any,
@Body() body: { amount: number; description?: string; callbackUrl: string },
) {
assertStubGatewayAllowed();
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
return {
success: true,
trackingCode,
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
message: 'Redirect user to gatewayUrl to complete payment',
};
}
@Post('gateway/verify')
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
async verifyGateway(
@Request() req: any,
@Body() body: { trackingCode: string; amount: number },
) {
assertStubGatewayAllowed();
await this.billingService.chargeWallet(
req.user.id,
body.amount,
`Payment gateway: ${body.trackingCode}`,
);
return {
success: true,
message: 'Payment verified and wallet charged',
trackingCode: body.trackingCode,
};
}
}
+10 -535
View File
@@ -3,35 +3,27 @@ import {
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
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 { BillingOpsService } from './billing-ops.service';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import {
ChargeWalletDto,
CalculateCostDto,
CalculateDeployCostDto,
SetOptionalServicesPricingDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
PayApplicationDto,
} from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
@@ -41,12 +33,7 @@ import {
BillingCycle,
AppLifecycleStatus,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
ProductType,
DatabaseType,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
@ApiTags('Billing')
@ApiBearerAuth()
@@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity';
export class BillingController {
constructor(
private readonly billingService: BillingService,
private readonly billingOpsService: BillingOpsService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
@Inject(forwardRef(() => KubernetesService))
private readonly kubernetesService: KubernetesService,
) {}
// ─── Pricing catalog (Admin) ──────────────────────────────────────
@@ -152,274 +138,6 @@ export class BillingController {
return this.billingService.setOptionalServicesPricing(dto);
}
// ─── Wallet (User) ───────────────────────────────────────────────
@Get('wallet')
@ApiOperation({ summary: 'Get my wallet balance' })
async getBalance(@Request() req: any) {
return this.billingService.getBalance(req.user.id);
}
@Post('wallet/charge')
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
}
@Get('wallet/transactions')
@ApiOperation({ summary: 'Get my wallet transactions' })
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
}
@Get('resource-credits')
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
async getResourceCredits(@Request() req: any) {
const credits = await this.billingService.getActiveCredits(req.user.id);
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: PayApplicationDto,
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
}
const app = await this.applicationsService.findOne(applicationId, req.user.id);
const payment = await this.billingService.resolveAppPayment(
req.user.id,
app,
cycle,
);
const coupon = await this.billingService.resolveCoupon(
req.user.id,
body.couponCode,
await this.billingService.getAppChargeBreakdown(app),
cycle,
payment.amountDue,
);
let invoice = null;
if (payment.amountDue > 0) {
invoice = await this.billingService.createInvoice({
userId: req.user.id,
applicationId: app.id,
reason: InvoiceReason.DEPLOY,
lines: [
{
label: `Application payment: ${app.name}`,
description: `Billing cycle: ${cycle}`,
amount: payment.amountDue,
metadata: {
cycle,
waivedAmount: payment.waivedAmount,
creditApplied: payment.creditId || null,
},
},
],
metadata: {
action: 'activate',
cycle,
},
discount: coupon ?? undefined,
});
}
let tx = null;
if (invoice) {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
tx = paid.transaction;
invoice = paid.invoice;
}
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
);
return {
transaction: tx,
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
discountAmount: coupon?.amount ?? 0,
discountCode: coupon?.code ?? null,
paidAmount: invoice ? Number(invoice.total) : 0,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
},
message: payment.waivedAmount > 0
? payment.amountDue > 0
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
: payment.amountDue > 0
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
: `Application "${activated.name}" activated`,
};
}
// ─── Payment Gateway ─────────────────────────────────────────────
@Post('gateway/initiate')
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
async initiateGateway(
@Request() req: any,
@Body() body: { amount: number; description?: string; callbackUrl: string },
) {
// In production, integrate with Zarinpal/IDPay/etc.
// For now, simulate a gateway redirect URL.
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
return {
success: true,
trackingCode,
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
message: 'Redirect user to gatewayUrl to complete payment',
};
}
@Post('gateway/verify')
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
async verifyGateway(
@Request() req: any,
@Body() body: { trackingCode: string; amount: number },
) {
// In production, verify with the gateway provider.
// For now, auto-approve and charge the wallet.
await this.billingService.chargeWallet(
req.user.id,
body.amount,
`Payment gateway: ${body.trackingCode}`,
);
return {
success: true,
message: 'Payment verified and wallet charged',
trackingCode: body.trackingCode,
};
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all wallets (Admin)' })
async getAllWallets() {
return this.billingService.getAllWallets();
}
@Post('admin/wallets/:userId/charge')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
async adminChargeWallet(
@Param('userId') userId: string,
@Body() dto: ChargeWalletDto,
) {
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
}
// ─── Application Renewal ──────────────────────────────────────────
@Get('applications/:applicationId/renewal-cost')
@@ -428,8 +146,7 @@ export class BillingController {
@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 app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
return {
applicationId: app.id,
@@ -448,7 +165,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
@@ -490,10 +207,8 @@ export class BillingController {
@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);
const app = await this.billingOpsService.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
@@ -533,7 +248,6 @@ export class BillingController {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
return {
@@ -567,7 +281,6 @@ export class BillingController {
}
if (body.bypassPayment) {
// Direct activation without payment (for special cases, support, etc.)
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
@@ -583,7 +296,6 @@ export class BillingController {
};
}
// 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
@@ -640,7 +352,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: CalculateUpgradeCostDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const result = await this.billingService.calculateUpgradeCost(app, dto);
return {
@@ -675,7 +387,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
@@ -727,20 +439,17 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.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);
let paidInvoice = null;
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
@@ -784,11 +493,11 @@ export class BillingController {
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, dto),
this.billingOpsService.buildUpgradeEntityPatch(app, dto),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
await this.billingOpsService.applyUpgradeToKubernetes(updatedApp, dto, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
@@ -813,238 +522,4 @@ export class BillingController {
: 'Resources updated (downgrade or no cost change).',
};
}
// ─── Helper Methods ───────────────────────────────────────────────
private async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, resources),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
const pt = app.productType ?? ProductType.APPLICATION;
if (dto.redisResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
redis: {
...app.optionalServiceResources?.redis,
...dto.redisResources,
storageGi:
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
},
};
}
if (dto.rabbitmqResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
rabbitmq: {
...app.optionalServiceResources?.rabbitmq,
...dto.rabbitmqResources,
storageGi:
dto.rabbitmqResources.storageGi ??
app.optionalServiceResources?.rabbitmq?.storageGi ??
2,
},
},
};
}
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
return {};
}
return {
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,
};
}
private async applyUpgradeToKubernetes(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
): Promise<void> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_DATABASE) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
},
'database',
);
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
return;
}
if (pt === ProductType.MANAGED_REDIS) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
return;
}
if (pt === ProductType.MANAGED_RABBITMQ) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
return;
}
if (dto.redisResources && app.enableRedis) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
}
if (dto.rabbitmqResources && app.enableRabbitmq) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
}
const touchesAppWorkload =
dto.cpuRequest !== undefined ||
dto.cpuLimit !== undefined ||
dto.memoryRequest !== undefined ||
dto.memoryLimit !== undefined ||
dto.replicas !== undefined;
if (touchesAppWorkload) {
await this.kubernetesService.updateResources(app, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
}
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
}
if (
dto.dbStorageSize &&
dto.dbStorageSize !== previous.dbStorageSize &&
previous.databaseType &&
previous.databaseType !== DatabaseType.NONE
) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
private async applyOptionalServiceUpgrade(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
service: 'redis' | 'rabbitmq',
): Promise<void> {
const res = app.optionalServiceResources?.[service];
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
service,
);
}
const prevGi =
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
const nextGi = dtoRes?.storageGi;
if (nextGi != null && nextGi > prevGi) {
const resize =
service === 'redis'
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
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);
}
}
+12 -3
View File
@@ -1,7 +1,10 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingOpsService } from './billing-ops.service';
import { BillingController } from './billing.controller';
import { BillingWalletController } from './billing-wallet.controller';
import { BillingInvoicesController } from './billing-invoices.controller';
import { PublicPricingController } from './public-pricing.controller';
import { DiscountController } from './discount.controller';
import { DiscountService } from './discount.service';
@@ -42,8 +45,14 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController, PublicPricingController, DiscountController],
providers: [BillingService, PricingCatalogService, DiscountService],
exports: [BillingService, PricingCatalogService, DiscountService],
controllers: [
BillingController,
BillingWalletController,
BillingInvoicesController,
PublicPricingController,
DiscountController,
],
providers: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
exports: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
})
export class BillingModule {}
@@ -0,0 +1,32 @@
import { ForbiddenException } from '@nestjs/common';
import { assertStubGatewayAllowed } from './payment-gateway.util';
describe('assertStubGatewayAllowed', () => {
const env = process.env;
beforeEach(() => {
process.env = { ...env };
});
afterAll(() => {
process.env = env;
});
it('allows in development', () => {
process.env.NODE_ENV = 'development';
delete process.env.PAYMENT_GATEWAY_STUB_ENABLED;
expect(() => assertStubGatewayAllowed()).not.toThrow();
});
it('blocks in production by default', () => {
process.env.NODE_ENV = 'production';
delete process.env.PAYMENT_GATEWAY_STUB_ENABLED;
expect(() => assertStubGatewayAllowed()).toThrow(ForbiddenException);
});
it('allows in production when explicitly enabled for staging', () => {
process.env.NODE_ENV = 'production';
process.env.PAYMENT_GATEWAY_STUB_ENABLED = 'true';
expect(() => assertStubGatewayAllowed()).not.toThrow();
});
});
@@ -0,0 +1,14 @@
import { ForbiddenException } from '@nestjs/common';
/**
* Stub gateway endpoints auto-approve payments without a real provider.
* Disabled in production unless PAYMENT_GATEWAY_STUB_ENABLED=true (staging only).
*/
export function assertStubGatewayAllowed(): void {
if (
process.env.NODE_ENV === 'production' &&
process.env.PAYMENT_GATEWAY_STUB_ENABLED !== 'true'
) {
throw new ForbiddenException('Payment gateway is not configured');
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import type { BuildProgress } from './build.service';
const KEY_PREFIX = 'build:progress:';
const TTL_SECONDS = 3600;
@Injectable()
export class BuildProgressStore implements OnModuleDestroy {
private readonly redis: Redis;
constructor(private readonly configService: ConfigService) {
this.redis = new Redis({
host: this.configService.get<string>('redis.host'),
port: this.configService.get<number>('redis.port'),
lazyConnect: true,
maxRetriesPerRequest: 1,
});
this.redis.connect().catch(() => {
// Redis may be unavailable in local unit tests — in-memory fallback remains in BuildService.
});
}
async get(deploymentId: string): Promise<BuildProgress | null> {
try {
const raw = await this.redis.get(`${KEY_PREFIX}${deploymentId}`);
return raw ? (JSON.parse(raw) as BuildProgress) : null;
} catch {
return null;
}
}
async set(deploymentId: string, progress: BuildProgress): Promise<void> {
try {
await this.redis.set(
`${KEY_PREFIX}${deploymentId}`,
JSON.stringify(progress),
'EX',
TTL_SECONDS,
);
} catch {
// Best-effort — local map still holds progress for this replica.
}
}
async clear(deploymentId: string): Promise<void> {
try {
await this.redis.del(`${KEY_PREFIX}${deploymentId}`);
} catch {
// ignore
}
}
onModuleDestroy(): void {
this.redis.disconnect();
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { BuildService } from './build.service';
import { BuildProgressStore } from './build-progress.store';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { ClustersModule } from '../clusters/clusters.module';
@@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
forwardRef(() => KubernetesModule),
ClustersModule,
],
providers: [BuildService],
providers: [BuildService, BuildProgressStore],
exports: [BuildService],
})
export class BuildModule {}
+55 -456
View File
@@ -1,469 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { BuildService } from './build.service';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
/**
* Tests for build service — Dockerfile generation for all runtimes
*/
describe('BuildService', () => {
let service: BuildService;
// ─────────────────────────────────────────────────────────────────────────────
// 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 .
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BuildService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const map: Record<string, string> = {
'build.namespace': 'cloudhost-builds',
'build.serviceAccount': 'kaniko-builder',
'registry.url': 'registry.local:5000',
};
return map[key];
}),
},
},
{ provide: ClustersService, useValue: {} },
{
provide: BuildProgressStore,
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
},
{ provide: RegistryService, useValue: {} },
],
}).compile();
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');
service = module.get(BuildService);
});
it('should default to Go 1.22', () => {
const df = goDockerfile({});
expect(df).toContain('FROM golang:1.22-alpine');
});
describe('generateDockerfile', () => {
it('generates Go Dockerfile with requested runtime version', () => {
const app = {
runtime: AppRuntime.GO,
runtimeVersion: '1.22',
port: 8080,
} as Application;
it('should build static binary with CGO_ENABLED=0', () => {
const df = goDockerfile({});
expect(df).toContain('CGO_ENABLED=0');
});
const dockerfile = (service as any).generateDockerfile(app) as string;
it('should use multi-stage build for smaller image', () => {
const df = goDockerfile({});
expect(df).toContain('AS builder');
expect(df).toContain('FROM alpine:3.19');
});
expect(dockerfile).toContain('FROM golang:1.22-alpine');
expect(dockerfile).toContain('EXPOSE 8080');
});
it('should include health check', () => {
const df = goDockerfile({ port: 8080 });
expect(df).toContain('HEALTHCHECK');
expect(df).toContain('http://localhost:8080/health');
});
it('generates Node.js Dockerfile with default port', () => {
const app = {
runtime: AppRuntime.NODEJS,
runtimeVersion: '20',
} as Application;
it('should create data directory for persistent storage', () => {
const df = goDockerfile({});
expect(df).toContain('mkdir -p /app/data');
});
const dockerfile = (service as any).generateDockerfile(app) as string;
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)
* 2. WordPress Dockerfile generation correctness
* 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution
*/
describe('WordPress Dockerfile generation', () => {
// Reproduce the wordpressDockerfile logic from build.service.ts
function wordpressDockerfile(app: {
runtimeVersion?: string;
phpVersion?: string;
codePath?: string;
port?: number;
}): string {
const wpVersion = app.runtimeVersion || '6.7';
const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath;
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
RUN docker-php-ext-install opcache
RUN a2enmod rewrite
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
${hasUploadedCode ? `COPY . /tmp/user-content
RUN mkdir -p /usr/src/wordpress-user
ENTRYPOINT ["cloudhost-entrypoint.sh"]
CMD []` : `CMD ["apache2-foreground"]`}
EXPOSE 80
`;
}
it('should use ENTRYPOINT (not CMD) when user uploaded code', () => {
const df = wordpressDockerfile({ codePath: '/some/path/source.zip' });
expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]');
expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]');
});
it('should use CMD apache2-foreground for fresh install (no code)', () => {
const df = wordpressDockerfile({});
expect(df).toContain('CMD ["apache2-foreground"]');
expect(df).not.toContain('ENTRYPOINT');
});
it('should use correct WordPress and PHP versions', () => {
const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' });
expect(df).toContain('FROM wordpress:6.4-php8.2-apache');
});
it('should default to WP 6.7 and PHP 8.3', () => {
const df = wordpressDockerfile({});
expect(df).toContain('FROM wordpress:6.7-php8.3-apache');
});
it('should COPY user content when codePath exists', () => {
const df = wordpressDockerfile({ codePath: '/tmp/source.zip' });
expect(df).toContain('COPY . /tmp/user-content');
});
it('should NOT copy user content for fresh install', () => {
const df = wordpressDockerfile({});
expect(df).not.toContain('COPY . /tmp/user-content');
});
});
describe('Helper pod PVC race condition', () => {
it('should wait for pod deletion (not just fire-and-forget)', () => {
// Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404
const deletionSteps = [
{ exists: true }, // pod still terminating
{ exists: true }, // still terminating
{ exists: false }, // gone (404)
];
let pollCount = 0;
let fullyTerminated = false;
for (const step of deletionSteps) {
pollCount++;
if (!step.exists) {
fullyTerminated = true;
break;
}
}
expect(fullyTerminated).toBe(true);
expect(pollCount).toBe(3);
});
it('should time out if pod never terminates', () => {
const maxPolls = 30; // e.g. 60s / 2s interval
let pollCount = 0;
let timedOut = false;
while (pollCount < maxPolls) {
pollCount++;
// Pod always exists (simulating stuck termination)
const exists = true;
if (!exists) break;
}
if (pollCount >= maxPolls) {
timedOut = true;
}
expect(timedOut).toBe(true);
});
});
describe('WordPress entrypoint script', () => {
const entrypointScript = `#!/bin/bash
set -e
# Merge user wp-content into PVC
if [ -d /usr/src/wordpress-user/wp-content ]; then
mkdir -p /var/www/html/wp-content
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
chown -R www-data:www-data /var/www/html/wp-content
fi
exec docker-entrypoint.sh apache2-foreground`;
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
expect(matches).toHaveLength(1);
});
it('should use exec to replace process', () => {
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
});
it('should merge wp-content on every start when staged content exists', () => {
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
expect(entrypointScript).not.toContain('.user-content-merged');
});
it('should not copy user wp-config.php (credentials come from env vars)', () => {
expect(entrypointScript).not.toContain('wp-config.php');
});
it('should set proper ownership after merging wp-content', () => {
expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content');
});
});
describe('WordPress zip structure handling', () => {
// The unzip init container handles single-subfolder flattening
it('should flatten single subfolder (public_html/) to root', () => {
// Simulate: zip contains only public_html/
const extractedItems = ['public_html'];
const count = extractedItems.length;
const firstItem = extractedItems[0];
let flattenedToRoot = false;
if (count === 1 && firstItem === 'public_html') {
// cp -a /tmp/extract/public_html/. /workspace-out/source/
flattenedToRoot = true;
}
expect(flattenedToRoot).toBe(true);
});
it('should copy as-is when multiple items exist', () => {
// Simulate: zip contains multiple items at root
const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php'];
const count = extractedItems.length;
let copiedAsIs = false;
if (count !== 1) {
copiedAsIs = true;
}
expect(copiedAsIs).toBe(true);
expect(dockerfile).toContain('FROM node:20');
expect(dockerfile).toContain('EXPOSE 3000');
});
});
});
+10 -2
View File
@@ -10,6 +10,7 @@ import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
const execFileAsync = promisify(execFile);
@@ -55,6 +56,7 @@ export class BuildService {
private configService: ConfigService,
private clustersService: ClustersService,
private registryService: RegistryService,
private progressStore: BuildProgressStore,
) {}
private beginBuildSession(deploymentId: string): void {
@@ -277,17 +279,23 @@ export class BuildService {
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
}
getProgress(deploymentId: string): BuildProgress | null {
return this.progressMap.get(deploymentId) ?? null;
async getProgress(deploymentId: string): Promise<BuildProgress | null> {
const local = this.progressMap.get(deploymentId);
if (local) return local;
const remote = await this.progressStore.get(deploymentId);
if (remote) this.progressMap.set(deploymentId, remote);
return remote;
}
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
if (!deploymentId) return;
this.progressMap.set(deploymentId, progress);
void this.progressStore.set(deploymentId, progress);
}
clearProgress(deploymentId: string): void {
this.progressMap.delete(deploymentId);
void this.progressStore.clear(deploymentId);
}
/**
+93 -93
View File
@@ -1,105 +1,105 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClustersService } from './clusters.service';
import { Cluster } from './entities/cluster.entity';
import { ClusterHealth } from './entities/cluster-health.entity';
import { ClusterPool } from './entities/cluster-pool.entity';
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
import { ClusterStatus } from '../common/enums';
import { RegistryService } from '../kubernetes/registry.service';
import { DataSource } from 'typeorm';
/**
* Tests for ClustersService getDefault and delete logic.
*/
describe('ClustersService', () => {
let service: ClustersService;
describe('ClustersService getDefault logic', () => {
// Simulate the fixed getDefault behavior
function getDefault(clusters: { id: string; isDefault: boolean; status: string }[]): { id: string } | null {
// Step 1: active + default
let result = clusters.find(c => c.isDefault && c.status === ClusterStatus.ACTIVE);
if (result) return { id: result.id };
const clustersRepository = {
findOne: jest.fn(),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
find: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
// Step 2: any active (fallback)
result = clusters.find(c => c.status === ClusterStatus.ACTIVE);
if (result) return { id: result.id };
const healthRepository = { find: jest.fn(), save: jest.fn() };
const poolRepository = { find: jest.fn(), findOne: jest.fn(), save: jest.fn() };
const allocationLogsRepository = { save: jest.fn(), find: jest.fn() };
const dataSource = { transaction: jest.fn() };
const registryService = { ensureRegistryPullSecret: jest.fn() };
return null;
}
beforeEach(async () => {
jest.clearAllMocks();
it('should return active default cluster', () => {
const clusters = [
{ id: '1', isDefault: true, status: ClusterStatus.ACTIVE },
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
];
expect(getDefault(clusters)?.id).toBe('1');
const module: TestingModule = await Test.createTestingModule({
providers: [
ClustersService,
{ provide: getRepositoryToken(Cluster), useValue: clustersRepository },
{ provide: getRepositoryToken(ClusterPool), useValue: poolRepository },
{ provide: getRepositoryToken(ClusterHealth), useValue: healthRepository },
{ provide: getRepositoryToken(ClusterAllocationLog), useValue: allocationLogsRepository },
{ provide: DataSource, useValue: dataSource },
{ provide: RegistryService, useValue: registryService },
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
if (key === 'CLUSTER_KUBECONFIG_KEY') return '';
if (key === 'cluster.kubeconfigKey') return '';
return undefined;
}),
},
},
],
}).compile();
service = module.get(ClustersService);
});
it('should skip inactive default and return active cluster', () => {
const clusters = [
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
];
expect(getDefault(clusters)?.id).toBe('2');
});
describe('getDefault', () => {
it('returns active default cluster', async () => {
const cluster = {
id: 'c-1',
name: 'primary',
isDefault: true,
status: ClusterStatus.ACTIVE,
kubeconfig: 'apiVersion: v1',
} as Cluster;
it('should return null when no active clusters exist', () => {
const clusters = [
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
];
expect(getDefault(clusters)).toBeNull();
});
clustersRepository.findOne.mockResolvedValueOnce(cluster);
it('should handle both clusters being default (picks active one)', () => {
const clusters = [
{ id: 'inactive', isDefault: true, status: ClusterStatus.INACTIVE },
{ id: 'active', isDefault: true, status: ClusterStatus.ACTIVE },
];
expect(getDefault(clusters)?.id).toBe('active');
});
});
describe('ClustersService delete logic', () => {
it('should reassign apps to replacement cluster on delete', () => {
// Simulate: cluster A (being deleted) has 3 apps, cluster B is active
const apps = [
{ id: 'app1', clusterId: 'A' },
{ id: 'app2', clusterId: 'A' },
{ id: 'app3', clusterId: 'B' },
];
const deletedClusterId = 'A';
const replacementId = 'B';
// Reassign
for (const app of apps) {
if (app.clusterId === deletedClusterId) {
app.clusterId = replacementId;
}
}
expect(apps.filter(a => a.clusterId === 'A')).toHaveLength(0);
expect(apps.filter(a => a.clusterId === 'B')).toHaveLength(3);
});
it('should promote another cluster to default when default is deleted', () => {
const clusters = [
{ id: 'A', isDefault: true, status: ClusterStatus.ACTIVE },
{ id: 'B', isDefault: false, status: ClusterStatus.ACTIVE },
];
// Delete A
const deleted = clusters.splice(0, 1)[0];
expect(deleted.isDefault).toBe(true);
// Promote
const newDefault = clusters.find(c => c.status === ClusterStatus.ACTIVE);
if (newDefault) newDefault.isDefault = true;
expect(clusters[0].isDefault).toBe(true);
expect(clusters[0].id).toBe('B');
});
it('should nullify clusterId when no replacement cluster exists', () => {
const apps = [{ id: 'app1', clusterId: 'A' as string | null }];
const hasReplacement = false;
if (!hasReplacement) {
for (const app of apps) {
app.clusterId = null;
}
}
expect(apps[0].clusterId).toBeNull();
const result = await service.getDefault();
expect(result.id).toBe('c-1');
expect(clustersRepository.findOne).toHaveBeenCalledWith({
where: { isDefault: true, status: ClusterStatus.ACTIVE },
});
});
it('falls back to any active cluster when no default is set', async () => {
const fallback = {
id: 'c-2',
name: 'fallback',
isDefault: false,
status: ClusterStatus.ACTIVE,
kubeconfig: 'apiVersion: v1',
} as Cluster;
clustersRepository.findOne
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fallback);
const result = await service.getDefault();
expect(result.id).toBe('c-2');
expect(clustersRepository.save).toHaveBeenCalled();
});
it('throws when no active cluster exists', async () => {
clustersRepository.findOne.mockResolvedValue(null);
await expect(service.getDefault()).rejects.toThrow(NotFoundException);
});
});
});
+5
View File
@@ -84,6 +84,11 @@ export default () => ({
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
cluster: {
/** AES-256-GCM key for encrypting stored kubeconfigs. Required in production. */
kubeconfigKey: process.env.CLUSTER_KUBECONFIG_KEY || '',
},
// OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar').
sms: {
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
@@ -0,0 +1,40 @@
import { validateProductionConfig } from './validate-production-config';
describe('validateProductionConfig', () => {
const env = process.env;
beforeEach(() => {
process.env = { ...env };
});
afterAll(() => {
process.env = env;
});
it('does nothing in development', () => {
process.env.NODE_ENV = 'development';
delete process.env.JWT_SECRET;
expect(() => validateProductionConfig()).not.toThrow();
});
it('throws in production when secrets are missing or default', () => {
process.env.NODE_ENV = 'production';
process.env.JWT_SECRET = 'default-jwt-secret';
process.env.JWT_REFRESH_SECRET = 'default-refresh-secret';
process.env.DB_PASSWORD = 'cloudhost_secret';
expect(() => validateProductionConfig()).toThrow(/Production configuration validation failed/);
expect(() => validateProductionConfig()).toThrow(/JWT_SECRET/);
expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/);
});
it('passes in production with strong secrets', () => {
process.env.NODE_ENV = 'production';
process.env.JWT_SECRET = 'a-very-long-random-production-secret';
process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret';
process.env.DB_PASSWORD = 'strong-db-password-here';
process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef';
expect(() => validateProductionConfig()).not.toThrow();
});
});
@@ -0,0 +1,34 @@
const DEFAULT_JWT_SECRET = 'default-jwt-secret';
const DEFAULT_REFRESH_SECRET = 'default-refresh-secret';
const DEFAULT_DB_PASSWORD = 'cloudhost_secret';
export function validateProductionConfig(): void {
if (process.env.NODE_ENV !== 'production') {
return;
}
const errors: string[] = [];
const jwtSecret = process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
const refreshSecret = process.env.JWT_REFRESH_SECRET || DEFAULT_REFRESH_SECRET;
const dbPassword = process.env.DB_PASSWORD || DEFAULT_DB_PASSWORD;
if (!process.env.JWT_SECRET || jwtSecret === DEFAULT_JWT_SECRET) {
errors.push('JWT_SECRET must be set to a strong random value in production');
}
if (!process.env.JWT_REFRESH_SECRET || refreshSecret === DEFAULT_REFRESH_SECRET) {
errors.push('JWT_REFRESH_SECRET must be set to a strong random value in production');
}
if (!process.env.DB_PASSWORD || dbPassword === DEFAULT_DB_PASSWORD) {
errors.push('DB_PASSWORD must be changed from the default in production');
}
if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) {
errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs');
}
if (errors.length > 0) {
throw new Error(
`Production configuration validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`,
);
}
}
@@ -10,6 +10,7 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DeploymentsService } from './deployments.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { UserRole } from '../common/enums';
@ApiTags('Deployments')
@ApiBearerAuth()
@@ -18,6 +19,12 @@ import { RolesGuard } from '../common/guards/roles.guard';
export class DeploymentsController {
constructor(private readonly deploymentsService: DeploymentsService) {}
private ownershipUserId(req: { user: { id: string; role: string } }): string | undefined {
const isStaff =
req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
return isStaff ? undefined : req.user.id;
}
@Post('applications/:appId/deploy')
@ApiOperation({ summary: 'Trigger a new deployment' })
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
@@ -26,14 +33,14 @@ export class DeploymentsController {
@Get('applications/:appId')
@ApiOperation({ summary: 'List deployments for an application' })
async findByApplication(@Param('appId') appId: string) {
return this.deploymentsService.findByApplication(appId);
async findByApplication(@Param('appId') appId: string, @Request() req: any) {
return this.deploymentsService.findByApplication(appId, this.ownershipUserId(req));
}
@Get(':id')
@ApiOperation({ summary: 'Get deployment details' })
async findOne(@Param('id') id: string) {
return this.deploymentsService.findOne(id);
async findOne(@Param('id') id: string, @Request() req: any) {
return this.deploymentsService.findOne(id, this.ownershipUserId(req));
}
@Get('applications/:appId/logs')
@@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { DeploymentsService } from './deployments.service';
import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService } from '../build/build.service';
import { ClustersService } from '../clusters/clusters.service';
describe('DeploymentsService authorization', () => {
let service: DeploymentsService;
const deploymentsRepository = {
find: jest.fn(),
findOne: jest.fn(),
createQueryBuilder: jest.fn(),
};
const applicationsService = {
findOne: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
DeploymentsService,
{ provide: getRepositoryToken(Deployment), useValue: deploymentsRepository },
{ provide: ApplicationsService, useValue: applicationsService },
{ provide: KubernetesService, useValue: {} },
{ provide: BuildService, useValue: {} },
{ provide: ClustersService, useValue: {} },
],
}).compile();
service = module.get(DeploymentsService);
});
describe('findByApplication', () => {
it('verifies application ownership before listing deployments', async () => {
const appId = 'app-1';
const userId = 'user-1';
const deployments = [{ id: 'd-1', applicationId: appId }] as Deployment[];
applicationsService.findOne.mockResolvedValue({ id: appId, userId });
deploymentsRepository.find.mockResolvedValue(deployments);
const result = await service.findByApplication(appId, userId);
expect(applicationsService.findOne).toHaveBeenCalledWith(appId, userId);
expect(result).toEqual(deployments);
});
it('propagates NotFoundException when user does not own the app', async () => {
applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found'));
await expect(service.findByApplication('app-1', 'other-user')).rejects.toThrow(NotFoundException);
expect(deploymentsRepository.find).not.toHaveBeenCalled();
});
});
describe('findOne', () => {
it('verifies application ownership before returning deployment', async () => {
const deployment = {
id: 'd-1',
applicationId: 'app-1',
application: { id: 'app-1' },
} as Deployment;
deploymentsRepository.findOne.mockResolvedValue(deployment);
applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-1' });
const result = await service.findOne('d-1', 'user-1');
expect(applicationsService.findOne).toHaveBeenCalledWith('app-1', 'user-1');
expect(result).toBe(deployment);
});
it('throws when deployment does not exist', async () => {
deploymentsRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('missing', 'user-1')).rejects.toThrow(NotFoundException);
expect(applicationsService.findOne).not.toHaveBeenCalled();
});
});
});
@@ -463,14 +463,15 @@ export class DeploymentsService {
}
}
async findByApplication(applicationId: string): Promise<Deployment[]> {
async findByApplication(applicationId: string, userId?: string): Promise<Deployment[]> {
await this.applicationsService.findOne(applicationId, userId);
return this.deploymentsRepository.find({
where: { applicationId },
order: { createdAt: 'DESC' },
});
}
async findOne(id: string): Promise<Deployment> {
async findOne(id: string, userId?: string): Promise<Deployment> {
const deployment = await this.deploymentsRepository.findOne({
where: { id },
relations: { application: true },
@@ -478,6 +479,7 @@ export class DeploymentsService {
if (!deployment) {
throw new NotFoundException('Deployment not found');
}
await this.applicationsService.findOne(deployment.applicationId, userId);
return deployment;
}
@@ -537,7 +539,7 @@ export class DeploymentsService {
if (!latest) return null;
const progress = this.buildService.getProgress(latest.id);
const progress = await this.buildService.getProgress(latest.id);
if (progress) return progress;
// No in-memory progress — infer from deployment status
+25
View File
@@ -0,0 +1,25 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { SkipThrottle } from '@nestjs/throttler';
@ApiTags('Health')
@Controller()
@SkipThrottle()
export class HealthController {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
@Get('health')
@ApiOperation({ summary: 'Liveness probe' })
health() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
@Get('ready')
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
async ready() {
await this.dataSource.query('SELECT 1');
return { status: 'ready', timestamp: new Date().toISOString() };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+2 -2
View File
@@ -26,10 +26,10 @@ describe('HelmService', () => {
expect(service).toBeDefined();
});
describe('chartPath', () => {
describe('resolveChartPath', () => {
it('should resolve to helm/cloudhost-app relative to project root', () => {
const expectedSuffix = path.join('helm', 'cloudhost-app');
expect((service as any).chartPath).toContain(expectedSuffix);
expect((service as any).resolveChartPath('cloudhost-app')).toContain(expectedSuffix);
});
});
@@ -9,6 +9,7 @@ jest.mock('@kubernetes/client-node', () => ({
import { RegistryService } from './registry.service';
import { KubernetesService } from './kubernetes.service';
import { K8sLifecycleService } from './k8s-lifecycle.service';
/**
* Regression tests for the @kubernetes/client-node 1.x migration.
@@ -80,19 +81,25 @@ describe('KubernetesService — k8s v1 client shape', () => {
let service: KubernetesService;
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
const k8sClientService = {
getK8sClient: jest.fn().mockResolvedValue({
coreApi: clients.coreApi,
appsApi: clients.appsApi,
networkingApi: clients.networkingApi,
kc: clients.kc,
}),
getKubeconfig: jest.fn(),
};
const k8sLifecycleService = new K8sLifecycleService(k8sClientService as any);
const svc = new KubernetesService(
configStub,
{} as any, // clustersService
{} as any, // helmService
{} as any, // registryService
k8sClientService as any,
k8sLifecycleService,
{} as any, // deploymentsRepository
);
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
coreApi: clients.coreApi,
appsApi: clients.appsApi,
networkingApi: clients.networkingApi,
kc: clients.kc,
});
return svc;
};
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import * as k8s from '@kubernetes/client-node';
import { ClustersService } from '../clusters/clusters.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
@Injectable()
export class K8sClientService {
constructor(private readonly clustersService: ClustersService) {}
async getK8sClient(clusterId?: string): Promise<{
coreApi: k8s.CoreV1Api;
appsApi: k8s.AppsV1Api;
networkingApi: k8s.NetworkingV1Api;
kc: k8s.KubeConfig;
}> {
const cluster = clusterId
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(cluster.kubeconfig);
kc.loadFromString(cluster.kubeconfig);
return {
coreApi: kc.makeApiClient(k8s.CoreV1Api),
appsApi: kc.makeApiClient(k8s.AppsV1Api),
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
kc,
};
}
async getKubeconfig(clusterId?: string): Promise<string> {
const cluster = clusterId
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
return cluster.kubeconfig;
}
}
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { Application } from '../applications/entities/application.entity';
import { K8sClientService } from './k8s-client.service';
import { primaryWorkloadLabel, userNamespace } from './k8s-workload.util';
/** Runtime logs and lightweight workload operations extracted from KubernetesService. */
@Injectable()
export class K8sLifecycleService {
constructor(private readonly k8sClient: K8sClientService) {}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.k8sClient.getK8sClient(app.clusterId);
const namespace = userNamespace(app.userId);
const podLabel = primaryWorkloadLabel(app);
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${podLabel}`,
});
if (pods.items.length === 0) {
return 'No pods found for this application.';
}
const podName = pods.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
return coreApi.readNamespacedPodLog({
name: podName,
namespace,
tailLines: 200,
});
}
}
@@ -0,0 +1,42 @@
import { Application } from '../applications/entities/application.entity';
import { DatabaseType, isManagedProductType } from '../common/enums';
/** Kubernetes namespace for a user's applications. */
export function userNamespace(userId: string): string {
return `user-${userId.split('-')[0]}`;
}
/** Primary pod label selector target for an application workload. */
export function primaryWorkloadLabel(app: Application): string {
if (isManagedProductType(app.productType)) {
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
return `${app.name}-db`;
}
if (app.enableRedis) return `${app.name}-redis`;
if (app.enableRabbitmq) return `${app.name}-rabbitmq`;
}
return app.name;
}
export function getApplicationWorkloadDeployments(
app: Application,
): { name: string; runningReplicas: number }[] {
const managed = isManagedProductType(app.productType);
const workloads: { name: string; runningReplicas: number }[] = [];
if (!managed) {
workloads.push({ name: app.name, runningReplicas: app.replicas || 1 });
}
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
workloads.push({ name: `${app.name}-db`, runningReplicas: 1 });
}
if (app.enableRedis) {
workloads.push({ name: `${app.name}-redis`, runningReplicas: 1 });
}
if (app.enableRabbitmq) {
workloads.push({ name: `${app.name}-rabbitmq`, runningReplicas: 1 });
}
return workloads;
}
+4 -2
View File
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { KubernetesService } from './kubernetes.service';
import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { K8sClientService } from './k8s-client.service';
import { K8sLifecycleService } from './k8s-lifecycle.service';
import { ElasticsearchService } from './elasticsearch.service';
import { ElasticsearchController } from './elasticsearch.controller';
import { LogsController } from './logs.controller';
@@ -13,7 +15,7 @@ import { Deployment } from '../deployments/entities/deployment.entity';
@Module({
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
controllers: [ElasticsearchController, LogsController],
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
})
export class KubernetesModule {}
+49 -95
View File
@@ -15,6 +15,8 @@ import { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { K8sClientService } from './k8s-client.service';
import { K8sLifecycleService } from './k8s-lifecycle.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
const execFileAsync = promisify(execFile);
@@ -74,6 +76,8 @@ export class KubernetesService implements OnModuleInit {
private clustersService: ClustersService,
private helmService: HelmService,
private registryService: RegistryService,
private k8sClientService: K8sClientService,
private k8sLifecycleService: K8sLifecycleService,
@InjectRepository(Deployment)
private deploymentsRepository: Repository<Deployment>,
) {}
@@ -99,34 +103,6 @@ export class KubernetesService implements OnModuleInit {
// Helm chart is used for deployments — no local template loading needed
}
private async getK8sClient(clusterId?: string): Promise<{
coreApi: k8s.CoreV1Api;
appsApi: k8s.AppsV1Api;
networkingApi: k8s.NetworkingV1Api;
kc: k8s.KubeConfig;
}> {
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(cluster.kubeconfig);
kc.loadFromString(cluster.kubeconfig);
return {
coreApi: kc.makeApiClient(k8s.CoreV1Api),
appsApi: kc.makeApiClient(k8s.AppsV1Api),
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
kc,
};
}
/**
* Get the raw kubeconfig string for a cluster.
*/
private async getKubeconfig(clusterId?: string): Promise<string> {
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
return cluster.kubeconfig;
}
/**
* Build Helm values object from an Application entity and image URI.
*/
@@ -412,7 +388,7 @@ export class KubernetesService implements OnModuleInit {
}
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const managed = isManagedProductType(app.productType);
const workloads = [
@@ -463,14 +439,14 @@ export class KubernetesService implements OnModuleInit {
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
try {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
const values = this.buildHelmValues(app, imageUri, previewNumber);
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
} catch (helmError: any) {
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
const { networkingApi } = await this.getK8sClient(app.clusterId);
const { networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const ctx: ManifestContext = {
appName: app.name,
namespace,
@@ -513,9 +489,9 @@ export class KubernetesService implements OnModuleInit {
// ── Helm-based deployment ─────────────────────────────────────────
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const values = this.buildHelmValues(app, imageUri, previewNumber);
const namespace = values.app.namespace as string;
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
@@ -531,7 +507,7 @@ export class KubernetesService implements OnModuleInit {
}
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const values = this.buildManagedHelmValues(app);
const namespace = values.app.namespace;
@@ -549,8 +525,8 @@ export class KubernetesService implements OnModuleInit {
// ── Direct K8s API deployment (fallback) ──────────────────────────
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const kubeconfig = await this.getKubeconfig(app.clusterId);
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const namespace = `user-${app.userId.split('-')[0]}`;
const context: ManifestContext = {
@@ -619,8 +595,8 @@ export class KubernetesService implements OnModuleInit {
if (isManagedProductType(app.productType)) {
return this.deployManagedViaK8sApi(app);
}
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const kubeconfig = await this.getKubeconfig(app.clusterId);
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const domain = this.configService.get('platform.domain');
@@ -2247,33 +2223,11 @@ export class KubernetesService implements OnModuleInit {
}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const podLabel = this.primaryWorkloadLabel(app);
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${podLabel}`,
});
if (pods.items.length === 0) {
return 'No pods found for this application.';
}
const podName = pods.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
const logResponse = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
tailLines: 200,
});
return logResponse;
return this.k8sLifecycleService.getPodLogs(app);
}
async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
@@ -2313,7 +2267,7 @@ export class KubernetesService implements OnModuleInit {
}
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const snapshot: Record<string, number> = {};
@@ -2342,7 +2296,7 @@ export class KubernetesService implements OnModuleInit {
* Returns the replica snapshot captured before scaling.
*/
async suspendApplication(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
@@ -2368,7 +2322,7 @@ export class KubernetesService implements OnModuleInit {
* Resume a suspended application using saved replica counts when available.
*/
async resumeApplication(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
@@ -2400,7 +2354,7 @@ export class KubernetesService implements OnModuleInit {
}
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
@@ -2595,7 +2549,7 @@ export class KubernetesService implements OnModuleInit {
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
*/
async getResourceUsage(app: Application): Promise<any> {
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const workloads: any[] = [];
@@ -2685,7 +2639,7 @@ export class KubernetesService implements OnModuleInit {
},
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const target = this.workloadDeploymentTarget(app, workload);
@@ -2807,7 +2761,7 @@ export class KubernetesService implements OnModuleInit {
throw new BadRequestException('Application is not assigned to a cluster');
}
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
const shortId = grantId.split('-')[0];
@@ -2874,7 +2828,7 @@ export class KubernetesService implements OnModuleInit {
const manifestPath = path.join(tmpDir, 'service.json');
try {
fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), {
fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), {
mode: 0o600,
});
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
@@ -2895,7 +2849,7 @@ export class KubernetesService implements OnModuleInit {
}
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
const { coreApi } = await this.getK8sClient(clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(clusterId);
try {
await coreApi.deleteNamespacedService({
name: k8sServiceName,
@@ -2912,7 +2866,7 @@ export class KubernetesService implements OnModuleInit {
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
if (!app.clusterId) return;
const namespace = this.getUserNamespace(app.userId);
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
try {
const services = await coreApi.listNamespacedService({
@@ -2942,7 +2896,7 @@ export class KubernetesService implements OnModuleInit {
};
case ServiceAccessTarget.REDIS: {
if (!app.clusterId) return {};
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const secret = await coreApi.readNamespacedSecret({
name: `${app.name}-redis-secret`,
namespace,
@@ -2953,7 +2907,7 @@ export class KubernetesService implements OnModuleInit {
case ServiceAccessTarget.RABBITMQ_AMQP:
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
if (!app.clusterId) return {};
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const secret = await coreApi.readNamespacedSecret({
name: `${app.name}-rabbitmq-secret`,
namespace,
@@ -2980,7 +2934,7 @@ export class KubernetesService implements OnModuleInit {
host: string;
ingressUrl?: string;
}> {
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const domain = this.configService.get('platform.domain');
const hostIp = this.getClusterHostIp(kc);
@@ -3044,13 +2998,13 @@ export class KubernetesService implements OnModuleInit {
async deleteApplication(app: Application): Promise<void> {
const namespace = this.getUserNamespace(app.userId);
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
await this.deleteTemporaryAccessServicesForApp(app);
// Step 1: Try Helm uninstall (handles most resources)
try {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.helmService.uninstall(app.name, namespace, kubeconfig);
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
} catch (error: any) {
@@ -3171,8 +3125,8 @@ export class KubernetesService implements OnModuleInit {
} = {},
): Promise<void> {
const namespace = this.getUserNamespace(app.userId);
const source = await this.getK8sClient(app.clusterId);
const target = await this.getK8sClient(targetClusterId);
const source = await this.k8sClientService.getK8sClient(app.clusterId);
const target = await this.k8sClientService.getK8sClient(targetClusterId);
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
@@ -3250,8 +3204,8 @@ export class KubernetesService implements OnModuleInit {
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 });
fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 });
fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 });
fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 });
try {
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
@@ -3407,7 +3361,7 @@ export class KubernetesService implements OnModuleInit {
* Polls pod status with label selector `app=<appName>-db`.
*/
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbLabel = `${app.name}-db`;
const start = Date.now();
@@ -3513,7 +3467,7 @@ export class KubernetesService implements OnModuleInit {
* then runs a restore Job that mounts the PVC and imports the dump.
*/
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbName = `${app.name}-db`;
@@ -3821,7 +3775,7 @@ export class KubernetesService implements OnModuleInit {
* Used when legacy PVCs were created without storageClassName.
*/
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const oldPvcName = `${app.name}-db`;
@@ -3997,7 +3951,7 @@ export class KubernetesService implements OnModuleInit {
* K8s only supports PVC expansion, not shrinking.
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
@@ -4070,7 +4024,7 @@ export class KubernetesService implements OnModuleInit {
*/
async getDatabasePvcSize(app: Application): Promise<string> {
try {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
@@ -4096,7 +4050,7 @@ export class KubernetesService implements OnModuleInit {
totalAllocatedGb: number;
totalUsedGb: number;
}> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const result = {
@@ -4271,7 +4225,7 @@ export class KubernetesService implements OnModuleInit {
* Returns usage in GB.
*/
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const pods = await coreApi.listNamespacedPod({
namespace,
@@ -4305,7 +4259,7 @@ export class KubernetesService implements OnModuleInit {
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
*/
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
try {
@@ -4362,7 +4316,7 @@ export class KubernetesService implements OnModuleInit {
* Resize app storage PVC (all app types).
*/
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
// Try new unified name first, then legacy wp-content name
@@ -4427,7 +4381,7 @@ export class KubernetesService implements OnModuleInit {
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
*/
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbName = `${app.name}-db`;
@@ -4608,7 +4562,7 @@ export class KubernetesService implements OnModuleInit {
* Strategy: Create archive, then sleep to allow exec retrieval.
*/
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-storage`;
@@ -4760,7 +4714,7 @@ export class KubernetesService implements OnModuleInit {
* Restore wp-content from a tar.gz archive into the WordPress PVC.
*/
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-storage`;
@@ -4902,7 +4856,7 @@ export class KubernetesService implements OnModuleInit {
const releaseName = app.name;
try {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
if (!helmRevisions || helmRevisions.length === 0) {
@@ -4940,7 +4894,7 @@ export class KubernetesService implements OnModuleInit {
const releaseName = app.name;
try {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
return {
+3
View File
@@ -3,6 +3,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { validateProductionConfig } from './config/validate-production-config';
// Prevent Node.js from crashing on unhandled errors
process.on('unhandledRejection', (reason, promise) => {
@@ -15,6 +16,8 @@ process.on('uncaughtException', (error) => {
});
async function bootstrap() {
validateProductionConfig();
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'],
+10
View File
@@ -0,0 +1,10 @@
/** Prevent Jest from loading ESM-only @kubernetes/client-node in unit tests. */
jest.mock('@kubernetes/client-node', () => ({
KubeConfig: jest.fn(),
CoreV1Api: jest.fn(),
AppsV1Api: jest.fn(),
BatchV1Api: jest.fn(),
NetworkingV1Api: jest.fn(),
CustomObjectsApi: jest.fn(),
HttpError: class HttpError extends Error {},
}));
+63
View File
@@ -0,0 +1,63 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { DeploymentsService } from '../src/deployments/deployments.service';
import { Deployment } from '../src/deployments/entities/deployment.entity';
import { ApplicationsService } from '../src/applications/applications.service';
import { KubernetesService } from '../src/kubernetes/kubernetes.service';
import { BuildService } from '../src/build/build.service';
import { ClustersService } from '../src/clusters/clusters.service';
/**
* Smoke test: deployment reads must enforce application ownership (IDOR fix).
*/
describe('Deployments authorization (e2e smoke)', () => {
let service: DeploymentsService;
const deploymentsRepository = {
find: jest.fn(),
findOne: jest.fn(),
createQueryBuilder: jest.fn(),
};
const applicationsService = {
findOne: jest.fn(),
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DeploymentsService,
{ provide: getRepositoryToken(Deployment), useValue: deploymentsRepository },
{ provide: ApplicationsService, useValue: applicationsService },
{ provide: KubernetesService, useValue: {} },
{ provide: BuildService, useValue: {} },
{ provide: ClustersService, useValue: {} },
],
}).compile();
service = module.get(DeploymentsService);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('rejects findOne when application ownership check fails', async () => {
deploymentsRepository.findOne.mockResolvedValue({
id: 'd-1',
applicationId: 'app-other',
});
applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found'));
await expect(service.findOne('d-1', 'user-a')).rejects.toThrow(NotFoundException);
});
it('allows findOne when user owns the application', async () => {
const deployment = { id: 'd-1', applicationId: 'app-1' };
deploymentsRepository.findOne.mockResolvedValue(deployment);
applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-a' });
await expect(service.findOne('d-1', 'user-a')).resolves.toBe(deployment);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFilesAfterEnv": ["<rootDir>/../src/test-setup.ts"]
}