diff --git a/backend/k8s/nixpacks-spike/nixpacks-spike.yaml b/backend/k8s/nixpacks-spike/nixpacks-spike.yaml deleted file mode 100644 index 620ccde..0000000 --- a/backend/k8s/nixpacks-spike/nixpacks-spike.yaml +++ /dev/null @@ -1,212 +0,0 @@ -# ───────────────────────────────────────────────────────────────────────────── -# فاز ۰ — Spike ریسک Nixpacks روی شبکه‌ی ایران (abrban / cloudhost-builds) -# -# هدف: قبل از مهاجرت سیستم بیلد به Nixpacks (فاز ۲)، مطمئن شویم زنجیره‌ی -# nixpacks (تولید Dockerfile) → kaniko (build واقعی + نصب وابستگی‌ها) -# پشت شبکه‌ی ایران کار می‌کند و کشف کنیم چه mirror/proxy لازم است. -# -# چرا این ساختار: `nixpacks build --out` فقط Dockerfile می‌سازد و دانلودی ندارد؛ -# دانلود سنگین (nixpkgs + npm/go modules) داخل مرحله‌ی Docker build اتفاق می‌افتد. -# پس برای تست واقعی شبکه باید kaniko همان Dockerfile تولیدی را build کند. -# با --no-push نیازی به رجیستری/کردنشال نیست — فقط build تست می‌شود. -# -# اجرا: -# kubectl apply -f nixpacks-spike.yaml -# kubectl -n cloudhost-builds logs -f job/nixpacks-spike-node -# kubectl -n cloudhost-builds logs -f job/nixpacks-spike-go -# # بعد از اتمام: -# kubectl -n cloudhost-builds delete -f nixpacks-spike.yaml -# -# اگر kaniko سرِ `RUN ... npm install` یا fetch nixpkgs گیر کرد → شبکه‌ی ایران -# مانع است؛ env های mirror را (بخش «نکات mirror» پایین فایل) فعال/تنظیم کنید و -# دوباره اجرا کنید. نتیجه را برای تصمیم فاز ۲ مستند کنید. -# ───────────────────────────────────────────────────────────────────────────── ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nixpacks-spike-node-src - namespace: cloudhost-builds -data: - package.json: | - { - "name": "nixpacks-spike", - "version": "1.0.0", - "private": true, - "scripts": { "start": "node index.js" }, - "dependencies": { "express": "^4.18.2" } - } - index.js: | - const express = require('express'); - const app = express(); - app.get('/', (_req, res) => res.send('nixpacks spike ok')); - app.listen(process.env.PORT || 3000, () => console.log('up')); ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: nixpacks-spike-node - namespace: cloudhost-builds -spec: - backoffLimit: 0 - ttlSecondsAfterFinished: 1800 - template: - spec: - restartPolicy: Never - volumes: - - name: workspace - emptyDir: {} - - name: src - configMap: - name: nixpacks-spike-node-src - initContainers: - # 1) staging سورس نمونه از ConfigMap به workspace - - name: stage-source - image: alpine:3.19 - imagePullPolicy: IfNotPresent - command: - - sh - - -c - - | - set -e - mkdir -p /workspace/source - cp /src/package.json /workspace/source/package.json - cp /src/index.js /workspace/source/index.js - echo ">>> staged source:" && ls -la /workspace/source - volumeMounts: - - { name: workspace, mountPath: /workspace } - - { name: src, mountPath: /src } - # 2) Nixpacks: تولید Dockerfile در /workspace/source/.nixpacks/Dockerfile - - name: nixpacks-plan - image: ghcr.io/railwayapp/nixpacks:latest - imagePullPolicy: IfNotPresent - # نگاشت همان تنظیماتی که فاز ۲ پاس می‌دهد (نسخه‌ی Node و PORT) - env: - - { name: NIXPACKS_NODE_VERSION, value: "20" } - # - { name: NPM_CONFIG_REGISTRY, value: "https://registry.npmmirror.com" } # ← در صورت نیاز - command: - - nixpacks - - build - - /workspace/source - - --out - - /workspace/source - volumeMounts: - - { name: workspace, mountPath: /workspace } - containers: - # 3) Kaniko: build واقعی Dockerfile تولیدی (تست دانلود وابستگی‌ها). بدون push. - - name: kaniko - image: gcr.io/kaniko-project/executor:v1.23.2 - imagePullPolicy: IfNotPresent - args: - - --dockerfile=/workspace/source/.nixpacks/Dockerfile - - --context=dir:///workspace/source - - --no-push - - --verbosity=info - volumeMounts: - - { name: workspace, mountPath: /workspace } - resources: - requests: { cpu: "500m", memory: "1Gi" } - limits: { cpu: "2", memory: "4Gi" } ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nixpacks-spike-go-src - namespace: cloudhost-builds -data: - go.mod: | - module nixpacksspike - - go 1.22 - main.go: | - package main - - import ( - "fmt" - "net/http" - "os" - ) - - func main() { - http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprintln(w, "nixpacks spike ok") - }) - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - http.ListenAndServe(":"+port, nil) - } ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: nixpacks-spike-go - namespace: cloudhost-builds -spec: - backoffLimit: 0 - ttlSecondsAfterFinished: 1800 - template: - spec: - restartPolicy: Never - volumes: - - name: workspace - emptyDir: {} - - name: src - configMap: - name: nixpacks-spike-go-src - initContainers: - - name: stage-source - image: alpine:3.19 - imagePullPolicy: IfNotPresent - command: - - sh - - -c - - | - set -e - mkdir -p /workspace/source - cp /src/go.mod /workspace/source/go.mod - cp /src/main.go /workspace/source/main.go - echo ">>> staged source:" && ls -la /workspace/source - volumeMounts: - - { name: workspace, mountPath: /workspace } - - { name: src, mountPath: /src } - - name: nixpacks-plan - image: ghcr.io/railwayapp/nixpacks:latest - imagePullPolicy: IfNotPresent - env: - # - { name: GOPROXY, value: "https://goproxy.cn,direct" } # ← در صورت نیاز (mirror چین) - command: - - nixpacks - - build - - /workspace/source - - --out - - /workspace/source - volumeMounts: - - { name: workspace, mountPath: /workspace } - containers: - - name: kaniko - image: gcr.io/kaniko-project/executor:v1.23.2 - imagePullPolicy: IfNotPresent - args: - - --dockerfile=/workspace/source/.nixpacks/Dockerfile - - --context=dir:///workspace/source - - --no-push - - --verbosity=info - volumeMounts: - - { name: workspace, mountPath: /workspace } - resources: - requests: { cpu: "500m", memory: "1Gi" } - limits: { cpu: "2", memory: "4Gi" } -# ───────────────────────────────────────────────────────────────────────────── -# نکات mirror (اگر build گیر کرد، uncomment/تنظیم و دوباره اجرا کنید): -# • npm: NPM_CONFIG_REGISTRY=https://registry.npmmirror.com (روی container kaniko -# اثر ندارد چون Dockerfile تولیدی است؛ بهتر است در فاز ۲ به‌صورت ARG/ENV -# داخل مرحله‌ی نصب تزریق شود — اینجا فقط برای nixpacks-plan گذاشته شده.) -# • nix: اگر دانلود nixpkgs (https://github.com/NixOS/...) شکست خورد، احتمال نیاز به -# HTTP(S)_PROXY روی container kaniko یا آینه‌سازی nixpkgs. در لاگ kaniko دنبال -# خطوط fetch tarball بگردید. -# • go: GOPROXY=https://goproxy.cn,direct یا proxy داخلی. -# • اگر pull از ghcr.io/gcr.io خود مشکل داشت → image ها را به رجیستری داخلی mirror کنید -# (همان الگوی LOGGING_*_IMAGE در configuration.ts). -# ───────────────────────────────────────────────────────────────────────────── diff --git a/backend/package-lock.json b/backend/package-lock.json index 73572b4..18fc8c6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,7 +25,6 @@ "handlebars": "^4.7.8", "helmet": "^8.2.0", "js-yaml": "^4.2.0", - "minio": "^8.0.7", "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -2588,18 +2587,6 @@ "typeorm": "^0.3.0 || ^1.0.0-dev" } }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4093,18 +4080,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -4131,12 +4106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4415,15 +4384,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/block-stream2": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", - "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", - "license": "MIT", - "dependencies": { - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4524,12 +4484,6 @@ "concat-map": "0.0.1" } }, - "node_modules/browser-or-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", - "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", - "license": "MIT" - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -4613,15 +4567,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -5203,15 +5148,6 @@ } } }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -5791,12 +5727,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6033,45 +5963,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.2.0", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.4.1", - "xml-naming": "^0.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6130,15 +6021,6 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, - "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -6912,18 +6794,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8274,39 +8144,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minio": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", - "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.4", - "block-stream2": "^2.1.0", - "browser-or-node": "^2.1.1", - "buffer-crc32": "^1.0.0", - "eventemitter3": "^5.0.1", - "fast-xml-parser": "^5.3.4", - "ipaddr.js": "^2.0.1", - "lodash": "^4.17.21", - "mime-types": "^2.1.35", - "query-string": "^7.1.3", - "stream-json": "^1.8.0", - "through2": "^4.0.2", - "xml2js": "^0.5.0 || ^0.6.2" - }, - "engines": { - "node": "^16 || ^18 || >=20" - } - }, - "node_modules/minio/node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -8789,21 +8626,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9231,24 +9053,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/query-string": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", - "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", - "license": "MIT", - "dependencies": { - "decode-uri-component": "^0.2.2", - "filter-obj": "^1.1.0", - "split-on-first": "^1.0.0", - "strict-uri-encode": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9475,15 +9279,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -9813,15 +9608,6 @@ "node": ">=0.10.0" } }, - "node_modules/split-on-first": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", - "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -9901,21 +9687,6 @@ "node": ">= 0.10.0" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -9935,15 +9706,6 @@ "text-decoder": "^1.1.0" } }, - "node_modules/strict-uri-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", - "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -10058,21 +9820,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.1" - } - }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -10422,15 +10169,6 @@ "b4a": "^1.6.4" } }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "license": "MIT", - "dependencies": { - "readable-stream": "3" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -11448,43 +11186,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 7b9be9a..236ab6c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,7 +38,6 @@ "handlebars": "^4.7.8", "helmet": "^8.2.0", "js-yaml": "^4.2.0", - "minio": "^8.0.7", "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -70,19 +69,13 @@ "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" } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d8f0468..a114504 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,8 +15,6 @@ 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 { RedisModule } from './common/redis/redis.module'; -import { StorageModule } from './common/storage/storage.module'; import configuration from './config/configuration'; @Module({ @@ -56,12 +54,6 @@ import configuration from './config/configuration'; inject: [ConfigService], }), - // Shared Redis client (build state across replicas) - RedisModule, - - // Shared MinIO storage (application source archives) - StorageModule, - // Feature modules AuthModule, UsersModule, diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 88f2f17..6dcb145 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -17,7 +17,6 @@ import { } from '../common/enums'; import { ensureAppUrlEnv } from './app-url.util'; import { normalizeCreateApplicationDto } from './managed-service.util'; -import { StorageService } from '../common/storage/storage.service'; @Injectable() export class ApplicationsService { @@ -28,7 +27,6 @@ export class ApplicationsService { private appsRepository: Repository, private clustersService: ClustersService, private configService: ConfigService, - private storageService: StorageService, ) {} private toDnsLabel(value: string): string { @@ -206,20 +204,18 @@ export class ApplicationsService { async delete(id: string, userId: string): Promise { const app = await this.findOne(id, userId); - // Delete the uploaded source archive from object storage. + // Delete uploaded files if (app.codePath) { - await this.storageService.removeSource(app.codePath); - } - // Remove any legacy on-disk dump/source dir (db dumps are still stored locally). - try { - const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; - const appDir = path.join(uploadDir, app.userId, app.id); - if (fs.existsSync(appDir)) { - fs.rmSync(appDir, { recursive: true, force: true }); - this.logger.log(`Deleted upload directory: ${appDir}`); + try { + const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; + const appDir = path.join(uploadDir, app.userId, app.id); + if (fs.existsSync(appDir)) { + fs.rmSync(appDir, { recursive: true, force: true }); + this.logger.log(`Deleted upload directory: ${appDir}`); + } + } catch (e: any) { + this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`); } - } catch (e: any) { - this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`); } await this.appsRepository.remove(app); @@ -265,14 +261,21 @@ export class ApplicationsService { } const app = await this.findOne(id, userId); + const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; + const appDir = path.join(uploadDir, app.userId, app.id); - // Stream the archive to MinIO; codePath stores the object key (build pods - // pull it via a presigned URL — no local disk, no PVC, no kubectl cp). - const key = await this.storageService.putSource(app.userId, app.id, file.buffer); - app.codePath = key; + // Ensure directory exists + fs.mkdirSync(appDir, { recursive: true }); + + // Save the zip file + const zipPath = path.join(appDir, 'source.zip'); + fs.writeFileSync(zipPath, file.buffer); + + // Update app with code path + app.codePath = zipPath; const saved = await this.appsRepository.save(app); - this.logger.log(`Uploaded code for ${app.name} → ${key} (${(file.size / 1024).toFixed(1)} KB)`); + this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`); return saved; } diff --git a/backend/src/build/build.module.ts b/backend/src/build/build.module.ts index 8b561f4..caa562f 100644 --- a/backend/src/build/build.module.ts +++ b/backend/src/build/build.module.ts @@ -1,6 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; import { BuildService } from './build.service'; -import { ScanService } from './scan.service'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { ClustersModule } from '../clusters/clusters.module'; @@ -9,7 +8,7 @@ import { ClustersModule } from '../clusters/clusters.module'; forwardRef(() => KubernetesModule), ClustersModule, ], - providers: [BuildService, ScanService], - exports: [BuildService, ScanService], + providers: [BuildService], + exports: [BuildService], }) export class BuildModule {} diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index e1af1c3..8cfe769 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -1,16 +1,298 @@ import { AppRuntime } from '../common/enums'; /** - * Tests for build service: - * • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes - * • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic - * - * NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic - * locally instead of importing BuildService — the service pulls in the ESM - * `@kubernetes/client-node`, which this project's Jest config does not transform. - * Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts. + * Tests for build service — Dockerfile generation for all runtimes */ +// ───────────────────────────────────────────────────────────────────────────── +// Go Dockerfile tests +// ───────────────────────────────────────────────────────────────────────────── +describe('Go Dockerfile generation', () => { + function goDockerfile(app: { runtimeVersion?: string; port?: number }): string { + const goVersion = app.runtimeVersion || '1.22'; + const port = app.port || 8080; + return `FROM golang:${goVersion}-alpine AS builder +WORKDIR /app +RUN apk add --no-cache git +COPY go.mod go.sum* ./ +RUN go mod download || true +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main . + +FROM alpine:3.19 +WORKDIR /app +RUN apk --no-cache add ca-certificates tzdata +RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup +COPY --from=builder /app/main . +RUN mkdir -p /app/data && chown -R appuser:appgroup /app +USER appuser +ENV PORT=${port} +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1 +CMD ["./main"] +`; + } + + it('should use correct Go version', () => { + const df = goDockerfile({ runtimeVersion: '1.21' }); + expect(df).toContain('FROM golang:1.21-alpine'); + }); + + it('should default to Go 1.22', () => { + const df = goDockerfile({}); + expect(df).toContain('FROM golang:1.22-alpine'); + }); + + it('should build static binary with CGO_ENABLED=0', () => { + const df = goDockerfile({}); + expect(df).toContain('CGO_ENABLED=0'); + }); + + it('should use multi-stage build for smaller image', () => { + const df = goDockerfile({}); + expect(df).toContain('AS builder'); + expect(df).toContain('FROM alpine:3.19'); + }); + + it('should include health check', () => { + const df = goDockerfile({ port: 8080 }); + expect(df).toContain('HEALTHCHECK'); + expect(df).toContain('http://localhost:8080/health'); + }); + + it('should create data directory for persistent storage', () => { + const df = goDockerfile({}); + expect(df).toContain('mkdir -p /app/data'); + }); + + it('should run as non-root user', () => { + const df = goDockerfile({}); + expect(df).toContain('USER appuser'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Python Dockerfile tests +// ───────────────────────────────────────────────────────────────────────────── +describe('Python Dockerfile generation', () => { + function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string { + const pythonVersion = app.runtimeVersion || '3.12'; + const port = app.port || 8000; + return `FROM python:${pythonVersion}-slim AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y build-essential libpq-dev +COPY requirements.txt* ./ +RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn + +FROM python:${pythonVersion}-slim +WORKDIR /app +RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser +COPY --from=builder /root/.local /home/appuser/.local +COPY . . +RUN mkdir -p /app/data && chown -R appuser:appgroup /app +USER appuser +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PORT=${port} +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1 +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"] +`; + } + + it('should use correct Python version', () => { + const df = pythonDockerfile({ runtimeVersion: '3.11' }); + expect(df).toContain('FROM python:3.11-slim'); + }); + + it('should default to Python 3.12', () => { + const df = pythonDockerfile({}); + expect(df).toContain('FROM python:3.12-slim'); + }); + + it('should use multi-stage build', () => { + const df = pythonDockerfile({}); + expect(df).toContain('AS builder'); + }); + + it('should install from requirements.txt', () => { + const df = pythonDockerfile({}); + expect(df).toContain('requirements.txt'); + }); + + it('should include health check', () => { + const df = pythonDockerfile({ port: 8000 }); + expect(df).toContain('HEALTHCHECK'); + expect(df).toContain('http://localhost:8000/health'); + }); + + it('should run as non-root user', () => { + const df = pythonDockerfile({}); + expect(df).toContain('USER appuser'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Django Dockerfile tests +// ───────────────────────────────────────────────────────────────────────────── +describe('Django Dockerfile generation', () => { + function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string { + const pythonVersion = app.runtimeVersion || '3.12'; + const port = app.port || 8000; + return `FROM python:${pythonVersion}-slim AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y build-essential libpq-dev +COPY requirements.txt* ./ +RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn + +FROM python:${pythonVersion}-slim +WORKDIR /app +COPY --from=builder /root/.local /home/appuser/.local +COPY . . +RUN mkdir -p /app/staticfiles /app/media /app/data +USER appuser +ENV PORT=${port} +ENV DJANGO_SETTINGS_MODULE=config.settings +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1 +CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"] +`; + } + + it('should use correct Python version', () => { + const df = djangoDockerfile({ runtimeVersion: '3.10' }); + expect(df).toContain('FROM python:3.10-slim'); + }); + + it('should set DJANGO_SETTINGS_MODULE', () => { + const df = djangoDockerfile({}); + expect(df).toContain('DJANGO_SETTINGS_MODULE'); + }); + + it('should create staticfiles and media directories', () => { + const df = djangoDockerfile({}); + expect(df).toContain('/app/staticfiles'); + expect(df).toContain('/app/media'); + }); + + it('should run migrations on startup', () => { + const df = djangoDockerfile({}); + expect(df).toContain('migrate'); + }); + + it('should use gunicorn for production', () => { + const df = djangoDockerfile({}); + expect(df).toContain('gunicorn'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// .NET Dockerfile tests +// ───────────────────────────────────────────────────────────────────────────── +describe('.NET Dockerfile generation', () => { + function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string { + const dotnetVersion = app.runtimeVersion || '8.0'; + const port = app.port || 5000; + return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build +WORKDIR /src +COPY *.csproj ./ +RUN dotnet restore || true +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion} +WORKDIR /app +COPY --from=build /app/publish . +RUN mkdir -p /app/data +USER appuser +ENV ASPNETCORE_URLS=http://+:${port} +ENV ASPNETCORE_ENVIRONMENT=Production +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1 +CMD ["dotnet", "app.dll"] +`; + } + + it('should use correct .NET version', () => { + const df = dotnetDockerfile({ runtimeVersion: '7.0' }); + expect(df).toContain('dotnet/sdk:7.0'); + expect(df).toContain('dotnet/aspnet:7.0'); + }); + + it('should default to .NET 8.0', () => { + const df = dotnetDockerfile({}); + expect(df).toContain('dotnet/sdk:8.0'); + }); + + it('should use multi-stage build', () => { + const df = dotnetDockerfile({}); + expect(df).toContain('AS build'); + expect(df).toContain('dotnet/aspnet'); + }); + + it('should publish in Release mode', () => { + const df = dotnetDockerfile({}); + expect(df).toContain('-c Release'); + }); + + it('should set ASPNETCORE_ENVIRONMENT to Production', () => { + const df = dotnetDockerfile({}); + expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production'); + }); + + it('should configure ASPNETCORE_URLS for correct port', () => { + const df = dotnetDockerfile({ port: 8080 }); + expect(df).toContain('ASPNETCORE_URLS=http://+:8080'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PHP Dockerfile tests +// ───────────────────────────────────────────────────────────────────────────── +describe('PHP Dockerfile generation', () => { + function phpDockerfile(app: { phpVersion?: string; port?: number }): string { + const phpVersion = app.phpVersion || '8.3'; + const port = app.port || 80; + return `FROM php:${phpVersion}-fpm-alpine +RUN apk add --no-cache nginx supervisor curl +RUN docker-php-ext-install pdo pdo_mysql opcache +WORKDIR /var/www/html +COPY . . +RUN mkdir -p /var/www/html/uploads /var/www/html/data +RUN chown -R www-data:www-data /var/www/html +EXPOSE ${port} +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] +`; + } + + it('should use correct PHP version', () => { + const df = phpDockerfile({ phpVersion: '8.2' }); + expect(df).toContain('FROM php:8.2-fpm-alpine'); + }); + + it('should default to PHP 8.3', () => { + const df = phpDockerfile({}); + expect(df).toContain('FROM php:8.3-fpm-alpine'); + }); + + it('should use FPM with nginx via supervisord', () => { + const df = phpDockerfile({}); + expect(df).toContain('supervisord'); + expect(df).toContain('nginx'); + }); + + it('should install common PHP extensions', () => { + const df = phpDockerfile({}); + expect(df).toContain('pdo'); + expect(df).toContain('opcache'); + }); + + it('should create upload and data directories', () => { + const df = phpDockerfile({}); + expect(df).toContain('/var/www/html/uploads'); + expect(df).toContain('/var/www/html/data'); + }); +}); + /** * Tests for the WordPress build flow — specifically: * 1. Helper pod PVC race condition (must wait for termination) @@ -185,75 +467,3 @@ describe('WordPress zip structure handling', () => { expect(copiedAsIs).toBe(true); }); }); - -describe('Nixpacks build preparation', () => { - // Local copies of the pure logic in build.service.ts (see NOTE at top of file). - function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; - } - - function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] { - const env: { name: string; value: string }[] = []; - if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) { - env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) }); - } - if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) { - env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) }); - } - return env; - } - - function nixpacksPrepareInitContainer( - app: { runtime: AppRuntime; runtimeVersion?: string }, - config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {}, - ): any { - const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest'; - const buildEnv = config.nixpacksBuildEnv || []; - const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' '); - const planEnv = nixpacksPlanEnv(app); - return { - name: 'nixpacks-prepare', - image, - env: planEnv.length ? planEnv : undefined, - command: [ - 'sh', - '-c', - `if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` + - `else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`, - ], - volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], - }; - } - - it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => { - const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string; - expect(script).toContain('if [ -f source/Dockerfile ]'); - expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile'); - expect(script).toContain('nixpacks build source --out source'); - expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile'); - }); - - it('uses the configured Nixpacks image (default when unset)', () => { - expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest'); - expect( - nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image, - ).toBe('registry.local/nixpacks:1.2.3'); - }); - - it('bakes build-time mirror env into the build via --env flags', () => { - const script = nixpacksPrepareInitContainer( - { runtime: AppRuntime.NODEJS }, - { nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] }, - ).command[2] as string; - expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`); - }); - - it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => { - const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' }); - expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' }); - }); - - it('shellQuote escapes embedded single quotes safely', () => { - expect(shellQuote("a'b")).toBe("'a'\\''b'"); - }); -}); diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 1774439..99555d7 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -1,15 +1,17 @@ -import { Injectable, Logger, Inject } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Redis } from 'ioredis'; -import { REDIS_CLIENT } from '../common/redis/redis.module'; import * as k8s from '@kubernetes/client-node'; -import { ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execFile, spawn, ChildProcess } from 'child_process'; import * as net from 'net'; +import { promisify } from 'util'; 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 { StorageService } from '../common/storage/storage.service'; + +const execFileAsync = promisify(execFile); export class BuildCancelledError extends Error { constructor() { @@ -26,24 +28,10 @@ interface ActiveBuildSession { buildPodName?: string; sourcePvcName?: string; helperPodName?: string; - clusterId?: string; processes: ChildProcess[]; socket?: net.Socket; } -/** - * Serializable subset of a build session persisted to Redis so a build can be - * cancelled (or have its live logs read) from a backend replica other than the - * one running the build. `clusterId` lets that replica rebuild a K8s client. - */ -interface PersistedBuildSession { - namespace?: string; - buildPodName?: string; - sourcePvcName?: string; - helperPodName?: string; - clusterId?: string; -} - export interface BuildProgress { phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed' | 'cancelled'; percent: number; @@ -55,10 +43,8 @@ export interface BuildProgress { @Injectable() export class BuildService { private readonly logger = new Logger(BuildService.name); - /** Local, non-serializable session state (child processes, sockets, API clients). */ + private readonly progressMap = new Map(); private readonly activeBuilds = new Map(); - /** Build state TTL in Redis (1h) — long enough for the slowest build + final read. */ - private static readonly STATE_TTL_SECONDS = 3600; /** * Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node * with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build. @@ -69,21 +55,8 @@ export class BuildService { private configService: ConfigService, private clustersService: ClustersService, private registryService: RegistryService, - private storageService: StorageService, - @Inject(REDIS_CLIENT) private readonly redis: Redis, ) {} - // ─── Redis keys for cross-replica build state ────────────────────── - private progressKey(id: string): string { - return `build:progress:${id}`; - } - private sessionKey(id: string): string { - return `build:session:${id}`; - } - private cancelKey(id: string): string { - return `build:cancelled:${id}`; - } - private beginBuildSession(deploymentId: string): void { this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] }); } @@ -96,61 +69,6 @@ export class BuildService { private updateBuildSession(deploymentId: string, update: Partial): void { const session = this.activeBuilds.get(deploymentId); if (session) Object.assign(session, update); - // Mirror the serializable subset to Redis so another replica can cancel / - // read logs for this build. Fire-and-forget — never block the build on it. - const persisted: PersistedBuildSession = { - namespace: session?.namespace, - buildPodName: session?.buildPodName, - sourcePvcName: session?.sourcePvcName, - helperPodName: session?.helperPodName, - clusterId: session?.clusterId, - }; - void this.redis - .set(this.sessionKey(deploymentId), JSON.stringify(persisted), 'EX', BuildService.STATE_TTL_SECONDS) - .catch(() => undefined); - } - - /** Read the persisted (cross-replica) session metadata for a build. */ - private async readPersistedSession(deploymentId: string): Promise { - try { - const raw = await this.redis.get(this.sessionKey(deploymentId)); - return raw ? (JSON.parse(raw) as PersistedBuildSession) : null; - } catch { - return null; - } - } - - /** Build a CoreV1Api/BatchV1Api pair for a cluster (used for cross-replica cleanup). */ - private async makeClusterApis(clusterId?: string): Promise<{ coreApi: k8s.CoreV1Api; batchApi: k8s.BatchV1Api } | null> { - try { - const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault(); - const kc = new k8s.KubeConfig(); - kc.loadFromString(cluster.kubeconfig); - return { coreApi: kc.makeApiClient(k8s.CoreV1Api), batchApi: kc.makeApiClient(k8s.BatchV1Api) }; - } catch { - return null; - } - } - - /** - * Whether this build has been cancelled — checks both the local session flag - * and the shared Redis flag, so a cancel issued on any replica is observed by - * the replica actually running the build. - */ - private async isCancelledShared(deploymentId?: string): Promise { - if (!deploymentId) return false; - if (this.activeBuilds.get(deploymentId)?.cancelled) return true; - try { - return (await this.redis.exists(this.cancelKey(deploymentId))) === 1; - } catch { - return false; - } - } - - private async throwIfCancelledShared(deploymentId?: string): Promise { - if (await this.isCancelledShared(deploymentId)) { - throw new BuildCancelledError(); - } } private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void { @@ -193,82 +111,94 @@ export class BuildService { } private endBuildSession(deploymentId?: string): void { - if (!deploymentId) return; - this.activeBuilds.delete(deploymentId); - void this.redis.del(this.sessionKey(deploymentId), this.cancelKey(deploymentId)).catch(() => undefined); - } - - /** Delete the K8s artifacts (helper pod, build job, source PVC, dockerfile configmap) of one build. */ - private async deleteBuildArtifacts( - coreApi: k8s.CoreV1Api, - batchApi: k8s.BatchV1Api, - namespace: string, - names: { buildPodName?: string; sourcePvcName?: string; helperPodName?: string }, - ): Promise { - const { buildPodName, sourcePvcName, helperPodName } = names; - const cleanup: Promise[] = []; - if (helperPodName) { - cleanup.push(coreApi.deleteNamespacedPod({ name: helperPodName, namespace, gracePeriodSeconds: 0 }).catch(() => undefined)); - } - if (buildPodName) { - cleanup.push( - batchApi - .deleteNamespacedJob({ name: buildPodName, namespace, gracePeriodSeconds: 0, propagationPolicy: 'Foreground' }) - .catch(() => undefined), - ); - } - if (sourcePvcName) { - cleanup.push(coreApi.deleteNamespacedPersistentVolumeClaim({ name: sourcePvcName, namespace }).catch(() => undefined)); - } - if (buildPodName) { - cleanup.push(coreApi.deleteNamespacedConfigMap({ name: `${buildPodName}-dockerfile`, namespace }).catch(() => undefined)); - } - await Promise.all(cleanup); + if (deploymentId) this.activeBuilds.delete(deploymentId); } async cancelBuild(deploymentId: string): Promise { - this.logger.log(`Cancelling build for deployment ${deploymentId}`); - // Shared flag so the (possibly different) replica running the build observes - // the cancellation via isCancelledShared and aborts its wait loop. - void this.redis.set(this.cancelKey(deploymentId), '1', 'EX', BuildService.STATE_TTL_SECONDS).catch(() => undefined); - const session = this.activeBuilds.get(deploymentId); - if (session) { - // Local build — stop in-process work and clean up via the live API clients. - session.cancelled = true; - if (session.socket) { - try { - session.socket.destroy(); - } catch { - /* ignore */ - } + if (!session) { + this.setProgress(deploymentId, { + phase: 'cancelled', + percent: 0, + message: 'Cancelled by user', + }); + return; + } + + session.cancelled = true; + this.logger.log(`Cancelling build for deployment ${deploymentId}`); + + if (session.socket) { + try { + session.socket.destroy(); + } catch { + /* ignore */ } - for (const proc of session.processes) { - try { - proc.kill('SIGKILL'); - } catch { - /* ignore */ - } - } - if (session.coreApi && session.batchApi && session.namespace) { - await this.deleteBuildArtifacts(session.coreApi, session.batchApi, session.namespace, session); - this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`); - } - } else { - // Build is running on another replica (or already finished) — reconstruct - // a client from the persisted session metadata and clean up its artifacts. - const persisted = await this.readPersistedSession(deploymentId); - if (persisted?.namespace) { - const apis = await this.makeClusterApis(persisted.clusterId); - if (apis) { - await this.deleteBuildArtifacts(apis.coreApi, apis.batchApi, persisted.namespace, persisted); - this.logger.log(`Cleaned up cross-replica K8s build resources for deployment ${deploymentId}`); - } + } + for (const proc of session.processes) { + try { + proc.kill('SIGKILL'); + } catch { + /* ignore */ } } - this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' }); - this.endBuildSession(deploymentId); + const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session; + if (coreApi && namespace) { + const cleanup: Promise[] = []; + if (helperPodName) { + cleanup.push( + coreApi + .deleteNamespacedPod({ + name: helperPodName, + namespace, + gracePeriodSeconds: 0, + }) + .catch(() => undefined), + ); + } + if (buildPodName && batchApi) { + cleanup.push( + batchApi + .deleteNamespacedJob({ + name: buildPodName, + namespace, + gracePeriodSeconds: 0, + propagationPolicy: 'Foreground', + }) + .catch(() => undefined), + ); + } + if (sourcePvcName) { + cleanup.push( + coreApi + .deleteNamespacedPersistentVolumeClaim({ + name: sourcePvcName, + namespace, + }) + .catch(() => undefined), + ); + } + if (buildPodName) { + cleanup.push( + coreApi + .deleteNamespacedConfigMap({ + name: `${buildPodName}-dockerfile`, + namespace, + }) + .catch(() => undefined), + ); + } + await Promise.all(cleanup); + this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`); + } + + this.setProgress(deploymentId, { + phase: 'cancelled', + percent: 0, + message: 'Cancelled by user', + }); + this.activeBuilds.delete(deploymentId); } /** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */ @@ -347,29 +277,17 @@ export class BuildService { this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`); } - async getProgress(deploymentId: string): Promise { - try { - const raw = await this.redis.get(this.progressKey(deploymentId)); - return raw ? (JSON.parse(raw) as BuildProgress) : null; - } catch { - return null; - } + getProgress(deploymentId: string): BuildProgress | null { + return this.progressMap.get(deploymentId) ?? null; } - /** - * Best-effort progress write. Kept synchronous (fire-and-forget) so the many - * call sites in the deploy pipeline don't need to await telemetry; a Redis - * blip must never fail a build. - */ setProgress(deploymentId: string | undefined, progress: BuildProgress): void { if (!deploymentId) return; - void this.redis - .set(this.progressKey(deploymentId), JSON.stringify(progress), 'EX', BuildService.STATE_TTL_SECONDS) - .catch(() => undefined); + this.progressMap.set(deploymentId, progress); } - async clearProgress(deploymentId: string): Promise { - await this.redis.del(this.progressKey(deploymentId)).catch(() => undefined); + clearProgress(deploymentId: string): void { + this.progressMap.delete(deploymentId); } /** @@ -388,13 +306,8 @@ export class BuildService { this.beginBuildSession(deploymentId); } - // Build mode: - // • templated → WordPress (and fresh installs): a generated Dockerfile is - // injected via ConfigMap (Nixpacks can't build a WordPress upload). - // • nixpacks → every code runtime: an init container picks the user's own - // Dockerfile if present (BYO), otherwise generates one with Nixpacks. - const useTemplated = app.runtime === AppRuntime.WORDPRESS; - const dockerfileContent = useTemplated ? this.wordpressDockerfile(app) : null; + // Determine Dockerfile based on runtime + const dockerfileContent = this.generateDockerfile(app); // Create Kaniko build pod const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, ''); @@ -416,32 +329,18 @@ export class BuildService { coreApi, batchApi, namespace: buildNamespace, - clusterId: cluster.id, }); } // Ensure the build namespace exists await this.ensureNamespace(coreApi, buildNamespace); - await this.throwIfCancelledShared(deploymentId); + this.throwIfCancelled(deploymentId); - // Determine source: uploaded code (MinIO object key in app.codePath) or git URL. - const hasUploadedCode = !!app.codePath; + // Determine if we have uploaded code or git URL + const codePath = app.codePath ? path.resolve(app.codePath) : null; + const hasUploadedCode = codePath && fs.existsSync(codePath); const hasGitUrl = !!app.gitUrl; - // For private git repos, the token is delivered via a per-build Secret (env) - // and used through git's credential store inside the pod — never embedded in - // the clone URL/args or the Job manifest (which would leak it into etcd/logs). - let gitHost = ''; - if (hasGitUrl) { - try { - gitHost = new URL(app.gitUrl!).host; - } catch { - /* malformed URL — fall back to inline injection below */ - } - } - const useGitTokenSecret = hasGitUrl && !!app.gitToken && !!gitHost; - const gitSecretName = `${buildPodName}-git`; - // Create ConfigMap with Dockerfile const dockerfileConfigMap = { apiVersion: 'v1', @@ -451,15 +350,22 @@ export class BuildService { namespace: buildNamespace, }, data: { - Dockerfile: dockerfileContent ?? '', + Dockerfile: dockerfileContent, }, }; - // For uploaded code, mint a short-lived presigned URL the build pod downloads - // from MinIO (replaces the PVC + helper pod + kubectl cp upload path). - let sourceDownloadUrl: string | undefined; + // If we have uploaded code, create a PVC and upload via kubectl cp + let sourcePvcName: string | undefined; if (hasUploadedCode) { - sourceDownloadUrl = await this.storageService.presignSourceGet(app.codePath!); + sourcePvcName = `${buildPodName}-source`; + if (deploymentId) { + this.updateBuildSession(deploymentId, { sourcePvcName }); + } + const zipSize = fs.statSync(codePath!).size; + // Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi + const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024))); + + await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId); } // Build the Kaniko Job spec @@ -481,51 +387,46 @@ export class BuildService { name: 'docker-config', secret: { secretName: 'registry-credentials' }, }, + { + name: 'dockerfile', + configMap: { + name: `${buildPodName}-dockerfile`, + }, + }, { name: 'workspace', emptyDir: {}, }, ]; - // The generated Dockerfile is only mounted (via ConfigMap) in templated mode. - if (useTemplated) { - volumes.push({ name: 'dockerfile', configMap: { name: `${buildPodName}-dockerfile` } }); - } - - // In templated mode each staging container copies the ConfigMap Dockerfile to - // /workspace/Dockerfile; in nixpacks mode the nixpacks-prepare container writes - // it instead, so staging just lays down the source. - const copyTemplatedDockerfile = useTemplated ? 'cp /workspace/Dockerfile /workspace-out/Dockerfile &&' : ''; - const stagingDockerfileMounts = useTemplated - ? [{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' }] - : []; - const initContainers: any[] = []; - if (hasUploadedCode && sourceDownloadUrl) { - // Download the source archive from MinIO via the presigned URL, then unzip - // it into /workspace/source (no PVC, no helper pod, no credentials in-pod). + if (hasUploadedCode && sourcePvcName) { + // Add the source PVC as a volume + volumes.push({ + name: 'source-pvc', + persistentVolumeClaim: { claimName: sourcePvcName }, + }); + + // Add init container that unzips the source code from PVC initContainers.push({ - name: 'fetch-source', + name: 'unzip-source', image: 'alpine:3.19', imagePullPolicy: 'IfNotPresent', - env: [{ name: 'SOURCE_URL', value: sourceDownloadUrl }], command: [ 'sh', '-c', ` - apk add --no-cache unzip tar gzip wget && - ${copyTemplatedDockerfile} - echo ">>> Downloading source archive from object storage..." && - wget -q -O /tmp/source.zip "$SOURCE_URL" && + apk add --no-cache unzip tar gzip && + cp /workspace/Dockerfile /workspace-out/Dockerfile && mkdir -p /tmp/extract && cd /tmp/extract && - if tar tzf /tmp/source.zip >/dev/null 2>&1; then + if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then echo ">>> Detected gzip tarball" && - tar xzf /tmp/source.zip - elif unzip -t /tmp/source.zip >/dev/null 2>&1; then + tar xzf /source-pvc/source.zip + elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then echo ">>> Detected zip archive" && - unzip -q /tmp/source.zip + unzip -q /source-pvc/source.zip else echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1 fi && @@ -541,66 +442,72 @@ export class BuildService { echo ">>> Multiple items or files — copying as-is" && cp -a /tmp/extract/. /workspace-out/source/ fi && - rm -rf /tmp/extract /tmp/source.zip && + rm -rf /tmp/extract && echo "--- Final workspace contents ---" && ls -la /workspace-out/source/ `, ], volumeMounts: [ { name: 'workspace', mountPath: '/workspace-out' }, - ...stagingDockerfileMounts, + { + name: 'dockerfile', + mountPath: '/workspace/Dockerfile', + subPath: 'Dockerfile', + }, + { name: 'source-pvc', mountPath: '/source-pvc' }, ], }); } else if (hasGitUrl) { - const branch = app.gitBranch || 'main'; - const gitCopyDockerfile = useTemplated ? 'cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&' : ''; - - // Clone command. Three cases: - // • token (https) → the token comes from $GIT_TOKEN (Secret env) and is - // expanded into the clone URL *inside* the container, so the literal - // token never lands in the Job manifest/etcd. We use env expansion - // rather than a git credential store because build pods often have no - // $HOME, which silently breaks the store helper. - // • token (non-https / unparseable) → inline injection fallback. - // • no token (public repo) → plain clone. - let cloneCmd: string; - let gitEnv: any[] | undefined; - if (useGitTokenSecret) { - gitEnv = [{ name: 'GIT_TOKEN', valueFrom: { secretKeyRef: { name: gitSecretName, key: 'token' } } }]; - const repoNoScheme = app.gitUrl!.replace(/^https?:\/\//, ''); - cloneCmd = `git clone --depth 1 --branch ${branch} "https://\${GIT_TOKEN}@${repoNoScheme}" /workspace-out/source &&`; - } else if (app.gitToken) { - const cloneUrl = app.gitUrl!.replace('https://', `https://${app.gitToken}@`); - cloneCmd = `git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&`; - } else { - cloneCmd = `git clone --depth 1 --branch ${branch} '${app.gitUrl}' /workspace-out/source &&`; + // Build the git clone URL — inject token for private repos + let cloneUrl = app.gitUrl!; + if (app.gitToken) { + // Convert https://github.com/user/repo.git → https://@github.com/user/repo.git + // Also works for GitLab, Bitbucket, etc. + try { + const url = new URL(cloneUrl); + url.username = app.gitToken; + url.password = ''; // Some providers use token as username, others as password + cloneUrl = url.toString(); + } catch { + // If URL parsing fails, try simple injection after protocol + cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`); + } } + const branch = app.gitBranch || 'main'; - // Clone git repo into /workspace/source + // Clone git repo into /workspace/source, then copy our generated Dockerfile initContainers.push({ name: 'git-clone', image: 'alpine/git:2.43.0', imagePullPolicy: 'IfNotPresent', - env: gitEnv, command: [ 'sh', '-c', ` echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" && - ${cloneCmd} - ${gitCopyDockerfile} + git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && + cp /dockerfile/Dockerfile /workspace-out/Dockerfile && echo ">>> Workspace contents:" && ls -la /workspace-out/source/ `, ], volumeMounts: [ { name: 'workspace', mountPath: '/workspace-out' }, - ...(useTemplated ? [{ name: 'dockerfile', mountPath: '/dockerfile' }] : []), + { name: 'dockerfile', mountPath: '/dockerfile' }, ], }); - } else if (useTemplated) { - // No uploaded code and no git — only valid for templated fresh installs - // (e.g. fresh WordPress). Create empty source dir + copy Dockerfile. + } + + // Kaniko container volume mounts + const kanikoVolumeMounts: any[] = [ + { name: 'docker-config', mountPath: '/kaniko/.docker' }, + { name: 'workspace', mountPath: '/workspace' }, + ]; + + // If no uploaded code and no git, we need to prepare the workspace + if (!hasUploadedCode && !hasGitUrl) { + // For runtimes that don't need source (e.g. fresh WordPress), + // add an init container that creates empty source dir + copies Dockerfile initContainers.push({ name: 'prepare-workspace', image: 'alpine:3.19', @@ -622,18 +529,6 @@ export class BuildService { }); } - // Nixpacks mode: after the source is staged, pick the user's Dockerfile (BYO) - // or generate one with Nixpacks, writing the result to /workspace/Dockerfile. - if (!useTemplated) { - initContainers.push(this.nixpacksPrepareInitContainer(app)); - } - - // Kaniko container volume mounts - const kanikoVolumeMounts: any[] = [ - { name: 'docker-config', mountPath: '/kaniko/.docker' }, - { name: 'workspace', mountPath: '/workspace' }, - ]; - const buildJob: k8s.V1Job = { apiVersion: 'batch/v1', kind: 'Job', @@ -669,26 +564,12 @@ export class BuildService { }; try { - if (useTemplated) { - const t0 = Date.now(); - await coreApi.createNamespacedConfigMap({ - namespace: buildNamespace!, - body: dockerfileConfigMap, - }); - this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`); - } - - // Per-build Secret holding the git token (mounted as $GIT_TOKEN env). - if (useGitTokenSecret) { - await coreApi.createNamespacedSecret({ - namespace: buildNamespace!, - body: { - metadata: { name: gitSecretName, namespace: buildNamespace }, - type: 'Opaque', - data: { token: Buffer.from(app.gitToken!).toString('base64') }, - }, - }); - } + const t0 = Date.now(); + await coreApi.createNamespacedConfigMap({ + namespace: buildNamespace!, + body: dockerfileConfigMap, + }); + this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`); const t1 = Date.now(); await batchApi.createNamespacedJob({ @@ -703,8 +584,7 @@ export class BuildService { percent: 15, message: 'Building Docker image...', }); - const buildTimeout = this.configService.get('build.timeoutSeconds') || 600; - await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, buildTimeout, deploymentId); + await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId); // Capture build logs on success let buildLog = ''; @@ -730,29 +610,284 @@ export class BuildService { (err as any).buildLog = buildLog; throw err; } finally { - // Clean up Dockerfile ConfigMap (templated mode only) - if (useTemplated) { + // Clean up build resources + if (sourcePvcName) { try { - await coreApi.deleteNamespacedConfigMap({ - name: `${buildPodName}-dockerfile`, + await coreApi.deleteNamespacedPersistentVolumeClaim({ + name: sourcePvcName, namespace: buildNamespace!, }); + this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`); } catch (e: any) { - this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); + this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`); } } - // Clean up the per-build git-token Secret. - if (useGitTokenSecret) { - try { - await coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace: buildNamespace! }); - } catch (e: any) { - this.logger.warn(`Failed to clean up git-token Secret: ${e.message}`); - } + // Clean up Dockerfile ConfigMap + try { + await coreApi.deleteNamespacedConfigMap({ + name: `${buildPodName}-dockerfile`, + namespace: buildNamespace!, + }); + } catch (e: any) { + this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); } this.endBuildSession(deploymentId); } } + /** + * Upload a local file to the helper pod using kubectl cp with progress tracking. + * kubectl cp uses tar over the k8s exec API — reliable for any file size. + */ + private streamFileToHelperPod(kubeconfig: string, namespace: string, podName: string, filePath: string, fileSize: number, deploymentId?: string): Promise { + const maxAttempts = 3; + + const runOnce = () => + new Promise((resolve, reject) => { + this.throwIfCancelled(deploymentId); + + const kubectl = spawn('kubectl', ['--kubeconfig', kubeconfig, 'cp', filePath, `${namespace}/${podName}:/data/source.zip`, '-c', 'helper', '--retries', '3'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.registerProcess(deploymentId, kubectl); + + let stderr = ''; + kubectl.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + // Track progress by polling remote file size + let progressTimer: NodeJS.Timeout | undefined; + const pollProgress = () => { + execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0'], { + timeout: 10_000, + }) + .then(({ stdout }) => { + const remoteSize = parseInt(stdout.trim(), 10) || 0; + const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100)); + this.setProgress(deploymentId, { + phase: 'uploading', + percent, + bytesUploaded: remoteSize, + totalBytes: fileSize, + message: `Uploading to cluster... ${percent}%`, + }); + }) + .catch(() => { + /* polling failure is non-fatal */ + }); + }; + progressTimer = setInterval(pollProgress, 3000); + pollProgress(); + + kubectl.on('error', (err) => { + clearInterval(progressTimer); + reject(new Error(`kubectl cp spawn error: ${err.message}`)); + }); + + kubectl.on('close', (code) => { + clearInterval(progressTimer); + if (code === 0) resolve(); + else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`)); + }); + }); + + return (async () => { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + this.throwIfCancelled(deploymentId); + if (attempt > 1) { + this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`); + await execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'rm', '-f', '/data/source.zip'], { timeout: 15_000 }).catch( + () => undefined, + ); + this.setProgress(deploymentId, { + phase: 'uploading', + percent: 0, + bytesUploaded: 0, + totalBytes: fileSize, + message: `Retrying upload (attempt ${attempt})...`, + }); + } + await runOnce(); + return; + } catch (err) { + if (err instanceof BuildCancelledError || (err as Error)?.name === 'BuildCancelledError') throw err; + if (attempt === maxAttempts) throw err; + this.logger.warn(`Upload attempt ${attempt} failed: ${(err as Error).message}`); + } + } + })(); + } + + /** + * Upload source zip to K8s via PVC + helper pod. + * This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB). + */ + private async uploadSourceViaPVC(kc: k8s.KubeConfig, coreApi: k8s.CoreV1Api, namespace: string, pvcName: string, zipPath: string, sizeGi: number, deploymentId?: string): Promise { + const t0 = Date.now(); + const helperPodName = `${pvcName}-helper`; + const zipSize = fs.statSync(zipPath).size; + + if (deploymentId) { + this.updateBuildSession(deploymentId, { helperPodName }); + } + + this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`); + + // 1. Create PVC + await coreApi.createNamespacedPersistentVolumeClaim({ + namespace, + body: { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: pvcName, namespace }, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { requests: { storage: `${sizeGi}Gi` } }, + }, + }, + }); + this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`); + + // 2. Create a helper pod that mounts the PVC and waits for data via a simple HTTP listener. + // We use alpine + nc (netcat) to receive the file over a port — much more reliable + // than kubectl cp or kubectl exec stdin pipe for large files. + const helperPod: k8s.V1Pod = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { name: helperPodName, namespace }, + spec: { + containers: [ + { + name: 'helper', + image: 'alpine:3.19', + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', 'sleep 3600'], + volumeMounts: [{ name: 'source', mountPath: '/data' }], + resources: { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }, + }, + ], + volumes: [ + { + name: 'source', + persistentVolumeClaim: { claimName: pvcName }, + }, + ], + restartPolicy: 'Never', + }, + }; + + await coreApi.createNamespacedPod({ namespace, body: helperPod }); + + // 3. Wait for helper pod to be Running + const podTimeout = 120_000; // 2 minutes + const podStart = Date.now(); + while (Date.now() - podStart < podTimeout) { + this.throwIfCancelled(deploymentId); + const pod = await coreApi.readNamespacedPod({ + name: helperPodName, + namespace, + }); + const phase = pod.status?.phase; + if (phase === 'Running') break; + if (phase === 'Failed' || phase === 'Unknown') { + throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`); + } + await new Promise((r) => setTimeout(r, 2000)); + } + if (Date.now() - podStart >= podTimeout) { + throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`); + } + this.logger.log(`[timing] Helper pod Running in ${Date.now() - t0}ms`); + + // 4. Write kubeconfig to temp file for kubectl + const tmpKubeconfig = path.join('/tmp', `kubeconfig-${pvcName}.yaml`); + const kcYaml = kc.exportConfig(); + fs.writeFileSync(tmpKubeconfig, kcYaml); + + try { + // 5. Upload the zip via kubectl cp (tar-based, reliable for any size). + const t2 = Date.now(); + this.setProgress(deploymentId, { + phase: 'uploading', + percent: 0, + bytesUploaded: 0, + totalBytes: zipSize, + message: 'Uploading source to cluster...', + }); + + await this.streamFileToHelperPod(tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId); + + this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`); + this.setProgress(deploymentId, { + phase: 'uploading', + percent: 100, + bytesUploaded: zipSize, + totalBytes: zipSize, + message: 'Upload complete, verifying...', + }); + + // 5b. Verify the file was written correctly (exact size) + const { stdout: sizeStr } = await execFileAsync( + 'kubectl', + ['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip'], + { timeout: 30_000 }, + ); + + const remoteSize = parseInt(sizeStr.trim(), 10); + if (isNaN(remoteSize) || remoteSize !== zipSize) { + throw new Error( + `Source upload incomplete: expected ${zipSize} bytes but got ${remoteSize} bytes on remote. ` + + `(${(zipSize / 1024 / 1024).toFixed(1)} MB expected, ${(remoteSize / 1024 / 1024).toFixed(1)} MB received)`, + ); + } + + this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`); + } finally { + // Clean up temp kubeconfig + try { + fs.unlinkSync(tmpKubeconfig); + } catch {} + + // 6. Delete the helper pod and WAIT for it to be fully terminated + // (PVC is ReadWriteOnce — if the pod is still terminating when the + // build Job starts, Kaniko can't mount the PVC → stuck in Pending) + try { + await coreApi.deleteNamespacedPod({ + name: helperPodName, + namespace, + gracePeriodSeconds: 0, + }); + this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`); + + const delTimeout = 60_000; + const delStart = Date.now(); + while (Date.now() - delStart < delTimeout) { + try { + await coreApi.readNamespacedPod({ name: helperPodName, namespace }); + // Pod still exists — wait + await new Promise((r) => setTimeout(r, 2000)); + } catch (err: any) { + if (err.code === 404 || err.body?.code === 404) { + this.logger.log(`Helper pod ${helperPodName} fully terminated`); + break; + } + // Other error — stop waiting + break; + } + } + } catch (e: any) { + this.logger.warn(`Failed to delete helper pod: ${e.message}`); + } + } + + this.logger.log(`[timing] Source upload via PVC completed in ${Date.now() - t0}ms`); + } + /** * Ensure the build namespace exists with all required resources * (namespace, service account, registry-credentials secret). @@ -814,66 +949,243 @@ export class BuildService { } } - /** Single-quote a string for safe inclusion in a `sh -c` command. */ - private shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; + /** + * Auto-detect runtime from source files when uploaded code is available. + * Falls back to app.runtime if detection is inconclusive or no code path. + */ + private detectRuntime(app: Application): AppRuntime { + const codePath = app.codePath ? path.resolve(app.codePath) : null; + if (!codePath || !fs.existsSync(codePath)) { + return app.runtime; + } + + // codePath points to the zip file (e.g. uploads///source.zip). + // The source directory is the parent of the zip, but the actual source is + // only available after extraction inside the build pod. However, we can + // peek inside the zip's file listing without extracting. + // For simplicity, check the directory containing the zip for any extracted files, + // or read the zip's central directory. + let sourceDir: string; + const stat = fs.statSync(codePath); + if (stat.isDirectory()) { + sourceDir = codePath; + } else { + // codePath is a file (zip) — try reading its parent or sibling extracted dir + sourceDir = path.dirname(codePath); + } + + let files: string[]; + try { + files = fs.readdirSync(sourceDir); + } catch { + return app.runtime; + } + + // If the directory only contains the zip, we can't detect — trust user + const nonZipFiles = files.filter((f) => !f.endsWith('.zip') && !f.endsWith('.sql')); + if (nonZipFiles.length === 0) { + return app.runtime; + } + + const hasPackageJson = files.includes('package.json'); + const hasComposerJson = files.includes('composer.json'); + const hasWpAdmin = files.includes('wp-admin'); + const hasWpContent = files.includes('wp-content'); + const hasWpConfig = files.includes('wp-config.php') || files.includes('wp-config-sample.php'); + + let detected: AppRuntime | null = null; + + if (hasWpAdmin || (hasWpContent && hasWpConfig)) { + detected = AppRuntime.WORDPRESS; + } else if (hasComposerJson && !hasPackageJson) { + detected = AppRuntime.LARAVEL; + } else if (hasPackageJson && !hasComposerJson) { + detected = AppRuntime.NODEJS; + } else if (hasPackageJson && hasComposerJson) { + // Both exist — trust the user-selected runtime + return app.runtime; + } + + if (detected && detected !== app.runtime) { + this.logger.warn(`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`); + return detected; + } + + return app.runtime; } - /** - * NIXPACKS_* planning env (read by the nixpacks process itself) derived from the - * app's selected runtime version. Other versions are inferred by Nixpacks from - * project files (go.mod, .python-version, …), the Nixpacks-idiomatic way. - */ - private nixpacksPlanEnv(app: Application): { name: string; value: string }[] { - const env: { name: string; value: string }[] = []; - if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) { - env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) }); + private generateDockerfile(app: Application): string { + const runtime = this.detectRuntime(app); + switch (runtime) { + case AppRuntime.NODEJS: + return this.nodeDockerfile(app); + case AppRuntime.LARAVEL: + return this.laravelDockerfile(app); + case AppRuntime.WORDPRESS: + return this.wordpressDockerfile(app); + case AppRuntime.GO: + return this.goDockerfile(app); + case AppRuntime.PHP: + return this.phpDockerfile(app); + case AppRuntime.PYTHON: + return this.pythonDockerfile(app); + case AppRuntime.DJANGO: + return this.djangoDockerfile(app); + case AppRuntime.DOTNET: + return this.dotnetDockerfile(app); + default: + throw new Error(`Unsupported runtime: ${runtime}`); } - if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) { - env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) }); - } - return env; } - /** - * Init container for nixpacks (non-WordPress) builds. Turns staged source at - * /workspace/source into a Dockerfile at /workspace/Dockerfile: - * • if the source ships its own Dockerfile → use it (BYO, full user control), - * • otherwise generate one with Nixpacks (`nixpacks build --out`). - * Mirror/proxy env from `build.nixpacksBuildEnv` is baked into the generated - * image so package installs in the Kaniko stage work behind the Iran network. - */ - private nixpacksPrepareInitContainer(app: Application): any { - const image = this.configService.get('build.nixpacksImage') || 'ghcr.io/railwayapp/nixpacks:latest'; - const buildEnv = this.configService.get('build.nixpacksBuildEnv') || []; - const envFlags = buildEnv.map((kv) => `--env ${this.shellQuote(kv)}`).join(' '); - const planEnv = this.nixpacksPlanEnv(app); + private nodeDockerfile(app: Application): string { + const port = app.port || 3000; + const nodeVersion = app.runtimeVersion || '20'; + return `# --- Build stage --- +FROM node:${nodeVersion}-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install --legacy-peer-deps && npm cache clean --force +COPY . . - return { - name: 'nixpacks-prepare', - image, - imagePullPolicy: 'IfNotPresent', - env: planEnv.length ? planEnv : undefined, - command: [ - 'sh', - '-c', - ` - set -e - cd /workspace - if [ -f source/Dockerfile ]; then - echo ">>> Using user-provided Dockerfile (BYO)" && - cp source/Dockerfile /workspace/Dockerfile - else - echo ">>> No Dockerfile found — generating with Nixpacks" && - nixpacks build source --out source ${envFlags} && - cp source/.nixpacks/Dockerfile /workspace/Dockerfile && - echo "--- Generated Dockerfile ---" && - cat /workspace/Dockerfile - fi - `, - ], - volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], - }; +# Auto-detect Next.js and enable standalone output +RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\ + [ -f "$cfg" ] || continue; \\ + if grep -q standalone "$cfg"; then \\ + echo "$cfg already has standalone"; \\ + else \\ + echo ">>> Next.js detected, injecting standalone output"; \\ + node -e 'var f=require("fs"),c=f.readFileSync(process.argv[1],"utf8");if(!c.includes("standalone")){f.writeFileSync(process.argv[1],c.replace("{","{ output: \\"standalone\\","))}' "$cfg"; \\ + echo "Patched $cfg:"; head -5 "$cfg"; \\ + fi; \\ + break; \\ + done + +RUN npm run build || echo ">>> Build script failed or not found — continuing" + +# Clean up dev dependencies and caches to reduce image size +RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true + +# --- Production stage --- +FROM node:${nodeVersion}-alpine AS runner +WORKDIR /app +RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 + +# Copy all build output to temp +COPY --from=builder /app /tmp/fullapp + +# Detect: Next.js standalone vs regular Node.js +RUN if [ -d /tmp/fullapp/.next/standalone ]; then \\ + echo ">>> Next.js standalone mode"; \\ + cp -a /tmp/fullapp/.next/standalone/. .; \\ + mkdir -p .next/static; \\ + [ -d /tmp/fullapp/.next/static ] && cp -a /tmp/fullapp/.next/static/. .next/static/; \\ + [ -d /tmp/fullapp/public ] && cp -a /tmp/fullapp/public ./public; \\ + echo "standalone" > /app/.mode; \\ + else \\ + echo ">>> Regular Node.js app"; \\ + cp -a /tmp/fullapp/. .; \\ + echo "regular" > /app/.mode; \\ + fi && rm -rf /tmp/fullapp + +USER appuser +ENV PORT=${port} +ENV HOSTNAME=0.0.0.0 +EXPOSE ${port} +CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f server.js ]; then node server.js; else npm start; fi"] +`; + } + + private laravelDockerfile(app: Application): string { + const phpVersion = app.phpVersion || '8.3'; + const port = app.port || 80; + return `# --- Build stage (match production PHP version for Composer) --- +FROM php:${phpVersion}-cli-alpine AS composer +RUN apk add --no-cache git unzip +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +WORKDIR /app +COPY composer.json composer.lock* ./ +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs +COPY . . +RUN composer dump-autoload --optimize --no-dev --no-scripts + +# --- Production stage --- +FROM php:${phpVersion}-fpm-alpine + +RUN apk add --no-cache nginx supervisor curl openssl \\ + && docker-php-ext-install pdo pdo_mysql opcache \\ + && docker-php-ext-install pdo_pgsql 2>/dev/null || true + +WORKDIR /var/www/html +COPY --from=composer /app . + +# Generate nginx config inline (no dependency on user files) +RUN mkdir -p /etc/nginx/http.d && \\ + echo 'server {' > /etc/nginx/http.d/default.conf && \\ + echo ' listen ${port};' >> /etc/nginx/http.d/default.conf && \\ + echo ' root /var/www/html/public;' >> /etc/nginx/http.d/default.conf && \\ + echo ' index index.php index.html;' >> /etc/nginx/http.d/default.conf && \\ + echo ' client_max_body_size 64M;' >> /etc/nginx/http.d/default.conf && \\ + echo ' location / { try_files \\$uri \\$uri/ /index.php?\\$query_string; }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ \\.php\\$ {' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \\ + echo ' include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \\ + echo ' }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ /\\.ht { deny all; }' >> /etc/nginx/http.d/default.conf && \\ + echo '}' >> /etc/nginx/http.d/default.conf + +# Generate supervisord config inline +RUN echo '[supervisord]' > /etc/supervisord.conf && \\ + echo 'nodaemon=true' >> /etc/supervisord.conf && \\ + echo 'logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '' >> /etc/supervisord.conf && \\ + echo '[program:php-fpm]' >> /etc/supervisord.conf && \\ + echo 'command=php-fpm -F' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '' >> /etc/supervisord.conf && \\ + echo '[program:nginx]' >> /etc/supervisord.conf && \\ + echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf + +# If user provides their own nginx/supervisor configs, use those instead +RUN [ -f docker/nginx.conf ] && cp docker/nginx.conf /etc/nginx/http.d/default.conf || true +RUN [ -f docker/supervisord.conf ] && cp docker/supervisord.conf /etc/supervisord.conf || true + +# Ensure storage and cache directories exist and are writable +RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \\ + && chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache + +RUN echo '#!/bin/sh' > /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'set -e' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'cd /var/www/html' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views storage/app/public bootstrap/cache' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'chown -R www-data:www-data storage bootstrap/cache' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "null" ]; then export APP_KEY="base64:$(openssl rand -base64 32)"; fi' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan config:clear || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan cache:clear || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan migrate --force --no-interaction || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan storage:link || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan config:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan route:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'php artisan view:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'exec /usr/bin/supervisord -c /etc/supervisord.conf' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + chmod +x /usr/local/bin/cloudhost-laravel-entrypoint.sh + +EXPOSE ${port} +CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"] +`; } private wordpressDockerfile(app: Application): string { @@ -993,6 +1305,286 @@ CMD []` `; } + // ─── Go Dockerfile ───────────────────────────────────────────────── + private goDockerfile(app: Application): string { + const goVersion = app.runtimeVersion || '1.22'; + const port = app.port || 8080; + return `# --- Build stage --- +FROM golang:${goVersion}-alpine AS builder +WORKDIR /app + +# Install git for fetching dependencies +RUN apk add --no-cache git + +# Copy go mod files first for better caching +COPY go.mod go.sum* ./ +RUN go mod download || true + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main . + +# --- Production stage --- +FROM alpine:3.19 +WORKDIR /app + +# Add CA certificates for HTTPS requests +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup + +# Copy the binary from builder +COPY --from=builder /app/main . +COPY --from=builder /app/static ./static 2>/dev/null || true +COPY --from=builder /app/templates ./templates 2>/dev/null || true +COPY --from=builder /app/public ./public 2>/dev/null || true + +# Create data directory for persistent storage +RUN mkdir -p /app/data && chown -R appuser:appgroup /app + +USER appuser + +ENV PORT=${port} +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\ + CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1 + +CMD ["./main"] +`; + } + + // ─── PHP (Plain) Dockerfile ──────────────────────────────────────── + private phpDockerfile(app: Application): string { + const phpVersion = app.phpVersion || '8.3'; + const port = app.port || 80; + return `FROM php:${phpVersion}-fpm-alpine + +RUN apk add --no-cache nginx supervisor curl \\ + && docker-php-ext-install pdo pdo_mysql opcache \\ + && docker-php-ext-install pdo_pgsql 2>/dev/null || true + +# Install common PHP extensions +RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\ + && docker-php-ext-configure gd --with-freetype --with-jpeg \\ + && docker-php-ext-install gd + +WORKDIR /var/www/html +COPY . . + +# Generate nginx config +RUN mkdir -p /etc/nginx/http.d && \\ + echo 'server {' > /etc/nginx/http.d/default.conf && \\ + echo ' listen ${port};' >> /etc/nginx/http.d/default.conf && \\ + echo ' root /var/www/html;' >> /etc/nginx/http.d/default.conf && \\ + echo ' index index.php index.html;' >> /etc/nginx/http.d/default.conf && \\ + echo ' client_max_body_size 64M;' >> /etc/nginx/http.d/default.conf && \\ + echo ' location / { try_files \\$uri \\$uri/ /index.php?\\$query_string; }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ \\.php\\$ {' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \\ + echo ' include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \\ + echo ' }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ /\\.ht { deny all; }' >> /etc/nginx/http.d/default.conf && \\ + echo '}' >> /etc/nginx/http.d/default.conf + +# Generate supervisord config +RUN echo '[supervisord]' > /etc/supervisord.conf && \\ + echo 'nodaemon=true' >> /etc/supervisord.conf && \\ + echo 'logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '[program:php-fpm]' >> /etc/supervisord.conf && \\ + echo 'command=php-fpm -F' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '[program:nginx]' >> /etc/supervisord.conf && \\ + echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf + +# Use custom configs if provided +RUN [ -f docker/nginx.conf ] && cp docker/nginx.conf /etc/nginx/http.d/default.conf || true +RUN [ -f docker/supervisord.conf ] && cp docker/supervisord.conf /etc/supervisord.conf || true + +# Create upload and data directories +RUN mkdir -p /var/www/html/uploads /var/www/html/data \\ + && chown -R www-data:www-data /var/www/html + +EXPOSE ${port} +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] +`; + } + + // ─── Python Dockerfile ───────────────────────────────────────────── + private pythonDockerfile(app: Application): string { + const pythonVersion = app.runtimeVersion || '3.12'; + const port = app.port || 8000; + return `# --- Build stage --- +FROM python:${pythonVersion}-slim AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \\ + build-essential libpq-dev \\ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies +COPY requirements.txt* ./ +RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ + pip install --no-cache-dir --user flask gunicorn + +# --- Production stage --- +FROM python:${pythonVersion}-slim +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \\ + libpq5 curl \\ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser + +# Copy installed packages from builder +COPY --from=builder /root/.local /home/appuser/.local + +# Copy application code +COPY . . + +# Create data directory +RUN mkdir -p /app/data && chown -R appuser:appgroup /app + +USER appuser +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PORT=${port} +ENV PYTHONUNBUFFERED=1 + +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\ + CMD curl -f http://localhost:${port}/health || exit 1 + +# Auto-detect: Flask, FastAPI, or plain Python +CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicorn main:app --host 0.0.0.0 --port ${port}; elif grep -qi flask main.py; then exec gunicorn -w 4 -b 0.0.0.0:${port} main:app; else exec python main.py; fi; elif [ -f app.py ]; then if grep -qi fastapi app.py; then exec uvicorn app:app --host 0.0.0.0 --port ${port}; elif grep -qi flask app.py; then exec gunicorn -w 4 -b 0.0.0.0:${port} app:app; else exec python app.py; fi; else exec gunicorn -w 4 -b 0.0.0.0:${port} app:app; fi" +`; + } + + // ─── Django Dockerfile ───────────────────────────────────────────── + private djangoDockerfile(app: Application): string { + const pythonVersion = app.runtimeVersion || '3.12'; + const port = app.port || 8000; + return `# --- Build stage --- +FROM python:${pythonVersion}-slim AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \\ + build-essential libpq-dev \\ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies +COPY requirements.txt* ./ +RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ + pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient + +# --- Production stage --- +FROM python:${pythonVersion}-slim +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \\ + libpq5 default-libmysqlclient-dev curl \\ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser + +# Copy installed packages from builder +COPY --from=builder /root/.local /home/appuser/.local + +# Copy application code +COPY . . + +# Create directories for static files and media +RUN mkdir -p /app/staticfiles /app/media /app/data \\ + && chown -R appuser:appgroup /app + +USER appuser +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PORT=${port} +ENV PYTHONUNBUFFERED=1 +ENV DJANGO_SETTINGS_MODULE=config.settings + +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\ + CMD curl -f http://localhost:${port}/health/ || curl -f http://localhost:${port}/ || exit 1 + +# Auto-detect project structure and run migrations + collectstatic +CMD sh -c "\\ + PROJECT_NAME=\\$(find . -maxdepth 2 -name 'wsgi.py' | head -1 | cut -d'/' -f2) && \\ + if [ -z \\\"\\$PROJECT_NAME\\\" ]; then PROJECT_NAME='config'; fi && \\ + echo \\\"Django project: \\$PROJECT_NAME\\\" && \\ + python manage.py migrate --noinput 2>/dev/null || true && \\ + python manage.py collectstatic --noinput 2>/dev/null || true && \\ + exec gunicorn \\$PROJECT_NAME.wsgi:application --bind 0.0.0.0:${port} --workers 4 --threads 2 \\ +" +`; + } + + // ─── .NET Dockerfile ─────────────────────────────────────────────── + private dotnetDockerfile(app: Application): string { + const dotnetVersion = app.runtimeVersion || '8.0'; + const port = app.port || 5000; + return `# --- Build stage --- +FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build +WORKDIR /src + +# Copy csproj and restore dependencies +COPY *.csproj ./ +RUN dotnet restore || true + +# Copy everything else and build +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore 2>/dev/null || \\ + dotnet publish -c Release -o /app/publish + +# --- Production stage --- +FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion} +WORKDIR /app + +# Create non-root user +RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser + +# Copy published app +COPY --from=build /app/publish . + +# Create data directory +RUN mkdir -p /app/data && chown -R appuser:appgroup /app + +USER appuser + +ENV ASPNETCORE_URLS=http://+:${port} +ENV DOTNET_RUNNING_IN_CONTAINER=true +ENV ASPNETCORE_ENVIRONMENT=Production + +EXPOSE ${port} +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\ + CMD curl -f http://localhost:${port}/health || curl -f http://localhost:${port}/ || exit 1 + +# Auto-detect entry point DLL +CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet $DLL"] +`; + } + /** * Whether a K8s API error is a transient connectivity/availability blip that * should be retried rather than failing the operation. Covers socket-level @@ -1016,7 +1608,7 @@ CMD []` let lastLoggedStatus = ''; while (Date.now() - startTime < timeoutMs) { - await this.throwIfCancelledShared(deploymentId); + this.throwIfCancelled(deploymentId); const elapsed = Date.now() - startTime; const buildPercent = Math.min(90, 15 + Math.round((elapsed / timeoutMs) * 75)); this.setProgress(deploymentId, { @@ -1143,26 +1735,14 @@ CMD []` */ async getLiveBuildLog(deploymentId: string): Promise { const session = this.activeBuilds.get(deploymentId); - if (session?.coreApi && session.namespace && session.buildPodName) { - try { - return await this.getBuildLogs(session.coreApi, session.buildPodName, session.namespace); - } catch { - return null; - } + if (!session?.coreApi || !session.namespace || !session.buildPodName) { + return null; } - // Build is running on another replica — read logs via the persisted session. - const persisted = await this.readPersistedSession(deploymentId); - if (persisted?.namespace && persisted.buildPodName) { - const apis = await this.makeClusterApis(persisted.clusterId); - if (apis) { - try { - return await this.getBuildLogs(apis.coreApi, persisted.buildPodName, persisted.namespace); - } catch { - return null; - } - } + try { + return await this.getBuildLogs(session.coreApi, session.buildPodName, session.namespace); + } catch { + return null; } - return null; } private async getBuildLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise { diff --git a/backend/src/build/scan.service.spec.ts b/backend/src/build/scan.service.spec.ts deleted file mode 100644 index c2cbde5..0000000 --- a/backend/src/build/scan.service.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Tests for the pure Trivy-report aggregation logic in ScanService.summarize. - * - * NOTE: like the other build specs, this reproduces the pure logic locally rather - * than importing ScanService — the service pulls in the ESM `@kubernetes/client-node`, - * which this project's Jest config does not transform. Keep in sync with scan.service.ts. - */ - -interface VulnerabilitySummary { - critical: number; - high: number; - medium: number; - low: number; - unknown: number; - total: number; - scannedAt: string; -} - -function parseTrivyJson(output: string): any | null { - if (!output) return null; - try { - return JSON.parse(output); - } catch { - const start = output.indexOf('{'); - const end = output.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - return JSON.parse(output.slice(start, end + 1)); - } catch { - return null; - } - } - return null; - } -} - -function summarize(trivyOutput: string): VulnerabilitySummary { - const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }; - const parsed = parseTrivyJson(trivyOutput); - const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : []; - for (const result of results) { - const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : []; - for (const v of vulns) { - const sev = String(v?.Severity || 'UNKNOWN').toUpperCase(); - if (sev === 'CRITICAL') counts.critical++; - else if (sev === 'HIGH') counts.high++; - else if (sev === 'MEDIUM') counts.medium++; - else if (sev === 'LOW') counts.low++; - else counts.unknown++; - } - } - return { - ...counts, - total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown, - scannedAt: new Date().toISOString(), - }; -} - -describe('ScanService.summarize', () => { - it('counts vulnerabilities per severity across results', () => { - const report = JSON.stringify({ - Results: [ - { Vulnerabilities: [{ Severity: 'CRITICAL' }, { Severity: 'HIGH' }, { Severity: 'high' }] }, - { Vulnerabilities: [{ Severity: 'MEDIUM' }, { Severity: 'LOW' }, { Severity: 'WeIrD' }] }, - { Vulnerabilities: null }, - {}, - ], - }); - const s = summarize(report); - expect(s).toMatchObject({ critical: 1, high: 2, medium: 1, low: 1, unknown: 1, total: 6 }); - expect(typeof s.scannedAt).toBe('string'); - }); - - it('returns all-zero summary for a clean image', () => { - expect(summarize(JSON.stringify({ Results: [{ Target: 'x' }] }))).toMatchObject({ - critical: 0, - high: 0, - medium: 0, - low: 0, - unknown: 0, - total: 0, - }); - }); - - it('tolerates leading log noise before the JSON', () => { - const noisy = `2026-06-20 INFO Need to update DB\n{"Results":[{"Vulnerabilities":[{"Severity":"CRITICAL"}]}]}`; - expect(summarize(noisy).critical).toBe(1); - }); - - it('returns a zero summary on unparseable output', () => { - expect(summarize('not json at all').total).toBe(0); - expect(summarize('').total).toBe(0); - }); -}); diff --git a/backend/src/build/scan.service.ts b/backend/src/build/scan.service.ts deleted file mode 100644 index d299cf1..0000000 --- a/backend/src/build/scan.service.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as k8s from '@kubernetes/client-node'; -import { Application } from '../applications/entities/application.entity'; -import { ClustersService } from '../clusters/clusters.service'; -import { RegistryService } from '../kubernetes/registry.service'; - -export interface VulnerabilitySummary { - critical: number; - high: number; - medium: number; - low: number; - unknown: number; - total: number; - scannedAt: string; -} - -/** - * Report-only image vulnerability scanning with Trivy. Runs a one-shot K8s Job - * that scans the freshly-pushed image in the in-cluster registry and stores a - * severity summary on the deployment. Never blocks a deployment — any failure is - * logged and ignored. - */ -@Injectable() -export class ScanService { - private readonly logger = new Logger(ScanService.name); - - constructor( - private readonly configService: ConfigService, - private readonly clustersService: ClustersService, - private readonly registryService: RegistryService, - ) {} - - /** Aggregate a Trivy JSON report (raw stdout) into per-severity counts. Pure & testable. */ - summarize(trivyOutput: string): VulnerabilitySummary { - const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }; - const parsed = this.parseTrivyJson(trivyOutput); - const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : []; - for (const result of results) { - const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : []; - for (const v of vulns) { - const sev = String(v?.Severity || 'UNKNOWN').toUpperCase(); - if (sev === 'CRITICAL') counts.critical++; - else if (sev === 'HIGH') counts.high++; - else if (sev === 'MEDIUM') counts.medium++; - else if (sev === 'LOW') counts.low++; - else counts.unknown++; - } - } - return { - ...counts, - total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown, - scannedAt: new Date().toISOString(), - }; - } - - /** Trivy prints clean JSON to stdout, but tolerate any leading noise from the log stream. */ - private parseTrivyJson(output: string): any | null { - if (!output) return null; - try { - return JSON.parse(output); - } catch { - const start = output.indexOf('{'); - const end = output.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - return JSON.parse(output.slice(start, end + 1)); - } catch { - return null; - } - } - return null; - } - } - - /** - * Scan a pushed image and return a severity summary, or null on any failure. - * Report-only: callers must treat null as "no data", never as a deploy gate. - */ - async scanImage(app: Application, imageUri: string): Promise { - if (this.configService.get('build.scanEnabled') === false) return null; - - const buildNs = this.registryService.getBuildNamespace(); - const image = this.configService.get('build.trivyImage') || 'aquasec/trivy:latest'; - const dbRepo = this.configService.get('build.trivyDbRepository') || ''; - const timeoutSeconds = this.configService.get('build.scanTimeoutSeconds') || 300; - const { username, password } = this.registryService.getRegistryCredentials(); - - const jobName = `scan-${app.name}-${Date.now()}`.substring(0, 63).replace(/[^a-z0-9-]/g, ''); - - try { - const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault(); - const kc = new k8s.KubeConfig(); - kc.loadFromString(cluster.kubeconfig); - const coreApi = kc.makeApiClient(k8s.CoreV1Api); - const batchApi = kc.makeApiClient(k8s.BatchV1Api); - - const env: { name: string; value: string }[] = [ - { name: 'TRIVY_INSECURE', value: 'true' }, // in-cluster registry is plain HTTP - { name: 'TRIVY_NON_SSL', value: 'true' }, - ]; - if (username) env.push({ name: 'TRIVY_USERNAME', value: username }); - if (password) env.push({ name: 'TRIVY_PASSWORD', value: password }); - if (dbRepo) env.push({ name: 'TRIVY_DB_REPOSITORY', value: dbRepo }); - - const job: k8s.V1Job = { - apiVersion: 'batch/v1', - kind: 'Job', - metadata: { name: jobName, namespace: buildNs }, - spec: { - backoffLimit: 0, - ttlSecondsAfterFinished: 120, - template: { - spec: { - restartPolicy: 'Never', - containers: [ - { - name: 'trivy', - image, - imagePullPolicy: 'IfNotPresent', - env, - args: ['image', '--quiet', '--no-progress', '--format', 'json', '--severity', 'CRITICAL,HIGH,MEDIUM,LOW', imageUri], - resources: { - requests: { cpu: '250m', memory: '512Mi' }, - limits: { cpu: '1', memory: '1Gi' }, - }, - }, - ], - }, - }, - }, - }; - - await batchApi.createNamespacedJob({ namespace: buildNs, body: job }); - await this.waitForJob(batchApi, jobName, buildNs, timeoutSeconds); - - const output = await this.getJobPodLogs(coreApi, jobName, buildNs); - const summary = this.summarize(output); - - await batchApi - .deleteNamespacedJob({ name: jobName, namespace: buildNs, gracePeriodSeconds: 0, propagationPolicy: 'Foreground' }) - .catch(() => undefined); - - this.logger.log(`Scan complete for ${imageUri}: ${summary.critical}C/${summary.high}H/${summary.medium}M/${summary.low}L`); - return summary; - } catch (e: any) { - this.logger.warn(`Image scan failed for ${imageUri} (report-only, ignored): ${e.message}`); - return null; - } - } - - private async waitForJob(batchApi: k8s.BatchV1Api, jobName: string, namespace: string, timeoutSeconds: number): Promise { - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const job = await batchApi.readNamespacedJob({ name: jobName, namespace }); - if (job.status?.succeeded) return; - if ((job.status?.failed ?? 0) > 0) return; // Trivy exits non-zero on findings with some flags; read logs anyway - await new Promise((r) => setTimeout(r, 4000)); - } - throw new Error(`Scan job ${jobName} timed out after ${timeoutSeconds}s`); - } - - private async getJobPodLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise { - const pods = await coreApi.listNamespacedPod({ namespace, labelSelector: `job-name=${jobName}` }); - const podName = pods.items[0]?.metadata?.name; - if (!podName) throw new Error(`No pod found for scan job ${jobName}`); - return coreApi.readNamespacedPodLog({ name: podName, namespace, container: 'trivy' }); - } -} diff --git a/backend/src/clusters/clusters.service.ts b/backend/src/clusters/clusters.service.ts index 358ff94..ffe88a1 100644 --- a/backend/src/clusters/clusters.service.ts +++ b/backend/src/clusters/clusters.service.ts @@ -1102,133 +1102,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy { await this.ensureK3sRegistryMirrors(appsApi, registryUrl); - // ── 8. MinIO (S3-compatible) for application source archives ── - await this.ensureMinioInfrastructure(coreApi, appsApi, buildNs); - this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`); } - /** - * Provision in-cluster MinIO (Deployment + PVC + Service + credentials Secret) - * in the build namespace. Application source archives are uploaded here by the - * API and pulled by build pods via presigned URLs. - */ - private async ensureMinioInfrastructure(coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, buildNs: string): Promise { - const accessKey = this.configService.get('minio.accessKey') || 'cloudhost'; - const secretKey = this.configService.get('minio.secretKey') || ''; - const pvcName = 'minio-data'; - const deployName = 'minio'; - const svcName = 'minio'; - const secretName = 'minio-credentials'; - - // 1. Credentials Secret (shared by the MinIO server env and the API client) - const minioSecret: k8s.V1Secret = { - metadata: { name: secretName, namespace: buildNs }, - type: 'Opaque', - data: { - accesskey: Buffer.from(accessKey).toString('base64'), - secretkey: Buffer.from(secretKey).toString('base64'), - }, - }; - try { - await coreApi.readNamespacedSecret({ name: secretName, namespace: buildNs }); - await coreApi.replaceNamespacedSecret({ name: secretName, namespace: buildNs, body: minioSecret }); - } catch (err: any) { - if (err.code === 404 || err.body?.code === 404) { - await coreApi.createNamespacedSecret({ namespace: buildNs, body: minioSecret }); - this.logger.log(`Created ${secretName} Secret`); - } else { - throw err; - } - } - - // 2. Data PVC - try { - await coreApi.readNamespacedPersistentVolumeClaim({ name: pvcName, namespace: buildNs }); - } catch (err: any) { - if (err.code === 404 || err.body?.code === 404) { - await coreApi.createNamespacedPersistentVolumeClaim({ - namespace: buildNs, - body: { - metadata: { name: pvcName, namespace: buildNs }, - spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: '20Gi' } } }, - }, - }); - this.logger.log(`Created PVC "${pvcName}" (20Gi)`); - } else { - throw err; - } - } - - // 3. Deployment - try { - await appsApi.readNamespacedDeployment({ name: deployName, namespace: buildNs }); - } catch (err: any) { - if (err.code === 404 || err.body?.code === 404) { - await appsApi.createNamespacedDeployment({ - namespace: buildNs, - body: { - metadata: { name: deployName, namespace: buildNs, labels: { app: 'minio' } }, - spec: { - replicas: 1, - selector: { matchLabels: { app: 'minio' } }, - template: { - metadata: { labels: { app: 'minio' } }, - spec: { - containers: [ - { - name: 'minio', - image: process.env.MINIO_IMAGE || 'minio/minio:latest', - args: ['server', '/data', '--console-address', ':9001'], - env: [ - { name: 'MINIO_ROOT_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'accesskey' } } }, - { name: 'MINIO_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'secretkey' } } }, - ], - ports: [{ containerPort: 9000 }, { containerPort: 9001 }], - volumeMounts: [{ name: 'data', mountPath: '/data' }], - resources: { - requests: { cpu: '100m', memory: '256Mi' }, - limits: { cpu: '1', memory: '1Gi' }, - }, - }, - ], - volumes: [{ name: 'data', persistentVolumeClaim: { claimName: pvcName } }], - }, - }, - }, - }, - }); - this.logger.log(`Created MinIO Deployment`); - } else { - throw err; - } - } - - // 4. ClusterIP Service (API :9000, console :9001) - try { - await coreApi.readNamespacedService({ name: svcName, namespace: buildNs }); - } catch (err: any) { - if (err.code === 404 || err.body?.code === 404) { - await coreApi.createNamespacedService({ - namespace: buildNs, - body: { - metadata: { name: svcName, namespace: buildNs, labels: { app: 'minio' } }, - spec: { - selector: { app: 'minio' }, - ports: [ - { name: 'api', port: 9000, targetPort: 9000 as any, protocol: 'TCP' }, - { name: 'console', port: 9001, targetPort: 9001 as any, protocol: 'TCP' }, - ], - }, - }, - }); - this.logger.log(`Created MinIO Service`); - } else { - throw err; - } - } - } - /** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */ private async ensureK3sRegistryMirrors(appsApi: k8s.AppsV1Api, registryUrl: string): Promise { const namespace = 'kube-system'; diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts deleted file mode 100644 index d3a6653..0000000 --- a/backend/src/common/redis/redis.module.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Global, Module, OnApplicationShutdown, Logger } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import IORedis, { Redis } from 'ioredis'; - -/** Injection token for the shared ioredis client. */ -export const REDIS_CLIENT = 'REDIS_CLIENT'; - -/** - * Shared Redis client used for build state (progress / cancel flags / build - * sessions) so the data survives across backend replicas — unlike the previous - * in-memory Maps that only worked with a single instance. - * - * Reuses the same connection details as the Bull queue (`redis.host/port`). - */ -@Global() -@Module({ - providers: [ - { - provide: REDIS_CLIENT, - inject: [ConfigService], - useFactory: (configService: ConfigService): Redis => { - const client = new IORedis({ - host: configService.get('redis.host'), - port: configService.get('redis.port'), - // Build state writes are best-effort telemetry — never let a Redis - // hiccup take down the request that triggered them. - maxRetriesPerRequest: 2, - enableOfflineQueue: true, - }); - client.on('error', (err) => { - new Logger('RedisClient').warn(`Redis connection error: ${err.message}`); - }); - return client; - }, - }, - ], - exports: [REDIS_CLIENT], -}) -export class RedisModule implements OnApplicationShutdown { - constructor(private readonly moduleRef: ModuleRef) {} - - async onApplicationShutdown(): Promise { - try { - const client = this.moduleRef.get(REDIS_CLIENT, { strict: false }); - await client?.quit(); - } catch { - /* ignore shutdown errors */ - } - } -} diff --git a/backend/src/common/storage/storage.module.ts b/backend/src/common/storage/storage.module.ts deleted file mode 100644 index ea195eb..0000000 --- a/backend/src/common/storage/storage.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { StorageService } from './storage.service'; - -/** - * Global module exposing the MinIO-backed {@link StorageService} so both the API - * (source upload) and the build pipeline (presigned download) can inject it. - */ -@Global() -@Module({ - providers: [StorageService], - exports: [StorageService], -}) -export class StorageModule {} diff --git a/backend/src/common/storage/storage.service.ts b/backend/src/common/storage/storage.service.ts deleted file mode 100644 index c758e69..0000000 --- a/backend/src/common/storage/storage.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as Minio from 'minio'; - -/** - * Object storage for application source archives, backed by the in-cluster MinIO - * (S3-compatible). Replaces the previous local-disk + PVC + kubectl-cp upload path: - * the API streams the uploaded zip straight to MinIO, and build pods pull it via a - * short-lived presigned URL (no credentials, no kubectl, no helper pod). - */ -@Injectable() -export class StorageService { - private readonly logger = new Logger(StorageService.name); - private readonly client: Minio.Client; - private readonly bucket: string; - private bucketReady = false; - - constructor(private readonly configService: ConfigService) { - this.bucket = this.configService.get('minio.bucket') || 'app-sources'; - this.client = new Minio.Client({ - endPoint: this.configService.get('minio.endpoint') || 'minio.cloudhost-builds.svc.cluster.local', - port: this.configService.get('minio.port') || 9000, - useSSL: this.configService.get('minio.useSSL') || false, - accessKey: this.configService.get('minio.accessKey') || 'cloudhost', - secretKey: this.configService.get('minio.secretKey') || '', - }); - } - - /** Object key for an app's source archive. */ - sourceKey(userId: string, appId: string): string { - return `${userId}/${appId}/source.zip`; - } - - private async ensureBucket(): Promise { - if (this.bucketReady) return; - const exists = await this.client.bucketExists(this.bucket).catch(() => false); - if (!exists) { - await this.client.makeBucket(this.bucket); - this.logger.log(`Created MinIO bucket "${this.bucket}"`); - } - this.bucketReady = true; - } - - /** Upload an app's source archive; returns the stored object key (saved as app.codePath). */ - async putSource(userId: string, appId: string, data: Buffer): Promise { - await this.ensureBucket(); - const key = this.sourceKey(userId, appId); - await this.client.putObject(this.bucket, key, data, data.length, { 'Content-Type': 'application/zip' }); - this.logger.log(`Uploaded source ${(data.length / 1024 / 1024).toFixed(1)}MB → ${this.bucket}/${key}`); - return key; - } - - /** Short-lived presigned GET URL the build pod uses to download the source. */ - async presignSourceGet(key: string, expirySeconds = 3600): Promise { - return this.client.presignedGetObject(this.bucket, key, expirySeconds); - } - - /** Best-effort delete of an app's source object (e.g. on app deletion). */ - async removeSource(key: string): Promise { - await this.client.removeObject(this.bucket, key).catch((e) => { - this.logger.warn(`Failed to remove source ${key}: ${e.message}`); - }); - } -} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index c814a8a..79e9207 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -125,45 +125,9 @@ export default () => ({ password: process.env.REGISTRY_PASSWORD || '', }, - // In-cluster MinIO (S3-compatible) for application source archives. - minio: { - endpoint: process.env.MINIO_ENDPOINT || 'minio.cloudhost-builds.svc.cluster.local', - port: parseInt(process.env.MINIO_PORT || '9000', 10), - useSSL: process.env.MINIO_USE_SSL === 'true', - accessKey: process.env.MINIO_ACCESS_KEY || 'cloudhost', - secretKey: process.env.MINIO_SECRET_KEY || 'CloudHost2024!Minio', - bucket: process.env.MINIO_BUCKET || 'app-sources', - }, - build: { namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds', serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder', - /** Max number of build+deploy pipelines processed concurrently across the queue. */ - concurrency: parseInt(process.env.BUILD_CONCURRENCY || '3', 10), - /** Per-image-build timeout (Kaniko job) in seconds. */ - timeoutSeconds: parseInt(process.env.BUILD_TIMEOUT_SECONDS || '600', 10), - /** Nixpacks builder image used to generate a Dockerfile for code runtimes. */ - nixpacksImage: process.env.NIXPACKS_IMAGE || 'ghcr.io/railwayapp/nixpacks:latest', - /** - * Build-time env baked into Nixpacks-generated images (mirrors/proxies for the - * Iran network, e.g. "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com"). - * Comma-separated KEY=VALUE pairs — set per the Phase 0 spike findings. - */ - nixpacksBuildEnv: (process.env.NIXPACKS_BUILD_ENV || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - /** Report-only Trivy image scan after a successful build. */ - scanEnabled: process.env.BUILD_SCAN_ENABLED !== 'false', - trivyImage: process.env.TRIVY_IMAGE || 'aquasec/trivy:latest', - /** Optional mirror for Trivy's vulnerability DB (Iran network); empty = default ghcr.io. */ - trivyDbRepository: process.env.TRIVY_DB_REPOSITORY || '', - /** Max seconds to wait for the Trivy scan job. */ - scanTimeoutSeconds: parseInt(process.env.BUILD_SCAN_TIMEOUT_SECONDS || '300', 10), - /** Registry garbage collection: keep the N most recent image tags per app repo. */ - registryGcEnabled: process.env.REGISTRY_GC_ENABLED !== 'false', - registryKeepVersions: parseInt(process.env.REGISTRY_KEEP_VERSIONS || '3', 10), - registryGcIntervalMs: parseInt(process.env.REGISTRY_GC_INTERVAL_MS || '86400000', 10), // daily }, elasticsearch: { diff --git a/backend/src/deployments/deployment.constants.ts b/backend/src/deployments/deployment.constants.ts deleted file mode 100644 index 322ae32..0000000 --- a/backend/src/deployments/deployment.constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Bull queue name for build+deploy pipelines. */ -export const DEPLOY_QUEUE = 'app-deploy'; diff --git a/backend/src/deployments/deployment.processor.ts b/backend/src/deployments/deployment.processor.ts deleted file mode 100644 index 4e30d89..0000000 --- a/backend/src/deployments/deployment.processor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { Job } from 'bull'; -import { DeploymentsService, DeploymentJobData } from './deployments.service'; -import { DEPLOY_QUEUE } from './deployment.constants'; - -/** - * Processes build+deploy pipelines off the `app-deploy` queue with a bounded - * concurrency (BUILD_CONCURRENCY, default 3) so simultaneous user deploys can't - * flood the cluster with Kaniko build jobs (2 CPU / 4Gi each). - * - * Concurrency is read at module-load time from env because Bull's `@Process` - * decorator option must be a constant. - */ -@Processor(DEPLOY_QUEUE) -export class DeploymentProcessor { - private readonly logger = new Logger(DeploymentProcessor.name); - - constructor(private readonly deploymentsService: DeploymentsService) {} - - @Process({ name: 'run', concurrency: parseInt(process.env.BUILD_CONCURRENCY || '3', 10) }) - async handleRun(job: Job): Promise { - const { deploymentId } = job.data; - this.logger.log(`Processing deployment ${deploymentId} (job ${job.id})`); - await this.deploymentsService.processDeploymentJob(job.data); - } -} diff --git a/backend/src/deployments/deployments.module.ts b/backend/src/deployments/deployments.module.ts index 767cebe..0d7e0e3 100644 --- a/backend/src/deployments/deployments.module.ts +++ b/backend/src/deployments/deployments.module.ts @@ -1,10 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { BullModule } from '@nestjs/bull'; import { DeploymentsService } from './deployments.service'; import { DeploymentsController } from './deployments.controller'; -import { DeploymentProcessor } from './deployment.processor'; -import { DEPLOY_QUEUE } from './deployment.constants'; import { Deployment } from './entities/deployment.entity'; import { ApplicationsModule } from '../applications/applications.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; @@ -14,14 +11,13 @@ import { ClustersModule } from '../clusters/clusters.module'; @Module({ imports: [ TypeOrmModule.forFeature([Deployment]), - BullModule.registerQueue({ name: DEPLOY_QUEUE }), forwardRef(() => ApplicationsModule), forwardRef(() => ClustersModule), KubernetesModule, BuildModule, ], controllers: [DeploymentsController], - providers: [DeploymentsService, DeploymentProcessor], + providers: [DeploymentsService], exports: [DeploymentsService], }) export class DeploymentsModule {} diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index a3ef9f4..8d6784c 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -1,15 +1,11 @@ -import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; import { Repository } from 'typeorm'; import * as fs from 'fs'; -import { DEPLOY_QUEUE } from './deployment.constants'; import { Deployment } from './entities/deployment.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service'; -import { ScanService } from '../build/scan.service'; import * as crypto from 'crypto'; import { AppLifecycleStatus, @@ -19,15 +15,8 @@ import { } from '../common/enums'; import { ClustersService } from '../clusters/clusters.service'; -/** Payload enqueued on the `app-deploy` queue for the build+deploy pipeline. */ -export interface DeploymentJobData { - deploymentId: string; - applicationId: string; - previewSubdomain: string | null; -} - @Injectable() -export class DeploymentsService implements OnModuleInit { +export class DeploymentsService { private readonly logger = new Logger(DeploymentsService.name); constructor( @@ -38,41 +27,8 @@ export class DeploymentsService implements OnModuleInit { private kubernetesService: KubernetesService, private buildService: BuildService, private clustersService: ClustersService, - private scanService: ScanService, - @InjectQueue(DEPLOY_QUEUE) - private deployQueue: Queue, ) {} - async onModuleInit(): Promise { - try { - await this.deploymentsRepository.query( - `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS "vulnerabilitySummary" jsonb`, - ); - } catch (e: any) { - this.logger.warn(`Could not ensure deployments.vulnerabilitySummary column: ${e.message}`); - } - } - - /** - * Queue worker entrypoint — runs one build+deploy pipeline. Bounded - * concurrency lives on the Bull processor, so this just dispatches to the - * managed (Helm-only) or app (build+deploy) pipeline. Both pipelines handle - * their own errors, so a failure here never triggers a Bull retry. - */ - async processDeploymentJob(data: DeploymentJobData): Promise { - const { deploymentId, applicationId, previewSubdomain } = data; - if (await this.isDeploymentCancelled(deploymentId)) { - this.logger.log(`Deployment ${deploymentId} already cancelled before pickup — skipping`); - return; - } - const app = await this.applicationsService.findOne(applicationId); - if (isManagedProductType(app.productType)) { - await this.executeManagedPipeline(deploymentId, app); - } else { - await this.executePipeline(deploymentId, app, previewSubdomain); - } - } - /** * Random 7-digit suffix for the preview host: -<7-digit>.. * Generated once per application (see resolvePreviewNumber) and persisted. @@ -121,13 +77,13 @@ export class DeploymentsService implements OnModuleInit { saved.previewSubdomain = previewSubdomain; } - // Enqueue the build+deploy pipeline. The Bull processor runs it with bounded - // concurrency so concurrent user deploys can't flood the cluster. - await this.deployQueue.add( - 'run', - { deploymentId: saved.id, applicationId: app.id, previewSubdomain }, - { removeOnComplete: true, removeOnFail: true }, - ); + // Trigger async pipeline (Helm-only for managed services, build+deploy for apps) + const run = isManagedProductType(app.productType) + ? this.executeManagedPipeline(saved.id, app) + : this.executePipeline(saved.id, app, previewSubdomain); + run.catch((error) => { + this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error); + }); return saved; } @@ -231,19 +187,6 @@ export class DeploymentsService implements OnModuleInit { // Save build log await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog }); - // Report-only vulnerability scan — runs alongside the deploy and is - // persisted when it finishes. Never blocks or fails the deployment. - void this.scanService - .scanImage(app, imageUri) - .then((summary) => { - if (summary) { - return this.deploymentsRepository.update(deploymentId, { - vulnerabilitySummary: summary as Record, - }); - } - }) - .catch((e) => this.logger.warn(`Scan persistence failed for ${deploymentId}: ${e.message}`)); - // Step 2: Update app with new image tag await this.applicationsService.updateImageTag(app.id, imageUri); @@ -543,10 +486,7 @@ export class DeploymentsService implements OnModuleInit { return this.kubernetesService.getPodLogs(app); } - async getBuildLogs( - applicationId: string, - userId: string, - ): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date; vulnerabilitySummary: Record | null }> { + async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> { const app = await this.applicationsService.findOne(applicationId, userId); const latest = await this.deploymentsRepository.findOne({ @@ -555,7 +495,7 @@ export class DeploymentsService implements OnModuleInit { }); if (!latest) { - return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date(), vulnerabilitySummary: null }; + return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() }; } if (this.isManagedOrHelmOnlyApp(app)) { @@ -564,7 +504,6 @@ export class DeploymentsService implements OnModuleInit { status: latest.status, version: latest.version, createdAt: latest.createdAt, - vulnerabilitySummary: null, }; } @@ -584,7 +523,6 @@ export class DeploymentsService implements OnModuleInit { status: latest.status, version: latest.version, createdAt: latest.createdAt, - vulnerabilitySummary: latest.vulnerabilitySummary ?? null, }; } @@ -599,7 +537,7 @@ export class DeploymentsService implements OnModuleInit { if (!latest) return null; - const progress = await this.buildService.getProgress(latest.id); + const progress = this.buildService.getProgress(latest.id); if (progress) return progress; // No in-memory progress — infer from deployment status @@ -751,12 +689,10 @@ export class DeploymentsService implements OnModuleInit { saved.previewSubdomain = previewSubdomain; } - // Enqueue the build & deploy pipeline (same queue as initial deploy) - await this.deployQueue.add( - 'run', - { deploymentId: saved.id, applicationId: app.id, previewSubdomain }, - { removeOnComplete: true, removeOnFail: true }, - ); + // Trigger async build & deploy pipeline (same as initial deploy) + this.executePipeline(saved.id, app, previewSubdomain).catch((error) => { + this.logger.error(`Redeploy pipeline failed for deployment ${saved.id}:`, error); + }); this.logger.log(`Redeploy triggered for ${app.name} (${app.gitUrl ? 'git: ' + app.gitUrl : 'zip'})`); return saved; diff --git a/backend/src/deployments/entities/deployment.entity.ts b/backend/src/deployments/entities/deployment.entity.ts index c3bc9e4..7c4cd74 100644 --- a/backend/src/deployments/entities/deployment.entity.ts +++ b/backend/src/deployments/entities/deployment.entity.ts @@ -33,14 +33,6 @@ export class Deployment { @Column({ type: 'text', nullable: true }) deployLog: string; - /** - * Report-only Trivy vulnerability summary for the built image - * (e.g. { critical, high, medium, low, unknown, total, scannedAt }). - * Non-blocking — never gates a deployment. - */ - @Column({ type: 'jsonb', nullable: true }) - vulnerabilitySummary: Record | null; - /** * Per-deployment preview number (derived deterministically from deployment.id). * Used to build preview ingress host under the main frontend domain. diff --git a/backend/src/kubernetes/kubernetes.module.ts b/backend/src/kubernetes/kubernetes.module.ts index 8ec8716..99bcd91 100644 --- a/backend/src/kubernetes/kubernetes.module.ts +++ b/backend/src/kubernetes/kubernetes.module.ts @@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { KubernetesService } from './kubernetes.service'; import { HelmService } from './helm.service'; import { RegistryService } from './registry.service'; -import { RegistryGcService } from './registry-gc.service'; import { ElasticsearchService } from './elasticsearch.service'; import { ElasticsearchController } from './elasticsearch.controller'; import { LogsController } from './logs.controller'; @@ -14,7 +13,7 @@ import { Deployment } from '../deployments/entities/deployment.entity'; @Module({ imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])], controllers: [ElasticsearchController, LogsController], - providers: [KubernetesService, HelmService, RegistryService, RegistryGcService, ElasticsearchService], + providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService], exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService], }) export class KubernetesModule {} diff --git a/backend/src/kubernetes/registry-gc.service.spec.ts b/backend/src/kubernetes/registry-gc.service.spec.ts deleted file mode 100644 index 98a83b6..0000000 --- a/backend/src/kubernetes/registry-gc.service.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { selectTagsToDelete } from './registry-gc.util'; - -describe('selectTagsToDelete', () => { - it('keeps the N newest numeric (Date.now) tags and deletes the rest', () => { - const tags = ['1000', '3000', '2000', '5000', '4000']; - const toDelete = selectTagsToDelete(tags, 3); - // newest 3 = 5000,4000,3000 → delete 2000,1000 - expect(toDelete.sort()).toEqual(['1000', '2000']); - }); - - it('deletes nothing when tag count is within the keep limit', () => { - expect(selectTagsToDelete(['1000', '2000'], 3)).toEqual([]); - expect(selectTagsToDelete([], 3)).toEqual([]); - }); - - it('treats non-numeric tags as oldest (eligible for deletion first)', () => { - const tags = ['latest', '2000', '1000']; - // numeric newest kept first: 2000,1000 kept (keep=2) → delete latest - expect(selectTagsToDelete(tags, 2)).toEqual(['latest']); - }); - - it('keep=0 deletes every tag', () => { - expect(selectTagsToDelete(['1000', '2000'], 0).sort()).toEqual(['1000', '2000']); - }); -}); diff --git a/backend/src/kubernetes/registry-gc.service.ts b/backend/src/kubernetes/registry-gc.service.ts deleted file mode 100644 index 0206003..0000000 --- a/backend/src/kubernetes/registry-gc.service.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Writable } from 'stream'; -import * as k8s from '@kubernetes/client-node'; -import { Redis } from 'ioredis'; -import { REDIS_CLIENT } from '../common/redis/redis.module'; -import { ClustersService } from '../clusters/clusters.service'; -import { RegistryService } from './registry.service'; -import { selectTagsToDelete } from './registry-gc.util'; - -/** - * Periodic registry garbage collection: prunes each app image repo to the N most - * recent tags (deleting older manifests) and then reclaims disk by running - * `registry garbage-collect` in the registry pod. Runs on the local/default - * cluster's in-cluster registry (the one reachable via cluster DNS). A Redis lock - * keeps a single replica running it at a time (see [[multi-instance-interval-jobs]]). - */ -@Injectable() -export class RegistryGcService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(RegistryGcService.name); - private timer?: NodeJS.Timeout; - private static readonly LOCK_KEY = 'registry:gc:lock'; - - constructor( - private readonly configService: ConfigService, - private readonly clustersService: ClustersService, - private readonly registryService: RegistryService, - @Inject(REDIS_CLIENT) private readonly redis: Redis, - ) {} - - onModuleInit(): void { - if (this.configService.get('build.registryGcEnabled') === false) return; - const interval = this.configService.get('build.registryGcIntervalMs') || 86_400_000; - this.timer = setInterval(() => void this.runGc(), interval); - // First pass shortly after boot. - setTimeout(() => void this.runGc(), 60_000); - this.logger.log(`Registry GC scheduled — interval: ${Math.round(interval / 3600000)}h`); - } - - onModuleDestroy(): void { - if (this.timer) clearInterval(this.timer); - } - - /** Run one GC pass guarded by a Redis lock so only one replica executes it. */ - async runGc(): Promise { - const keep = this.configService.get('build.registryKeepVersions') || 3; - const locked = await this.redis.set(RegistryGcService.LOCK_KEY, '1', 'EX', 900, 'NX').catch(() => null); - if (locked !== 'OK') { - this.logger.debug('Registry GC already running on another replica — skipping'); - return; - } - try { - await this.pruneDefaultClusterRegistry(keep); - } catch (e: any) { - this.logger.warn(`Registry GC failed: ${e.message}`); - } finally { - await this.redis.del(RegistryGcService.LOCK_KEY).catch(() => undefined); - } - } - - private authHeader(): string { - const { username, password } = this.registryService.getRegistryCredentials(); - return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); - } - - private async registryFetch(pathOrUrl: string, init: RequestInit = {}): Promise { - const base = `http://${this.registryService.getRegistryUrl()}`; - return fetch(`${base}${pathOrUrl}`, { - ...init, - headers: { Authorization: this.authHeader(), ...(init.headers || {}) }, - }); - } - - private async pruneDefaultClusterRegistry(keep: number): Promise { - // 1. List repositories - const catalogRes = await this.registryFetch('/v2/_catalog?n=10000'); - if (!catalogRes.ok) throw new Error(`catalog ${catalogRes.status}`); - const repositories: string[] = ((await catalogRes.json()) as any)?.repositories || []; - - let deletedTotal = 0; - for (const repo of repositories) { - // Skip Kaniko cache repos — pruning them just slows the next build. - if (repo.endsWith('/cache')) continue; - deletedTotal += await this.pruneRepo(repo, keep); - } - - if (deletedTotal > 0) { - this.logger.log(`Registry GC: deleted ${deletedTotal} old manifest(s); reclaiming disk…`); - await this.runGarbageCollect(); - } else { - this.logger.debug('Registry GC: nothing to prune'); - } - } - - /** Delete all but the newest `keep` tags of one repo. Returns count deleted. */ - private async pruneRepo(repo: string, keep: number): Promise { - const tagsRes = await this.registryFetch(`/v2/${repo}/tags/list`); - if (!tagsRes.ok) return 0; - const tags: string[] = ((await tagsRes.json()) as any)?.tags || []; - const toDelete = selectTagsToDelete(tags, keep); - if (toDelete.length === 0) return 0; - - const seenDigests = new Set(); - let deleted = 0; - for (const tag of toDelete) { - try { - const head = await this.registryFetch(`/v2/${repo}/manifests/${tag}`, { - method: 'GET', - headers: { - Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json', - }, - }); - const digest = head.headers.get('docker-content-digest'); - if (!digest || seenDigests.has(digest)) continue; // multiple tags can share a digest - seenDigests.add(digest); - const del = await this.registryFetch(`/v2/${repo}/manifests/${digest}`, { method: 'DELETE' }); - if (del.ok || del.status === 202) deleted++; - } catch (e: any) { - this.logger.warn(`Failed to delete ${repo}:${tag}: ${e.message}`); - } - } - if (deleted > 0) this.logger.log(`Registry GC: pruned ${deleted} tag(s) from ${repo} (kept ${keep})`); - return deleted; - } - - /** Reclaim disk by running `registry garbage-collect` inside the registry pod. Best-effort. */ - private async runGarbageCollect(): Promise { - try { - const buildNs = this.registryService.getBuildNamespace(); - const cluster = await this.clustersService.getDefault(); - const kc = new k8s.KubeConfig(); - kc.loadFromString(cluster.kubeconfig); - const coreApi = kc.makeApiClient(k8s.CoreV1Api); - - const pods = await coreApi.listNamespacedPod({ namespace: buildNs, labelSelector: 'app=registry' }); - const podName = pods.items[0]?.metadata?.name; - if (!podName) { - this.logger.warn('Registry GC: no registry pod found for garbage-collect'); - return; - } - - const exec = new k8s.Exec(kc); - const sink = new Writable({ write: (_c, _e, cb) => cb() }); - await new Promise((resolve, reject) => { - exec - .exec( - buildNs, - podName, - 'registry', - ['/bin/registry', 'garbage-collect', '/etc/docker/registry/config.yml'], - sink, - sink, - null, - false, - (status) => (status.status === 'Failure' ? reject(new Error(status.message)) : resolve()), - ) - .catch(reject); - }); - this.logger.log('Registry GC: garbage-collect completed'); - } catch (e: any) { - this.logger.warn(`Registry garbage-collect failed (manifests already deleted): ${e.message}`); - } - } -} diff --git a/backend/src/kubernetes/registry-gc.util.ts b/backend/src/kubernetes/registry-gc.util.ts deleted file mode 100644 index 70b3816..0000000 --- a/backend/src/kubernetes/registry-gc.util.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Decide which image tags to delete, keeping the `keep` most recent. Tags are - * `Date.now()` strings, so newest = highest numeric value; non-numeric tags sort - * last (treated as oldest) and are eligible for deletion once `keep` is met. - * - * Kept in its own (dependency-free) module so it can be unit-tested without - * pulling in the ESM `@kubernetes/client-node` that the GC service imports. - */ -export function selectTagsToDelete(tags: string[], keep: number): string[] { - const sorted = [...tags].sort((a, b) => { - const na = Number(a); - const nb = Number(b); - if (Number.isNaN(na) && Number.isNaN(nb)) return a < b ? 1 : -1; - if (Number.isNaN(na)) return 1; - if (Number.isNaN(nb)) return -1; - return nb - na; // newest first - }); - return sorted.slice(Math.max(0, keep)); -} diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 6868aff..4eb5715 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -12,7 +12,6 @@ import { useLocalizedRouter } from '@/i18n/navigation'; import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ScrollText } from 'lucide-react'; import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; -import { VulnerabilityBadge } from '@/components/vulnerability-badge'; import { DeletingModal } from '@/components/deleting-modal'; import { useApplicationDelete } from '@/lib/use-application-delete'; import { isApplicationProduct, isManagedProduct } from '@/lib/product-type'; @@ -1511,10 +1510,7 @@ export default function AppDetailPage() { {deployments.slice(0, 10).map((d) => (
-
-

