chore(deps): upgrade all dependencies to latest stable

Bring backend and frontend to the latest stable releases (no pre-releases),
including major upgrades that required code migration. Both projects pass
typecheck and production builds.

Backend
- NestJS 10 -> 11 (common/core/platform-express/jwt/passport/bull/cli/
  schematics/testing), @nestjs/config 3->4, @nestjs/swagger 7->11,
  @nestjs/typeorm 10->11
- @kubernetes/client-node 0.21 -> 1.4: migrate ~200+ call sites across 6
  services to the v1 single-object argument API, unwrapped responses, err.code,
  setHeaderOptions for patch content-type, applyToHTTPSOptions. Add regression
  spec k8s-client-v1-migration.spec.ts.
- typeorm 0.3 -> 1.0: relations/select string arrays -> object form
- uuid 9->14 (drops @types/uuid), multer 1->2, bcrypt 5->6, helmet 7->8,
  class-validator 0.14->0.15
- TypeScript 5->6, ESLint 8->9, @typescript-eslint 6->8, jest 29->30,
  @types/node 20->24; tsconfig: strictPropertyInitialization:false,
  ignoreDeprecations, rootDir, explicit types[]
- @nestjs/config 4: jwt.strategy uses getOrThrow; @types/express kept at 4
  (Nest 11 runs Express 4)

