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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
Generated
+29
-30
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,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'],
|
||||
|
||||
@@ -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 {},
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user