{d.version || d.imageTag}

- -
+

{d.version || d.imageTag}

{new Date(d.createdAt).toLocaleString(locale)}

diff --git a/frontend/src/components/vulnerability-badge.tsx b/frontend/src/components/vulnerability-badge.tsx deleted file mode 100644 index d136467..0000000 --- a/frontend/src/components/vulnerability-badge.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ShieldCheck, ShieldAlert } from 'lucide-react'; -import type { VulnerabilitySummary } from '@/types'; - -/** - * Compact, report-only image-scan badge (Trivy). Renders nothing when there is - * no scan data yet. Shows a green "clean" pill, or the count of the highest - * severities found. Title carries the full per-severity breakdown. - */ -export function VulnerabilityBadge({ summary }: { summary?: VulnerabilitySummary | null }) { - if (!summary) return null; - - const { critical, high, medium, low, total } = summary; - const breakdown = `Critical ${critical} · High ${high} · Medium ${medium} · Low ${low}`; - - if (total === 0) { - return ( - - 0 CVE - - ); - } - - const severe = critical > 0 || high > 0; - const cls = severe - ? 'text-red-700 bg-red-50 border-red-200' - : 'text-amber-700 bg-amber-50 border-amber-200'; - const label = severe ? `${critical}C / ${high}H` : `${medium}M / ${low}L`; - - return ( - - {label} - - ); -} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 845632f..365a887 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -159,17 +159,6 @@ export interface Deployment { triggeredBy: string; createdAt: string; finishedAt?: string; - vulnerabilitySummary?: VulnerabilitySummary | null; -} - -export interface VulnerabilitySummary { - critical: number; - high: number; - medium: number; - low: number; - unknown: number; - total: number; - scannedAt: string; } export type DeploymentStatus =