Frontend
- React 18->19, Next 14->16 (async params via official codemod),
  Tailwind 3->4 (@tailwindcss/postcss, @import + @config, inline custom @apply),
  framer-motion 11->12, zustand 4->5, three 0.169->0.184, @react-three/* majors
- TypeScript 5->6 (tsconfig target es5->ES2017), ESLint 8->9,
  eslint-config-next 14->16

Infra/docs
- Dockerfiles node:20-alpine -> node:24-alpine (require-esm for k8s client)
- Add UPGRADE.md / UPGRADE.en.md; refresh README tech-stack versions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-14 18:05:33 +03:30
parent 23386b73de
commit 8b77656bb7
31 changed files with 8974 additions and 7684 deletions
+4 -3
View File
@@ -8,7 +8,7 @@ A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.j
``` ```
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Next.js 14 │ REST │ NestJS API │ K8s │ Kubernetes │ │ Next.js 16 │ REST │ NestJS API │ K8s │ Kubernetes │
│ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │ │ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │
└─────────────┘ └────────┬────────┘ └──────────────┘ └─────────────┘ └────────┬────────┘ └──────────────┘
@@ -20,14 +20,15 @@ A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.j
| Layer | Technology | | Layer | Technology |
| ------------ | ------------------------------------------------------- | | ------------ | ------------------------------------------------------- |
| Frontend | Next.js 14, Tailwind CSS, React Query, Zustand | | Frontend | Next.js 16, Tailwind CSS v4, React Query, Zustand |
| Backend API | NestJS 10, TypeORM, Passport JWT, Bull (Redis) | | Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) | | Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
| Deployment | Helm v3 charts, @kubernetes/client-node | | Deployment | Helm v3 charts, @kubernetes/client-node |
| Database | PostgreSQL 16 | | Database | PostgreSQL 16 |
| Queue | Redis 7 + BullMQ | | Queue | Redis 7 + BullMQ |
> 📖 See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed system design. > 📖 See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed system design.
> 🔼 See [UPGRADE.en.md](UPGRADE.en.md) ([فارسی](UPGRADE.md)) for the latest dependency-upgrade notes (React 19, Next 16, NestJS 11, Tailwind 4, k8s-client v1).
--- ---
+163
View File
@@ -0,0 +1,163 @@
# Dependency Upgrade — Latest Stable Versions
Branch: `chore/upgrade-dependencies-latest` (based on `feat/abrban-landing`)
🌐 Persian version: [UPGRADE.md](UPGRADE.md)
All backend and frontend dependencies were upgraded to their **latest stable** release
(no pre-releases), including major upgrades that required code migration. Both projects
pass type-checking and production builds.
---
## Status
| Project | Typecheck | Build | Tests |
|---|---|---|---|
| **backend** | ✅ `tsc` clean | ✅ `nest build` | 95/96 pass (1 pre-existing helm failure, unrelated) + 6 new migration tests |
| **frontend** | ✅ `tsc` clean | ✅ `next build` (42 routes) | — |
> **Minimum runtime Node: 20.19** (because `@kubernetes/client-node@1` is ESM). Dockerfiles were bumped to `node:24-alpine` (LTS).
---
## Backend — `dependencies`
| Package | Before | After | Kind |
|---|---|---|---|
| `@kubernetes/client-node` | `^0.21.0` | `^1.4.0` | **major** |
| `@nestjs/bull` | `^10.1.0` | `^11.0.4` | major |
| `@nestjs/common` | `^10.3.0` | `^11.1.24` | major |
| `@nestjs/config` | `^3.1.0` | `^4.0.4` | major |
| `@nestjs/core` | `^10.3.0` | `^11.1.26` | major |
| `@nestjs/jwt` | `^10.2.0` | `^11.0.2` | major |
| `@nestjs/passport` | `^10.0.3` | `^11.0.5` | major |
| `@nestjs/platform-express` | `^10.3.0` | `^11.1.26` | major |
| `@nestjs/swagger` | `^7.2.0` | `^11.4.4` | major |
| `@nestjs/typeorm` | `^10.0.1` | `^11.0.1` | major |
| `bcrypt` | `^5.1.1` | `^6.0.0` | major |
| `class-validator` | `^0.14.1` | `^0.15.1` | minor (0.x) |
| `helmet` | `^7.1.0` | `^8.2.0` | major |
| `js-yaml` | `^4.1.0` | `^4.2.0` | minor |
| `multer` | `^1.4.5-lts.1` | `^2.1.1` | **major** |
| `pg` | `^8.11.0` | `^8.21.0` | minor |
| `rxjs` | `^7.8.1` | `^7.8.2` | patch (8 not yet stable) |
| `typeorm` | `^0.3.19` | `^1.0.0` | **major** |
| `uuid` | `^9.0.0` | `^14.0.0` | **major** |
Unchanged (already latest stable): `bull`, `class-transformer`, `handlebars`, `passport`, `passport-jwt`, `reflect-metadata`.
## Backend — `devDependencies`
| Package | Before | After |
|---|---|---|
| `@nestjs/cli` | `^10.3.0` | `^11.0.23` |
| `@nestjs/schematics` | `^10.1.0` | `^11.1.0` |
| `@nestjs/testing` | `^10.3.0` | `^11.1.26` |
| `@types/bcrypt` | `^5.0.2` | `^6.0.0` |
| `@types/jest` | `^29.5.11` | `^30.0.0` |
| `@types/multer` | `^1.4.11` | `^2.1.0` |
| `@types/node` | `^20.11.0` | `^24.0.0` |
| `@typescript-eslint/eslint-plugin` | `^6.19.0` | `^8.61.0` |
| `@typescript-eslint/parser` | `^6.19.0` | `^8.61.0` |
| `eslint` | `^8.56.0` | `^9.0.0` |
| `jest` | `^29.7.0` | `^30.4.2` |
| `prettier` | `^3.2.0` | `^3.8.4` |
| `ts-jest` | `^29.1.1` | `^29.4.11` |
| `typescript` | `^5.3.3` | `^6.0.0` |
| `@types/uuid` | `^9.0.7` | **removed** (`uuid@14` ships its own types) |
> **`@types/express` deliberately kept at `^4.17.21`**: NestJS 11 runs on Express **4** at runtime; `@types/express@5` only matches Express 5.
---
## Frontend — `dependencies`
| Package | Before | After | Kind |
|---|---|---|---|
| `react` | `^18.2.0` | `^19.2.7` | **major** |
| `react-dom` | `^18.2.0` | `^19.2.7` | **major** |
| `next` | `14.1.0` | `16.2.9` | **major (2 versions)** |
| `tailwindcss`* | `^3.4.1` | `^4.3.1` | **major** |
| `framer-motion` | `^11.18.2` | `^12.40.0` | **major** |
| `zustand` | `^4.5.0` | `^5.0.14` | **major** |
| `three` | `^0.169.0` | `^0.184.0` | minor (0.x, breaking) |
| `@react-three/fiber` | `^8.18.0` | `^9.6.1` | **major** |
| `@react-three/drei` | `^9.122.0` | `^10.7.7` | **major** |
| `@react-three/postprocessing` | `^2.19.1` | `^3.0.4` | **major** |
| `@tanstack/react-query` | `^5.17.0` | `^5.101.0` | minor |
| `axios` | `^1.6.0` | `^1.17.0` | minor |
| `lucide-react` | `^1.7.0` | `^1.18.0` | minor |
| `react-hook-form` | `^7.49.0` | `^7.79.0` | minor |
| `react-toastify` | `^11.0.5` | `^11.1.0` | minor |
\* `tailwindcss` lives in devDependencies; listed here for readability.
Unchanged: `clsx`, `lenis`.
## Frontend — `devDependencies`
| Package | Before | After |
|---|---|---|
| `@types/react` | `^18.2.0` | `^19.2.17` |
| `@types/react-dom` | `^18.2.0` | `^19.2.3` |
| `@types/three` | `^0.169.0` | `^0.184.1` |
| `@types/node` | `^20.11.0` | `^24.0.0` |
| `tailwindcss` | `^3.4.1` | `^4.3.1` |
| `eslint` | `^8.56.0` | `^9.0.0` |
| `eslint-config-next` | `14.1.0` | `16.2.9` |
| `postcss` | `^8.4.33` | `^8.5.15` |
| `typescript` | `^5.3.3` | `^6.0.3` |
| `@tailwindcss/postcss` | — | **`^4.3.1` (new)** |
| `autoprefixer` | `^10.4.17` | **removed** (Tailwind 4 handles vendor prefixing) |
---
## Code migrations required
### Backend
- **`@kubernetes/client-node` 0.x → 1.x** (the biggest task — ~200+ call sites across 6 files):
- Every API method moved from positional arguments to a **single options object**:
`readNamespacedPod(name, ns)``readNamespacedPod({ name, namespace })`.
- Responses are no longer wrapped in `{ body }` — they return **directly**: `res.body.items``res.items`.
- Error shape changed: `err.statusCode`**`err.code`**.
- Patch `Content-Type` is set via `k8s.setHeaderOptions(...)` (the method's 2nd argument).
- `kc.applyToRequest(opts)``kc.applyToHTTPSOptions(opts)`.
- Files: `kubernetes/kubernetes.service.ts`, `build/build.service.ts`, `clusters/clusters.service.ts`,
`clusters/cluster-tools.service.ts`, `kubernetes/elasticsearch.service.ts`, `kubernetes/registry.service.ts`.
- **Regression test:** `kubernetes/k8s-client-v1-migration.spec.ts` — asserts the object-argument shape,
response unwrapping, and 404 detection via `err.code`.
- **TypeORM 0.3 → 1.0:** string-array `relations`/`select` is no longer accepted; converted to object form
(`relations: ['user']``relations: { user: true }`) across 9 services.
- **`@nestjs/config` 3 → 4:** `ConfigService.get()` now returns `T | undefined`; `auth/strategies/jwt.strategy.ts`
uses `getOrThrow<string>('jwt.secret')` (fails fast at boot if the secret is missing).
- **TypeScript 5 → 6** (in `backend/tsconfig.json`):
- `strictPropertyInitialization: false` (TS6 enables it alongside `strictNullChecks`; TypeORM entities rely on it being off).
- `ignoreDeprecations: "6.0"` (for `baseUrl`, removed in TS7 — still needed by `tsconfig-paths`).
- `rootDir: "./src"` (new TS6 emit requirement).
- `types: ["node", "jest", "multer"]` (TS6 no longer auto-includes every `@types/*`; these are ambient-augmentation-only packages).
### Frontend
- **Next 14 → 16:** `params`/`searchParams` are now `Promise`s; applied with the official codemod
`npx @next/codemod next-async-request-api` (in `app/[lang]/layout.tsx` and where needed).
- **TypeScript 6:** `target: "es5"``"ES2017"` in `frontend/tsconfig.json` (es5 is deprecated in TS6).
- **Tailwind 3 → 4:**
- `postcss.config.js`: the `tailwindcss` + `autoprefixer` plugins were replaced by **`@tailwindcss/postcss`**.
- `src/app/globals.css`: the three `@tailwind ...` directives were replaced with `@import 'tailwindcss';` + `@config '../../tailwind.config.ts';`
(so the existing JS config — `primary` colors and fonts — is loaded under v4).
- `@apply`s that composed **custom** classes (`@apply badge ...`, `@apply card ...`) were inlined
(v4 doesn't support `@apply`-ing custom classes).
### Infrastructure
- `backend/Dockerfile` and `frontend/Dockerfile`: base bumped from `node:20-alpine` to **`node:24-alpine`** (guarantees `require(esm)` for the k8s client and aligns with `@types/node@24`).
---
## Notes & follow-ups
- **Residual audit advisories:** backend has a few via a transitive chain (`bull` → old `uuid`); frontend has 2 moderate. They can't be fixed without breaking changes and live in indirect deps.
- **Next 16 warning:** the `middleware` file convention is deprecated in favor of `proxy` (still works). Rename `middleware.ts``proxy.ts` in a later pass.
- **Pre-existing failing test:** `helm.service.spec.ts chartPath` asserts a property that doesn't exist on the service; it failed before this upgrade too and is unrelated.
- **Runtime validation:** with no real k8s cluster available, type-level correctness and builds are verified; before production, the k8s control-plane paths should be exercised against a test cluster.
+161
View File
@@ -0,0 +1,161 @@
# ارتقای وابستگی‌ها به آخرین نسخه‌های استیبل
برنچ: `chore/upgrade-dependencies-latest` (از روی `feat/abrban-landing`)
🌐 English version: [UPGRADE.en.md](UPGRADE.en.md)
تمام وابستگی‌های backend و frontend به **آخرین نسخه‌ی استیبل** (نه pre-release) ارتقا یافتند،
شامل ارتقاهای major که نیاز به migration کد داشتند. هر دو پروژه build و typecheck سبز دارند.
---
## خلاصه‌ی وضعیت
| پروژه | typecheck | build | تست‌ها |
|---|---|---|---|
| **backend** | ✅ `tsc` صفر خطا | ✅ `nest build` | ۹۵/۹۶ پاس (۱ شکستِ از-پیش-موجودِ helm، بی‌ربط) + ۶ تست جدیدِ migration |
| **frontend** | ✅ `tsc` صفر خطا | ✅ `next build` (۴۲ مسیر) | — |
> **حداقل نسخه‌ی Node در runtime: ۲۰.۱۹** (به‌خاطر ESM‌بودنِ `@kubernetes/client-node@1`). Dockerfileها به `node:24-alpine` (LTS) ارتقا یافتند.
---
## Backend — `dependencies`
| بسته | قبل | بعد | نوع |
|---|---|---|---|
| `@kubernetes/client-node` | `^0.21.0` | `^1.4.0` | **major** |
| `@nestjs/bull` | `^10.1.0` | `^11.0.4` | major |
| `@nestjs/common` | `^10.3.0` | `^11.1.24` | major |
| `@nestjs/config` | `^3.1.0` | `^4.0.4` | major |
| `@nestjs/core` | `^10.3.0` | `^11.1.26` | major |
| `@nestjs/jwt` | `^10.2.0` | `^11.0.2` | major |
| `@nestjs/passport` | `^10.0.3` | `^11.0.5` | major |
| `@nestjs/platform-express` | `^10.3.0` | `^11.1.26` | major |
| `@nestjs/swagger` | `^7.2.0` | `^11.4.4` | major |
| `@nestjs/typeorm` | `^10.0.1` | `^11.0.1` | major |
| `bcrypt` | `^5.1.1` | `^6.0.0` | major |
| `class-validator` | `^0.14.1` | `^0.15.1` | minor (0.x) |
| `helmet` | `^7.1.0` | `^8.2.0` | major |
| `js-yaml` | `^4.1.0` | `^4.2.0` | minor |
| `multer` | `^1.4.5-lts.1` | `^2.1.1` | **major** |
| `pg` | `^8.11.0` | `^8.21.0` | minor |
| `rxjs` | `^7.8.1` | `^7.8.2` | patch (۸ هنوز استیبل نیست) |
| `typeorm` | `^0.3.19` | `^1.0.0` | **major** |
| `uuid` | `^9.0.0` | `^14.0.0` | **major** |
بدون تغییر (از قبل آخرین استیبل): `bull`, `class-transformer`, `handlebars`, `passport`, `passport-jwt`, `reflect-metadata`.
## Backend — `devDependencies`
| بسته | قبل | بعد |
|---|---|---|
| `@nestjs/cli` | `^10.3.0` | `^11.0.23` |
| `@nestjs/schematics` | `^10.1.0` | `^11.1.0` |
| `@nestjs/testing` | `^10.3.0` | `^11.1.26` |
| `@types/bcrypt` | `^5.0.2` | `^6.0.0` |
| `@types/jest` | `^29.5.11` | `^30.0.0` |
| `@types/multer` | `^1.4.11` | `^2.1.0` |
| `@types/node` | `^20.11.0` | `^24.0.0` |
| `@typescript-eslint/eslint-plugin` | `^6.19.0` | `^8.61.0` |
| `@typescript-eslint/parser` | `^6.19.0` | `^8.61.0` |
| `eslint` | `^8.56.0` | `^9.0.0` |
| `jest` | `^29.7.0` | `^30.4.2` |
| `prettier` | `^3.2.0` | `^3.8.4` |
| `ts-jest` | `^29.1.1` | `^29.4.11` |
| `typescript` | `^5.3.3` | `^6.0.0` |
| `@types/uuid` | `^9.0.7` | **حذف شد** (`uuid@14` تایپ‌هایش را خودش دارد) |
> **`@types/express` عمداً روی `^4.17.21` نگه داشته شد**: NestJS 11 در runtime روی Express **۴** اجرا می‌شود؛ `@types/express@5` فقط با Express 5 سازگار است.
---
## Frontend — `dependencies`
| بسته | قبل | بعد | نوع |
|---|---|---|---|
| `react` | `^18.2.0` | `^19.2.7` | **major** |
| `react-dom` | `^18.2.0` | `^19.2.7` | **major** |
| `next` | `14.1.0` | `16.2.9` | **major (۲ نسخه)** |
| `tailwindcss`* | `^3.4.1` | `^4.3.1` | **major** |
| `framer-motion` | `^11.18.2` | `^12.40.0` | **major** |
| `zustand` | `^4.5.0` | `^5.0.14` | **major** |
| `three` | `^0.169.0` | `^0.184.0` | minor (0.x، breaking) |
| `@react-three/fiber` | `^8.18.0` | `^9.6.1` | **major** |
| `@react-three/drei` | `^9.122.0` | `^10.7.7` | **major** |
| `@react-three/postprocessing` | `^2.19.1` | `^3.0.4` | **major** |
| `@tanstack/react-query` | `^5.17.0` | `^5.101.0` | minor |
| `axios` | `^1.6.0` | `^1.17.0` | minor |
| `lucide-react` | `^1.7.0` | `^1.18.0` | minor |
| `react-hook-form` | `^7.49.0` | `^7.79.0` | minor |
| `react-toastify` | `^11.0.5` | `^11.1.0` | minor |
\* `tailwindcss` در بخش devDependencies است؛ این‌جا برای خوانایی کنار بقیه آمد.
بدون تغییر: `clsx`, `lenis`.
## Frontend — `devDependencies`
| بسته | قبل | بعد |
|---|---|---|
| `@types/react` | `^18.2.0` | `^19.2.17` |
| `@types/react-dom` | `^18.2.0` | `^19.2.3` |
| `@types/three` | `^0.169.0` | `^0.184.1` |
| `@types/node` | `^20.11.0` | `^24.0.0` |
| `tailwindcss` | `^3.4.1` | `^4.3.1` |
| `eslint` | `^8.56.0` | `^9.0.0` |
| `eslint-config-next` | `14.1.0` | `16.2.9` |
| `postcss` | `^8.4.33` | `^8.5.15` |
| `typescript` | `^5.3.3` | `^6.0.3` |
| `@tailwindcss/postcss` | — | **`^4.3.1` (جدید)** |
| `autoprefixer` | `^10.4.17` | **حذف شد** (Tailwind 4 خودش vendor-prefix می‌زند) |
---
## تغییرات کدِ لازم برای migration
### Backend
- **`@kubernetes/client-node` 0.x → 1.x** (بزرگ‌ترین کار — ~۲۰۰+ نقطه‌ی فراخوانی در ۶ فایل):
- همه‌ی متدهای API از آرگومان ترتیبی به **یک object** تغییر کردند:
`readNamespacedPod(name, ns)``readNamespacedPod({ name, namespace })`.
- پاسخ‌ها دیگر در `{ body }` پیچیده نمی‌شوند و **مستقیم** برمی‌گردند: `res.body.items``res.items`.
- شکل خطا عوض شد: `err.statusCode`**`err.code`**.
- Content-Type برای patchها با `k8s.setHeaderOptions(...)` (آرگومان دومِ متد) ست می‌شود.
- `kc.applyToRequest(opts)``kc.applyToHTTPSOptions(opts)`.
- فایل‌ها: `kubernetes/kubernetes.service.ts`, `build/build.service.ts`, `clusters/clusters.service.ts`,
`clusters/cluster-tools.service.ts`, `kubernetes/elasticsearch.service.ts`, `kubernetes/registry.service.ts`.
- **تست رگرسیون:** `kubernetes/k8s-client-v1-migration.spec.ts` — شکل آرگومان object، unwrapِ پاسخ و تشخیص ۴۰۴ با `err.code`.
- **TypeORM 0.3 → 1.0:** آرایه‌ی رشته‌ایِ `relations`/`select` دیگر پذیرفته نمی‌شود؛ به فرم object تبدیل شد
(`relations: ['user']``relations: { user: true }`) در ۹ سرویس.
- **`@nestjs/config` 3 → 4:** `ConfigService.get()` حالا `T | undefined` برمی‌گرداند؛ در `auth/strategies/jwt.strategy.ts`
از `getOrThrow<string>('jwt.secret')` استفاده شد (اگر secret نباشد همان boot کرش می‌کند).
- **TypeScript 5 → 6** (در `backend/tsconfig.json`):
- `strictPropertyInitialization: false` (TS6 آن را با `strictNullChecks` فعال می‌کند؛ entityهای TypeORM به آن وابسته‌اند).
- `ignoreDeprecations: "6.0"` (برای `baseUrl` که در TS7 حذف می‌شود — هنوز برای `tsconfig-paths` لازم است).
- `rootDir: "./src"` (الزام جدیدِ emit در TS6).
- `types: ["node", "jest", "multer"]` (TS6 دیگر همه‌ی `@types/*` را خودکار include نمی‌کند؛ این پکیج‌ها فقط ambient augmentation دارند).
### Frontend
- **Next 14 → 16:** `params`/`searchParams` حالا `Promise` هستند؛ با codemod رسمی
`npx @next/codemod next-async-request-api` اعمال شد (در `app/[lang]/layout.tsx` و موارد لازم).
- **TypeScript 6:** `target: "es5"``"ES2017"` در `frontend/tsconfig.json` (es5 در TS6 منسوخ شده).
- **Tailwind 3 → 4:**
- `postcss.config.js`: پلاگین `tailwindcss` + `autoprefixer` با **`@tailwindcss/postcss`** جایگزین شد.
- `src/app/globals.css`: سه دستورِ `@tailwind ...` با `@import 'tailwindcss';` + `@config '../../tailwind.config.ts';` جایگزین شد
(تا کانفیگِ JS موجود — رنگ‌های `primary` و فونت‌ها — در v4 لود شود).
- `@apply`ـهایی که کلاس‌های **سفارشی** را ترکیب می‌کردند (`@apply badge ...`، `@apply card ...`) به‌صورت inline باز شدند
(v4 از ترکیب کلاس سفارشی در `@apply` پشتیبانی نمی‌کند).
### زیرساخت
- `backend/Dockerfile` و `frontend/Dockerfile`: پایه از `node:20-alpine` به **`node:24-alpine`** (تضمین `require(esm)` برای k8s client و هماهنگی با `@types/node@24`).
---
## نکات و کارهای باقی‌مانده
- **آسیب‌پذیری‌های باقی‌مانده‌ی audit:** backend چند مورد از طریق زنجیره‌ی transitive (`bull``uuid` قدیمی)؛ frontend ۲ مورد moderate. بدون breaking قابل‌رفع نیستند و مربوط به وابستگی‌های غیرمستقیم‌اند.
- **هشدار Next 16:** قرارداد فایل `middleware` به نفع `proxy` منسوخ اعلام شده (هنوز کار می‌کند). در ارتقای بعدی `middleware.ts``proxy.ts`.
- **تستِ از-پیش-شکسته:** `helm.service.spec.ts chartPath` به property‌ای اشاره می‌کند که در سرویس وجود ندارد؛ این پیش از این ارتقا هم fail می‌شد و ربطی به تغییرات اینجا ندارد.
- **اعتبارسنجی runtime:** چون کلاسترِ k8s واقعی در دسترس نبود، صحت typeها و buildها تأیید شده؛ پیش از پروداکشن، مسیرهای کنترل‌پلینِ k8s باید روی یک کلاسترِ تست اجرا شوند.
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- Stage 1: Build ---- # ---- Stage 1: Build ----
FROM node:20-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -10,7 +10,7 @@ COPY . .
RUN npm run build RUN npm run build
# ---- Stage 2: Production ---- # ---- Stage 2: Production ----
FROM node:20-alpine AS production FROM node:24-alpine AS production
RUN apk add --no-cache dumb-init curl bash \ RUN apk add --no-cache dumb-init curl bash \
&& curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \ && curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \
+3511 -3652
View File
File diff suppressed because it is too large Load Diff
+33 -34
View File
@@ -21,53 +21,52 @@
"seed": "ts-node -r tsconfig-paths/register src/seed.ts" "seed": "ts-node -r tsconfig-paths/register src/seed.ts"
}, },
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^0.21.0", "@kubernetes/client-node": "^1.4.0",
"@nestjs/bull": "^10.1.0", "@nestjs/bull": "^11.0.4",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^11.1.24",
"@nestjs/config": "^3.1.0", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^11.1.26",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^11.1.26",
"@nestjs/swagger": "^7.2.0", "@nestjs/swagger": "^11.4.4",
"@nestjs/typeorm": "^10.0.1", "@nestjs/typeorm": "^11.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^6.0.0",
"bull": "^4.12.0", "bull": "^4.12.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.15.1",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^7.1.0", "helmet": "^8.2.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.2.0",
"multer": "^1.4.5-lts.1", "multer": "^2.1.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.0", "pg": "^8.21.0",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.2",
"typeorm": "^0.3.19", "typeorm": "^1.0.0",
"uuid": "^9.0.0" "uuid": "^14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.0", "@nestjs/cli": "^11.0.23",
"@nestjs/schematics": "^10.1.0", "@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^10.3.0", "@nestjs/testing": "^11.1.26",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/multer": "^1.4.11", "@types/multer": "^2.1.0",
"@types/node": "^20.11.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/parser": "^6.19.0", "eslint": "^9.0.0",
"eslint": "^8.56.0", "jest": "^30.4.2",
"jest": "^29.7.0", "prettier": "^3.8.4",
"prettier": "^3.2.0", "ts-jest": "^29.4.11",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^6.0.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": ["js", "json", "ts"],
+1 -1
View File
@@ -312,7 +312,7 @@ export class AccessService implements OnModuleInit {
async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise<void> { async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise<void> {
const grant = await this.grantsRepo.findOne({ const grant = await this.grantsRepo.findOne({
where: { id: grantId }, where: { id: grantId },
relations: ['application'], relations: { application: true },
}); });
if (!grant) throw new NotFoundException('Access grant not found'); if (!grant) throw new NotFoundException('Access grant not found');
if (!system && userId !== undefined && grant.userId !== userId) { if (!system && userId !== undefined && grant.userId !== userId) {
@@ -79,7 +79,7 @@ export class ApplicationMigrationsService {
async list(applicationId?: string): Promise<ApplicationMigrationJob[]> { async list(applicationId?: string): Promise<ApplicationMigrationJob[]> {
return this.jobsRepository.find({ return this.jobsRepository.find({
where: applicationId ? { applicationId } : {}, where: applicationId ? { applicationId } : {},
relations: ['application', 'sourceCluster', 'targetCluster'], relations: { application: true, sourceCluster: true, targetCluster: true },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
take: 100, take: 100,
}); });
@@ -88,7 +88,7 @@ export class ApplicationMigrationsService {
async findOne(id: string): Promise<ApplicationMigrationJob> { async findOne(id: string): Promise<ApplicationMigrationJob> {
const job = await this.jobsRepository.findOne({ const job = await this.jobsRepository.findOne({
where: { id }, where: { id },
relations: ['application', 'sourceCluster', 'targetCluster'], relations: { application: true, sourceCluster: true, targetCluster: true },
}); });
if (!job) { if (!job) {
throw new NotFoundException('Migration job not found'); throw new NotFoundException('Migration job not found');
@@ -209,7 +209,7 @@ export class ApplicationsService {
const app = await this.appsRepository.findOne({ const app = await this.appsRepository.findOne({
where, where,
relations: ['deployments'], relations: { deployments: true },
}); });
if (!app) { if (!app) {
+1 -1
View File
@@ -15,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret'), secretOrKey: configService.getOrThrow<string>('jwt.secret'),
}); });
} }
+3 -3
View File
@@ -161,7 +161,7 @@ export class BillingService {
const wallet = await this.getOrCreateWallet(userId); const wallet = await this.getOrCreateWallet(userId);
return this.txRepo.find({ return this.txRepo.find({
where: { walletId: wallet.id }, where: { walletId: wallet.id },
relations: ['invoice'], relations: { invoice: true },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
take: limit, take: limit,
}); });
@@ -198,7 +198,7 @@ export class BillingService {
// Admin: get all wallets // Admin: get all wallets
async getAllWallets(): Promise<Wallet[]> { async getAllWallets(): Promise<Wallet[]> {
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } }); return this.walletRepo.find({ relations: { user: true }, order: { balance: 'DESC' } });
} }
// ─── Invoices ───────────────────────────────────────────────────── // ─── Invoices ─────────────────────────────────────────────────────
@@ -300,7 +300,7 @@ export class BillingService {
async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> { async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOne({ const invoice = await this.invoiceRepo.findOne({
where: { id: invoiceId }, where: { id: invoiceId },
relations: ['user', 'application', 'lines', 'transactions'], relations: { user: true, application: true, lines: true, transactions: true },
order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } }, order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } },
}); });
if (!invoice) throw new NotFoundException('Invoice not found'); if (!invoice) throw new NotFoundException('Invoice not found');
+322 -250
View File
@@ -49,8 +49,7 @@ export class BuildService {
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node * 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. * with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build.
*/ */
private readonly kanikoImage = private readonly kanikoImage = process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
@@ -77,7 +76,11 @@ export class BuildService {
if (!session) return; if (!session) return;
session.processes.push(proc); session.processes.push(proc);
if (session.cancelled) { if (session.cancelled) {
try { proc.kill('SIGKILL'); } catch { /* ignore */ } try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
} }
} }
@@ -85,11 +88,19 @@ export class BuildService {
const session = this.getSession(deploymentId); const session = this.getSession(deploymentId);
if (!session) return; if (!session) return;
if (session.socket) { if (session.socket) {
try { session.socket.destroy(); } catch { /* ignore */ } try {
session.socket.destroy();
} catch {
/* ignore */
}
} }
session.socket = socket; session.socket = socket;
if (session.cancelled) { if (session.cancelled) {
try { socket.destroy(); } catch { /* ignore */ } try {
socket.destroy();
} catch {
/* ignore */
}
} }
} }
@@ -106,7 +117,11 @@ export class BuildService {
async cancelBuild(deploymentId: string): Promise<void> { async cancelBuild(deploymentId: string): Promise<void> {
const session = this.activeBuilds.get(deploymentId); const session = this.activeBuilds.get(deploymentId);
if (!session) { if (!session) {
this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' }); this.setProgress(deploymentId, {
phase: 'cancelled',
percent: 0,
message: 'Cancelled by user',
});
return; return;
} }
@@ -114,10 +129,18 @@ export class BuildService {
this.logger.log(`Cancelling build for deployment ${deploymentId}`); this.logger.log(`Cancelling build for deployment ${deploymentId}`);
if (session.socket) { if (session.socket) {
try { session.socket.destroy(); } catch { /* ignore */ } try {
session.socket.destroy();
} catch {
/* ignore */
}
} }
for (const proc of session.processes) { for (const proc of session.processes) {
try { proc.kill('SIGKILL'); } catch { /* ignore */ } try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
} }
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session; const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
@@ -125,29 +148,56 @@ export class BuildService {
const cleanup: Promise<unknown>[] = []; const cleanup: Promise<unknown>[] = [];
if (helperPodName) { if (helperPodName) {
cleanup.push( cleanup.push(
coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0).catch(() => undefined), coreApi
.deleteNamespacedPod({
name: helperPodName,
namespace,
gracePeriodSeconds: 0,
})
.catch(() => undefined),
); );
} }
if (buildPodName && batchApi) { if (buildPodName && batchApi) {
cleanup.push( cleanup.push(
batchApi.deleteNamespacedJob(buildPodName, namespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined), batchApi
.deleteNamespacedJob({
name: buildPodName,
namespace,
gracePeriodSeconds: 0,
propagationPolicy: 'Foreground',
})
.catch(() => undefined),
); );
} }
if (sourcePvcName) { if (sourcePvcName) {
cleanup.push( cleanup.push(
coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, namespace).catch(() => undefined), coreApi
.deleteNamespacedPersistentVolumeClaim({
name: sourcePvcName,
namespace,
})
.catch(() => undefined),
); );
} }
if (buildPodName) { if (buildPodName) {
cleanup.push( cleanup.push(
coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, namespace).catch(() => undefined), coreApi
.deleteNamespacedConfigMap({
name: `${buildPodName}-dockerfile`,
namespace,
})
.catch(() => undefined),
); );
} }
await Promise.all(cleanup); await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`); this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
} }
this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' }); this.setProgress(deploymentId, {
phase: 'cancelled',
percent: 0,
message: 'Cancelled by user',
});
this.activeBuilds.delete(deploymentId); this.activeBuilds.delete(deploymentId);
} }
@@ -156,9 +206,7 @@ export class BuildService {
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds'; const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const prefix = `build-${app.name}-`; const prefix = `build-${app.name}-`;
const cluster = app.clusterId const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
? await this.clustersService.findOne(app.clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig); kc.loadFromString(cluster.kubeconfig);
@@ -168,34 +216,60 @@ export class BuildService {
const cleanup: Promise<unknown>[] = []; const cleanup: Promise<unknown>[] = [];
const [pods, pvcs, jobs, configMaps] = await Promise.all([ const [pods, pvcs, jobs, configMaps] = await Promise.all([
coreApi.listNamespacedPod(buildNamespace), coreApi.listNamespacedPod({ namespace: buildNamespace }),
coreApi.listNamespacedPersistentVolumeClaim(buildNamespace), coreApi.listNamespacedPersistentVolumeClaim({
batchApi.listNamespacedJob(buildNamespace), namespace: buildNamespace,
coreApi.listNamespacedConfigMap(buildNamespace), }),
batchApi.listNamespacedJob({ namespace: buildNamespace }),
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
]); ]);
for (const pod of pods.body.items) { for (const pod of pods.items) {
const name = pod.metadata?.name || ''; const name = pod.metadata?.name || '';
if (name.startsWith(prefix)) { if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedPod(name, buildNamespace, undefined, undefined, 0).catch(() => undefined)); cleanup.push(
coreApi
.deleteNamespacedPod({
name,
namespace: buildNamespace,
gracePeriodSeconds: 0,
})
.catch(() => undefined),
);
} }
} }
for (const pvc of pvcs.body.items) { for (const pvc of pvcs.items) {
const name = pvc.metadata?.name || ''; const name = pvc.metadata?.name || '';
if (name.startsWith(prefix)) { if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedPersistentVolumeClaim(name, buildNamespace).catch(() => undefined)); cleanup.push(
coreApi
.deleteNamespacedPersistentVolumeClaim({
name,
namespace: buildNamespace,
})
.catch(() => undefined),
);
} }
} }
for (const job of jobs.body.items) { for (const job of jobs.items) {
const name = job.metadata?.name || ''; const name = job.metadata?.name || '';
if (name.startsWith(prefix)) { if (name.startsWith(prefix)) {
cleanup.push(batchApi.deleteNamespacedJob(name, buildNamespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined)); cleanup.push(
batchApi
.deleteNamespacedJob({
name,
namespace: buildNamespace,
gracePeriodSeconds: 0,
propagationPolicy: 'Foreground',
})
.catch(() => undefined),
);
} }
} }
for (const cm of configMaps.body.items) { for (const cm of configMaps.items) {
const name = cm.metadata?.name || ''; const name = cm.metadata?.name || '';
if (name.startsWith(prefix)) { if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedConfigMap(name, buildNamespace).catch(() => undefined)); cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
} }
} }
@@ -243,9 +317,7 @@ export class BuildService {
} }
// Use the cluster's kubeconfig instead of default // Use the cluster's kubeconfig instead of default
const cluster = app.clusterId const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
? await this.clustersService.findOne(app.clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig); kc.loadFromString(cluster.kubeconfig);
@@ -253,7 +325,11 @@ export class BuildService {
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api);
if (deploymentId) { if (deploymentId) {
this.updateBuildSession(deploymentId, { coreApi, batchApi, namespace: buildNamespace }); this.updateBuildSession(deploymentId, {
coreApi,
batchApi,
namespace: buildNamespace,
});
} }
// Ensure the build namespace exists // Ensure the build namespace exists
@@ -289,9 +365,7 @@ export class BuildService {
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi // Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024))); const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
await this.uploadSourceViaPVC( await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId);
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId,
);
} }
// Build the Kaniko Job spec // Build the Kaniko Job spec
@@ -339,7 +413,10 @@ export class BuildService {
name: 'unzip-source', name: 'unzip-source',
image: 'alpine:3.19', image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: [
'sh',
'-c',
`
apk add --no-cache unzip tar gzip && apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile && cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /tmp/extract && mkdir -p /tmp/extract &&
@@ -368,10 +445,15 @@ export class BuildService {
rm -rf /tmp/extract && rm -rf /tmp/extract &&
echo "--- Final workspace contents ---" && echo "--- Final workspace contents ---" &&
ls -la /workspace-out/source/ ls -la /workspace-out/source/
`], `,
],
volumeMounts: [ volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' }, { name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' }, {
name: 'dockerfile',
mountPath: '/workspace/Dockerfile',
subPath: 'Dockerfile',
},
{ name: 'source-pvc', mountPath: '/source-pvc' }, { name: 'source-pvc', mountPath: '/source-pvc' },
], ],
}); });
@@ -398,13 +480,17 @@ export class BuildService {
name: 'git-clone', name: 'git-clone',
image: 'alpine/git:2.43.0', image: 'alpine/git:2.43.0',
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: [
'sh',
'-c',
`
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" && echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile && cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Workspace contents:" && echo ">>> Workspace contents:" &&
ls -la /workspace-out/source/ ls -la /workspace-out/source/
`], `,
],
volumeMounts: [ volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' }, { name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' }, { name: 'dockerfile', mountPath: '/dockerfile' },
@@ -426,12 +512,16 @@ export class BuildService {
name: 'prepare-workspace', name: 'prepare-workspace',
image: 'alpine:3.19', image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: [
'sh',
'-c',
`
mkdir -p /workspace-out/source && mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile && cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Prepared empty workspace for fresh install" && echo ">>> Prepared empty workspace for fresh install" &&
ls -la /workspace-out/ ls -la /workspace-out/
`], `,
],
volumeMounts: [ volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' }, { name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' }, { name: 'dockerfile', mountPath: '/dockerfile' },
@@ -475,15 +565,25 @@ export class BuildService {
try { try {
const t0 = Date.now(); const t0 = Date.now();
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap); await coreApi.createNamespacedConfigMap({
namespace: buildNamespace!,
body: dockerfileConfigMap,
});
this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`); this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`);
const t1 = Date.now(); const t1 = Date.now();
await batchApi.createNamespacedJob(buildNamespace!, buildJob); await batchApi.createNamespacedJob({
namespace: buildNamespace!,
body: buildJob,
});
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`); this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
// Wait for build to complete // Wait for build to complete
this.setProgress(deploymentId, { phase: 'building', percent: 15, message: 'Building Docker image...' }); this.setProgress(deploymentId, {
phase: 'building',
percent: 15,
message: 'Building Docker image...',
});
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId); await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId);
// Capture build logs on success // Capture build logs on success
@@ -513,7 +613,10 @@ export class BuildService {
// Clean up build resources // Clean up build resources
if (sourcePvcName) { if (sourcePvcName) {
try { try {
await coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, buildNamespace!); await coreApi.deleteNamespacedPersistentVolumeClaim({
name: sourcePvcName,
namespace: buildNamespace!,
});
this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`); this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`);
} catch (e: any) { } catch (e: any) {
this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`); this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`);
@@ -521,7 +624,10 @@ export class BuildService {
} }
// Clean up Dockerfile ConfigMap // Clean up Dockerfile ConfigMap
try { try {
await coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, buildNamespace!); await coreApi.deleteNamespacedConfigMap({
name: `${buildPodName}-dockerfile`,
namespace: buildNamespace!,
});
} catch (e: any) { } catch (e: any) {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
} }
@@ -533,75 +639,68 @@ export class BuildService {
* Upload a local file to the helper pod using kubectl cp with progress tracking. * 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. * kubectl cp uses tar over the k8s exec API — reliable for any file size.
*/ */
private streamFileToHelperPod( private streamFileToHelperPod(kubeconfig: string, namespace: string, podName: string, filePath: string, fileSize: number, deploymentId?: string): Promise<void> {
kubeconfig: string,
namespace: string,
podName: string,
filePath: string,
fileSize: number,
deploymentId?: string,
): Promise<void> {
const maxAttempts = 3; const maxAttempts = 3;
const runOnce = () => new Promise<void>((resolve, reject) => { const runOnce = () =>
this.throwIfCancelled(deploymentId); new Promise<void>((resolve, reject) => {
this.throwIfCancelled(deploymentId);
const kubectl = spawn('kubectl', [ const kubectl = spawn('kubectl', ['--kubeconfig', kubeconfig, 'cp', filePath, `${namespace}/${podName}:/data/source.zip`, '-c', 'helper', '--retries', '3'], {
'--kubeconfig', kubeconfig, stdio: ['ignore', 'pipe', 'pipe'],
'cp', filePath, `${namespace}/${podName}:/data/source.zip`, });
'-c', 'helper', this.registerProcess(deploymentId, kubectl);
'--retries', '3',
], { stdio: ['ignore', 'pipe', 'pipe'] });
this.registerProcess(deploymentId, kubectl);
let stderr = ''; let stderr = '';
kubectl.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); kubectl.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
// Track progress by polling remote file size // Track progress by polling remote file size
let progressTimer: NodeJS.Timeout | undefined; let progressTimer: NodeJS.Timeout | undefined;
const pollProgress = () => { const pollProgress = () => {
execFileAsync('kubectl', [ execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0'], {
'--kubeconfig', kubeconfig, timeout: 10_000,
'exec', '-n', namespace, podName, '-c', 'helper', '--', })
'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0', .then(({ stdout }) => {
], { timeout: 10_000 }).then(({ stdout }) => { const remoteSize = parseInt(stdout.trim(), 10) || 0;
const remoteSize = parseInt(stdout.trim(), 10) || 0; const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100)); this.setProgress(deploymentId, {
this.setProgress(deploymentId, { phase: 'uploading',
phase: 'uploading', percent,
percent, bytesUploaded: remoteSize,
bytesUploaded: remoteSize, totalBytes: fileSize,
totalBytes: fileSize, message: `Uploading to cluster... ${percent}%`,
message: `Uploading to cluster... ${percent}%`, });
}); })
}).catch(() => { /* polling failure is non-fatal */ }); .catch(() => {
}; /* polling failure is non-fatal */
progressTimer = setInterval(pollProgress, 3000); });
pollProgress(); };
progressTimer = setInterval(pollProgress, 3000);
pollProgress();
kubectl.on('error', (err) => { kubectl.on('error', (err) => {
clearInterval(progressTimer); clearInterval(progressTimer);
reject(new Error(`kubectl cp spawn error: ${err.message}`)); 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()}`));
});
}); });
kubectl.on('close', (code) => {
clearInterval(progressTimer);
if (code === 0) resolve();
else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`));
});
});
return (async () => { return (async () => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
this.throwIfCancelled(deploymentId); this.throwIfCancelled(deploymentId);
if (attempt > 1) { if (attempt > 1) {
this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`); this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`);
await execFileAsync('kubectl', [ await execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'rm', '-f', '/data/source.zip'], { timeout: 15_000 }).catch(
'--kubeconfig', kubeconfig, () => undefined,
'exec', '-n', namespace, podName, '-c', 'helper', '--', );
'rm', '-f', '/data/source.zip',
], { timeout: 15_000 }).catch(() => undefined);
this.setProgress(deploymentId, { this.setProgress(deploymentId, {
phase: 'uploading', phase: 'uploading',
percent: 0, percent: 0,
@@ -625,15 +724,7 @@ export class BuildService {
* Upload source zip to K8s via PVC + helper pod. * Upload source zip to K8s via PVC + helper pod.
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB). * This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
*/ */
private async uploadSourceViaPVC( private async uploadSourceViaPVC(kc: k8s.KubeConfig, coreApi: k8s.CoreV1Api, namespace: string, pvcName: string, zipPath: string, sizeGi: number, deploymentId?: string): Promise<void> {
kc: k8s.KubeConfig,
coreApi: k8s.CoreV1Api,
namespace: string,
pvcName: string,
zipPath: string,
sizeGi: number,
deploymentId?: string,
): Promise<void> {
const t0 = Date.now(); const t0 = Date.now();
const helperPodName = `${pvcName}-helper`; const helperPodName = `${pvcName}-helper`;
const zipSize = fs.statSync(zipPath).size; const zipSize = fs.statSync(zipPath).size;
@@ -645,13 +736,16 @@ export class BuildService {
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`); this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
// 1. Create PVC // 1. Create PVC
await coreApi.createNamespacedPersistentVolumeClaim(namespace, { await coreApi.createNamespacedPersistentVolumeClaim({
apiVersion: 'v1', namespace,
kind: 'PersistentVolumeClaim', body: {
metadata: { name: pvcName, namespace }, apiVersion: 'v1',
spec: { kind: 'PersistentVolumeClaim',
accessModes: ['ReadWriteOnce'], metadata: { name: pvcName, namespace },
resources: { requests: { storage: `${sizeGi}Gi` } }, spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: `${sizeGi}Gi` } },
},
}, },
}); });
this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`); this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`);
@@ -664,39 +758,46 @@ export class BuildService {
kind: 'Pod', kind: 'Pod',
metadata: { name: helperPodName, namespace }, metadata: { name: helperPodName, namespace },
spec: { spec: {
containers: [{ containers: [
name: 'helper', {
image: 'alpine:3.19', name: 'helper',
imagePullPolicy: 'IfNotPresent', image: 'alpine:3.19',
command: ['sh', '-c', 'sleep 3600'], imagePullPolicy: 'IfNotPresent',
volumeMounts: [{ name: 'source', mountPath: '/data' }], command: ['sh', '-c', 'sleep 3600'],
resources: { volumeMounts: [{ name: 'source', mountPath: '/data' }],
requests: { cpu: '100m', memory: '128Mi' }, resources: {
limits: { cpu: '500m', memory: '256Mi' }, requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '256Mi' },
},
}, },
}], ],
volumes: [{ volumes: [
name: 'source', {
persistentVolumeClaim: { claimName: pvcName }, name: 'source',
}], persistentVolumeClaim: { claimName: pvcName },
},
],
restartPolicy: 'Never', restartPolicy: 'Never',
}, },
}; };
await coreApi.createNamespacedPod(namespace, helperPod); await coreApi.createNamespacedPod({ namespace, body: helperPod });
// 3. Wait for helper pod to be Running // 3. Wait for helper pod to be Running
const podTimeout = 120_000; // 2 minutes const podTimeout = 120_000; // 2 minutes
const podStart = Date.now(); const podStart = Date.now();
while (Date.now() - podStart < podTimeout) { while (Date.now() - podStart < podTimeout) {
this.throwIfCancelled(deploymentId); this.throwIfCancelled(deploymentId);
const pod = await coreApi.readNamespacedPod(helperPodName, namespace); const pod = await coreApi.readNamespacedPod({
const phase = pod.body.status?.phase; name: helperPodName,
namespace,
});
const phase = pod.status?.phase;
if (phase === 'Running') break; if (phase === 'Running') break;
if (phase === 'Failed' || phase === 'Unknown') { if (phase === 'Failed' || phase === 'Unknown') {
throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`); throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`);
} }
await new Promise(r => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
} }
if (Date.now() - podStart >= podTimeout) { if (Date.now() - podStart >= podTimeout) {
throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`); throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`);
@@ -719,9 +820,7 @@ export class BuildService {
message: 'Uploading source to cluster...', message: 'Uploading source to cluster...',
}); });
await this.streamFileToHelperPod( await this.streamFileToHelperPod(tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId);
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.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
this.setProgress(deploymentId, { this.setProgress(deploymentId, {
@@ -733,41 +832,47 @@ export class BuildService {
}); });
// 5b. Verify the file was written correctly (exact size) // 5b. Verify the file was written correctly (exact size)
const { stdout: sizeStr } = await execFileAsync('kubectl', [ const { stdout: sizeStr } = await execFileAsync(
'--kubeconfig', tmpKubeconfig, 'kubectl',
'exec', '-n', namespace, helperPodName, '-c', 'helper', ['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip'],
'--', 'sh', '-c', 'wc -c < /data/source.zip', { timeout: 30_000 },
], { timeout: 30_000 }); );
const remoteSize = parseInt(sizeStr.trim(), 10); const remoteSize = parseInt(sizeStr.trim(), 10);
if (isNaN(remoteSize) || remoteSize !== zipSize) { if (isNaN(remoteSize) || remoteSize !== zipSize) {
throw new Error( throw new Error(
`Source upload incomplete: expected ${zipSize} bytes but got ${remoteSize} bytes on remote. ` + `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)`, `(${(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}) ✓`); this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`);
} finally { } finally {
// Clean up temp kubeconfig // Clean up temp kubeconfig
try { fs.unlinkSync(tmpKubeconfig); } catch {} try {
fs.unlinkSync(tmpKubeconfig);
} catch {}
// 6. Delete the helper pod and WAIT for it to be fully terminated // 6. Delete the helper pod and WAIT for it to be fully terminated
// (PVC is ReadWriteOnce — if the pod is still terminating when the // (PVC is ReadWriteOnce — if the pod is still terminating when the
// build Job starts, Kaniko can't mount the PVC → stuck in Pending) // build Job starts, Kaniko can't mount the PVC → stuck in Pending)
try { try {
await coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0); await coreApi.deleteNamespacedPod({
name: helperPodName,
namespace,
gracePeriodSeconds: 0,
});
this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`); this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`);
const delTimeout = 60_000; const delTimeout = 60_000;
const delStart = Date.now(); const delStart = Date.now();
while (Date.now() - delStart < delTimeout) { while (Date.now() - delStart < delTimeout) {
try { try {
await coreApi.readNamespacedPod(helperPodName, namespace); await coreApi.readNamespacedPod({ name: helperPodName, namespace });
// Pod still exists — wait // Pod still exists — wait
await new Promise(r => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Helper pod ${helperPodName} fully terminated`); this.logger.log(`Helper pod ${helperPodName} fully terminated`);
break; break;
} }
@@ -790,12 +895,12 @@ export class BuildService {
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> { private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
// 1. Ensure namespace // 1. Ensure namespace
try { try {
await coreApi.readNamespace(namespace); await coreApi.readNamespace({ name: namespace });
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Namespace "${namespace}" not found — creating it`); this.logger.log(`Namespace "${namespace}" not found — creating it`);
await coreApi.createNamespace({ await coreApi.createNamespace({
metadata: { name: namespace }, body: { metadata: { name: namespace } },
}); });
} else { } else {
throw err; throw err;
@@ -805,12 +910,13 @@ export class BuildService {
// 2. Ensure service account for Kaniko // 2. Ensure service account for Kaniko
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder'; const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
try { try {
await coreApi.readNamespacedServiceAccount(saName, namespace); await coreApi.readNamespacedServiceAccount({ name: saName, namespace });
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`ServiceAccount "${saName}" not found in "${namespace}" — creating it`); this.logger.log(`ServiceAccount "${saName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedServiceAccount(namespace, { await coreApi.createNamespacedServiceAccount({
metadata: { name: saName, namespace }, namespace,
body: { metadata: { name: saName, namespace } },
}); });
} else { } else {
throw err; throw err;
@@ -820,15 +926,21 @@ export class BuildService {
// 3. Ensure registry-credentials secret (docker config for Kaniko to push) // 3. Ensure registry-credentials secret (docker config for Kaniko to push)
const registrySecretName = 'registry-credentials'; const registrySecretName = 'registry-credentials';
try { try {
await coreApi.readNamespacedSecret(registrySecretName, namespace); await coreApi.readNamespacedSecret({
name: registrySecretName,
namespace,
});
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`); this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedSecret(namespace, { await coreApi.createNamespacedSecret({
metadata: { name: registrySecretName, namespace }, namespace,
type: 'kubernetes.io/dockerconfigjson', body: {
data: { metadata: { name: registrySecretName, namespace },
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'), type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
}, },
}); });
} else { } else {
@@ -870,7 +982,7 @@ export class BuildService {
} }
// If the directory only contains the zip, we can't detect — trust user // If the directory only contains the zip, we can't detect — trust user
const nonZipFiles = files.filter(f => !f.endsWith('.zip') && !f.endsWith('.sql')); const nonZipFiles = files.filter((f) => !f.endsWith('.zip') && !f.endsWith('.sql'));
if (nonZipFiles.length === 0) { if (nonZipFiles.length === 0) {
return app.runtime; return app.runtime;
} }
@@ -895,9 +1007,7 @@ export class BuildService {
} }
if (detected && detected !== app.runtime) { if (detected && detected !== app.runtime) {
this.logger.warn( this.logger.warn(`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`);
`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`,
);
return detected; return detected;
} }
@@ -1094,7 +1204,9 @@ RUN a2enmod rewrite
# Increase PHP upload limits for WordPress media # Increase PHP upload limits for WordPress media
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
${hasUploadedCode ? `# Copy user's custom WordPress files ${
hasUploadedCode
? `# Copy user's custom WordPress files
COPY . /tmp/user-content COPY . /tmp/user-content
# Auto-detect: full public_html root (has wp-admin) vs wp-content only # Auto-detect: full public_html root (has wp-admin) vs wp-content only
@@ -1176,14 +1288,20 @@ RUN { \\
echo ''; \\ echo ''; \\
echo 'exec docker-entrypoint.sh apache2-foreground'; \\ echo 'exec docker-entrypoint.sh apache2-foreground'; \\
} > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh } > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh
` : `# Fresh install — no user content to merge `
`} : `# Fresh install — no user content to merge
`
}
# Set proper ownership # Set proper ownership
RUN chown -R www-data:www-data /var/www/html RUN chown -R www-data:www-data /var/www/html
EXPOSE 80 EXPOSE 80
${hasUploadedCode ? `ENTRYPOINT ["cloudhost-entrypoint.sh"] ${
CMD []` : `CMD ["apache2-foreground"]`} hasUploadedCode
? `ENTRYPOINT ["cloudhost-entrypoint.sh"]
CMD []`
: `CMD ["apache2-foreground"]`
}
`; `;
} }
@@ -1478,27 +1596,13 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) { if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) {
return true; return true;
} }
const haystack = [ const haystack = [err?.code, err?.message, err?.body?.message, err?.cause?.code].filter(Boolean).join(' ');
err?.code,
err?.message,
err?.body?.message,
err?.cause?.code,
]
.filter(Boolean)
.join(' ');
return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test( return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test(
haystack, haystack,
); );
} }
private async waitForJobCompletion( private async waitForJobCompletion(batchApi: k8s.BatchV1Api, coreApi: k8s.CoreV1Api, jobName: string, namespace: string, timeoutSeconds: number, deploymentId?: string): Promise<void> {
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
timeoutSeconds: number,
deploymentId?: string,
): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
const timeoutMs = timeoutSeconds * 1000; const timeoutMs = timeoutSeconds * 1000;
let lastLoggedStatus = ''; let lastLoggedStatus = '';
@@ -1513,9 +1617,9 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
message: 'Building Docker image...', message: 'Building Docker image...',
}); });
// ── Check Job status (with retry for transient connection errors) ── // ── Check Job status (with retry for transient connection errors) ──
let job: { body: k8s.V1Job }; let job: k8s.V1Job;
try { try {
job = await batchApi.readNamespacedJob(jobName, namespace); job = await batchApi.readNamespacedJob({ name: jobName, namespace });
} catch (pollErr: any) { } catch (pollErr: any) {
// The Kaniko job keeps running independently of these status polls. // The Kaniko job keeps running independently of these status polls.
// A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build // A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build
@@ -1523,12 +1627,12 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
if (this.isTransientK8sError(pollErr)) { if (this.isTransientK8sError(pollErr)) {
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown'; const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`); this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`);
await new Promise(r => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
continue; continue;
} }
throw pollErr; throw pollErr;
} }
const status = job.body.status; const status = job.status;
if (status?.succeeded && status.succeeded > 0) { if (status?.succeeded && status.succeeded > 0) {
this.logger.log(`Build job ${jobName} succeeded`); this.logger.log(`Build job ${jobName} succeeded`);
@@ -1536,51 +1640,42 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
} }
// Check if the Job has permanently failed (all retries exhausted) // Check if the Job has permanently failed (all retries exhausted)
const failedCondition = (status?.conditions || []).find( const failedCondition = (status?.conditions || []).find((c) => c.type === 'Failed' && c.status === 'True');
(c) => c.type === 'Failed' && c.status === 'True',
);
if (failedCondition) { if (failedCondition) {
const logs = await this.getBuildLogs(coreApi, jobName, namespace); const logs = await this.getBuildLogs(coreApi, jobName, namespace);
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`); throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
} }
// Safety net: if failures exceed backoffLimit and no pod is still running // Safety net: if failures exceed backoffLimit and no pod is still running
const backoffLimit = job.body.spec?.backoffLimit ?? 0; const backoffLimit = job.spec?.backoffLimit ?? 0;
const failedCount = status?.failed ?? 0; const failedCount = status?.failed ?? 0;
if (failedCount > backoffLimit) { if (failedCount > backoffLimit) {
// Double-check: are there still active pods? // Double-check: are there still active pods?
const activePods = (status as any)?.active ?? 0; const activePods = (status as any)?.active ?? 0;
if (activePods === 0) { if (activePods === 0) {
const logs = await this.getBuildLogs(coreApi, jobName, namespace); const logs = await this.getBuildLogs(coreApi, jobName, namespace);
throw new Error( throw new Error(`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`);
`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`,
);
} }
} }
// Log intermediate pod failures (retries still available) // Log intermediate pod failures (retries still available)
if (failedCount > 0) { if (failedCount > 0) {
this.logger.warn( this.logger.warn(`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`);
`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`,
);
} }
// ── Check Pod status for early failure detection ── // ── Check Pod status for early failure detection ──
try { try {
const pods = await coreApi.listNamespacedPod( const pods = await coreApi.listNamespacedPod({
namespace, undefined, undefined, undefined, undefined, namespace,
`job-name=${jobName}`, labelSelector: `job-name=${jobName}`,
); });
for (const pod of pods.body.items) { for (const pod of pods.items) {
const podName = pod.metadata?.name || 'unknown'; const podName = pod.metadata?.name || 'unknown';
const phase = pod.status?.phase; const phase = pod.status?.phase;
// Check all container statuses (init + regular) for stuck states // Check all container statuses (init + regular) for stuck states
const allStatuses = [ const allStatuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])];
...(pod.status?.initContainerStatuses || []),
...(pod.status?.containerStatuses || []),
];
for (const cs of allStatuses) { for (const cs of allStatuses) {
const waiting = cs.state?.waiting; const waiting = cs.state?.waiting;
@@ -1589,17 +1684,11 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
const msg = waiting.message || ''; const msg = waiting.message || '';
// These are unrecoverable — fail fast instead of waiting 10 minutes // These are unrecoverable — fail fast instead of waiting 10 minutes
const fatalReasons = [ const fatalReasons = ['ErrImagePull', 'ImagePullBackOff', 'CreateContainerConfigError', 'InvalidImageName', 'CrashLoopBackOff'];
'ErrImagePull', 'ImagePullBackOff',
'CreateContainerConfigError', 'InvalidImageName',
'CrashLoopBackOff',
];
if (fatalReasons.includes(reason)) { if (fatalReasons.includes(reason)) {
const logs = await this.getBuildLogs(coreApi, jobName, namespace); const logs = await this.getBuildLogs(coreApi, jobName, namespace);
throw new Error( throw new Error(`Build pod ${podName} stuck: ${reason}${msg}\nLogs:\n${logs}`);
`Build pod ${podName} stuck: ${reason}${msg}\nLogs:\n${logs}`,
);
} }
// Log non-fatal waiting states periodically // Log non-fatal waiting states periodically
@@ -1638,50 +1727,33 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s\nLogs:\n${logs}`); throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s\nLogs:\n${logs}`);
} }
private async getBuildLogs( private async getBuildLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
): Promise<string> {
try { try {
const pods = await coreApi.listNamespacedPod( const pods = await coreApi.listNamespacedPod({
namespace, namespace,
undefined, labelSelector: `job-name=${jobName}`,
undefined, });
undefined,
undefined,
`job-name=${jobName}`,
);
if (pods.body.items.length === 0) { if (pods.items.length === 0) {
return 'No pods found for build job.'; return 'No pods found for build job.';
} }
const podName = pods.body.items[0].metadata?.name; const podName = pods.items[0].metadata?.name;
if (!podName) return 'Pod name not found.'; if (!podName) return 'Pod name not found.';
// Get logs from all containers (init + kaniko) // Get logs from all containers (init + kaniko)
let allLogs = ''; let allLogs = '';
const containers = [ const containers = [...(pods.items[0].spec?.initContainers || []), ...(pods.items[0].spec?.containers || [])];
...(pods.body.items[0].spec?.initContainers || []),
...(pods.body.items[0].spec?.containers || []),
];
for (const container of containers) { for (const container of containers) {
try { try {
const logResponse = await coreApi.readNamespacedPodLog( const logResponse = await coreApi.readNamespacedPodLog({
podName, name: podName,
namespace, namespace,
container.name, container: container.name,
undefined, tailLines: 500,
undefined, });
undefined, allLogs += `\n--- ${container.name} ---\n${logResponse}`;
undefined,
undefined,
undefined,
500,
);
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
} catch { } catch {
allLogs += `\n--- ${container.name} --- (no logs available)`; allLogs += `\n--- ${container.name} --- (no logs available)`;
} }
+72 -118
View File
@@ -1,21 +1,10 @@
import { import { Injectable, Logger, BadRequestException, Inject, forwardRef } from '@nestjs/common';
Injectable,
Logger,
BadRequestException,
Inject,
forwardRef,
} from '@nestjs/common';
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
import { ClustersService } from './clusters.service'; import { ClustersService } from './clusters.service';
import { HelmService } from '../kubernetes/helm.service'; import { HelmService } from '../kubernetes/helm.service';
import { ElasticsearchService } from '../kubernetes/elasticsearch.service'; import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
import { import { ClusterToolDefinition, ClusterToolId, ClusterToolState, ClusterToolStatus } from './cluster-tools.types';
ClusterToolDefinition,
ClusterToolId,
ClusterToolState,
ClusterToolStatus,
} from './cluster-tools.types';
const CERT_MANAGER_RELEASE = 'cert-manager'; const CERT_MANAGER_RELEASE = 'cert-manager';
const CERT_MANAGER_NAMESPACE = 'cert-manager'; const CERT_MANAGER_NAMESPACE = 'cert-manager';
@@ -27,9 +16,7 @@ const CLUSTER_ISSUER_NAME = 'letsencrypt-prod';
const ACME_PROD_SERVER = 'https://acme-v02.api.letsencrypt.org/directory'; const ACME_PROD_SERVER = 'https://acme-v02.api.letsencrypt.org/directory';
// k3s ships Traefik by default; match the app Ingress class (INGRESS_CLASS) // k3s ships Traefik by default; match the app Ingress class (INGRESS_CLASS)
// so the ACME HTTP-01 solver Ingress is actually served by the controller. // so the ACME HTTP-01 solver Ingress is actually served by the controller.
const DEFAULT_INGRESS_CLASS = (process.env.INGRESS_CLASS || 'traefik') const DEFAULT_INGRESS_CLASS = (process.env.INGRESS_CLASS || 'traefik').trim().toLowerCase();
.trim()
.toLowerCase();
const ISSUER_GROUP = 'cert-manager.io'; const ISSUER_GROUP = 'cert-manager.io';
const ISSUER_VERSION = 'v1'; const ISSUER_VERSION = 'v1';
@@ -44,8 +31,7 @@ export class ClusterToolsService {
{ {
id: 'cert-manager', id: 'cert-manager',
name: 'cert-manager', name: 'cert-manager',
description: description: 'Automated TLS certificate management. Required before creating a ClusterIssuer.',
'Automated TLS certificate management. Required before creating a ClusterIssuer.',
category: 'Certificates', category: 'Certificates',
dependencies: [], dependencies: [],
installFields: [], installFields: [],
@@ -53,8 +39,7 @@ export class ClusterToolsService {
{ {
id: 'cluster-issuer', id: 'cluster-issuer',
name: "ClusterIssuer (Let's Encrypt)", name: "ClusterIssuer (Let's Encrypt)",
description: description: 'Cluster-wide ACME issuer (letsencrypt-prod) using HTTP01 challenge. Requires cert-manager.',
'Cluster-wide ACME issuer (letsencrypt-prod) using HTTP01 challenge. Requires cert-manager.',
category: 'Certificates', category: 'Certificates',
dependencies: ['cert-manager'], dependencies: ['cert-manager'],
installFields: [ installFields: [
@@ -71,8 +56,7 @@ export class ClusterToolsService {
{ {
id: 'central-elastic', id: 'central-elastic',
name: 'Central Elasticsearch + Kibana', name: 'Central Elasticsearch + Kibana',
description: description: 'Shared logging stack powering the unified Logs page. Installed via Helm in the "logging" namespace.',
'Shared logging stack powering the unified Logs page. Installed via Helm in the "logging" namespace.',
category: 'Logging', category: 'Logging',
dependencies: [], dependencies: [],
installFields: [], installFields: [],
@@ -96,11 +80,7 @@ export class ClusterToolsService {
return Promise.all( return Promise.all(
this.catalog.map(async (def) => { this.catalog.map(async (def) => {
try { try {
const { status, message, details } = await this.statusOf( const { status, message, details } = await this.statusOf(def.id, clusterId, kubeconfig);
def.id,
clusterId,
kubeconfig,
);
return { ...def, status, message, details }; return { ...def, status, message, details };
} catch (err: any) { } catch (err: any) {
return { return {
@@ -113,11 +93,7 @@ export class ClusterToolsService {
); );
} }
async install( async install(clusterId: string, toolId: ClusterToolId, params: Record<string, string> = {}): Promise<{ status: ClusterToolStatus; message: string }> {
clusterId: string,
toolId: ClusterToolId,
params: Record<string, string> = {},
): Promise<{ status: ClusterToolStatus; message: string }> {
const def = this.requireTool(toolId); const def = this.requireTool(toolId);
const kubeconfig = await this.getKubeconfig(clusterId); const kubeconfig = await this.getKubeconfig(clusterId);
@@ -125,9 +101,7 @@ export class ClusterToolsService {
for (const depId of def.dependencies) { for (const depId of def.dependencies) {
const dep = await this.statusOf(depId, clusterId, kubeconfig); const dep = await this.statusOf(depId, clusterId, kubeconfig);
if (dep.status !== 'installed') { if (dep.status !== 'installed') {
throw new BadRequestException( throw new BadRequestException(`"${this.requireTool(depId).name}" must be installed before "${def.name}".`);
`"${this.requireTool(depId).name}" must be installed before "${def.name}".`,
);
} }
} }
@@ -141,10 +115,7 @@ export class ClusterToolsService {
} }
} }
async uninstall( async uninstall(clusterId: string, toolId: ClusterToolId): Promise<{ status: ClusterToolStatus; message: string }> {
clusterId: string,
toolId: ClusterToolId,
): Promise<{ status: ClusterToolStatus; message: string }> {
this.requireTool(toolId); this.requireTool(toolId);
const kubeconfig = await this.getKubeconfig(clusterId); const kubeconfig = await this.getKubeconfig(clusterId);
@@ -160,9 +131,7 @@ export class ClusterToolsService {
// ── cert-manager ─────────────────────────────────────────────────── // ── cert-manager ───────────────────────────────────────────────────
private async installCertManager( private async installCertManager(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.helmService.installRemoteChart({ await this.helmService.installRemoteChart({
repoName: CERT_MANAGER_REPO_NAME, repoName: CERT_MANAGER_REPO_NAME,
repoUrl: CERT_MANAGER_REPO_URL, repoUrl: CERT_MANAGER_REPO_URL,
@@ -176,28 +145,18 @@ export class ClusterToolsService {
}); });
return { return {
status: 'installing', status: 'installing',
message: message: 'cert-manager install started. Pods are starting — allow 12 minutes to become ready.',
'cert-manager install started. Pods are starting — allow 12 minutes to become ready.',
}; };
} }
private async uninstallCertManager( private async uninstallCertManager(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
kubeconfig: string, await this.helmService.uninstall(CERT_MANAGER_RELEASE, CERT_MANAGER_NAMESPACE, kubeconfig);
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.helmService.uninstall(
CERT_MANAGER_RELEASE,
CERT_MANAGER_NAMESPACE,
kubeconfig,
);
return { status: 'not_installed', message: 'cert-manager removed.' }; return { status: 'not_installed', message: 'cert-manager removed.' };
} }
// ── ClusterIssuer ────────────────────────────────────────────────── // ── ClusterIssuer ──────────────────────────────────────────────────
private async installClusterIssuer( private async installClusterIssuer(kubeconfig: string, params: Record<string, string>): Promise<{ status: ClusterToolStatus; message: string }> {
kubeconfig: string,
params: Record<string, string>,
): Promise<{ status: ClusterToolStatus; message: string }> {
const email = (params.email || '').trim(); const email = (params.email || '').trim();
if (!email) { if (!email) {
throw new BadRequestException('An ACME email is required for the ClusterIssuer.'); throw new BadRequestException('An ACME email is required for the ClusterIssuer.');
@@ -220,33 +179,31 @@ export class ClusterToolsService {
}; };
try { try {
await api.getClusterCustomObject( await api.getClusterCustomObject({
ISSUER_GROUP, group: ISSUER_GROUP,
ISSUER_VERSION, version: ISSUER_VERSION,
ISSUER_PLURAL, plural: ISSUER_PLURAL,
CLUSTER_ISSUER_NAME, name: CLUSTER_ISSUER_NAME,
); });
await api.replaceClusterCustomObject( await api.replaceClusterCustomObject({
ISSUER_GROUP, group: ISSUER_GROUP,
ISSUER_VERSION, version: ISSUER_VERSION,
ISSUER_PLURAL, plural: ISSUER_PLURAL,
CLUSTER_ISSUER_NAME, name: CLUSTER_ISSUER_NAME,
body, body,
); });
this.logger.log(`Updated ClusterIssuer "${CLUSTER_ISSUER_NAME}"`); this.logger.log(`Updated ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
} catch (err: any) { } catch (err: any) {
if (this.isNotFound(err)) { if (this.isNotFound(err)) {
await api.createClusterCustomObject( await api.createClusterCustomObject({
ISSUER_GROUP, group: ISSUER_GROUP,
ISSUER_VERSION, version: ISSUER_VERSION,
ISSUER_PLURAL, plural: ISSUER_PLURAL,
body, body,
); });
this.logger.log(`Created ClusterIssuer "${CLUSTER_ISSUER_NAME}"`); this.logger.log(`Created ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
} else if (this.isMissingCrd(err)) { } else if (this.isMissingCrd(err)) {
throw new BadRequestException( throw new BadRequestException('cert-manager CRDs are not available yet. Wait for cert-manager to finish installing, then retry.');
'cert-manager CRDs are not available yet. Wait for cert-manager to finish installing, then retry.',
);
} else { } else {
throw err; throw err;
} }
@@ -258,40 +215,35 @@ export class ClusterToolsService {
}; };
} }
private async uninstallClusterIssuer( private async uninstallClusterIssuer(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
const api = this.customObjectsApi(kubeconfig); const api = this.customObjectsApi(kubeconfig);
try { try {
await api.deleteClusterCustomObject( await api.deleteClusterCustomObject({
ISSUER_GROUP, group: ISSUER_GROUP,
ISSUER_VERSION, version: ISSUER_VERSION,
ISSUER_PLURAL, plural: ISSUER_PLURAL,
CLUSTER_ISSUER_NAME, name: CLUSTER_ISSUER_NAME,
); });
} catch (err: any) { } catch (err: any) {
if (!this.isNotFound(err) && !this.isMissingCrd(err)) throw err; if (!this.isNotFound(err) && !this.isMissingCrd(err)) throw err;
} }
return { status: 'not_installed', message: `ClusterIssuer "${CLUSTER_ISSUER_NAME}" removed.` }; return {
status: 'not_installed',
message: `ClusterIssuer "${CLUSTER_ISSUER_NAME}" removed.`,
};
} }
// ── Central Elasticsearch ────────────────────────────────────────── // ── Central Elasticsearch ──────────────────────────────────────────
private async installCentralElastic( private async installCentralElastic(clusterId: string): Promise<{ status: ClusterToolStatus; message: string }> {
clusterId: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
const result = await this.elasticsearchService.deploy(clusterId); const result = await this.elasticsearchService.deploy(clusterId);
return { return {
status: result.deploying ? 'installing' : 'installed', status: result.deploying ? 'installing' : 'installed',
message: result.deploying message: result.deploying ? 'Logging stack installed. Allow 25 minutes for Elasticsearch and Kibana to become ready.' : 'Elasticsearch and Kibana are ready.',
? 'Logging stack installed. Allow 25 minutes for Elasticsearch and Kibana to become ready.'
: 'Elasticsearch and Kibana are ready.',
}; };
} }
private async uninstallCentralElastic( private async uninstallCentralElastic(clusterId: string): Promise<{ status: ClusterToolStatus; message: string }> {
clusterId: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.elasticsearchService.undeploy(clusterId); await this.elasticsearchService.undeploy(clusterId);
return { return {
status: 'not_installed', status: 'not_installed',
@@ -305,14 +257,14 @@ export class ClusterToolsService {
toolId: ClusterToolId, toolId: ClusterToolId,
clusterId: string, clusterId: string,
kubeconfig: string, kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message?: string; details?: Record<string, unknown> }> { ): Promise<{
status: ClusterToolStatus;
message?: string;
details?: Record<string, unknown>;
}> {
switch (toolId) { switch (toolId) {
case 'cert-manager': { case 'cert-manager': {
const helm = await this.helmService.status( const helm = await this.helmService.status(CERT_MANAGER_RELEASE, CERT_MANAGER_NAMESPACE, kubeconfig);
CERT_MANAGER_RELEASE,
CERT_MANAGER_NAMESPACE,
kubeconfig,
);
if (!helm) return { status: 'not_installed' }; if (!helm) return { status: 'not_installed' };
return { return {
status: this.mapHelmStatus(helm.status), status: this.mapHelmStatus(helm.status),
@@ -323,17 +275,21 @@ export class ClusterToolsService {
case 'cluster-issuer': { case 'cluster-issuer': {
const api = this.customObjectsApi(kubeconfig); const api = this.customObjectsApi(kubeconfig);
try { try {
const res: any = await api.getClusterCustomObject( const res: any = await api.getClusterCustomObject({
ISSUER_GROUP, group: ISSUER_GROUP,
ISSUER_VERSION, version: ISSUER_VERSION,
ISSUER_PLURAL, plural: ISSUER_PLURAL,
CLUSTER_ISSUER_NAME, name: CLUSTER_ISSUER_NAME,
); });
const conditions: any[] = res.body?.status?.conditions || []; const conditions: any[] = res?.status?.conditions || [];
const ready = conditions.find((c) => c.type === 'Ready'); const ready = conditions.find((c) => c.type === 'Ready');
const email = res.body?.spec?.acme?.email; const email = res?.spec?.acme?.email;
if (ready?.status === 'True') { if (ready?.status === 'True') {
return { status: 'installed', message: 'Ready', details: { email } }; return {
status: 'installed',
message: 'Ready',
details: { email },
};
} }
return { return {
status: 'installing', status: 'installing',
@@ -357,9 +313,7 @@ export class ClusterToolsService {
}; };
return { return {
status: map[state.status] || 'unknown', status: map[state.status] || 'unknown',
message: state.helmReleaseStatus message: state.helmReleaseStatus ? `helm: ${state.helmReleaseStatus}` : undefined,
? `helm: ${state.helmReleaseStatus}`
: undefined,
details: state.health ? { health: state.health.status } : undefined, details: state.health ? { health: state.health.status } : undefined,
}; };
} }
@@ -394,14 +348,14 @@ export class ClusterToolsService {
} }
private isNotFound(err: any): boolean { private isNotFound(err: any): boolean {
return err?.statusCode === 404 || err?.body?.code === 404; return err?.code === 404 || err?.statusCode === 404 || err?.body?.code === 404;
} }
/** cert-manager CRD not yet registered → API returns 404 on the group or NotFound kind. */ /** cert-manager CRD not yet registered → API returns 404 on the group or NotFound kind. */
private isMissingCrd(err: any): boolean { private isMissingCrd(err: any): boolean {
const msg = (err?.body?.message || err?.message || '').toString(); const body = err?.body;
return /could not find the requested resource|the server could not find|no matches for kind|NotFound/i.test( const bodyMsg = typeof body === 'string' ? body : body?.message;
msg, const msg = (bodyMsg || err?.message || '').toString();
); return /could not find the requested resource|the server could not find|no matches for kind|NotFound/i.test(msg);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -473,7 +473,7 @@ export class DeploymentsService {
async findOne(id: string): Promise<Deployment> { async findOne(id: string): Promise<Deployment> {
const deployment = await this.deploymentsRepository.findOne({ const deployment = await this.deploymentsRepository.findOne({
where: { id }, where: { id },
relations: ['application'], relations: { application: true },
}); });
if (!deployment) { if (!deployment) {
throw new NotFoundException('Deployment not found'); throw new NotFoundException('Deployment not found');
+83 -150
View File
@@ -1,12 +1,4 @@
import { import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
Injectable,
Logger,
ServiceUnavailableException,
Inject,
forwardRef,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@@ -142,19 +134,11 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
/** Hostnames only resolvable from inside the workload cluster (not from a laptop). */ /** Hostnames only resolvable from inside the workload cluster (not from a laptop). */
private isClusterInternalHost(host: string): boolean { private isClusterInternalHost(host: string): boolean {
const h = host.toLowerCase(); const h = host.toLowerCase();
return ( return h.includes('svc.cluster.local') || h.includes('.cluster.') || h === 'elasticsearch' || h === 'kibana';
h.includes('svc.cluster.local') ||
h.includes('.cluster.') ||
h === 'elasticsearch' ||
h === 'kibana'
);
} }
private configuredElasticsearchHost(): string { private configuredElasticsearchHost(): string {
return ( return this.configService.get<string>('elasticsearch.host') || `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`;
this.configService.get<string>('elasticsearch.host') ||
`${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`
);
} }
/** HTTP target: loopback when we tunnel; cluster DNS when the API pod runs in-cluster. */ /** HTTP target: loopback when we tunnel; cluster DNS when the API pod runs in-cluster. */
@@ -213,9 +197,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt)); const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt));
this.reconnectAttempt += 1; this.reconnectAttempt += 1;
this.logger.warn( this.logger.warn(`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`);
`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`,
);
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null; this.reconnectTimer = null;
void this.ensureLocalElasticsearchAccess().then((ok) => { void this.ensureLocalElasticsearchAccess().then((ok) => {
@@ -280,10 +262,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> { private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
const targetClusterId = clusterId || null; const targetClusterId = clusterId || null;
if ( if (this.portForwardChild && this.portForwardClusterId === targetClusterId) {
this.portForwardChild &&
this.portForwardClusterId === targetClusterId
) {
return; return;
} }
if (this.portForwardChild) { if (this.portForwardChild) {
@@ -296,18 +275,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
this.portForwardKubeconfigFile = kubeconfigFile; this.portForwardKubeconfigFile = kubeconfigFile;
this.portForwardClusterId = targetClusterId; this.portForwardClusterId = targetClusterId;
const args = [ const args = ['--kubeconfig', kubeconfigFile, 'port-forward', '-n', this.ES_NAMESPACE, `svc/${this.ES_NAME}`, `${localPort}:9200`];
'--kubeconfig', this.logger.log(`Starting kubectl port-forward to Elasticsearch on cluster "${cluster.name}" (local log search)`);
kubeconfigFile,
'port-forward',
'-n',
this.ES_NAMESPACE,
`svc/${this.ES_NAME}`,
`${localPort}:9200`,
];
this.logger.log(
`Starting kubectl port-forward to Elasticsearch on cluster "${cluster.name}" (local log search)`,
);
const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
this.portForwardChild = child; this.portForwardChild = child;
this.portForwardStartedByUs = true; this.portForwardStartedByUs = true;
@@ -318,12 +287,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
this.portForwardStartedByUs = false; this.portForwardStartedByUs = false;
} }
if (wasOurs) { if (wasOurs) {
const reason = const reason = code !== 0 && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'connection closed';
code !== 0 && code !== null
? `exit code ${code}`
: signal
? `signal ${signal}`
: 'connection closed';
this.schedulePortForwardReconnect(reason); this.schedulePortForwardReconnect(reason);
} }
}); });
@@ -339,10 +303,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
* When the API runs on the host with ELASTICSEARCH_HOST=127.0.0.1, open a tunnel to the cluster. * When the API runs on the host with ELASTICSEARCH_HOST=127.0.0.1, open a tunnel to the cluster.
* Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop). * Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop).
*/ */
private async ensureLocalElasticsearchAccess(options?: { private async ensureLocalElasticsearchAccess(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<boolean> {
waitForCluster?: boolean;
clusterId?: string;
}): Promise<boolean> {
if (this.ensureInFlight) { if (this.ensureInFlight) {
await this.ensureInFlight; await this.ensureInFlight;
return this.probeElasticsearch(); return this.probeElasticsearch();
@@ -357,10 +318,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
} }
private async ensureLocalElasticsearchAccessImpl(options?: { private async ensureLocalElasticsearchAccessImpl(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<void> {
waitForCluster?: boolean;
clusterId?: string;
}): Promise<void> {
if (!this.shouldAutoPortForward()) { if (!this.shouldAutoPortForward()) {
return; return;
} }
@@ -395,9 +353,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`); this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`);
} else { } else {
this.stopDevPortForward(); this.stopDevPortForward();
this.logger.warn( this.logger.warn(`Could not reach Elasticsearch on 127.0.0.1:${port}. Will retry. Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${port}:9200`);
`Could not reach Elasticsearch on 127.0.0.1:${port}. Will retry. Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${port}:9200`,
);
this.schedulePortForwardReconnect('probe timeout'); this.schedulePortForwardReconnect('probe timeout');
} }
} }
@@ -413,10 +369,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
if (this.isClusterInternalHost(configured)) { if (this.isClusterInternalHost(configured)) {
if (this.isRunningInKubernetes()) { if (this.isRunningInKubernetes()) {
return ( return 'Ensure the logging stack is deployed on the same cluster as this API pod ' + `(Helm release ${LOGGING_HELM_RELEASE} in namespace ${LOGGING_HELM_NAMESPACE}).`;
'Ensure the logging stack is deployed on the same cluster as this API pod ' +
`(Helm release ${LOGGING_HELM_RELEASE} in namespace ${LOGGING_HELM_NAMESPACE}).`
);
} }
return ( return (
'Run the API inside the cluster, or restart the API locally so it can auto port-forward Elasticsearch ' + 'Run the API inside the cluster, or restart the API locally so it can auto port-forward Elasticsearch ' +
@@ -427,9 +380,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
private async getK8sClients(clusterId?: string) { private async getK8sClients(clusterId?: string) {
const cluster = clusterId const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig); kc.loadFromString(cluster.kubeconfig);
@@ -455,18 +406,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
*/ */
async getDeployState(clusterId?: string): Promise<LoggingDeployState> { async getDeployState(clusterId?: string): Promise<LoggingDeployState> {
const { cluster } = await this.getK8sClients(clusterId); const { cluster } = await this.getK8sClients(clusterId);
const helmStatus = await this.helmService.status( const helmStatus = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
LOGGING_HELM_RELEASE,
LOGGING_HELM_NAMESPACE,
cluster.kubeconfig,
);
let hasEsWorkload = false; let hasEsWorkload = false;
try { try {
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig); kc.loadFromString(cluster.kubeconfig);
const appsApi = kc.makeApiClient(k8s.AppsV1Api); const appsApi = kc.makeApiClient(k8s.AppsV1Api);
await appsApi.readNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE); await appsApi.readNamespacedStatefulSet({
name: this.ES_NAME,
namespace: this.ES_NAMESPACE,
});
hasEsWorkload = true; hasEsWorkload = true;
} catch { } catch {
hasEsWorkload = false; hasEsWorkload = false;
@@ -512,26 +462,27 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig); kc.loadFromString(kubeconfig);
const storageApi = kc.makeApiClient(k8s.StorageV1Api); const storageApi = kc.makeApiClient(k8s.StorageV1Api);
const provisioner = const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
try { try {
await storageApi.readStorageClass(storageClass); await storageApi.readStorageClass({ name: storageClass });
return; return;
} catch (err: any) { } catch (err: any) {
if (err.statusCode !== 404 && err.body?.code !== 404) { if (err.code !== 404 && err.body?.code !== 404) {
throw err; throw err;
} }
} }
await storageApi.createStorageClass({ await storageApi.createStorageClass({
apiVersion: 'storage.k8s.io/v1', body: {
kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1',
metadata: { name: storageClass }, kind: 'StorageClass',
provisioner, metadata: { name: storageClass },
allowVolumeExpansion: true, provisioner,
reclaimPolicy: 'Delete', allowVolumeExpansion: true,
volumeBindingMode: 'WaitForFirstConsumer', reclaimPolicy: 'Delete',
volumeBindingMode: 'WaitForFirstConsumer',
},
}); });
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`); this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
} }
@@ -578,37 +529,25 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
try { try {
// Check ES pods // Check ES pods
const esPods = await coreApi.listNamespacedPod( const esPods = await coreApi.listNamespacedPod({
this.ES_NAMESPACE, namespace: this.ES_NAMESPACE,
undefined, labelSelector: 'app=elasticsearch',
undefined, });
undefined,
undefined,
'app=elasticsearch',
);
const kibanaPods = await coreApi.listNamespacedPod( const kibanaPods = await coreApi.listNamespacedPod({
this.ES_NAMESPACE, namespace: this.ES_NAMESPACE,
undefined, labelSelector: 'app=kibana',
undefined, });
undefined,
undefined,
'app=kibana',
);
if (esPods.body.items.length === 0) { if (esPods.items.length === 0) {
return null; return null;
} }
const esPod = esPods.body.items[0]; const esPod = esPods.items[0];
const isEsReady = esPod.status?.conditions?.some( const isEsReady = esPod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True');
(c) => c.type === 'Ready' && c.status === 'True',
);
const kibanaPod = kibanaPods.body.items[0]; const kibanaPod = kibanaPods.items[0];
const isKibanaReady = kibanaPod?.status?.conditions?.some( const isKibanaReady = kibanaPod?.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') || false;
(c) => c.type === 'Ready' && c.status === 'True',
) || false;
return { return {
status: isEsReady ? 'green' : 'yellow', status: isEsReady ? 'green' : 'yellow',
@@ -640,22 +579,14 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
timeout: '10m', timeout: '10m',
}); });
} catch (error: any) { } catch (error: any) {
const release = await this.helmService.status( const release = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
LOGGING_HELM_RELEASE,
LOGGING_HELM_NAMESPACE,
cluster.kubeconfig,
);
if (!release) { if (!release) {
throw error; throw error;
} }
this.logger.warn( this.logger.warn(`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`);
`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`,
);
} }
this.logger.log( this.logger.log(`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`);
`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
);
const state = await this.getDeployState(clusterId); const state = await this.getDeployState(clusterId);
@@ -688,7 +619,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
/** /**
* Get Elasticsearch connection info for apps * Get Elasticsearch connection info for apps
*/ */
getConnectionInfo(): { host: string; port: number; username: string; password: string } { getConnectionInfo(): {
host: string;
port: number;
username: string;
password: string;
} {
return { return {
host: this.effectiveElasticsearchHost(), host: this.effectiveElasticsearchHost(),
port: this.configService.get<number>('elasticsearch.port') || 9200, port: this.configService.get<number>('elasticsearch.port') || 9200,
@@ -744,12 +680,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
const must: any[] = [ const must: any[] = [
{ {
bool: { bool: {
should: [ should: [{ term: { ownerId: userId } }, { term: { 'ownerId.keyword': userId } }, { term: { namespace } }, { term: { 'namespace.keyword': namespace } }],
{ term: { ownerId: userId } },
{ term: { 'ownerId.keyword': userId } },
{ term: { namespace } },
{ term: { 'namespace.keyword': namespace } },
],
minimum_should_match: 1, minimum_should_match: 1,
}, },
}, },
@@ -758,10 +689,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
if (filters.applicationId) { if (filters.applicationId) {
must.push({ must.push({
bool: { bool: {
should: [ should: [{ term: { applicationId: filters.applicationId } }, { term: { 'applicationId.keyword': filters.applicationId } }],
{ term: { applicationId: filters.applicationId } },
{ term: { 'applicationId.keyword': filters.applicationId } },
],
minimum_should_match: 1, minimum_should_match: 1,
}, },
}); });
@@ -784,10 +712,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
if (filters.workload) { if (filters.workload) {
must.push({ must.push({
bool: { bool: {
should: [ should: [{ term: { workload: filters.workload } }, { term: { 'workload.keyword': filters.workload } }],
{ term: { workload: filters.workload } },
{ term: { 'workload.keyword': filters.workload } },
],
minimum_should_match: 1, minimum_should_match: 1,
}, },
}); });
@@ -796,10 +721,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
if (filters.level) { if (filters.level) {
must.push({ must.push({
bool: { bool: {
should: [ should: [{ term: { level: filters.level.toLowerCase() } }, { term: { 'level.keyword': filters.level.toLowerCase() } }],
{ term: { level: filters.level.toLowerCase() } },
{ term: { 'level.keyword': filters.level.toLowerCase() } },
],
minimum_should_match: 1, minimum_should_match: 1,
}, },
}); });
@@ -854,9 +776,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> { private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
const deployed = await this.isDeployed(clusterId); const deployed = await this.isDeployed(clusterId);
if (!deployed) { if (!deployed) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException('Central logging is not configured. Ask an administrator to deploy Elasticsearch.');
'Central logging is not configured. Ask an administrator to deploy Elasticsearch.',
);
} }
if (this.shouldAutoPortForward()) { if (this.shouldAutoPortForward()) {
@@ -896,12 +816,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private normalizeHit(hit: any): NormalizedLogEntry { private normalizeHit(hit: any): NormalizedLogEntry {
const src = hit._source || {}; const src = hit._source || {};
const message = const message = src.message || src.log || src.msg || (typeof src.error === 'string' ? src.error : src.error?.message) || '';
src.message ||
src.log ||
src.msg ||
(typeof src.error === 'string' ? src.error : src.error?.message) ||
'';
return { return {
id: hit._id || '', id: hit._id || '',
@@ -942,7 +857,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
async searchLogStats( async searchLogStats(
userId: string, userId: string,
filters: { applicationId?: string; applicationName?: string; workload?: string; period?: string }, filters: {
applicationId?: string;
applicationName?: string;
workload?: string;
period?: string;
},
clusterId?: string, clusterId?: string,
): Promise<LogStatsResult> { ): Promise<LogStatsResult> {
const periodMap: Record<string, string> = { const periodMap: Record<string, string> = {
@@ -965,8 +885,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
query: { bool: { must } }, query: { bool: { must } },
size: 0, size: 0,
aggs: { aggs: {
by_level: { terms: { field: 'level.keyword', size: 10, missing: 'unknown' } }, by_level: {
by_workload: { terms: { field: 'workload.keyword', size: 10, missing: 'app' } }, terms: { field: 'level.keyword', size: 10, missing: 'unknown' },
},
by_workload: {
terms: { field: 'workload.keyword', size: 10, missing: 'app' },
},
error_count: { filter: { term: { 'level.keyword': 'error' } } }, error_count: { filter: { term: { 'level.keyword': 'error' } } },
warn_count: { filter: { term: { 'level.keyword': 'warn' } } }, warn_count: { filter: { term: { 'level.keyword': 'warn' } } },
}, },
@@ -994,7 +918,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
async searchRecentErrors( async searchRecentErrors(
userId: string, userId: string,
filters: { applicationId?: string; applicationName?: string; workload?: string; hours?: number; limit?: number }, filters: {
applicationId?: string;
applicationName?: string;
workload?: string;
hours?: number;
limit?: number;
},
clusterId?: string, clusterId?: string,
): Promise<NormalizedLogEntry[]> { ): Promise<NormalizedLogEntry[]> {
const hours = filters.hours || 24; const hours = filters.hours || 24;
@@ -1019,9 +949,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h)); return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
} }
async getLoggingStatus( async getLoggingStatus(clusterId?: string): Promise<{
clusterId?: string, available: boolean;
): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> { deployed: boolean;
recovering?: boolean;
message?: string;
}> {
let deployed = await this.isDeployed(clusterId); let deployed = await this.isDeployed(clusterId);
if (!deployed && this.shouldAutoPortForward()) { if (!deployed && this.shouldAutoPortForward()) {
deployed = await this.waitForLoggingStack(8_000); deployed = await this.waitForLoggingStack(8_000);
@@ -0,0 +1,159 @@
// @kubernetes/client-node 1.x ships as ESM (package.json "type": "module").
// Production (Node 20.19+/24) loads it fine via require(esm), but Jest's own
// CommonJS module system cannot parse it, so we mock it. Unit tests inject mock
// API clients anyway — the only runtime helper the tested paths touch is
// setHeaderOptions (used by the migrated patch calls).
jest.mock('@kubernetes/client-node', () => ({
setHeaderOptions: (key: string, value: string) => ({ headers: { [key]: value } }),
}));
import { RegistryService } from './registry.service';
import { KubernetesService } from './kubernetes.service';
/**
* Regression tests for the @kubernetes/client-node 1.x migration.
*
* v1.x changed every API method from positional arguments returning `{ body }`
* to a single options object returning the body directly, and changed the
* thrown error shape from `.statusCode` to `.code`. These tests pin that the
* migrated services:
* 1. call the client with the new single-object argument shape,
* 2. read the unwrapped response (no `.body`),
* 3. detect "not found" via the new `err.code` field.
*
* They mock the API clients so no real cluster is required.
*/
const configStub = { get: jest.fn().mockReturnValue(undefined) } as any;
describe('RegistryService — k8s v1 client shape', () => {
let service: RegistryService;
beforeEach(() => {
service = new RegistryService(configStub);
});
it('replaces the pull secret with the v1 single-object argument', async () => {
const coreApi = {
replaceNamespacedSecret: jest.fn().mockResolvedValue({}),
createNamespacedSecret: jest.fn(),
} as any;
await service.ensureRegistryPullSecret(coreApi, 'team-ns');
expect(coreApi.replaceNamespacedSecret).toHaveBeenCalledTimes(1);
const arg = coreApi.replaceNamespacedSecret.mock.calls[0][0];
// v1 passes ONE object, not positional (name, namespace, body)
expect(coreApi.replaceNamespacedSecret.mock.calls[0]).toHaveLength(1);
expect(arg).toMatchObject({ name: 'registry-pull-secret', namespace: 'team-ns' });
expect(arg.body?.metadata?.name).toBe('registry-pull-secret');
expect(coreApi.createNamespacedSecret).not.toHaveBeenCalled();
});
it('creates the secret when replace fails with the v1 err.code 404', async () => {
const coreApi = {
replaceNamespacedSecret: jest.fn().mockRejectedValue({ code: 404 }),
createNamespacedSecret: jest.fn().mockResolvedValue({}),
} as any;
await service.ensureRegistryPullSecret(coreApi, 'team-ns');
expect(coreApi.createNamespacedSecret).toHaveBeenCalledTimes(1);
const arg = coreApi.createNamespacedSecret.mock.calls[0][0];
expect(coreApi.createNamespacedSecret.mock.calls[0]).toHaveLength(1);
expect(arg).toMatchObject({ namespace: 'team-ns' });
expect(arg.body?.type).toBe('kubernetes.io/dockerconfigjson');
});
it('rethrows non-404 errors instead of creating', async () => {
const coreApi = {
replaceNamespacedSecret: jest.fn().mockRejectedValue({ code: 500 }),
createNamespacedSecret: jest.fn(),
} as any;
await expect(service.ensureRegistryPullSecret(coreApi, 'team-ns')).rejects.toEqual({ code: 500 });
expect(coreApi.createNamespacedSecret).not.toHaveBeenCalled();
});
});
describe('KubernetesService — k8s v1 client shape', () => {
let service: KubernetesService;
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
const svc = new KubernetesService(
configStub,
{} as any, // clustersService
{} as any, // helmService
{} as any, // registryService
{} as any, // deploymentsRepository
);
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
coreApi: clients.coreApi,
appsApi: clients.appsApi,
networkingApi: clients.networkingApi,
kc: clients.kc,
});
return svc;
};
const app = {
id: 'app-1',
name: 'my-app',
userId: 'abc123-def456',
clusterId: 'cluster-1',
productType: 'web_service',
databaseType: 'none',
} as any;
it('getPodLogs lists pods and reads the log with v1 object args and unwrapped result', async () => {
const coreApi = {
listNamespacedPod: jest.fn().mockResolvedValue({ items: [{ metadata: { name: 'pod-1' } }] }),
readNamespacedPodLog: jest.fn().mockResolvedValue('hello logs'),
};
service = makeService({ coreApi });
const logs = await service.getPodLogs(app);
// unwrapped string returned directly (v0.x returned { body })
expect(logs).toBe('hello logs');
const listArg = coreApi.listNamespacedPod.mock.calls[0][0];
expect(listArg).toMatchObject({ namespace: 'user-abc123' });
expect(typeof listArg.labelSelector).toBe('string');
const logArg = coreApi.readNamespacedPodLog.mock.calls[0][0];
expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123', tailLines: 200 });
});
it('getDatabasePvcSize reads the PVC with v1 object args and unwrapped spec', async () => {
const coreApi = {
readNamespacedPersistentVolumeClaim: jest
.fn()
.mockResolvedValue({ spec: { resources: { requests: { storage: '5Gi' } } } }),
};
service = makeService({ coreApi });
const size = await service.getDatabasePvcSize(app);
expect(size).toBe('5Gi');
const arg = coreApi.readNamespacedPersistentVolumeClaim.mock.calls[0][0];
expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123' });
});
it('scaleDeployment patches with the v1 object body and a header-options 2nd arg', async () => {
const appsApi = { patchNamespacedDeployment: jest.fn().mockResolvedValue({}) };
service = makeService({ appsApi });
await service.scaleDeployment(app, 3);
expect(appsApi.patchNamespacedDeployment).toHaveBeenCalledTimes(1);
const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0];
expect(param).toMatchObject({
name: 'my-app',
namespace: 'user-abc123',
body: { spec: { replicas: 3 } },
});
// v1 takes the merge-patch content-type via the 2nd ConfigurationOptions arg
expect(options).toBeDefined();
});
});
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -24,10 +24,7 @@ export class RegistryService {
/** Registry host:port used for build push and app image pull. */ /** Registry host:port used for build push and app image pull. */
getRegistryUrl(): string { getRegistryUrl(): string {
const buildNs = this.getBuildNamespace(); const buildNs = this.getBuildNamespace();
const url = const url = this.configService.get<string>('registry.pullUrl') || this.configService.get<string>('registry.url') || `registry.${buildNs}.svc.cluster.local:5000`;
this.configService.get<string>('registry.pullUrl') ||
this.configService.get<string>('registry.url') ||
`registry.${buildNs}.svc.cluster.local:5000`;
return url.replace(/^https?:\/\//, ''); return url.replace(/^https?:\/\//, '');
} }
@@ -67,15 +64,14 @@ export class RegistryService {
buildDockerConfigJson(): string { buildDockerConfigJson(): string {
const { username, password } = this.getRegistryCredentials(); const { username, password } = this.getRegistryCredentials();
const auth = const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : '';
username && password
? Buffer.from(`${username}:${password}`).toString('base64')
: '';
const host = this.getRegistryUrl(); const host = this.getRegistryUrl();
return JSON.stringify({ return JSON.stringify({
auths: { auths: {
[host]: { auth }, [host]: { auth },
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { auth }, [`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: {
auth,
},
}, },
}); });
} }
@@ -97,10 +93,14 @@ export class RegistryService {
}; };
try { try {
await coreApi.replaceNamespacedSecret(secretName, namespace, secret); await coreApi.replaceNamespacedSecret({
name: secretName,
namespace,
body: secret,
});
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
await coreApi.createNamespacedSecret(namespace, secret); await coreApi.createNamespacedSecret({ namespace, body: secret });
this.logger.log(`Created ${secretName} in ${namespace}`); this.logger.log(`Created ${secretName} in ${namespace}`);
} else { } else {
throw err; throw err;
+1 -1
View File
@@ -264,7 +264,7 @@ export class SnapshotsService implements OnModuleInit {
async findOne(snapshotId: string, userId: string): Promise<AppSnapshot> { async findOne(snapshotId: string, userId: string): Promise<AppSnapshot> {
const snapshot = await this.snapshotsRepo.findOne({ const snapshot = await this.snapshotsRepo.findOne({
where: { id: snapshotId }, where: { id: snapshotId },
relations: ['application'], relations: { application: true },
}); });
if (!snapshot) throw new NotFoundException('Snapshot not found'); if (!snapshot) throw new NotFoundException('Snapshot not found');
+5 -5
View File
@@ -45,7 +45,7 @@ export class TicketsService {
async findMyTickets(userId: string): Promise<Ticket[]> { async findMyTickets(userId: string): Promise<Ticket[]> {
return this.ticketsRepo.find({ return this.ticketsRepo.find({
where: { userId }, where: { userId },
relations: ['messages', 'messages.sender'], relations: { messages: { sender: true } },
order: { updatedAt: 'DESC' }, order: { updatedAt: 'DESC' },
}); });
} }
@@ -54,7 +54,7 @@ export class TicketsService {
async findOne(ticketId: string, userId: string, userRole: UserRole): Promise<Ticket> { async findOne(ticketId: string, userId: string, userRole: UserRole): Promise<Ticket> {
const ticket = await this.ticketsRepo.findOne({ const ticket = await this.ticketsRepo.findOne({
where: { id: ticketId }, where: { id: ticketId },
relations: ['messages', 'messages.sender', 'user'], relations: { messages: { sender: true }, user: true },
}); });
if (!ticket) { if (!ticket) {
@@ -140,7 +140,7 @@ export class TicketsService {
} }
return this.ticketsRepo.find({ return this.ticketsRepo.find({
where, where,
relations: ['user', 'messages'], relations: { user: true, messages: true },
order: { updatedAt: 'DESC' }, order: { updatedAt: 'DESC' },
}); });
} }
@@ -153,7 +153,7 @@ export class TicketsService {
return this.ticketsRepo.find({ return this.ticketsRepo.find({
where, where,
relations: ['user', 'messages'], relations: { user: true, messages: true },
order: { updatedAt: 'DESC' }, order: { updatedAt: 'DESC' },
}); });
} }
@@ -166,7 +166,7 @@ export class TicketsService {
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>; byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
}> { }> {
const allTickets = await this.ticketsRepo.find({ const allTickets = await this.ticketsRepo.find({
relations: ['messages', 'messages.sender'], relations: { messages: { sender: true } },
}); });
const totalTickets = allTickets.length; const totalTickets = allTickets.length;
+11 -2
View File
@@ -68,8 +68,17 @@ export class UsersService {
const users = await this.usersRepository.find({ const users = await this.usersRepository.find({
where, where,
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'], select: {
relations: ['applications'], id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
namespace: true,
createdAt: true,
},
relations: { applications: true },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
+4
View File
@@ -10,10 +10,14 @@
"target": "ES2021", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./", "baseUrl": "./",
"ignoreDeprecations": "6.0",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node", "jest", "multer"],
"strictNullChecks": true, "strictNullChecks": true,
"strictPropertyInitialization": false,
"noImplicitAny": true, "noImplicitAny": true,
"strictBindCallApply": true, "strictBindCallApply": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
+3 -3
View File
@@ -1,12 +1,12 @@
# ---- Stage 1: Dependencies ---- # ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps FROM node:24-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
# ---- Stage 2: Build ---- # ---- Stage 2: Build ----
FROM node:20-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
@@ -18,7 +18,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
# ---- Stage 3: Production ---- # ---- Stage 3: Production ----
FROM node:20-alpine AS production FROM node:24-alpine AS production
RUN apk add --no-cache dumb-init RUN apk add --no-cache dumb-init
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2560 -2101
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -9,33 +9,33 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@react-three/drei": "^9.122.0", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^8.18.0", "@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^2.19.1", "@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.101.0",
"axios": "^1.6.0", "axios": "^1.17.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"framer-motion": "^11.18.2", "framer-motion": "^12.40.0",
"lenis": "^1.3.23", "lenis": "^1.3.23",
"lucide-react": "^1.7.0", "lucide-react": "^1.18.0",
"next": "14.1.0", "next": "16.2.9",
"react": "^18.2.0", "react": "^19.2.7",
"react-dom": "^18.2.0", "react-dom": "^19.2.7",
"react-hook-form": "^7.49.0", "react-hook-form": "^7.79.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.1.0",
"three": "^0.169.0", "three": "^0.184.0",
"zustand": "^4.5.0" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@tailwindcss/postcss": "^4.3.1",
"@types/react": "^18.2.0", "@types/node": "^24.0.0",
"@types/react-dom": "^18.2.0", "@types/react": "^19.2.17",
"@types/three": "^0.169.0", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.17", "@types/three": "^0.184.1",
"eslint": "^8.56.0", "eslint": "^9.0.0",
"eslint-config-next": "14.1.0", "eslint-config-next": "16.2.9",
"postcss": "^8.4.33", "postcss": "^8.5.15",
"tailwindcss": "^3.4.1", "tailwindcss": "^4.3.1",
"typescript": "^5.3.3" "typescript": "^6.0.3"
} }
} }
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };
+18 -12
View File
@@ -14,23 +14,29 @@ export function generateStaticParams() {
return locales.map((lang) => ({ lang })); return locales.map((lang) => ({ lang }));
} }
export async function generateMetadata({ export async function generateMetadata(
params, props: {
}: { params: Promise<{ lang: string }>;
params: { lang: string }; }
}): Promise<Metadata> { ): Promise<Metadata> {
const params = await props.params;
if (!isLocale(params.lang)) return {}; if (!isLocale(params.lang)) return {};
const dict = await getDictionary(params.lang); const dict = await getDictionary(params.lang);
return { title: dict.meta.title, description: dict.meta.description }; return { title: dict.meta.title, description: dict.meta.description };
} }
export default async function RootLayout({ export default async function RootLayout(
children, props: {
params, children: React.ReactNode;
}: { params: Promise<{ lang: string }>;
children: React.ReactNode; }
params: { lang: string }; ) {
}) { const params = await props.params;
const {
children
} = props;
if (!isLocale(params.lang)) notFound(); if (!isLocale(params.lang)) notFound();
const locale: Locale = params.lang; const locale: Locale = params.lang;
const dict = await getDictionary(locale); const dict = await getDictionary(locale);
+10 -10
View File
@@ -1,6 +1,5 @@
@tailwind base; @import 'tailwindcss';
@tailwind components; @config '../../tailwind.config.ts';
@tailwind utilities;
@layer base { @layer base {
body { body {
@@ -82,19 +81,20 @@
transition-shadow duration-200; transition-shadow duration-200;
} }
.card-hover { .card-hover {
@apply card hover:shadow-md hover:border-gray-300; @apply bg-white rounded-2xl shadow-sm border border-gray-200/80 p-6
transition-shadow duration-200 hover:shadow-md hover:border-gray-300;
} }
/* ─── Badges ──────────────────────────────────────────── */ /* ─── Badges ──────────────────────────────────────────── */
.badge { .badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold; @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold;
} }
.badge-green { @apply badge bg-emerald-100 text-emerald-700; } .badge-green { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700; }
.badge-red { @apply badge bg-red-100 text-red-700; } .badge-red { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700; }
.badge-blue { @apply badge bg-blue-100 text-blue-700; } .badge-blue { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700; }
.badge-yellow { @apply badge bg-amber-100 text-amber-700; } .badge-yellow { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700; }
.badge-gray { @apply badge bg-gray-100 text-gray-600; } .badge-gray { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600; }
.badge-purple { @apply badge bg-purple-100 text-purple-700; } .badge-purple { @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-700; }
/* ─── Stat card ──────────────────────────────────────── */ /* ─── Stat card ──────────────────────────────────────── */
.stat-card { .stat-card {
+25 -7
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -11,13 +15,27 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }