diff --git a/backend/k8s/nixpacks-spike/nixpacks-spike.yaml b/backend/k8s/nixpacks-spike/nixpacks-spike.yaml new file mode 100644 index 0000000..620ccde --- /dev/null +++ b/backend/k8s/nixpacks-spike/nixpacks-spike.yaml @@ -0,0 +1,212 @@ +# ───────────────────────────────────────────────────────────────────────────── +# فاز ۰ — 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 18fc8c6..73572b4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "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", @@ -2587,6 +2588,18 @@ "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", @@ -4080,6 +4093,18 @@ "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", @@ -4106,6 +4131,12 @@ "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", @@ -4384,6 +4415,15 @@ "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", @@ -4484,6 +4524,12 @@ "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", @@ -4567,6 +4613,15 @@ "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", @@ -5148,6 +5203,15 @@ } } }, + "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", @@ -5727,6 +5791,12 @@ "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", @@ -5963,6 +6033,45 @@ ], "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", @@ -6021,6 +6130,15 @@ "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", @@ -6794,6 +6912,18 @@ "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", @@ -8144,6 +8274,39 @@ "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", @@ -8626,6 +8789,21 @@ "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", @@ -9053,6 +9231,24 @@ "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", @@ -9279,6 +9475,15 @@ "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", @@ -9608,6 +9813,15 @@ "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", @@ -9687,6 +9901,21 @@ "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", @@ -9706,6 +9935,15 @@ "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", @@ -9820,6 +10058,21 @@ "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", @@ -10169,6 +10422,15 @@ "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", @@ -11186,6 +11448,43 @@ } } }, + "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 236ab6c..7b9be9a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "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", @@ -69,13 +70,19 @@ "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 a114504..d8f0468 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,8 @@ 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({ @@ -54,6 +56,12 @@ 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 6dcb145..88f2f17 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -17,6 +17,7 @@ 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 { @@ -27,6 +28,7 @@ export class ApplicationsService { private appsRepository: Repository, private clustersService: ClustersService, private configService: ConfigService, + private storageService: StorageService, ) {} private toDnsLabel(value: string): string { @@ -204,18 +206,20 @@ export class ApplicationsService { async delete(id: string, userId: string): Promise { const app = await this.findOne(id, userId); - // Delete uploaded files + // Delete the uploaded source archive from object storage. if (app.codePath) { - 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}`); + 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}`); } + } catch (e: any) { + this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`); } await this.appsRepository.remove(app); @@ -261,21 +265,14 @@ 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); - // 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; + // 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; const saved = await this.appsRepository.save(app); - this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`); + this.logger.log(`Uploaded code for ${app.name} → ${key} (${(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 caa562f..8b561f4 100644 --- a/backend/src/build/build.module.ts +++ b/backend/src/build/build.module.ts @@ -1,5 +1,6 @@ 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'; @@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module'; forwardRef(() => KubernetesModule), ClustersModule, ], - providers: [BuildService], - exports: [BuildService], + providers: [BuildService, ScanService], + exports: [BuildService, ScanService], }) export class BuildModule {} diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index 8cfe769..e1af1c3 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -1,298 +1,16 @@ import { AppRuntime } from '../common/enums'; /** - * Tests for build service — Dockerfile generation for all runtimes + * 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. */ -// ───────────────────────────────────────────────────────────────────────────── -// 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) @@ -467,3 +185,75 @@ 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 99555d7..5f6051f 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -1,17 +1,15 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } 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 * as fs from 'fs'; -import * as path from 'path'; -import { execFile, spawn, ChildProcess } from 'child_process'; +import { 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'; - -const execFileAsync = promisify(execFile); +import { StorageService } from '../common/storage/storage.service'; export class BuildCancelledError extends Error { constructor() { @@ -28,10 +26,24 @@ 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; @@ -43,8 +55,10 @@ export interface BuildProgress { @Injectable() export class BuildService { private readonly logger = new Logger(BuildService.name); - private readonly progressMap = new Map(); + /** Local, non-serializable session state (child processes, sockets, API clients). */ 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. @@ -55,8 +69,21 @@ 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: [] }); } @@ -69,6 +96,61 @@ 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 { @@ -111,94 +193,82 @@ export class BuildService { } private endBuildSession(deploymentId?: string): void { - if (deploymentId) this.activeBuilds.delete(deploymentId); + 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); } async cancelBuild(deploymentId: string): Promise { - const session = this.activeBuilds.get(deploymentId); - 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}`); + // 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); - if (session.socket) { - try { - session.socket.destroy(); - } catch { - /* ignore */ + 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 */ + } } - } - for (const proc of session.processes) { - try { - proc.kill('SIGKILL'); - } 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}`); + } } } - 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); + this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' }); + this.endBuildSession(deploymentId); } /** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */ @@ -277,17 +347,29 @@ export class BuildService { this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`); } - getProgress(deploymentId: string): BuildProgress | null { - return this.progressMap.get(deploymentId) ?? null; + async getProgress(deploymentId: string): Promise { + try { + const raw = await this.redis.get(this.progressKey(deploymentId)); + return raw ? (JSON.parse(raw) as BuildProgress) : null; + } catch { + return 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; - this.progressMap.set(deploymentId, progress); + void this.redis + .set(this.progressKey(deploymentId), JSON.stringify(progress), 'EX', BuildService.STATE_TTL_SECONDS) + .catch(() => undefined); } - clearProgress(deploymentId: string): void { - this.progressMap.delete(deploymentId); + async clearProgress(deploymentId: string): Promise { + await this.redis.del(this.progressKey(deploymentId)).catch(() => undefined); } /** @@ -306,8 +388,13 @@ export class BuildService { this.beginBuildSession(deploymentId); } - // Determine Dockerfile based on runtime - const dockerfileContent = this.generateDockerfile(app); + // 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; // Create Kaniko build pod const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, ''); @@ -329,18 +416,32 @@ export class BuildService { coreApi, batchApi, namespace: buildNamespace, + clusterId: cluster.id, }); } // Ensure the build namespace exists await this.ensureNamespace(coreApi, buildNamespace); - this.throwIfCancelled(deploymentId); + await this.throwIfCancelledShared(deploymentId); - // Determine if we have uploaded code or git URL - const codePath = app.codePath ? path.resolve(app.codePath) : null; - const hasUploadedCode = codePath && fs.existsSync(codePath); + // Determine source: uploaded code (MinIO object key in app.codePath) or git URL. + const hasUploadedCode = !!app.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', @@ -350,22 +451,15 @@ export class BuildService { namespace: buildNamespace, }, data: { - Dockerfile: dockerfileContent, + Dockerfile: dockerfileContent ?? '', }, }; - // If we have uploaded code, create a PVC and upload via kubectl cp - let sourcePvcName: string | undefined; + // 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 (hasUploadedCode) { - 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); + sourceDownloadUrl = await this.storageService.presignSourceGet(app.codePath!); } // Build the Kaniko Job spec @@ -387,46 +481,51 @@ 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 && 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 + 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). initContainers.push({ - name: 'unzip-source', + name: 'fetch-source', image: 'alpine:3.19', imagePullPolicy: 'IfNotPresent', + env: [{ name: 'SOURCE_URL', value: sourceDownloadUrl }], command: [ 'sh', '-c', ` - apk add --no-cache unzip tar gzip && - cp /workspace/Dockerfile /workspace-out/Dockerfile && + apk add --no-cache unzip tar gzip wget && + ${copyTemplatedDockerfile} + echo ">>> Downloading source archive from object storage..." && + wget -q -O /tmp/source.zip "$SOURCE_URL" && mkdir -p /tmp/extract && cd /tmp/extract && - if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then + if tar tzf /tmp/source.zip >/dev/null 2>&1; then echo ">>> Detected gzip tarball" && - tar xzf /source-pvc/source.zip - elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then + tar xzf /tmp/source.zip + elif unzip -t /tmp/source.zip >/dev/null 2>&1; then echo ">>> Detected zip archive" && - unzip -q /source-pvc/source.zip + unzip -q /tmp/source.zip else echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1 fi && @@ -442,72 +541,67 @@ export class BuildService { echo ">>> Multiple items or files — copying as-is" && cp -a /tmp/extract/. /workspace-out/source/ fi && - rm -rf /tmp/extract && + rm -rf /tmp/extract /tmp/source.zip && echo "--- Final workspace contents ---" && ls -la /workspace-out/source/ `, ], volumeMounts: [ { name: 'workspace', mountPath: '/workspace-out' }, - { - name: 'dockerfile', - mountPath: '/workspace/Dockerfile', - subPath: 'Dockerfile', - }, - { name: 'source-pvc', mountPath: '/source-pvc' }, + ...stagingDockerfileMounts, ], }); } else if (hasGitUrl) { - // 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'; + const gitCopyDockerfile = useTemplated ? 'cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&' : ''; - // Clone git repo into /workspace/source, then copy our generated Dockerfile + // Clone command. Three cases: + // • token + parseable host → token comes from $GIT_TOKEN (Secret env) via + // git's credential store; the clone URL stays token-free. + // • token + unparseable host (rare) → fall back to inline token injection. + // • 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' } } }]; + cloneCmd = + `git config --global credential.helper store && ` + + `printf 'https://%s@%s\\n' "$GIT_TOKEN" '${gitHost}' > "$HOME/.git-credentials" && ` + + `chmod 600 "$HOME/.git-credentials" && ` + + `git clone --depth 1 --branch ${branch} '${app.gitUrl}' /workspace-out/source && ` + + `rm -f "$HOME/.git-credentials" &&`; + } 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 &&`; + } + + // Clone git repo into /workspace/source 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}" && - git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && - cp /dockerfile/Dockerfile /workspace-out/Dockerfile && + ${cloneCmd} + ${gitCopyDockerfile} echo ">>> Workspace contents:" && ls -la /workspace-out/source/ `, ], volumeMounts: [ { name: 'workspace', mountPath: '/workspace-out' }, - { name: 'dockerfile', mountPath: '/dockerfile' }, + ...(useTemplated ? [{ name: 'dockerfile', mountPath: '/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 + } 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. initContainers.push({ name: 'prepare-workspace', image: 'alpine:3.19', @@ -529,6 +623,18 @@ 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', @@ -564,12 +670,26 @@ export class BuildService { }; try { - const t0 = Date.now(); - await coreApi.createNamespacedConfigMap({ - namespace: buildNamespace!, - body: dockerfileConfigMap, - }); - this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`); + 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 t1 = Date.now(); await batchApi.createNamespacedJob({ @@ -584,7 +704,8 @@ export class BuildService { percent: 15, message: 'Building Docker image...', }); - await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId); + const buildTimeout = this.configService.get('build.timeoutSeconds') || 600; + await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, buildTimeout, deploymentId); // Capture build logs on success let buildLog = ''; @@ -610,284 +731,29 @@ export class BuildService { (err as any).buildLog = buildLog; throw err; } finally { - // Clean up build resources - if (sourcePvcName) { + // Clean up Dockerfile ConfigMap (templated mode only) + if (useTemplated) { try { - await coreApi.deleteNamespacedPersistentVolumeClaim({ - name: sourcePvcName, + await coreApi.deleteNamespacedConfigMap({ + name: `${buildPodName}-dockerfile`, namespace: buildNamespace!, }); - this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`); } catch (e: any) { - this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`); + this.logger.warn(`Failed to clean up ConfigMap: ${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}`); + // 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}`); + } } 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). @@ -949,243 +815,66 @@ 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. + * 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 detectRuntime(app: Application): AppRuntime { - const codePath = app.codePath ? path.resolve(app.codePath) : null; - if (!codePath || !fs.existsSync(codePath)) { - return app.runtime; + 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) }); } - - // 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); + if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) { + env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) }); } - - 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; + return env; } - 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}`); - } - } + /** + * 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 . . - -# 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"] -`; + 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' }], + }; } private wordpressDockerfile(app: Application): string { @@ -1305,286 +994,6 @@ 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 @@ -1608,7 +1017,7 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! let lastLoggedStatus = ''; while (Date.now() - startTime < timeoutMs) { - this.throwIfCancelled(deploymentId); + await this.throwIfCancelledShared(deploymentId); const elapsed = Date.now() - startTime; const buildPercent = Math.min(90, 15 + Math.round((elapsed / timeoutMs) * 75)); this.setProgress(deploymentId, { @@ -1735,14 +1144,26 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! */ async getLiveBuildLog(deploymentId: string): Promise { const session = this.activeBuilds.get(deploymentId); - if (!session?.coreApi || !session.namespace || !session.buildPodName) { - return null; + if (session?.coreApi && session.namespace && session.buildPodName) { + try { + return await this.getBuildLogs(session.coreApi, session.buildPodName, session.namespace); + } catch { + return null; + } } - try { - return await this.getBuildLogs(session.coreApi, session.buildPodName, session.namespace); - } catch { - 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; + } + } } + 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 new file mode 100644 index 0000000..c2cbde5 --- /dev/null +++ b/backend/src/build/scan.service.spec.ts @@ -0,0 +1,94 @@ +/** + * 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 new file mode 100644 index 0000000..d299cf1 --- /dev/null +++ b/backend/src/build/scan.service.ts @@ -0,0 +1,169 @@ +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 ffe88a1..358ff94 100644 --- a/backend/src/clusters/clusters.service.ts +++ b/backend/src/clusters/clusters.service.ts @@ -1102,9 +1102,133 @@ 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 new file mode 100644 index 0000000..d3a6653 --- /dev/null +++ b/backend/src/common/redis/redis.module.ts @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..ea195eb --- /dev/null +++ b/backend/src/common/storage/storage.module.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..c758e69 --- /dev/null +++ b/backend/src/common/storage/storage.service.ts @@ -0,0 +1,64 @@ +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 79e9207..c814a8a 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -125,9 +125,45 @@ 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 new file mode 100644 index 0000000..322ae32 --- /dev/null +++ b/backend/src/deployments/deployment.constants.ts @@ -0,0 +1,2 @@ +/** 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 new file mode 100644 index 0000000..4e30d89 --- /dev/null +++ b/backend/src/deployments/deployment.processor.ts @@ -0,0 +1,27 @@ +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 0d7e0e3..767cebe 100644 --- a/backend/src/deployments/deployments.module.ts +++ b/backend/src/deployments/deployments.module.ts @@ -1,7 +1,10 @@ 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'; @@ -11,13 +14,14 @@ 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], + providers: [DeploymentsService, DeploymentProcessor], exports: [DeploymentsService], }) export class DeploymentsModule {} diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index 8d6784c..a3ef9f4 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -1,11 +1,15 @@ -import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } 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, @@ -15,8 +19,15 @@ 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 { +export class DeploymentsService implements OnModuleInit { private readonly logger = new Logger(DeploymentsService.name); constructor( @@ -27,8 +38,41 @@ export class DeploymentsService { 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. @@ -77,13 +121,13 @@ export class DeploymentsService { saved.previewSubdomain = previewSubdomain; } - // 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); - }); + // 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 }, + ); return saved; } @@ -187,6 +231,19 @@ export class DeploymentsService { // 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); @@ -486,7 +543,10 @@ export class DeploymentsService { return this.kubernetesService.getPodLogs(app); } - async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> { + async getBuildLogs( + applicationId: string, + userId: string, + ): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date; vulnerabilitySummary: Record | null }> { const app = await this.applicationsService.findOne(applicationId, userId); const latest = await this.deploymentsRepository.findOne({ @@ -495,7 +555,7 @@ export class DeploymentsService { }); if (!latest) { - return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() }; + return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date(), vulnerabilitySummary: null }; } if (this.isManagedOrHelmOnlyApp(app)) { @@ -504,6 +564,7 @@ export class DeploymentsService { status: latest.status, version: latest.version, createdAt: latest.createdAt, + vulnerabilitySummary: null, }; } @@ -523,6 +584,7 @@ export class DeploymentsService { status: latest.status, version: latest.version, createdAt: latest.createdAt, + vulnerabilitySummary: latest.vulnerabilitySummary ?? null, }; } @@ -537,7 +599,7 @@ export class DeploymentsService { if (!latest) return null; - const progress = this.buildService.getProgress(latest.id); + const progress = await this.buildService.getProgress(latest.id); if (progress) return progress; // No in-memory progress — infer from deployment status @@ -689,10 +751,12 @@ export class DeploymentsService { saved.previewSubdomain = previewSubdomain; } - // 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); - }); + // 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 }, + ); 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 7c4cd74..c3bc9e4 100644 --- a/backend/src/deployments/entities/deployment.entity.ts +++ b/backend/src/deployments/entities/deployment.entity.ts @@ -33,6 +33,14 @@ 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 99bcd91..8ec8716 100644 --- a/backend/src/kubernetes/kubernetes.module.ts +++ b/backend/src/kubernetes/kubernetes.module.ts @@ -3,6 +3,7 @@ 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'; @@ -13,7 +14,7 @@ import { Deployment } from '../deployments/entities/deployment.entity'; @Module({ imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])], controllers: [ElasticsearchController, LogsController], - providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService], + providers: [KubernetesService, HelmService, RegistryService, RegistryGcService, 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 new file mode 100644 index 0000000..98a83b6 --- /dev/null +++ b/backend/src/kubernetes/registry-gc.service.spec.ts @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..0206003 --- /dev/null +++ b/backend/src/kubernetes/registry-gc.service.ts @@ -0,0 +1,164 @@ +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 new file mode 100644 index 0000000..70b3816 --- /dev/null +++ b/backend/src/kubernetes/registry-gc.util.ts @@ -0,0 +1,19 @@ +/** + * 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 4eb5715..6868aff 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -12,6 +12,7 @@ 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'; @@ -1510,7 +1511,10 @@ 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 new file mode 100644 index 0000000..d136467 --- /dev/null +++ b/frontend/src/components/vulnerability-badge.tsx @@ -0,0 +1,40 @@ +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 365a887..845632f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -159,6 +159,17 @@ 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 =